| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import type { QuestionAnswer, WireAsk } from "../lib/types"; |
| 4 | |
| 5 | const dom = new JSDOM('<div id="root"></div>', { url: "http://localhost/", pretendToBeVisual: true }); |
| 6 | Object.assign(globalThis, { |
| 7 | window: dom.window, document: dom.window.document, Element: dom.window.Element, |
| 8 | HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, localStorage: dom.window.localStorage, |
| 9 | IS_REACT_ACT_ENVIRONMENT: true, |
| 10 | }); |
| 11 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 12 | const { default: React, act } = await import("react"); |
| 13 | const { createRoot } = await import("react-dom/client"); |
| 14 | const { AskCard } = await import("../components/AskCard"); |
| 15 | const { LocaleProvider } = await import("../lib/i18n"); |
| 16 | const root = createRoot(document.getElementById("root")!); |
| 17 | const answers: QuestionAnswer[][] = []; |
| 18 | let onStop: () => void | Promise<void> = () => {}; |
| 19 | const ask: WireAsk = { |
| 20 | id: "1", runtimeEpoch: "runtime-a", turnId: "turn-a", |
| 21 | questions: [{ id: "q1", prompt: "Choose", options: [{ label: "Option A" }, { label: "Option B" }] }], |
| 22 | }; |
| 23 | async function render(next: WireAsk | null, scope = "tab-a") { |
| 24 | await act(async () => root.render(<LocaleProvider>{next && <AskCard ask={next} draftScope={scope} |
| 25 | onAnswer={(_id, value) => { answers.push(value); }} onStop={onStop} />}</LocaleProvider>)); |
| 26 | } |
| 27 | function input() { return document.querySelector<HTMLInputElement>(".ask-shelf__custom")!; } |
| 28 | async function type(text: string) { |
| 29 | await act(async () => input().focus()); // Keyboard focus does not click the custom row. |
| 30 | await act(async () => { |
| 31 | Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input(), text); |
| 32 | input().dispatchEvent(new dom.window.Event("input", { bubbles: true })); |
| 33 | }); |
| 34 | } |
| 35 | |
| 36 | await render(ask); |
| 37 | await type("Keyboard answer"); |
| 38 | await act(async () => input().dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); |
| 39 | assert.deepEqual(answers, [[{ questionId: "q1", selected: ["Keyboard answer"] }]]); |
| 40 | console.log(" PASS keyboard focus and Enter submit the custom answer exactly once"); |
| 41 | |
| 42 | await render(null); |
| 43 | await render(ask); |
| 44 | assert.equal(input().value, "", "successful submission clears its draft"); |
| 45 | await type("Keep this draft"); |
| 46 | await render(null); |
| 47 | await render({ ...ask }); |
| 48 | assert.equal(input().value, "Keep this draft", "same prompt survives unmount and replay"); |
| 49 | |
| 50 | await render({ ...ask, runtimeEpoch: "runtime-b" }); |
| 51 | assert.equal(input().value, "", "runtime replacement resets a mounted card even with the same prompt and turn IDs"); |
| 52 | await type("Runtime B draft"); |
| 53 | await render({ ...ask, runtimeEpoch: "runtime-b", turnId: "turn-b" }); |
| 54 | assert.equal(input().value, "", "new turn cannot inherit the previous turn's draft"); |
| 55 | await render(null); |
| 56 | await render({ ...ask, runtimeEpoch: "runtime-c" }); |
| 57 | assert.equal(input().value, "", "a replacement runtime cannot inherit an unmounted card's draft"); |
| 58 | await render(ask, "tab-b"); |
| 59 | assert.equal(input().value, "", "tabs have separate drafts"); |
| 60 | await render(ask); |
| 61 | assert.equal(input().value, "Keep this draft", "switching back restores only the original prompt's draft"); |
| 62 | console.log(" PASS drafts follow tab, runtime, turn, and prompt identity across replay and replacement"); |
| 63 | |
| 64 | let stopCalls = 0; |
| 65 | onStop = async () => { stopCalls++; throw new Error("transport unavailable"); }; |
| 66 | await render(ask); |
| 67 | function stopButton() { return document.querySelector<HTMLButtonElement>(".prompt-shelf__header-button:last-child")!; } |
| 68 | await act(async () => stopButton().click()); |
| 69 | assert.equal(stopCalls, 1); |
| 70 | assert.equal(stopButton().disabled, false, "failed cancellation permits retry"); |
| 71 | assert.ok(document.querySelector('[role="alert"]'), "failed cancellation is visible"); |
| 72 | await render(null); |
| 73 | await render(ask); |
| 74 | assert.equal(input().value, "Keep this draft", "failed cancellation preserves the draft across navigation"); |
| 75 | onStop = async () => { stopCalls++; }; |
| 76 | await render(ask); |
| 77 | await act(async () => stopButton().click()); |
| 78 | assert.equal(stopCalls, 2, "retry reaches the transport"); |
| 79 | await render(null); |
| 80 | await render(ask); |
| 81 | assert.equal(input().value, "", "accepted cancellation clears its draft"); |
| 82 | console.log(" PASS failed Stop preserves the draft and permits a successful retry"); |
| 83 | await act(async () => root.unmount()); |
| 84 | dom.window.close(); |
| 85 |