| 1 | // Run: tsx src/__tests__/mcp-interaction.test.tsx |
| 2 | // MCP elicitation: wire event → reducer state, StructuredForm schema |
| 3 | // normalization/coercion, MCPInteractionCard rendering and answer wiring. |
| 4 | |
| 5 | import { JSDOM } from "jsdom"; |
| 6 | import { readFileSync } from "node:fs"; |
| 7 | import { registerHooks } from "node:module"; |
| 8 | import React from "react"; |
| 9 | import { act } from "react"; |
| 10 | import { createRoot } from "react-dom/client"; |
| 11 | |
| 12 | registerHooks({ |
| 13 | resolve(specifier, context, nextResolve) { |
| 14 | if (specifier.endsWith(".css") || specifier.endsWith(".svg")) { |
| 15 | return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url }); |
| 16 | } |
| 17 | return nextResolve(specifier, context); |
| 18 | }, |
| 19 | }); |
| 20 | |
| 21 | import { LocaleProvider } from "../lib/i18n"; |
| 22 | import type { WireEvent } from "../lib/types"; |
| 23 | import { initialState, reducer } from "../lib/useController"; |
| 24 | import { app, onEvent } from "../lib/bridge"; |
| 25 | import { DECISION_SURFACE_MOCK_TRIGGERS } from "../lib/decisionSurfaceMock"; |
| 26 | import { |
| 27 | coerceStructuredValues, |
| 28 | initialStructuredValues, |
| 29 | missingStructuredRequired, |
| 30 | normalizeStructuredSchema, |
| 31 | parseStructuredSchema, |
| 32 | } from "../components/StructuredForm"; |
| 33 | |
| 34 | const { MCPInteractionCard } = await import("../components/MCPInteractionCard"); |
| 35 | |
| 36 | let passed = 0; |
| 37 | let failed = 0; |
| 38 | |
| 39 | function ok(value: boolean, label: string) { |
| 40 | if (value) { |
| 41 | process.stdout.write(` PASS ${label}\n`); |
| 42 | passed += 1; |
| 43 | } else { |
| 44 | process.stdout.write(` FAIL ${label}\n`); |
| 45 | failed += 1; |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | type ControllerState = Parameters<typeof reducer>[0]; |
| 50 | |
| 51 | const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8"); |
| 52 | const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8"); |
| 53 | ok( |
| 54 | /\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource), |
| 55 | "App decision surface recomputes when an MCP interaction arrives", |
| 56 | ); |
| 57 | |
| 58 | const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>"); |
| 59 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 60 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 61 | globalThis.document = dom.window.document; |
| 62 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 63 | globalThis.Node = dom.window.Node; |
| 64 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 65 | globalThis.Event = dom.window.Event; |
| 66 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 67 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 68 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 69 | (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void; detachEvent: () => void }).attachEvent = () => {}; |
| 70 | (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void; detachEvent: () => void }).detachEvent = () => {}; |
| 71 | |
| 72 | // ── Schema normalization ───────────────────────────────────────────────────── |
| 73 | |
| 74 | { |
| 75 | const fields = normalizeStructuredSchema({ |
| 76 | type: "object", |
| 77 | required: ["code", "count"], |
| 78 | properties: { |
| 79 | code: { type: "string", title: "Device code", minLength: 4, maxLength: 8 }, |
| 80 | count: { type: "integer", minimum: 1, maximum: 5, default: 2 }, |
| 81 | ok: { type: "boolean" }, |
| 82 | flavor: { enum: ["vanilla", "mint"], enumNames: ["Vanilla bean", "Fresh mint"] }, |
| 83 | region: { type: "string", oneOf: [{ const: "us", title: "United States" }, { const: "sg", title: "Singapore" }] }, |
| 84 | scopes: { |
| 85 | type: "array", |
| 86 | items: { anyOf: [{ const: "read", title: "Read data" }, { const: "write", title: "Write data" }] }, |
| 87 | minItems: 1, |
| 88 | maxItems: 2, |
| 89 | default: ["read"], |
| 90 | }, |
| 91 | email: { type: "string", format: "email" }, |
| 92 | }, |
| 93 | }); |
| 94 | ok(fields.length === 7, "old and new flat schema properties become fields"); |
| 95 | const code = fields.find((f) => f.key === "code"); |
| 96 | ok(code?.kind === "string" && code.required && code.minLength === 4, "string field carries required + bounds"); |
| 97 | const count = fields.find((f) => f.key === "count"); |
| 98 | ok(count?.kind === "integer" && count.defaultValue === 2 && count.maximum === 5, "integer field carries default + max"); |
| 99 | ok(fields.find((f) => f.key === "ok")?.kind === "boolean", "boolean field detected"); |
| 100 | ok(fields.find((f) => f.key === "flavor")?.options?.[0].label === "Vanilla bean", "legacy enumNames labels stay compatible"); |
| 101 | ok(fields.find((f) => f.key === "region")?.options?.[1].label === "Singapore", "2025-11-25 titled single-select is normalized"); |
| 102 | const scopes = fields.find((f) => f.key === "scopes"); |
| 103 | ok(scopes?.kind === "multi-enum" && Array.isArray(scopes.defaultValue) && scopes.defaultValue[0] === "read", "2025-11-25 titled multi-select carries defaults"); |
| 104 | ok(fields.find((f) => f.key === "email")?.format === "email", "string formats reach the renderer"); |
| 105 | const nested = parseStructuredSchema({ type: "object", properties: { account: { type: "object" } } }); |
| 106 | ok(nested.unsupported && nested.fields[0]?.kind === "unsupported", "nested schemas fail closed instead of becoming text"); |
| 107 | ok(normalizeStructuredSchema(null).length === 0, "null schema yields no fields"); |
| 108 | ok(normalizeStructuredSchema({}).length === 0, "schema without properties yields no fields"); |
| 109 | } |
| 110 | |
| 111 | // ── Value coercion ─────────────────────────────────────────────────────────── |
| 112 | |
| 113 | { |
| 114 | const fields = normalizeStructuredSchema({ |
| 115 | type: "object", |
| 116 | required: ["name"], |
| 117 | properties: { |
| 118 | name: { type: "string", minLength: 2 }, |
| 119 | age: { type: "integer" }, |
| 120 | pi: { type: "number" }, |
| 121 | ok: { type: "boolean", default: true }, |
| 122 | roles: { type: "array", items: { type: "string", enum: ["reader", "writer"] }, minItems: 1 }, |
| 123 | }, |
| 124 | }); |
| 125 | const defaults = initialStructuredValues(fields); |
| 126 | ok(defaults.ok === true, "boolean default stays explicit"); |
| 127 | ok(missingStructuredRequired(fields, defaults)[0] === "name", "missing required reported by label"); |
| 128 | const { content, invalid } = coerceStructuredValues(fields, { |
| 129 | ...defaults, |
| 130 | name: "jo", |
| 131 | age: "41", |
| 132 | pi: "3.5", |
| 133 | roles: ["reader"], |
| 134 | }); |
| 135 | ok(invalid.length === 0, "valid values coerce without errors"); |
| 136 | ok(content.name === "jo" && content.age === 41 && content.pi === 3.5 && content.ok === true, "scalar values coerce to JSON types"); |
| 137 | ok(Array.isArray(content.roles) && content.roles[0] === "reader", "multi-select answers remain string arrays"); |
| 138 | const bad = coerceStructuredValues(fields, { ...defaults, name: "j", age: "4.2", roles: [] }); |
| 139 | ok(bad.invalid.includes("name") && bad.invalid.includes("age") && bad.invalid.includes("roles"), "bounds, fractional integers, and selection limits are rejected"); |
| 140 | } |
| 141 | |
| 142 | // ── Reducer ────────────────────────────────────────────────────────────────── |
| 143 | |
| 144 | { |
| 145 | const event: WireEvent = { |
| 146 | kind: "mcp_interaction", |
| 147 | turnId: "t1", |
| 148 | itemId: "42", |
| 149 | mcpInteraction: { |
| 150 | id: "42", |
| 151 | server: "github", |
| 152 | mode: "form", |
| 153 | message: "confirm", |
| 154 | requestedSchema: { type: "object", properties: { code: { type: "string" } }, required: ["code"] }, |
| 155 | }, |
| 156 | } as unknown as WireEvent; |
| 157 | const next = reducer({ ...initialState }, { type: "event", e: event } as never); |
| 158 | ok((next as ControllerState).mcpInteraction?.id === "42", "mcp_interaction event sets state"); |
| 159 | ok((next as ControllerState).pendingPrompt === true, "mcp_interaction waits for the user"); |
| 160 | |
| 161 | const answered = reducer(next, { |
| 162 | type: "event", |
| 163 | e: { kind: "prompt_answered", turnId: "t1", itemId: "42" } as unknown as WireEvent, |
| 164 | } as never); |
| 165 | ok((answered as ControllerState).mcpInteraction === undefined, "prompt_answered clears the card"); |
| 166 | } |
| 167 | |
| 168 | // ── Browser mock lifecycle ────────────────────────────────────────────────── |
| 169 | |
| 170 | { |
| 171 | const events: WireEvent[] = []; |
| 172 | const unsubscribe = onEvent((event) => events.push(event)); |
| 173 | await app.SubmitToTabWithID("mock-mcp-tab", DECISION_SURFACE_MOCK_TRIGGERS.mcp_interaction, "mock-mcp-submission"); |
| 174 | const interaction = events.find((event) => event.kind === "mcp_interaction"); |
| 175 | ok(interaction?.tabId === "mock-mcp-tab", "browser mock routes the MCP interaction to its origin tab"); |
| 176 | ok(interaction?.mcpInteraction?.server === "github", "browser mock identifies the requesting MCP server"); |
| 177 | const schema = interaction?.mcpInteraction?.requestedSchema as { properties?: Record<string, unknown> } | undefined; |
| 178 | ok(Object.keys(schema?.properties ?? {}).join(",") === "code,environment,permissions,remember", "browser mock exposes old and new structured controls"); |
| 179 | await app.AnswerMCPInteractionForTab("mock-mcp-tab", interaction?.mcpInteraction?.id ?? "", "accept", { |
| 180 | code: "123-456", |
| 181 | environment: "staging", |
| 182 | permissions: ["repo:read"], |
| 183 | remember: false, |
| 184 | }); |
| 185 | ok(events.some((event) => event.kind === "prompt_answered" && event.tabId === "mock-mcp-tab"), "browser mock acknowledges the MCP answer on the origin tab"); |
| 186 | ok(events.some((event) => event.kind === "turn_done" && event.tabId === "mock-mcp-tab"), "browser mock completes the preview turn after an answer"); |
| 187 | await app.SubmitToTabWithID("mock-mcp-tab", DECISION_SURFACE_MOCK_TRIGGERS.mcp_interaction, "mock-mcp-submission-2"); |
| 188 | const interactions = events.filter((event) => event.kind === "mcp_interaction"); |
| 189 | ok(interactions.length === 2, "browser mock can be triggered repeatedly"); |
| 190 | ok(interactions[0].mcpInteraction?.id !== interactions[1].mcpInteraction?.id, "repeated browser mocks use distinct prompt ids"); |
| 191 | await app.AnswerMCPInteractionForTab("mock-mcp-tab", interactions[1].mcpInteraction?.id ?? "", "cancel", null); |
| 192 | unsubscribe(); |
| 193 | } |
| 194 | |
| 195 | // ── Card rendering + submit wiring ─────────────────────────────────────────── |
| 196 | |
| 197 | { |
| 198 | const answers: { id: string; action: string; content?: Record<string, unknown> }[] = []; |
| 199 | const root = createRoot(document.getElementById("root")!); |
| 200 | await act(async () => { |
| 201 | root.render( |
| 202 | <LocaleProvider> |
| 203 | <MCPInteractionCard |
| 204 | instanceKey="test:7" |
| 205 | interaction={{ |
| 206 | id: "7", |
| 207 | server: "github", |
| 208 | mode: "form", |
| 209 | message: "Enter the device code", |
| 210 | requestedSchema: { |
| 211 | type: "object", |
| 212 | required: ["code"], |
| 213 | properties: { code: { type: "string", title: "Device code", default: "123-456" } }, |
| 214 | }, |
| 215 | }} |
| 216 | busy={false} |
| 217 | onAnswer={(id, action, content) => answers.push({ id, action, content })} |
| 218 | /> |
| 219 | </LocaleProvider>, |
| 220 | ); |
| 221 | }); |
| 222 | const text = document.body.textContent ?? ""; |
| 223 | ok(text.includes("github") && text.includes("Device code"), "card shows server and field label"); |
| 224 | const dialog = document.querySelector("[role='dialog']"); |
| 225 | const descriptionID = dialog?.getAttribute("aria-describedby") ?? ""; |
| 226 | ok(Boolean(descriptionID) && document.getElementById(descriptionID)?.textContent === "Enter the device code", "server message describes the dialog without crowding its title"); |
| 227 | |
| 228 | const input = document.querySelector(".structured-form-control") as HTMLInputElement | null; |
| 229 | ok(input !== null, "form field rendered as input"); |
| 230 | // jsdom cannot reliably drive React controlled-input keystrokes; the default |
| 231 | // value prefills the field so the accept path is exercised end-to-end, and |
| 232 | // typed-value coercion is covered above. |
| 233 | ok(input?.value === "123-456", "required field prefilled from schema default"); |
| 234 | ok(document.activeElement === input, "new form focuses its first field"); |
| 235 | ok(document.querySelectorAll("[role='option']").length === 0, "immediate MCP actions are not exposed as listbox options"); |
| 236 | ok(document.querySelector(".prompt-shelf-bar-actions")?.getAttribute("role") === "group", "MCP actions expose button-group semantics"); |
| 237 | const submit = Array.from(document.querySelectorAll("button, [role='button']")).find((b) => |
| 238 | (b.textContent ?? "").toLowerCase().includes("submit"), |
| 239 | ); |
| 240 | ok(submit !== undefined, "submit action rendered"); |
| 241 | if (submit) { |
| 242 | await act(async () => { |
| 243 | submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 244 | }); |
| 245 | } |
| 246 | |
| 247 | ok( |
| 248 | answers.length === 1 && answers[0].action === "accept" && (answers[0].content as Record<string, unknown>)?.code === "123-456", |
| 249 | "submit sends accept with the typed form values", |
| 250 | ); |
| 251 | await act(async () => { |
| 252 | root.unmount(); |
| 253 | }); |
| 254 | } |
| 255 | |
| 256 | // ── Unsupported schema fallback ───────────────────────────────────────────── |
| 257 | |
| 258 | { |
| 259 | const root = createRoot(document.getElementById("root")!); |
| 260 | await act(async () => { |
| 261 | root.render( |
| 262 | <LocaleProvider> |
| 263 | <MCPInteractionCard |
| 264 | instanceKey="test:unsupported" |
| 265 | interaction={{ |
| 266 | id: "unsupported", |
| 267 | server: "future-server", |
| 268 | mode: "form", |
| 269 | message: "Provide account details", |
| 270 | requestedSchema: { type: "object", properties: { account: { type: "object" } } }, |
| 271 | }} |
| 272 | busy={false} |
| 273 | onAnswer={() => {}} |
| 274 | /> |
| 275 | </LocaleProvider>, |
| 276 | ); |
| 277 | }); |
| 278 | const submit = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Submit"); |
| 279 | const cancel = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Cancel"); |
| 280 | ok(submit?.disabled === true, "unknown nested schema cannot be submitted as lossy text"); |
| 281 | ok(cancel?.disabled === false && Boolean(document.querySelector("[role='alert']")), "unsupported form keeps a clear safe exit and explanation"); |
| 282 | await act(async () => root.unmount()); |
| 283 | } |
| 284 | |
| 285 | // ── Required-field validation ─────────────────────────────────────────────── |
| 286 | |
| 287 | { |
| 288 | const answers: string[] = []; |
| 289 | const root = createRoot(document.getElementById("root")!); |
| 290 | await act(async () => { |
| 291 | root.render( |
| 292 | <LocaleProvider> |
| 293 | <MCPInteractionCard |
| 294 | instanceKey="test:8" |
| 295 | interaction={{ |
| 296 | id: "8", |
| 297 | server: "calendar-with-a-deliberately-long-server-name", |
| 298 | mode: "form", |
| 299 | message: "Please provide the account identifier used for this calendar connection.", |
| 300 | requestedSchema: { |
| 301 | type: "object", |
| 302 | required: ["account"], |
| 303 | properties: { account: { type: "string", title: "Account email", format: "email" } }, |
| 304 | }, |
| 305 | }} |
| 306 | busy={false} |
| 307 | onAnswer={(_id, action) => answers.push(action)} |
| 308 | /> |
| 309 | </LocaleProvider>, |
| 310 | ); |
| 311 | }); |
| 312 | const submit = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Submit"); |
| 313 | await act(async () => submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); |
| 314 | const input = document.querySelector(".structured-form-control") as HTMLInputElement | null; |
| 315 | ok(answers.length === 0, "invalid form does not answer the blocked MCP request"); |
| 316 | ok(input?.required === true && input.getAttribute("aria-invalid") === "true", "required field exposes native and ARIA validation state"); |
| 317 | ok(Boolean(input?.getAttribute("aria-describedby")), "field error is associated with its control"); |
| 318 | ok(document.activeElement === input, "failed submit returns focus to the first invalid field"); |
| 319 | await act(async () => root.unmount()); |
| 320 | } |
| 321 | |
| 322 | // ── URL card ───────────────────────────────────────────────────────────────── |
| 323 | |
| 324 | { |
| 325 | const answers: { id: string; action: string; content?: Record<string, unknown> }[] = []; |
| 326 | const opened: string[] = []; |
| 327 | const root = createRoot(document.getElementById("root")!); |
| 328 | await act(async () => { |
| 329 | root.render( |
| 330 | <LocaleProvider> |
| 331 | <MCPInteractionCard |
| 332 | instanceKey="test:9" |
| 333 | interaction={{ |
| 334 | id: "9", |
| 335 | server: "linear", |
| 336 | mode: "url", |
| 337 | message: "Finish sign-in", |
| 338 | url: "https://auth.example.com/cb?state=xyz", |
| 339 | }} |
| 340 | busy={false} |
| 341 | onAnswer={(id, action, content) => answers.push({ id, action, content })} |
| 342 | onOpenLink={(url) => opened.push(url)} |
| 343 | /> |
| 344 | </LocaleProvider>, |
| 345 | ); |
| 346 | }); |
| 347 | const text = document.body.textContent ?? ""; |
| 348 | const urlServer = document.querySelector(".mcp-interaction-url > span:first-child")?.textContent; |
| 349 | const urlHost = document.querySelector(".mcp-interaction-url > strong")?.textContent; |
| 350 | ok(urlServer === "linear" && urlHost === "auth.example.com", "url card shows the exact server and target host"); |
| 351 | ok(!text.includes("?state=xyz"), "url card hides query params from the summary line"); |
| 352 | const urlButtons = Array.from(document.querySelectorAll(".prompt-shelf-bar-actions button")); |
| 353 | const open = urlButtons.find((button) => (button.textContent ?? "").startsWith("Open ")); |
| 354 | ok(urlButtons.some((button) => button.textContent === "Cancel"), "url mode keeps cancel distinct from decline"); |
| 355 | if (open) { |
| 356 | await act(async () => { |
| 357 | open.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 358 | }); |
| 359 | } |
| 360 | ok(opened.length === 1 && opened[0] === "https://auth.example.com/cb?state=xyz", "open link passes the exact URL once"); |
| 361 | const accept = Array.from(document.querySelectorAll("button, [role='button']")).find((b) => |
| 362 | (b.textContent ?? "").toLowerCase() === "accept", |
| 363 | ); |
| 364 | if (accept) { |
| 365 | await act(async () => { |
| 366 | accept.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 367 | }); |
| 368 | } |
| 369 | ok(answers.length === 1 && answers[0].action === "accept" && answers[0].content === undefined, "accept without form content"); |
| 370 | await act(async () => { |
| 371 | root.unmount(); |
| 372 | }); |
| 373 | } |
| 374 | |
| 375 | // ── Request-instance replacement ───────────────────────────────────────────── |
| 376 | |
| 377 | { |
| 378 | const root = createRoot(document.getElementById("root")!); |
| 379 | const interaction = { |
| 380 | id: "reused-form-id", |
| 381 | server: "identity-server", |
| 382 | mode: "form" as const, |
| 383 | message: "Enter an account", |
| 384 | requestedSchema: { |
| 385 | type: "object", |
| 386 | required: ["account"], |
| 387 | properties: { |
| 388 | account: { type: "string", title: "Account", format: "email" }, |
| 389 | remember: { type: "boolean", title: "Remember this account" }, |
| 390 | }, |
| 391 | }, |
| 392 | }; |
| 393 | const render = (instanceKey: string) => ( |
| 394 | <LocaleProvider> |
| 395 | <MCPInteractionCard instanceKey={instanceKey} interaction={interaction} busy={false} onAnswer={() => {}} /> |
| 396 | </LocaleProvider> |
| 397 | ); |
| 398 | await act(async () => root.render(render("request:first"))); |
| 399 | const firstInput = document.querySelector(".structured-form-control") as HTMLInputElement; |
| 400 | const firstCheckbox = document.querySelector(".structured-form-checkbox") as HTMLInputElement; |
| 401 | await act(async () => firstCheckbox.click()); |
| 402 | const submit = Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Submit"); |
| 403 | await act(async () => submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); |
| 404 | ok(firstCheckbox.checked && firstInput.getAttribute("aria-invalid") === "true", "first MCP request owns its field value and validation error"); |
| 405 | |
| 406 | await act(async () => root.render(render("request:replacement"))); |
| 407 | const replacementInput = document.querySelector(".structured-form-control") as HTMLInputElement; |
| 408 | const replacementCheckbox = document.querySelector(".structured-form-checkbox") as HTMLInputElement; |
| 409 | ok(!replacementCheckbox.checked && replacementInput.getAttribute("aria-invalid") !== "true", "same MCP id/schema resets values and errors for a new request instance"); |
| 410 | await act(async () => root.unmount()); |
| 411 | } |
| 412 | |
| 413 | { |
| 414 | const root = createRoot(document.getElementById("root")!); |
| 415 | const interaction = { |
| 416 | id: "reused-url-id", |
| 417 | server: "identity-server", |
| 418 | mode: "url" as const, |
| 419 | message: "Authorize access", |
| 420 | url: "https://auth.example.com/reused", |
| 421 | }; |
| 422 | const render = (instanceKey: string) => ( |
| 423 | <LocaleProvider> |
| 424 | <MCPInteractionCard instanceKey={instanceKey} interaction={interaction} busy={false} onAnswer={() => {}} onOpenLink={() => {}} /> |
| 425 | </LocaleProvider> |
| 426 | ); |
| 427 | await act(async () => root.render(render("url:first"))); |
| 428 | const open = Array.from(document.querySelectorAll("button")).find((button) => (button.textContent ?? "").startsWith("Open ")); |
| 429 | await act(async () => open?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); |
| 430 | ok(document.querySelector(".prompt-shelf-bar-hint")?.textContent === "Finish in your browser, then accept.", "first MCP URL request records that its link was opened"); |
| 431 | |
| 432 | await act(async () => root.render(render("url:replacement"))); |
| 433 | ok(document.querySelector(".prompt-shelf-bar-hint")?.textContent === "Open the link, finish in your browser, then accept.", "same MCP id resets opened-link state for a new request instance"); |
| 434 | await act(async () => root.unmount()); |
| 435 | } |
| 436 | |
| 437 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 438 | if (failed > 0) process.exit(1); |
| 439 |