| 1 | import assert from "node:assert/strict"; |
| 2 | import { mock } from "node:test"; |
| 3 | import { register } from "node:module"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import { installRemoteSurfaceDom } from "./helpers/remoteSurfaceDom"; |
| 7 | import { LocaleProvider } from "../lib/i18n"; |
| 8 | import type { LoadingFixtureInput } from "../test-support/sessionLoadingFixture"; |
| 9 | |
| 10 | register(new URL("../../scripts/svg-loader.mjs", import.meta.url)); |
| 11 | register(new URL("../../scripts/css-loader.mjs", import.meta.url)); |
| 12 | const dom = installRemoteSurfaceDom(); |
| 13 | globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; |
| 14 | const { SessionLoadingFixture } = await import("../test-support/sessionLoadingFixture"); |
| 15 | const root = createRoot(document.getElementById("root")!); |
| 16 | const paint = (input: LoadingFixtureInput) => act(async () => root.render( |
| 17 | <LocaleProvider><SessionLoadingFixture {...input} /></LocaleProvider>, |
| 18 | )); |
| 19 | const tick = (ms: number) => act(async () => mock.timers.tick(ms)); |
| 20 | const indicator = () => document.querySelector(".session-loading-indicator"); |
| 21 | const noRecovery = () => assert.equal(document.querySelector(".session-recovery, .session-recovery-placeholder"), null, |
| 22 | "normal loading never presents recovery or retry language"); |
| 23 | mock.timers.enable({ apis: ["setTimeout"] }); |
| 24 | try { |
| 25 | for (const surface of ["local", "remote", "transcript"] as const) { |
| 26 | const input: LoadingFixtureInput = { surface, identity: `${surface}-A`, phase: "loading" }; |
| 27 | await paint(input); |
| 28 | await tick(249); |
| 29 | assert.equal(indicator(), null, `${surface}: quick loads have no visible loading feedback`); |
| 30 | noRecovery(); |
| 31 | await paint({ ...input, phase: "ready" }); |
| 32 | assert.match(document.body.textContent!, new RegExp(`Content ${input.identity}`), "ready content appears without a minimum wait"); |
| 33 | await tick(2500); |
| 34 | assert.equal(indicator(), null, "completed timers cannot resurrect the indicator"); |
| 35 | |
| 36 | await paint(input); |
| 37 | await tick(250); |
| 38 | assert.equal(document.querySelectorAll(".session-loading-indicator").length, 1, "one loading owner across surfaces"); |
| 39 | const shortLabel = indicator()!.textContent; |
| 40 | await tick(1750); |
| 41 | assert.notEqual(indicator()!.textContent, shortLabel, "slow load adds a specific explanation after two seconds"); |
| 42 | noRecovery(); |
| 43 | await paint({ ...input, identity: `${surface}-B` }); |
| 44 | assert.equal(indicator(), null, "a new session never inherits the old loading stage"); |
| 45 | await tick(200); |
| 46 | await paint(input); |
| 47 | await tick(50); |
| 48 | assert.equal(indicator(), null, "rapid A/B/A switching cancels B's deadline"); |
| 49 | await tick(200); |
| 50 | assert.ok(indicator(), "the latest A owns its own deadline"); |
| 51 | await paint({ ...input, phase: "ready" }); |
| 52 | assert.equal(indicator(), null, "ready content removes an already visible indicator immediately"); |
| 53 | |
| 54 | await paint({ ...input, cached: true }); |
| 55 | const cachedNode = document.querySelector(".chat-node"); |
| 56 | assert.ok(cachedNode, "cached target content is mounted while history refreshes"); |
| 57 | await tick(2000); |
| 58 | assert.equal(document.querySelector(".chat-node"), cachedNode, "loading stages do not replace cached content"); |
| 59 | assert.equal(document.querySelectorAll(".session-loading-indicator").length, 1); |
| 60 | noRecovery(); |
| 61 | await paint({ ...input, phase: "ready" }); |
| 62 | assert.equal(document.querySelector(".chat-node"), cachedNode, "completion preserves the cached node"); |
| 63 | await paint(input); |
| 64 | await tick(2000); |
| 65 | await paint({ ...input, generation: 2 }); |
| 66 | assert.equal(indicator(), null, "same-tab session replacement resets feedback before paint"); |
| 67 | await tick(249); |
| 68 | assert.equal(indicator(), null, "replacement generation owns a fresh deadline"); |
| 69 | await paint({ ...input, generation: 2, phase: "ready" }); |
| 70 | } |
| 71 | for (const surface of ["local", "remote"] as const) { |
| 72 | let retries = 0; |
| 73 | const input: LoadingFixtureInput = { surface, identity: `${surface}-error`, phase: "loading", onRetry: async () => { retries++; } }; |
| 74 | await paint(input); |
| 75 | await tick(100); |
| 76 | await paint({ ...input, phase: "error" }); |
| 77 | assert.ok(document.querySelector('[role="alert"]'), "real errors are immediate, even inside the loading grace period"); |
| 78 | assert.equal(indicator(), null); |
| 79 | await act(async () => document.querySelector<HTMLButtonElement>(".session-recovery .btn--primary")!.click()); |
| 80 | assert.equal(retries, 1, "error offers the owning retry action"); |
| 81 | await paint(input); |
| 82 | noRecovery(); |
| 83 | await tick(249); |
| 84 | assert.equal(indicator(), null, "retry gets a fresh quiet period"); |
| 85 | await paint({ ...input, phase: "ready" }); |
| 86 | await tick(2500); |
| 87 | assert.equal(indicator(), null); |
| 88 | } |
| 89 | await paint({ surface: "local", identity: "readable-runtime", phase: "loading", cached: true, source: "runtime" }); |
| 90 | await tick(2000); |
| 91 | assert.equal(indicator(), null, "runtime activation does not obscure already readable history"); |
| 92 | assert.match(document.body.textContent!, /Content readable-runtime/); |
| 93 | await paint({ surface: "remote", identity: "connecting", phase: "loading", source: "connection" }); |
| 94 | await tick(2000); |
| 95 | assert.ok(indicator()); |
| 96 | noRecovery(); |
| 97 | await act(async () => root.unmount()); |
| 98 | await tick(3000); |
| 99 | console.log("PASS session loading: quiet fast loads, staged slow loads, cache continuity, retry, and identity-fenced timers on local/remote/standalone surfaces"); |
| 100 | } finally { |
| 101 | mock.timers.reset(); |
| 102 | dom.window.close(); |
| 103 | } |
| 104 |