|
| 1 | +import { |
| 2 | + afterAll, |
| 3 | + afterEach, |
| 4 | + beforeAll, |
| 5 | + beforeEach, |
| 6 | + expect, |
| 7 | + test, |
| 8 | +} from "bun:test"; |
| 9 | +import { GlobalRegistrator } from "@happy-dom/global-registrator"; |
| 10 | +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 11 | +import { |
| 12 | + createMemoryHistory, |
| 13 | + createRootRoute, |
| 14 | + createRouter, |
| 15 | + RouterProvider, |
| 16 | +} from "@tanstack/react-router"; |
| 17 | +import { act, cleanup, fireEvent, render } from "@testing-library/react"; |
| 18 | +import userEvent from "@testing-library/user-event"; |
| 19 | +import type { ComponentType, ReactNode } from "react"; |
| 20 | +import { AgentDialog } from "@/components/agents/agent-dialog"; |
| 21 | +import { CreateAgentDialog } from "@/components/agents/create-agent-dialog"; |
| 22 | +import { type AgentProfile, agentKeys } from "@/lib/agents/queries"; |
| 23 | +import { computerKeys } from "@/lib/computers/queries"; |
| 24 | +import { Route as BoundariesRoute } from "@/routes/_authed/admin/boundaries"; |
| 25 | + |
| 26 | +/** |
| 27 | + * The Enter that confirms a character an input method is composing is not an Enter. |
| 28 | + * |
| 29 | + * Japanese, Chinese and Korean are typed through an input method (IME), and Enter is how the |
| 30 | + * character being built is confirmed. That press still arrives as a keydown with `key === "Enter"`. |
| 31 | + * Chromium marks it `isComposing`, and WebKit sends it after `compositionend` with the key code 229. |
| 32 | + * The chat composer already skips it: `prompt-area` checks `isComposing` on every Enter it handles. |
| 33 | + * Three text fields in the app acted on it instead, each with a text field's text still unconfirmed: |
| 34 | + * a coworker's name saved in place, the new-coworker wizard moving on to its next step, and a |
| 35 | + * boundary rule saved into the policy in force. |
| 36 | + * |
| 37 | + * THE HARNESS IS THIS REPOSITORY'S: `GlobalRegistrator` in `beforeAll`/`afterAll`, `cleanup` in |
| 38 | + * `afterEach`, queries off `render()`'s own return, and a `QueryClient` with `retry: false`. Each |
| 39 | + * screen is its real component, drawn inside a router of one route rather than through its own |
| 40 | + * route singleton, so nothing here is left pointing another file's router at a decoy. |
| 41 | + */ |
| 42 | + |
| 43 | +beforeAll(() => GlobalRegistrator.register()); |
| 44 | +afterEach(cleanup); |
| 45 | +afterAll(() => GlobalRegistrator.unregister()); |
| 46 | + |
| 47 | +const originalFetch = global.fetch; |
| 48 | + |
| 49 | +/** Every write a screen sent, as `METHOD path`, so "nothing was saved" is an assertion. */ |
| 50 | +let writes: { request: string; body: unknown }[] = []; |
| 51 | + |
| 52 | +const PROFILE: AgentProfile = { |
| 53 | + id: "expenses", |
| 54 | + name: "Expenses", |
| 55 | + title: "Finance Operations", |
| 56 | + roleDescription: "Review receipts.", |
| 57 | + avatarSeed: "expenses", |
| 58 | + visibility: "private", |
| 59 | + endpoint: null, |
| 60 | + builtIn: true, |
| 61 | + hasAuth: false, |
| 62 | + hasCallbackToken: false, |
| 63 | + hidden: false, |
| 64 | + systemOwned: false, |
| 65 | + canManage: true, |
| 66 | + mine: true, |
| 67 | +}; |
| 68 | + |
| 69 | +const POLICY = { mode: "enforce", deny: [], allow: [] }; |
| 70 | + |
| 71 | +beforeEach(() => { |
| 72 | + writes = []; |
| 73 | + global.fetch = Object.assign( |
| 74 | + async ( |
| 75 | + path: Parameters<typeof fetch>[0], |
| 76 | + init?: Parameters<typeof fetch>[1], |
| 77 | + ) => { |
| 78 | + const method = init?.method ?? "GET"; |
| 79 | + const body = |
| 80 | + typeof init?.body === "string" ? JSON.parse(init.body) : undefined; |
| 81 | + if (method !== "GET") writes.push({ request: `${method} ${path}`, body }); |
| 82 | + const json = (value: unknown) => Response.json(value); |
| 83 | + if (path === "/api/computers/policy") { |
| 84 | + return json({ policy: method === "PUT" ? body : POLICY }); |
| 85 | + } |
| 86 | + if (path === "/api/agents/capabilities") { |
| 87 | + return json({ capabilities: { builtInAvailable: true } }); |
| 88 | + } |
| 89 | + if (path === `/api/agents/${PROFILE.id}`) { |
| 90 | + return json({ agent: method === "PATCH" ? PROFILE : PROFILE }); |
| 91 | + } |
| 92 | + return new Response(null, { status: 404 }); |
| 93 | + }, |
| 94 | + { preconnect: originalFetch.preconnect }, |
| 95 | + ); |
| 96 | +}); |
| 97 | + |
| 98 | +afterEach(() => { |
| 99 | + global.fetch = originalFetch; |
| 100 | +}); |
| 101 | + |
| 102 | +/** A screen drawn inside a router of one route, for the `Link` and `useNavigate` it holds. */ |
| 103 | +function draw(screen: ReactNode, seed?: (client: QueryClient) => void) { |
| 104 | + const queryClient = new QueryClient({ |
| 105 | + defaultOptions: { queries: { retry: false } }, |
| 106 | + }); |
| 107 | + seed?.(queryClient); |
| 108 | + const router = createRouter({ |
| 109 | + history: createMemoryHistory({ initialEntries: ["/"] }), |
| 110 | + routeTree: createRootRoute({ component: () => screen }), |
| 111 | + }); |
| 112 | + return render( |
| 113 | + <QueryClientProvider client={queryClient}> |
| 114 | + <RouterProvider router={router} /> |
| 115 | + </QueryClientProvider>, |
| 116 | + ); |
| 117 | +} |
| 118 | + |
| 119 | +/** Both shapes the confirming Enter arrives in: Chromium's, then WebKit's. */ |
| 120 | +async function confirmComposedCharacter(field: Element) { |
| 121 | + await act(async () => { |
| 122 | + fireEvent.keyDown(field, { key: "Enter", isComposing: true }); |
| 123 | + fireEvent.keyDown(field, { key: "Enter", keyCode: 229 }); |
| 124 | + }); |
| 125 | +} |
| 126 | + |
| 127 | +/** A person replacing what a field holds, one key at a time. */ |
| 128 | +async function type(field: Element, value: string) { |
| 129 | + const user = userEvent.setup({ document: field.ownerDocument }); |
| 130 | + await user.clear(field); |
| 131 | + await user.type(field, value); |
| 132 | +} |
| 133 | + |
| 134 | +/** Long enough for a write the keydown started to have reached `fetch`. */ |
| 135 | +async function settle() { |
| 136 | + await act(async () => { |
| 137 | + await new Promise((resolve) => setTimeout(resolve, 50)); |
| 138 | + }); |
| 139 | +} |
| 140 | + |
| 141 | +test("a coworker's name is not saved by the Enter that confirms a composed character", async () => { |
| 142 | + const view = draw( |
| 143 | + <AgentDialog agentId={PROFILE.id} onClose={() => {}} open />, |
| 144 | + (client) => client.setQueryData(agentKeys.detail(PROFILE.id), PROFILE), |
| 145 | + ); |
| 146 | + |
| 147 | + fireEvent.click(await view.findByRole("button", { name: "Edit name" })); |
| 148 | + const field = view.getByDisplayValue(PROFILE.name); |
| 149 | + await type(field, "経費"); |
| 150 | + |
| 151 | + await confirmComposedCharacter(field); |
| 152 | + await settle(); |
| 153 | + expect(writes).toEqual([]); |
| 154 | + expect(view.getByDisplayValue("経費")).toBeTruthy(); |
| 155 | + |
| 156 | + // An ordinary Enter still saves, once. |
| 157 | + await act(async () => { |
| 158 | + fireEvent.keyDown(field, { key: "Enter", keyCode: 13 }); |
| 159 | + }); |
| 160 | + await settle(); |
| 161 | + expect(writes.map((write) => write.request)).toEqual([ |
| 162 | + `PATCH /api/agents/${PROFILE.id}`, |
| 163 | + ]); |
| 164 | + expect(writes[0]?.body).toMatchObject({ name: "経費" }); |
| 165 | +}); |
| 166 | + |
| 167 | +test("the new-coworker wizard does not move on from the Enter that confirms a composed character", async () => { |
| 168 | + const view = draw( |
| 169 | + <CreateAgentDialog onClose={() => {}} onCreated={() => {}} open />, |
| 170 | + ); |
| 171 | + |
| 172 | + const name = await view.findByLabelText("Name"); |
| 173 | + await type(name, "経費"); |
| 174 | + await type(view.getByLabelText("Title"), "Finance Operations"); |
| 175 | + await type(view.getByLabelText("Role"), "Review receipts."); |
| 176 | + |
| 177 | + await confirmComposedCharacter(name); |
| 178 | + await settle(); |
| 179 | + expect(view.getByText("Step 1 of 3")).toBeTruthy(); |
| 180 | + |
| 181 | + // An ordinary Enter still means Continue. |
| 182 | + await act(async () => { |
| 183 | + fireEvent.keyDown(name, { key: "Enter", keyCode: 13 }); |
| 184 | + }); |
| 185 | + expect(await view.findByText("Step 2 of 3")).toBeTruthy(); |
| 186 | +}); |
| 187 | + |
| 188 | +test("a boundary rule is not saved by the Enter that confirms a composed character", async () => { |
| 189 | + const Boundaries = BoundariesRoute.options.component as ComponentType; |
| 190 | + const view = draw(<Boundaries />, (client) => |
| 191 | + client.setQueryData(computerKeys.policy(), POLICY), |
| 192 | + ); |
| 193 | + |
| 194 | + const field = await view.findByLabelText("A rule, written in CEL"); |
| 195 | + const rule = 'contains(element.name, "送信")'; |
| 196 | + await type(field, rule); |
| 197 | + |
| 198 | + await confirmComposedCharacter(field); |
| 199 | + await settle(); |
| 200 | + expect(writes).toEqual([]); |
| 201 | + expect(view.getByDisplayValue(rule)).toBeTruthy(); |
| 202 | + |
| 203 | + // An ordinary Enter still adds the rule, once. |
| 204 | + await act(async () => { |
| 205 | + fireEvent.keyDown(field, { key: "Enter", keyCode: 13 }); |
| 206 | + }); |
| 207 | + await settle(); |
| 208 | + expect(writes).toEqual([ |
| 209 | + { |
| 210 | + request: "PUT /api/computers/policy", |
| 211 | + body: { ...POLICY, deny: [rule] }, |
| 212 | + }, |
| 213 | + ]); |
| 214 | +}); |
0 commit comments