| 1 | // Run: tsx src/__tests__/composer-run-strip.test.tsx |
| 2 | // |
| 3 | // The run state lives inside the composer card (no floating pill, no layout |
| 4 | // jump), stop has a fixed home next to send, and a pending approval/ask shifts |
| 5 | // the strip into a waiting state instead of a ticking "working" spinner. |
| 6 | |
| 7 | import { readFileSync } from "node:fs"; |
| 8 | import { dirname, resolve } from "node:path"; |
| 9 | import { fileURLToPath } from "node:url"; |
| 10 | import { JSDOM } from "jsdom"; |
| 11 | import React from "react"; |
| 12 | import { act } from "react"; |
| 13 | import { createRoot } from "react-dom/client"; |
| 14 | import { Composer } from "../components/Composer"; |
| 15 | import { LocaleProvider } from "../lib/i18n"; |
| 16 | import { ToastProvider } from "../lib/toast"; |
| 17 | import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types"; |
| 18 | |
| 19 | let passed = 0; |
| 20 | let failed = 0; |
| 21 | |
| 22 | function ok(value: boolean, label: string) { |
| 23 | if (value) { |
| 24 | process.stdout.write(` PASS ${label}\n`); |
| 25 | passed += 1; |
| 26 | } else { |
| 27 | process.stdout.write(` FAIL ${label}\n`); |
| 28 | failed += 1; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | function eq(actual: unknown, expected: unknown, label: string) { |
| 33 | if (actual === expected) ok(true, label); |
| 34 | else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); |
| 35 | } |
| 36 | |
| 37 | function flushTimers(): Promise<void> { |
| 38 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 39 | } |
| 40 | |
| 41 | class TestResizeObserver { |
| 42 | observe() {} |
| 43 | unobserve() {} |
| 44 | disconnect() {} |
| 45 | } |
| 46 | |
| 47 | function installDom() { |
| 48 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 49 | pretendToBeVisual: true, |
| 50 | url: "http://localhost/", |
| 51 | }); |
| 52 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 53 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 54 | globalThis.document = dom.window.document; |
| 55 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 56 | globalThis.Node = dom.window.Node; |
| 57 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 58 | globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement; |
| 59 | globalThis.Event = dom.window.Event; |
| 60 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 61 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 62 | globalThis.InputEvent = dom.window.InputEvent; |
| 63 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 64 | globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent; |
| 65 | globalThis.MutationObserver = dom.window.MutationObserver; |
| 66 | globalThis.File = dom.window.File; |
| 67 | globalThis.FileReader = dom.window.FileReader; |
| 68 | globalThis.localStorage = dom.window.localStorage; |
| 69 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 70 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 71 | globalThis.ResizeObserver = TestResizeObserver; |
| 72 | Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); |
| 73 | Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); |
| 74 | Object.defineProperty(window, "matchMedia", { |
| 75 | configurable: true, |
| 76 | value: () => ({ |
| 77 | matches: true, |
| 78 | media: "(prefers-reduced-motion: reduce)", |
| 79 | onchange: null, |
| 80 | addEventListener() {}, |
| 81 | removeEventListener() {}, |
| 82 | addListener() {}, |
| 83 | removeListener() {}, |
| 84 | dispatchEvent: () => false, |
| 85 | }), |
| 86 | }); |
| 87 | return dom; |
| 88 | } |
| 89 | |
| 90 | async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) { |
| 91 | const rootEl = document.getElementById("root"); |
| 92 | if (!rootEl) throw new Error("missing root"); |
| 93 | const root = createRoot(rootEl); |
| 94 | const calls = { cancel: 0, tokenModes: [] as TokenMode[], approvalModes: [] as ToolApprovalMode[] }; |
| 95 | let currentProps: Parameters<typeof Composer>[0] = { |
| 96 | running: false, |
| 97 | collaborationMode: "normal" as CollaborationMode, |
| 98 | toolApprovalMode: "ask" as ToolApprovalMode, |
| 99 | tokenMode: "full" as TokenMode, |
| 100 | goal: "", |
| 101 | cwd: "/repo", |
| 102 | modelLabel: "DeepSeek-R1", |
| 103 | onSend: () => {}, |
| 104 | onCancel: () => { |
| 105 | calls.cancel += 1; |
| 106 | return undefined; |
| 107 | }, |
| 108 | onCycleMode: () => {}, |
| 109 | onSetMode: () => {}, |
| 110 | onSetCollaborationMode: () => {}, |
| 111 | onSetToolApprovalMode: (mode) => { |
| 112 | calls.approvalModes.push(mode); |
| 113 | }, |
| 114 | onToggleYoloApprovalMode: () => {}, |
| 115 | onClearGoal: () => {}, |
| 116 | onSwitchModel: () => {}, |
| 117 | onSetEffort: () => {}, |
| 118 | onSetTokenMode: (mode) => { |
| 119 | calls.tokenModes.push(mode); |
| 120 | }, |
| 121 | ready: true, |
| 122 | ...props, |
| 123 | }; |
| 124 | const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => { |
| 125 | currentProps = { ...currentProps, ...nextProps }; |
| 126 | await act(async () => { |
| 127 | root.render( |
| 128 | <LocaleProvider> |
| 129 | <ToastProvider> |
| 130 | <Composer {...currentProps} /> |
| 131 | </ToastProvider> |
| 132 | </LocaleProvider>, |
| 133 | ); |
| 134 | await flushTimers(); |
| 135 | }); |
| 136 | }; |
| 137 | await paint(); |
| 138 | return { root, calls, rerender: paint }; |
| 139 | } |
| 140 | |
| 141 | function installWindowTimerQueue() { |
| 142 | let clock = 0; |
| 143 | let nextId = 1; |
| 144 | const tasks = new Map<number, { at: number; callback: () => void }>(); |
| 145 | const originalSetTimeout = window.setTimeout; |
| 146 | const originalClearTimeout = window.clearTimeout; |
| 147 | |
| 148 | window.setTimeout = ((handler: TimerHandler, delay = 0, ...args: unknown[]) => { |
| 149 | if (typeof handler !== "function") throw new Error("string timers are unsupported in tests"); |
| 150 | const id = nextId++; |
| 151 | tasks.set(id, { at: clock + Number(delay), callback: () => handler(...args) }); |
| 152 | return id; |
| 153 | }) as typeof window.setTimeout; |
| 154 | window.clearTimeout = ((id?: number) => { |
| 155 | if (id !== undefined) tasks.delete(id); |
| 156 | }) as typeof window.clearTimeout; |
| 157 | |
| 158 | return { |
| 159 | advance(ms: number) { |
| 160 | clock += ms; |
| 161 | while (true) { |
| 162 | const next = [...tasks.entries()] |
| 163 | .filter(([, task]) => task.at <= clock) |
| 164 | .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; |
| 165 | if (!next) break; |
| 166 | tasks.delete(next[0]); |
| 167 | next[1].callback(); |
| 168 | } |
| 169 | }, |
| 170 | restore() { |
| 171 | tasks.clear(); |
| 172 | window.setTimeout = originalSetTimeout; |
| 173 | window.clearTimeout = originalClearTimeout; |
| 174 | }, |
| 175 | }; |
| 176 | } |
| 177 | |
| 178 | console.log("\ncomposer run strip"); |
| 179 | |
| 180 | // Idle: no strip, no stop button, plain send arrow. |
| 181 | { |
| 182 | const dom = installDom(); |
| 183 | const { root, calls } = await renderComposer(); |
| 184 | |
| 185 | eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip"); |
| 186 | eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button"); |
| 187 | ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button"); |
| 188 | eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone"); |
| 189 | const yolo = document.querySelector<HTMLButtonElement>(".composer-modebar__item--yolo"); |
| 190 | ok(yolo !== null, "approval bar always exposes Yolo alongside Ask and Auto"); |
| 191 | await act(async () => { |
| 192 | yolo?.click(); |
| 193 | await flushTimers(); |
| 194 | }); |
| 195 | eq(calls.approvalModes.at(-1), "yolo", "the visible Yolo option selects Yolo approval"); |
| 196 | |
| 197 | await act(async () => { |
| 198 | root.unmount(); |
| 199 | }); |
| 200 | dom.window.close(); |
| 201 | } |
| 202 | |
| 203 | // Work mode is a first-class, always-visible selector. Its three profiles live |
| 204 | // in their own menu instead of the task-intent menu, and selecting a profile |
| 205 | // preserves the existing token-mode callback contract. |
| 206 | { |
| 207 | const dom = installDom(); |
| 208 | const { root, calls } = await renderComposer(); |
| 209 | |
| 210 | const profileTrigger = document.querySelector(".composer-profile-trigger") as HTMLButtonElement | null; |
| 211 | if (!profileTrigger) throw new Error("work mode trigger did not render"); |
| 212 | eq(profileTrigger.textContent?.trim(), "Balanced", "standalone control shows only the current profile"); |
| 213 | eq(profileTrigger.getAttribute("aria-label"), "Work mode · Balanced", "work mode trigger keeps its full accessible name"); |
| 214 | ok(profileTrigger.querySelector(".lucide-equal") !== null, "balanced work mode uses a simple equal icon"); |
| 215 | await act(async () => { |
| 216 | profileTrigger.focus(); |
| 217 | await flushTimers(); |
| 218 | }); |
| 219 | eq(document.querySelector('[role="tooltip"]')?.textContent, "Work mode · Balanced: Full tools, model-directed execution", "work mode tooltip combines category, value, and summary"); |
| 220 | await act(async () => { |
| 221 | profileTrigger.blur(); |
| 222 | await flushTimers(); |
| 223 | }); |
| 224 | |
| 225 | await act(async () => { |
| 226 | profileTrigger.click(); |
| 227 | await flushTimers(); |
| 228 | }); |
| 229 | const profileMenu = document.querySelector(".composer-profile-menu"); |
| 230 | ok(profileMenu !== null, "standalone work mode trigger opens its own menu"); |
| 231 | eq(profileMenu?.querySelectorAll('[role="menuitemradio"]').length, 3, "work mode menu exposes exactly three profiles"); |
| 232 | |
| 233 | const delivery = Array.from(profileMenu?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? []) |
| 234 | .find((item) => item.textContent?.includes("Delivery")); |
| 235 | if (!delivery) throw new Error("delivery work mode option did not render"); |
| 236 | ok(delivery.querySelector(".lucide-flag") !== null, "delivery work mode uses a simple completion flag"); |
| 237 | await act(async () => { |
| 238 | delivery.click(); |
| 239 | await flushTimers(); |
| 240 | }); |
| 241 | eq(calls.tokenModes.at(-1), "delivery", "selecting delivery keeps the token-mode callback contract"); |
| 242 | |
| 243 | const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null; |
| 244 | if (!intentTrigger) throw new Error("task intent trigger did not render"); |
| 245 | await act(async () => { |
| 246 | intentTrigger.click(); |
| 247 | await flushTimers(); |
| 248 | }); |
| 249 | eq(document.querySelector(".composer-intent-menu")?.textContent?.includes("Work mode"), false, "task-intent menu no longer owns work mode"); |
| 250 | eq(document.querySelectorAll('.composer-intent-menu [role="menuitemradio"]').length, 3, "task method menu exposes direct, plan, and goal"); |
| 251 | |
| 252 | await act(async () => { |
| 253 | root.unmount(); |
| 254 | }); |
| 255 | dom.window.close(); |
| 256 | } |
| 257 | |
| 258 | // A short Creation hover must stay a no-op. In particular, leaving before the |
| 259 | // 120ms open delay must not manufacture a closing-only popover or flash the |
| 260 | // trigger's open styling 140ms later. |
| 261 | { |
| 262 | const dom = installDom(); |
| 263 | const timers = installWindowTimerQueue(); |
| 264 | const { root } = await renderComposer({ showContextWindowRing: true }); |
| 265 | |
| 266 | for (const selector of [".composer-task-mode-trigger", ".composer-profile-trigger"]) { |
| 267 | const trigger = document.querySelector(selector) as HTMLButtonElement | null; |
| 268 | if (!trigger) throw new Error(`missing Creation hover trigger: ${selector}`); |
| 269 | |
| 270 | await act(async () => { |
| 271 | trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null })); |
| 272 | timers.advance(119); |
| 273 | trigger.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body })); |
| 274 | timers.advance(140); |
| 275 | }); |
| 276 | |
| 277 | ok(!trigger.classList.contains(`${selector.slice(1)}--open`), `${selector} stays visually closed after a short hover`); |
| 278 | ok( |
| 279 | document.querySelector(selector.includes("task") ? ".composer-intent-menu" : ".composer-profile-menu") === null, |
| 280 | `${selector} does not render a closing-only menu`, |
| 281 | ); |
| 282 | } |
| 283 | |
| 284 | const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null; |
| 285 | if (!intentTrigger) throw new Error("missing Creation intent trigger"); |
| 286 | await act(async () => { |
| 287 | intentTrigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null })); |
| 288 | timers.advance(120); |
| 289 | }); |
| 290 | ok(intentTrigger.classList.contains("composer-task-mode-trigger--open"), "a sustained Creation hover still opens the trigger"); |
| 291 | ok(document.querySelector(".composer-intent-menu") !== null, "a sustained Creation hover still renders the menu"); |
| 292 | |
| 293 | timers.restore(); |
| 294 | await act(async () => { |
| 295 | root.unmount(); |
| 296 | }); |
| 297 | dom.window.close(); |
| 298 | } |
| 299 | |
| 300 | // Runtime controller transitions disable every mode axis and submit together, |
| 301 | // so rapid Goal + Delivery + approval-mode clicks cannot mutate a half-rebuilt runtime. |
| 302 | { |
| 303 | const dom = installDom(); |
| 304 | const { root } = await renderComposer({ disabled: true, goal: "ship it", collaborationMode: "goal" }); |
| 305 | const profile = document.querySelector<HTMLButtonElement>(".composer-profile-trigger"); |
| 306 | const task = document.querySelector<HTMLButtonElement>(".composer-task-mode-trigger"); |
| 307 | const approvals = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-modebar--approval button")); |
| 308 | const send = document.querySelector<HTMLButtonElement>(".composer__btn--send"); |
| 309 | ok(Boolean(profile?.disabled), "runtime transition disables Delivery profile changes"); |
| 310 | ok(Boolean(task?.disabled), "runtime transition disables Goal mode changes"); |
| 311 | ok(approvals.length === 3 && approvals.every((button) => button.disabled), "runtime transition disables Ask/Auto/Yolo changes"); |
| 312 | ok(Boolean(send?.disabled), "runtime transition disables submit"); |
| 313 | |
| 314 | await act(async () => { |
| 315 | root.unmount(); |
| 316 | }); |
| 317 | dom.window.close(); |
| 318 | } |
| 319 | |
| 320 | // Running: strip lives inside the card, ticker is aria-hidden, stop cancels. |
| 321 | { |
| 322 | const dom = installDom(); |
| 323 | const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() }); |
| 324 | |
| 325 | const strip = document.querySelector(".composer-card .composer-run-strip"); |
| 326 | ok(strip !== null, "running strip renders inside the composer card"); |
| 327 | const ticker = strip?.querySelector(".composer-run-strip__text"); |
| 328 | eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree"); |
| 329 | const live = strip?.querySelector(".sr-only[role=\"status\"]"); |
| 330 | eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only"); |
| 331 | ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier"); |
| 332 | |
| 333 | const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null; |
| 334 | if (!stop) throw new Error("running composer stop button did not render"); |
| 335 | await act(async () => { |
| 336 | stop.click(); |
| 337 | await flushTimers(); |
| 338 | }); |
| 339 | eq(calls.cancel, 1, "stop button next to send cancels the turn"); |
| 340 | |
| 341 | await act(async () => { |
| 342 | root.unmount(); |
| 343 | }); |
| 344 | dom.window.close(); |
| 345 | } |
| 346 | |
| 347 | // Waiting on approval: strip switches to the waiting state and stops ticking. |
| 348 | { |
| 349 | const dom = installDom(); |
| 350 | const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() }); |
| 351 | |
| 352 | await rerender({ pendingApprovalLabel: "Run command", disabled: true }); |
| 353 | |
| 354 | const strip = document.querySelector(".composer-run-strip"); |
| 355 | ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting"); |
| 356 | const text = strip?.querySelector(".composer-run-strip__text"); |
| 357 | eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval"); |
| 358 | eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible"); |
| 359 | eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card"); |
| 360 | ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier"); |
| 361 | |
| 362 | const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[]; |
| 363 | ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer"); |
| 364 | |
| 365 | await rerender({ pendingApprovalLabel: null, pendingAsk: true }); |
| 366 | eq( |
| 367 | document.querySelector(".composer-run-strip__text")?.textContent, |
| 368 | "Waiting for your answer", |
| 369 | "pending ask question shows the ask waiting state", |
| 370 | ); |
| 371 | ok( |
| 372 | modeButtons.every((b) => b.disabled), |
| 373 | "approval bar stays disabled for non-approval reasons", |
| 374 | ); |
| 375 | |
| 376 | await rerender({ pendingAsk: false, disabled: false }); |
| 377 | ok( |
| 378 | document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true", |
| 379 | "resolving the prompt returns the strip to the ticking spinner", |
| 380 | ); |
| 381 | |
| 382 | await act(async () => { |
| 383 | root.unmount(); |
| 384 | }); |
| 385 | dom.window.close(); |
| 386 | } |
| 387 | |
| 388 | // Cancel restores queued guidance: stop means "stop acting", never "discard |
| 389 | // what I typed". |
| 390 | { |
| 391 | const dom = installDom(); |
| 392 | const { root, calls } = await renderComposer({ |
| 393 | running: true, |
| 394 | turnStartAt: Date.now(), |
| 395 | guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"], |
| 396 | }); |
| 397 | |
| 398 | ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf"); |
| 399 | |
| 400 | const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null; |
| 401 | if (!stop) throw new Error("stop button did not render"); |
| 402 | await act(async () => { |
| 403 | stop.click(); |
| 404 | await flushTimers(); |
| 405 | }); |
| 406 | |
| 407 | eq(calls.cancel, 1, "stop cancels the running turn"); |
| 408 | const ta = document.querySelector("textarea") as HTMLTextAreaElement; |
| 409 | eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft"); |
| 410 | eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf"); |
| 411 | |
| 412 | await act(async () => { |
| 413 | root.unmount(); |
| 414 | }); |
| 415 | dom.window.close(); |
| 416 | } |
| 417 | |
| 418 | // Waiting on the user pauses the ticker clock: elapsed time means model time. |
| 419 | { |
| 420 | const dom = installDom(); |
| 421 | const start = Date.now() - 30000; |
| 422 | const { root, rerender } = await renderComposer({ running: true, turnStartAt: start }); |
| 423 | |
| 424 | await rerender({ pendingApprovalLabel: "Run command", disabled: true }); |
| 425 | await act(async () => { |
| 426 | await new Promise((resolve) => setTimeout(resolve, 2400)); |
| 427 | }); |
| 428 | await rerender({ pendingApprovalLabel: null, disabled: false }); |
| 429 | |
| 430 | const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? ""; |
| 431 | ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`); |
| 432 | ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time"); |
| 433 | |
| 434 | await act(async () => { |
| 435 | root.unmount(); |
| 436 | }); |
| 437 | dom.window.close(); |
| 438 | } |
| 439 | |
| 440 | // Decision surface suspension pauses the clock without a waiting strip. |
| 441 | { |
| 442 | const dom = installDom(); |
| 443 | const start = Date.now() - 15000; |
| 444 | const { root, rerender } = await renderComposer({ running: true, turnStartAt: start }); |
| 445 | |
| 446 | await rerender({ suspendedByDecision: true, disabled: true }); |
| 447 | eq(document.querySelector(".composer-run-strip--waiting"), null, "decision suspension does not render a waiting strip"); |
| 448 | await act(async () => { |
| 449 | await new Promise((resolve) => setTimeout(resolve, 2400)); |
| 450 | }); |
| 451 | await rerender({ suspendedByDecision: false, disabled: false }); |
| 452 | |
| 453 | const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? ""; |
| 454 | ok(/ 15s| 16s/.test(ticker), `suspendedByDecision excludes wait time from model clock (got "${ticker}")`); |
| 455 | ok(!/ 17s| 18s/.test(ticker), "suspended wait is not counted as model work"); |
| 456 | |
| 457 | await act(async () => { |
| 458 | root.unmount(); |
| 459 | }); |
| 460 | dom.window.close(); |
| 461 | } |
| 462 | |
| 463 | // Background user-wait is controller-scoped: B already waited ~3s off-screen |
| 464 | // while A was foregrounded. Model work for B must stay ~5s (8s turn − 3s wait), |
| 465 | // and tab A's local pause must never be subtracted from B. |
| 466 | { |
| 467 | const dom = installDom(); |
| 468 | const tabAStart = Date.now() - 60_000; |
| 469 | const tabBStart = Date.now() - 8_000; |
| 470 | const tabBWaitStarted = Date.now() - 3_000; |
| 471 | const { root, rerender } = await renderComposer({ |
| 472 | running: true, |
| 473 | turnStartAt: tabAStart, |
| 474 | sessionKey: "tab-a", |
| 475 | suspendedByDecision: true, |
| 476 | disabled: true, |
| 477 | }); |
| 478 | |
| 479 | // A stays locally suspended for a while (clear-context style / no controller wait). |
| 480 | await act(async () => { |
| 481 | await new Promise((resolve) => setTimeout(resolve, 2400)); |
| 482 | }); |
| 483 | |
| 484 | // Switch to B, already awaiting approval since tabBWaitStarted (background). |
| 485 | await rerender({ |
| 486 | sessionKey: "tab-b", |
| 487 | turnStartAt: tabBStart, |
| 488 | turnWaitAccumMs: 0, |
| 489 | promptWaitStartedAt: tabBWaitStarted, |
| 490 | suspendedByDecision: true, |
| 491 | disabled: true, |
| 492 | }); |
| 493 | await act(async () => { |
| 494 | await new Promise((resolve) => setTimeout(resolve, 300)); |
| 495 | }); |
| 496 | |
| 497 | // Controller closes the open wait into turnWaitAccumMs on resolve. |
| 498 | const closedWaitMs = Date.now() - tabBWaitStarted; |
| 499 | await rerender({ |
| 500 | suspendedByDecision: false, |
| 501 | disabled: false, |
| 502 | promptWaitStartedAt: undefined, |
| 503 | turnWaitAccumMs: closedWaitMs, |
| 504 | }); |
| 505 | |
| 506 | const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? ""; |
| 507 | // 8s turn age − ~3.3s user wait ≈ 5s model work (not ~8s wall, not ~0–2s from A leak). |
| 508 | ok(/ 4s| 5s| 6s/.test(ticker), `tab B excludes background user-wait from model clock (got "${ticker}")`); |
| 509 | ok(!/ 7s| 8s| 9s| 10s| 11s/.test(ticker), "background suspension is not counted as model work"); |
| 510 | ok(!/ 5[5-9]s| 6[0-9]s/.test(ticker), "tab B does not show tab A's ~60s turn age as model time"); |
| 511 | |
| 512 | await act(async () => { |
| 513 | root.unmount(); |
| 514 | }); |
| 515 | dom.window.close(); |
| 516 | } |
| 517 | |
| 518 | // Resize consistency: --composer-height always carries the logical height in |
| 519 | // every writer (React render, live drag, keyboard), with the run strip's |
| 520 | // reservation isolated in a CSS calc — so dragging a resized composer during a |
| 521 | // running turn cannot flash-shrink the card. |
| 522 | { |
| 523 | const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8"); |
| 524 | ok( |
| 525 | stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"), |
| 526 | "resized card height combines logical height and strip reservation in CSS", |
| 527 | ); |
| 528 | |
| 529 | const dom = installDom(); |
| 530 | const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() }); |
| 531 | |
| 532 | const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement; |
| 533 | await act(async () => { |
| 534 | handle.focus(); |
| 535 | handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true })); |
| 536 | await flushTimers(); |
| 537 | }); |
| 538 | |
| 539 | const card = document.querySelector(".composer-card") as HTMLElement; |
| 540 | eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one"); |
| 541 | eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable"); |
| 542 | |
| 543 | // Drag while running: the live writer stays in logical-height space. |
| 544 | await act(async () => { |
| 545 | handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 })); |
| 546 | await flushTimers(); |
| 547 | }); |
| 548 | eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card"); |
| 549 | |
| 550 | await act(async () => { |
| 551 | document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 })); |
| 552 | document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 })); |
| 553 | await flushTimers(); |
| 554 | }); |
| 555 | eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path"); |
| 556 | eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag"); |
| 557 | eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height"); |
| 558 | |
| 559 | await rerender({ running: false, turnStartAt: undefined }); |
| 560 | eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation"); |
| 561 | eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height"); |
| 562 | |
| 563 | await act(async () => { |
| 564 | root.unmount(); |
| 565 | }); |
| 566 | dom.window.close(); |
| 567 | } |
| 568 | |
| 569 | console.log(`\n${passed} passed, ${failed} failed`); |
| 570 | if (failed > 0) process.exit(1); |
| 571 |