From e9a5974379943e471aa340e683b47cc89db60015 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 03:23:14 -0700 Subject: [PATCH 01/96] ci(release): build macOS desktop on GitHub macos-26-xlarge runners (#22264) Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0e60fdfc75d..21e9e7ba99c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -551,7 +551,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-26-xlarge platform: mac target: dmg arch: arm64 @@ -574,7 +574,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS x64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-26-xlarge platform: mac target: dmg arch: x64 From d36b7da2485bc889b320a285deea181f637d760a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 03:29:56 -0700 Subject: [PATCH 02/96] fix(desktop): macOS release signing works on macOS 26.6 runners (#22277) Co-authored-by: Claude Opus 5.5 --- apps/desktop/package.json | 2 +- pnpm-lock.yaml | 76 ++++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d78ac0fd286a..a6fe979100d6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,7 +38,7 @@ "@types/node": "catalog:", "acorn": "8.16.0", "cross-env": "^10.1.0", - "electron-builder": "26.15.6", + "electron-builder": "26.16.1", "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6b5f31e6fd..a202831a6fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,8 +299,8 @@ importers: specifier: ^10.1.0 version: 10.1.0 electron-builder: - specifier: 26.15.6 - version: 26.15.6(electron-builder-squirrel-windows@26.15.6) + specifier: 26.16.1 + version: 26.16.1(electron-builder-squirrel-windows@26.16.1) tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -4498,10 +4498,6 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@noble/hashes@2.2.0': - resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} - engines: {node: '>= 20.19.0'} - '@nodable/entities@2.1.1': resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} @@ -7590,12 +7586,12 @@ packages: anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} - app-builder-lib@26.15.6: - resolution: {integrity: sha512-lN6BliNJLnS2ruBdYd1j1rzd2yl4ZwKqyTtAwVznIkkadOR9cFvRz/tNQ6nd824AobnFZKhcdFrpIGDnr0PRSg==} + app-builder-lib@26.16.1: + resolution: {integrity: sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.15.6 - electron-builder-squirrel-windows: 26.15.6 + dmg-builder: 26.16.1 + electron-builder-squirrel-windows: 26.16.1 arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -7820,8 +7816,8 @@ packages: resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} engines: {node: '>=12.0.0'} - builder-util@26.15.3: - resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + builder-util@26.16.0: + resolution: {integrity: sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==} engines: {node: '>=14.0.0'} bun-types@1.3.14: @@ -8474,8 +8470,8 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dmg-builder@26.15.6: - resolution: {integrity: sha512-nr5vQxEhM0REomp1qiHbc6V99yrfBZy+wUU56VXADfSOlLj8PdLqsHiRe7b+FbqKesiyv4ax+k1GVwGonYKuCg==} + dmg-builder@26.16.1: + resolution: {integrity: sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw==} dnssd-advertise@1.1.6: resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} @@ -8689,16 +8685,16 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-builder-squirrel-windows@26.15.6: - resolution: {integrity: sha512-Vgw0bVXtTjZmD3O0Wl1gbo4eiI0Mqp7FQBsFgMR+wNwySiPJ08jadS0PZiSwP2cKCVaEclBHbKeBVTwil+SJfQ==} + electron-builder-squirrel-windows@26.16.1: + resolution: {integrity: sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A==} - electron-builder@26.15.6: - resolution: {integrity: sha512-jxlHRjqYrlTgLVo/aoACGpiki3QFYv8s4f2djsqaEbwTBZ9PcTBK03Tj/HMa65kiE0hdZxxbZdmVFo22eou2wA==} + electron-builder@26.16.1: + resolution: {integrity: sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg==} engines: {node: '>=14.0.0'} hasBin: true - electron-publish@26.15.3: - resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} + electron-publish@26.16.0: + resolution: {integrity: sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==} electron-store@8.2.0: resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==} @@ -15605,8 +15601,6 @@ snapshots: '@noble/hashes@1.8.0': {} - '@noble/hashes@2.2.0': {} - '@nodable/entities@2.1.1': optional: true @@ -18186,7 +18180,7 @@ snapshots: anynum@1.0.1: optional: true - app-builder-lib@26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6): + app-builder-lib@26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1): dependencies: '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 @@ -18196,23 +18190,23 @@ snapshots: '@electron/rebuild': 4.0.4 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 - '@noble/hashes': 2.2.0 + '@noble/hashes': 1.8.0 '@peculiar/webcrypto': 1.7.1 '@types/fs-extra': 9.0.13 ajv: 8.20.0 asn1js: 3.0.10 async-exit-hook: 2.0.1 - builder-util: 26.15.3 + builder-util: 26.16.0 builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.15.6(electron-builder-squirrel-windows@26.15.6) + dmg-builder: 26.16.1(electron-builder-squirrel-windows@26.16.1) dotenv: 16.6.1 dotenv-expand: 11.0.7 ejs: 3.1.10 - electron-builder-squirrel-windows: 26.15.6(dmg-builder@26.15.6) - electron-publish: 26.15.3 + electron-builder-squirrel-windows: 26.16.1(dmg-builder@26.16.1) + electron-publish: 26.16.0 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 @@ -18589,7 +18583,7 @@ snapshots: transitivePeerDependencies: - supports-color - builder-util@26.15.3: + builder-util@26.16.0: dependencies: '@types/debug': 4.1.13 builder-util-runtime: 9.7.0 @@ -19237,10 +19231,10 @@ snapshots: minimatch: 3.1.5 p-limit: 3.1.0 - dmg-builder@26.15.6(electron-builder-squirrel-windows@26.15.6): + dmg-builder@26.16.1(electron-builder-squirrel-windows@26.16.1): dependencies: - app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6) - builder-util: 26.15.3 + app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) + builder-util: 26.16.0 fs-extra: 10.1.0 js-yaml: 4.3.2 transitivePeerDependencies: @@ -19329,23 +19323,23 @@ snapshots: dependencies: jake: 10.9.4 - electron-builder-squirrel-windows@26.15.6(dmg-builder@26.15.6): + electron-builder-squirrel-windows@26.16.1(dmg-builder@26.16.1): dependencies: - app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6) - builder-util: 26.15.3 + app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) + builder-util: 26.16.0 electron-winstaller: 5.4.0 transitivePeerDependencies: - dmg-builder - supports-color - electron-builder@26.15.6(electron-builder-squirrel-windows@26.15.6): + electron-builder@26.16.1(electron-builder-squirrel-windows@26.16.1): dependencies: - app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6) - builder-util: 26.15.3 + app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) + builder-util: 26.16.0 builder-util-runtime: 9.7.0 chalk: 4.1.2 ci-info: 4.4.0 - dmg-builder: 26.15.6(electron-builder-squirrel-windows@26.15.6) + dmg-builder: 26.16.1(electron-builder-squirrel-windows@26.16.1) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 @@ -19354,11 +19348,11 @@ snapshots: - electron-builder-squirrel-windows - supports-color - electron-publish@26.15.3: + electron-publish@26.16.0: dependencies: '@types/fs-extra': 9.0.13 aws4: 1.13.2 - builder-util: 26.15.3 + builder-util: 26.16.0 builder-util-runtime: 9.7.0 chalk: 4.1.2 form-data: 4.0.6 From 67c0fc0cdaf963b103518862dd7b68a9c758ecf2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 04:58:59 -0700 Subject: [PATCH 03/96] fix(web): pinned threads' pin icon stays put on hover (#22303) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b036590edd05..bcd24bf8498f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2301,6 +2301,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {props.project ? ( ) : null} + {/* Left of the status slot, the pin would slide when hover swaps the + label for the wider row controls. */} + {pinIndicator} {props.projectDisplayName ? ( )} - {pinIndicator} {sortable?.isDragging ? ( dragDestination ) : ( From 72ebd98cc61ae26c7ee852143eb268b6e48b3d4a Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:48:11 +0530 Subject: [PATCH 04/96] fix(web): show pointer cursors on changed-file controls (#22266) --- apps/web/src/components/GitActionsControl.tsx | 2 +- apps/web/src/components/chat/ChangedFilesTree.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 23fe91ce6b0f..42663d4439bc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1938,7 +1938,7 @@ export default function GitActionsControl({ )} , ); + const { container } = screen; await act(async () => { await import("./chat/MathTypeset"); }); @@ -1360,13 +1429,15 @@ describe("ChatMarkdown math", () => { ); it("keeps message formulas reachable by keyboard", async () => { + setup(); settingsOverrides.mathRenderingEnabled = true; - const { container } = await render( + await using screen = await render( <> , ); + const { container } = screen; container.querySelector("button")!.focus(); await act(() => userEvent.tab()); expect(document.activeElement).toBe( @@ -1375,11 +1446,12 @@ describe("ChatMarkdown math", () => { }); it("renders table formulas and exports their TeX instead of duplicate glyph text", async () => { - const rendered = await renderMath( + setup(); + await using rendered = await renderMath( "| Name | Formula |\n| --- | --- |\n| Norm | $|x|$ and \\(\\|y\\|\\) |\n| Price | $5 and $10 |", ); - const table = rendered.host.querySelector("table")!; + const table = rendered.container.querySelector("table")!; expect([...table.rows].map((row) => row.cells.length)).toEqual([2, 2, 2]); expect(table.querySelectorAll(".katex")).toHaveLength(2); const norm = table.querySelectorAll(".katex")[1]!; @@ -1402,34 +1474,36 @@ describe("ChatMarkdown math", () => { expect(payload.text).toBe("\\(|x|\\)"); expect(payload.html).toContain("\\(|x|\\)"); expect(payload.html).not.toContain("katex"); - expect(captureAssistantTextSelection(rendered.host, selection)?.selector.text).toBe( + expect(captureAssistantTextSelection(rendered.container, selection)?.selector.text).toBe( "\\(|x|\\)", ); }); it("renders explicit math fences and keeps unfinished streamed fences readable", async () => { - const rendered = await renderMath("```math\nx_i^2\n```\n\n`$literal$`\n\n$unfinished"); + setup(); + await using rendered = await renderMath("```math\nx_i^2\n```\n\n`$literal$`\n\n$unfinished"); - expect(rendered.host.querySelectorAll(".katex")).toHaveLength(1); - expect(rendered.host.textContent).toContain("$literal$"); - expect(rendered.host.textContent).toContain("$unfinished"); + expect(rendered.container.querySelectorAll(".katex")).toHaveLength(1); + expect(rendered.container.textContent).toContain("$literal$"); + expect(rendered.container.textContent).toContain("$unfinished"); - const streamed = await renderMath("```math\nx_i^2", { isStreaming: true }); + await using streamed = await renderMath("```math\nx_i^2", { isStreaming: true }); - expect(streamed.host.querySelector(".katex")).toBeNull(); - expect(streamed.host.textContent).toContain("x_i^2"); + expect(streamed.container.querySelector(".katex")).toBeNull(); + expect(streamed.container.textContent).toContain("x_i^2"); }); it.each(["$$\nx^2\n", "\\[\nx^2\n"])( "keeps unfinished display %s and following prose readable while streaming", async (source) => { + setup(); for (const text of [source, `${source}\nFollowing prose.`]) { - const { host } = await renderMath(text, { isStreaming: true }); - expect(host.querySelector(".katex")).toBeNull(); - expect(host.querySelector("[data-markdown-math]")).toBeNull(); - expect(host.textContent).toContain("x^2"); + await using math = await renderMath(text, { isStreaming: true }); + expect(math.container.querySelector(".katex")).toBeNull(); + expect(math.container.querySelector("[data-markdown-math]")).toBeNull(); + expect(math.container.textContent).toContain("x^2"); if (text.includes("Following prose.")) - expect(host.textContent).toContain("Following prose."); + expect(math.container.textContent).toContain("Following prose."); } }, ); @@ -1437,46 +1511,51 @@ describe("ChatMarkdown math", () => { it.each(["$$x^2$$", "$$\nx^2\n$$", "\\[\nx^2\n\\]", "```math\nx^2\n```"])( "renders completed %s while the response is streaming", async (source) => { - const { host } = await renderMath(source, { isStreaming: true }); - expect(host.querySelectorAll(".katex")).toHaveLength(1); - expect(host.querySelector('[data-markdown-math="display"]')).not.toBeNull(); + setup(); + await using math = await renderMath(source, { isStreaming: true }); + expect(math.container.querySelectorAll(".katex")).toHaveLength(1); + expect(math.container.querySelector('[data-markdown-math="display"]')).not.toBeNull(); }, ); it("shows unsupported TeX as source and keeps active TeX commands inert", async () => { - const rendered = await renderMath( + setup(); + await using rendered = await renderMath( "\\(\\notACommand{x}\\) and \\(\\href{https://example.com}{x}\\)", ); - expect(rendered.host.textContent).toContain("\\(\\notACommand{x}\\)"); - expect(rendered.host.querySelector('a[href="https://example.com"]')).toBeNull(); + expect(rendered.container.textContent).toContain("\\(\\notACommand{x}\\)"); + expect(rendered.container.querySelector('a[href="https://example.com"]')).toBeNull(); }); it("typesets only when enabled, keeping prices and task offsets intact", async () => { + setup(); const text = "Area $\\pi r^2$ costs $5 and $10.\n\n\\[\nE = mc^2\n\\]\n\n- [ ] check \\(x\\)"; - const off = await renderText(text); - expect(off.querySelector("[data-markdown-math]")).toBeNull(); - const html = await renderText('x'); - expect(html.querySelector("[data-markdown-math]")).toBeNull(); + await using off = await renderText(text); + expect(off.container.querySelector("[data-markdown-math]")).toBeNull(); + await using html = await renderText('x'); + expect(html.container.querySelector("[data-markdown-math]")).toBeNull(); for (const parseRawHtml of [true, false]) { let editedText = text; - const { host } = await renderMath(text, { + await using math = await renderMath(text, { parseRawHtml, onTaskListChange: ({ markerOffset, checked }) => { editedText = setMarkdownTaskChecked(text, markerOffset, checked); }, }); expect( - [...host.querySelectorAll("[data-markdown-math]")].map((node) => + [...math.container.querySelectorAll("[data-markdown-math]")].map((node) => node.getAttribute("data-markdown-copy"), ), ).toEqual(["\\(\\pi r^2\\)", "\\[\nE = mc^2\n\\]\n\n", "\\(x\\)"]); - await act(() => host.querySelector('input[type="checkbox"]')!.click()); + await act(() => + math.container.querySelector('input[type="checkbox"]')!.click(), + ); expect(editedText).toBe(text.replace("- [ ]", "- [x]")); } - const { host } = await renderMath("- intro\n\n $$\n x^2\n $$"); + await using math = await renderMath("- intro\n\n $$\n x^2\n $$"); expect( - [...host.querySelectorAll("[data-markdown-math]")].map((node) => + [...math.container.querySelectorAll("[data-markdown-math]")].map((node) => node.getAttribute("data-markdown-copy"), ), ).toEqual(["\\[\nx^2\n\\]\n\n"]); diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index 3edfa7696c80..755cef4b6fa6 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -8,10 +8,9 @@ import { type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; import { useRightPanelStore } from "../rightPanelStore"; const state = vi.hoisted(() => ({ @@ -130,10 +129,9 @@ const serverConfig = { shellRevealInFileManagerKind: "xdg-open", environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } }, }; -const roots: Root[] = []; -let container: HTMLDivElement; -beforeEach(() => { +function setup() { + using stack = new DisposableStack(); state.allowed = true; state.listeners.clear(); state.linkedPullRequest = null; @@ -144,40 +142,35 @@ beforeEach(() => { state.openFile.mockReset(); state.openExternal.mockReset().mockResolvedValue(undefined); state.copy.mockReset().mockResolvedValue(undefined); + const { openFile } = useRightPanelStore.getState(); useRightPanelStore.setState({ openFile: state.openFile }); - vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy); - vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => { - const item = items[0]!; - await state.copy(await (await item.getType("text/plain")).text()); - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function renderMarkdown(text: string) { - container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - await act(async () => - root.render(), + stack.defer(() => useRightPanelStore.setState({ openFile })); + stack.use(vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy)); + stack.use( + vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => { + const item = items[0]!; + await state.copy(await (await item.getType("text/plain")).text()); + }), ); + return stack.move(); } -function anchor() { - return container.querySelector("a")!; -} - -async function openContextMenu() { - await act(async () => { - anchor().dispatchEvent( - new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }), - ); +async function renderMarkdown(text: string) { + const screen = await render(); + const anchor = () => screen.container.querySelector("a")!; + return Object.assign(screen, { + anchor, + openContextMenu: () => + act(async () => { + anchor().dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 10, + clientY: 10, + }), + ); + }), }); } @@ -193,20 +186,22 @@ async function setAllowed(allowed: boolean) { } it("removes host actions after revocation while keeping file preview and copying", async () => { - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); + using _setup = setup(); + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); await setAllowed(false); state.choose.mockResolvedValue("copy-full"); - await openContextMenu(); + await screen.openContextMenu(); expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md")); - await act(async () => anchor().click()); + await act(async () => screen.anchor().click()); expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); expect(state.openEditor).not.toHaveBeenCalled(); }); it("starts copying before the nested-file lookup finishes", async () => { + using _setup = setup(); let finishLookup: () => void = () => { throw new Error("Lookup not started"); }; @@ -218,8 +213,8 @@ it("starts copying before the nested-file lookup finishes", async () => { }), ); state.choose.mockResolvedValue("copy-full"); - await renderMarkdown("`readme.md:10:2`"); - await openContextMenu(); + await using screen = await renderMarkdown("`readme.md:10:2`"); + await screen.openContextMenu(); expect(navigator.clipboard.write).toHaveBeenCalledOnce(); expect(state.copy).not.toHaveBeenCalled(); await act(async () => finishLookup()); @@ -227,6 +222,7 @@ it("starts copying before the nested-file lookup finishes", async () => { }); it("does not launch an editor after revocation during a native context menu", async () => { + using _setup = setup(); let choose: (action: string) => void = () => { throw new Error("Menu not opened"); }; @@ -236,8 +232,8 @@ it("does not launch an editor after revocation during a native context menu", as choose = resolve; }), ); - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); expect(offeredActions()).toContain("open"); await act(async () => { state.allowed = false; @@ -247,6 +243,7 @@ it("does not launch an editor after revocation during a native context menu", as }); it("does not reveal a file after revocation during its workspace lookup", async () => { + using _setup = setup(); let finishLookup: () => void = () => { throw new Error("Lookup not started"); }; @@ -257,8 +254,8 @@ it("does not reveal a file after revocation during its workspace lookup", async }), ); state.choose.mockResolvedValue("reveal"); - await renderMarkdown("[Readme](readme.md)"); - await openContextMenu(); + await using screen = await renderMarkdown("[Readme](readme.md)"); + await screen.openContextMenu(); expect(state.search).toHaveBeenCalledOnce(); await act(async () => { state.allowed = false; @@ -270,11 +267,12 @@ it("does not reveal a file after revocation during its workspace lookup", async it.each([false, true])( "keeps PR links usable without offering metadata edits (linked=%s)", async (linked) => { + using _setup = setup(); state.allowed = false; state.linkedPullRequest = linked ? linkedPullRequest : null; state.choose.mockResolvedValue("open-external"); - await renderMarkdown(`[Review](${linkedPullRequest.url})`); - await openContextMenu(); + await using screen = await renderMarkdown(`[Review](${linkedPullRequest.url})`); + await screen.openContextMenu(); expect(offeredActions()).toEqual(["open-external", "copy-link"]); await vi.waitFor(() => expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url)); expect(state.updateMetadata).not.toHaveBeenCalled(); @@ -284,6 +282,7 @@ it.each([false, true])( it.each(["link-to-thread", "unlink-from-thread"])( "rechecks %s after a native context menu returns", async (action) => { + using _setup = setup(); state.linkedPullRequest = action === "unlink-from-thread" ? linkedPullRequest : null; let choose: (action: string) => void = () => { throw new Error("Menu not opened"); @@ -294,8 +293,8 @@ it.each(["link-to-thread", "unlink-from-thread"])( choose = resolve; }), ); - await renderMarkdown(`[Review](${linkedPullRequest.url})`); - await openContextMenu(); + await using screen = await renderMarkdown(`[Review](${linkedPullRequest.url})`); + await screen.openContextMenu(); expect(offeredActions()).toContain(action); await act(async () => { state.allowed = false; @@ -305,7 +304,7 @@ it.each(["link-to-thread", "unlink-from-thread"])( await setAllowed(true); state.choose.mockResolvedValue(action); - await openContextMenu(); + await screen.openContextMenu(); await vi.waitFor(() => expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({ environmentId: threadRef.environmentId, diff --git a/apps/web/src/components/CommandPalette.focus.browser.test.tsx b/apps/web/src/components/CommandPalette.focus.browser.test.tsx index 74b5b576823e..a48f8865a1d6 100644 --- a/apps/web/src/components/CommandPalette.focus.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.focus.browser.test.tsx @@ -1,13 +1,14 @@ -import { act, useImperativeHandle, useRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useImperativeHandle, useRef, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, fakeTimers, render } from "../../test/render"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { CommandPalette } from "./CommandPalette"; import { useComposerHandleContext } from "../composerHandleContext"; import { openCommandPalette } from "../commandPaletteBus"; import { primaryServerKeybindingsAtom } from "../state/server"; -vi.mock("@tanstack/react-router", () => ({ +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), useNavigate: () => vi.fn(), useParams: () => null, useLocation: () => "/settings", @@ -66,26 +67,20 @@ vi.mock("./settings/useAvailableSettingsSearchItems", () => ({ useAvailableSettingsSearchItems: () => [], })); -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => { - root.unmount(); - await vi.runOnlyPendingTimersAsync(); +// Navigation hooks are mocked above, and the test router would load on the fake clock too. +async function renderPalette(children: ReactNode) { + await using stack = new AsyncDisposableStack(); + stack.use(fakeTimers()); + // Runs after unmount, so timers the palette leaves behind fire on the fake clock. + stack.defer(async () => { + await act(() => vi.runOnlyPendingTimersAsync()); }); - container.remove(); - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); + const screen = stack.use( + await render({children}, { router: false }), + ); + const environment = stack.move(); + return Object.assign(screen, { [Symbol.asyncDispose]: () => environment.disposeAsync() }); +} async function flushFocus() { await act(() => vi.advanceTimersByTimeAsync(0)); @@ -120,14 +115,8 @@ function Composer() { describe("command palette focus restoration", () => { it("returns to the previously focused Settings field when no composer is mounted", async () => { - await act(() => - root.render( - - - , - ), - ); - const field = container.querySelector("input")!; + await using screen = await renderPalette(); + const field = screen.container.querySelector("input")!; await act(() => field.focus()); await act(() => openCommandPalette()); await flushFocus(); @@ -137,14 +126,8 @@ describe("command palette focus restoration", () => { }); it("focuses the replacement command input after leaving the file picker", async () => { - await act(() => - root.render( - - - , - ), - ); - const field = container.querySelector("input")!; + await using screen = await renderPalette(); + const field = screen.container.querySelector("input")!; await act(() => field.focus()); await act(() => field.dispatchEvent( @@ -175,20 +158,18 @@ describe("command palette focus restoration", () => { }); it("focuses the mounted composer at the end instead of the previous field", async () => { - await act(() => - root.render( - - - - , - ), + await using screen = await renderPalette( + <> + + + , ); - await act(() => container.querySelector("input")!.focus()); + await act(() => screen.container.querySelector("input")!.focus()); await act(() => openCommandPalette()); await flushFocus(); expect(document.activeElement?.closest("[data-command-palette]")).not.toBeNull(); await dismissPalette(); - const composer = container.querySelector("textarea")!; + const composer = screen.container.querySelector("textarea")!; expect(document.activeElement).toBe(composer); expect(composer.selectionStart).toBe(composer.value.length); }); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 98ae97a2e8c8..1088efee4387 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -3,21 +3,13 @@ import { serializeAssistantCitation, withAssistantCitationComment, } from "@t3tools/shared/assistantCitations"; -import { act, createRef, useState, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { createRef, useState } from "react"; +import { expect, it } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { findAssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; - -// No router is mounted; the citation chip only needs navigation to exist. -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - useNavigate: () => async () => {}, - Link: (({ children }: { children: ReactNode }) => {children}) as never, -})); - +import { act, render } from "../../test/render"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; const citation = { @@ -33,20 +25,6 @@ const citation = { }; const promptBefore = `See ${serializeAssistantCitation(citation)} `; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); - function Composer({ question, editorRef, @@ -88,9 +66,9 @@ function Composer({ it("keeps an unsaved citation comment when a question takes the composer", async () => { const editorRef = createRef(); - const drafts = () => container.querySelector("output")?.dataset; + await using screen = await render(); + const drafts = () => screen.container.querySelector("output")?.dataset; - await act(async () => root.render()); const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); if (!sourceAnchor) throw new Error("Citation source did not resolve"); await act(async () => @@ -107,9 +85,9 @@ it("keeps an unsaved citation comment when a question takes the composer", async await comment.click(); await userEvent.keyboard("check this"); - await act(async () => root.render()); + await screen.rerender(); expect(drafts()?.answer).toBe(""); - await act(async () => root.render()); + await screen.rerender(); expect(drafts()?.prompt).toBe( `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 14ae4e40bac3..6b4884efdc9f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -10,9 +10,10 @@ import { import { userEvent } from "vite-plus/test/browser"; import type { ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; -import { act, createElement, createRef, useEffect, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createElement, createRef, useEffect, useState } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; import { detectComposerTrigger } from "~/composer-logic"; import { serializeEditorDoc } from "~/composer-rich-text-doc"; @@ -22,22 +23,6 @@ import { type ComposerPromptEditorProps, } from "./ComposerPromptEditor"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - const babysit: ServerProviderSkill = { name: "babysit", path: "/skills/babysit/SKILL.md", @@ -75,27 +60,39 @@ function Composer({ ); } -async function mountEditor( +function composer( value: string, richTextEnabled = true, skills: ReadonlyArray = [], onChange?: ComposerPromptEditorProps["onChange"], ) { - await act(async () => - root.render( - , - ), + return ( + ); +} + +function editorIn(container: HTMLElement) { const element = container.querySelector(".tiptap"); if (!element) throw new Error("Composer did not mount"); return element.editor; } +async function mountEditor(...props: Parameters) { + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render(composer(...props))); + const editor = editorIn(screen.container); + const environment = stack.move(); + return Object.assign(screen, { + [Symbol.asyncDispose]: () => environment.disposeAsync(), + editor, + /** Rerenders the same composer with new props, as a parent would. */ + update: async (...next: Parameters) => { + await screen.rerender(composer(...next)); + return editorIn(screen.container); + }, + }); +} + async function replaceDomText(editor: Editor, text: string, isComposing = false) { await act(async () => { const node = editor.view.dom.querySelector("p")?.firstChild; @@ -140,7 +137,8 @@ it.each([ { value: "clean _build and node", code: [] }, { value: "clean `_build` and `node", code: ["_build"] }, ])("keeps a typed underscore literal after $value", async ({ value, code }) => { - const editor = await mountEditor(value); + await using screen = await mountEditor(value); + const { editor } = screen; await act(async () => { editor.commands.focus("end"); const { from, to } = editor.state.selection; @@ -152,7 +150,8 @@ it.each([ }); it("still italicizes text when typing a closing asterisk", async () => { - const editor = await mountEditor("clean *build"); + await using screen = await mountEditor("clean *build"); + const { editor } = screen; await act(async () => { editor.commands.focus("end"); const { from, to } = editor.state.selection; @@ -166,7 +165,8 @@ it.each([false, true])( "composes an umlaut without wrapping the preedit text (rich text: %s)", async (richTextEnabled) => { // The middle dot stands in for the provisional text shown by the Compose key. - const editor = await mountEditor("·", richTextEnabled); + await using screen = await mountEditor("·", richTextEnabled); + const { editor } = screen; await act(async () => { editor.view.focus(); editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); @@ -182,7 +182,8 @@ it.each([false, true])( ); it("commits a composed quote without surrounding the preedit text", async () => { - const editor = await mountEditor("·"); + await using screen = await mountEditor("·"); + const { editor } = screen; await act(async () => { editor.view.focus(); editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); @@ -193,7 +194,8 @@ it("commits a composed quote without surrounding the preedit text", async () => }); it("lets composition replace selected text without surrounding it", async () => { - const editor = await mountEditor("word"); + await using screen = await mountEditor("word"); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); @@ -203,7 +205,8 @@ it("lets composition replace selected text without surrounding it", async () => }); it("replaces text outside the selected range without surrounding it", async () => { - const editor = await mountEditor("word"); + await using screen = await mountEditor("word"); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); typeText(editor, 2, 3, '"'); @@ -214,7 +217,8 @@ it("replaces text outside the selected range without surrounding it", async () = it.each([false, true])( "still surrounds selected text with quotes (rich text: %s)", async (richTextEnabled) => { - const editor = await mountEditor("word", richTextEnabled); + await using screen = await mountEditor("word", richTextEnabled); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.focus(); @@ -226,7 +230,8 @@ it.each([false, true])( it("keeps unknown dollar names editable when restoring and pasting a draft", async () => { const value = "echo $HOME $PATH $anything $notaskill $20 $20k $babysit "; - const editor = await mountEditor(value, true, [babysit]); + await using screen = await mountEditor(value, true, [babysit]); + const { editor } = screen; expect(editor.state.doc.textContent).toBe(value.replace("$babysit", "")); expect(serializeEditorDoc(editor.state.doc).value).toBe(value); await act(async () => { @@ -246,7 +251,13 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy it("keeps skill queries active before whitespace and line breaks, suppressing only real chips", async () => { const onChange = vi.fn(); - const editor = await mountEditor("use now\nfirst \n$babysit tail", true, [babysit], onChange); + await using screen = await mountEditor( + "use now\nfirst \n$babysit tail", + true, + [babysit], + onChange, + ); + const { editor } = screen; const trigger = () => { const [value, , expandedCursor, suppressTrigger] = onChange.mock.lastCall!; return suppressTrigger ? null : detectComposerTrigger(value, expandedCursor); @@ -284,16 +295,17 @@ it("keeps skill queries active before whitespace and line breaks, suppressing on it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; - let editor = await mountEditor(value); + await using screen = await mountEditor(value); + let { editor } = screen; await act(async () => { editor.commands.setTextSelection("echo $HO".length + 1); }); - editor = await mountEditor(value, false); + editor = await screen.update(value, false); await act(async () => { editor.commands.insertContent("X"); }); expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXME now"); - editor = await mountEditor(value, true); + editor = await screen.update(value, true); await act(async () => { editor.commands.insertContent("Y"); }); @@ -304,18 +316,19 @@ it("reconciles provider skills and metadata without losing formatting, caret, or const value = "run **use $babysit now** then $HOME"; const disabled = { ...babysit, enabled: false }; const agentOnly = { ...babysit, userInvocable: false }; - const editor = await mountEditor(value, true, [disabled]); + await using screen = await mountEditor(value, true, [disabled]); + const { editor } = screen; expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); - await mountEditor(value, true, [agentOnly]); + await screen.update(value, true, [agentOnly]); expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); await act(async () => { editor.commands.setTextSelection("run use $baby".length + 1); }); - await mountEditor(value, true, [disabled, babysit]); + await screen.update(value, true, [disabled, babysit]); expect(editor.state.doc.textContent).toBe("run use now then $HOME"); const chip = serializeEditorDoc(editor.state.doc).runs.find((run) => run.kind === "token")!; expect(editor.state.selection.from).toBe(chip.pmPos + 1); - await mountEditor(value, true, [disabled]); + await screen.update(value, true, [disabled]); expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); expect(serializeEditorDoc(editor.state.doc).value).toBe(value); await act(async () => { @@ -328,25 +341,25 @@ it("reconciles provider skills and metadata without losing formatting, caret, or expect(serializeEditorDoc(editor.state.doc).value).toBe(value); expect(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); - await mountEditor(value, true, [babysit]); + await screen.update(value, true, [babysit]); await act(async () => { editor.commands.deleteRange({ from: chip.pmPos, to: chip.pmPos + 1 }); }); - await mountEditor(value, true, [agentOnly]); + await screen.update(value, true, [agentOnly]); await act(async () => { editor.commands.undo(); }); expect(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); expect(serializeEditorDoc(editor.state.doc).value).toBe(value); - await mountEditor(value, true, [{ ...babysit, description: "Previous provider description" }]); + await screen.update(value, true, [{ ...babysit, description: "Previous provider description" }]); await act(async () => { - container + screen.container .querySelector('[aria-label="Skill Babysit. Show details"]')! .click(); }); expect(document.body.textContent).toContain("Previous provider description"); - await mountEditor(value, true, [ + await screen.update(value, true, [ { ...disabled, displayName: "Disabled Babysit" }, { ...babysit, @@ -354,11 +367,11 @@ it("reconciles provider skills and metadata without losing formatting, caret, or shortDescription: " Current short description ", }, ]); - expect(container.textContent).toContain("Current Babysit"); + expect(screen.container.textContent).toContain("Current Babysit"); expect(document.body.textContent).toContain("Current short description"); expect(document.body.textContent).not.toContain("Previous provider description"); - await mountEditor(value, true, [{ ...babysit, displayName: "Current Babysit" }]); - expect(container.textContent).toContain("Current Babysit"); + await screen.update(value, true, [{ ...babysit, displayName: "Current Babysit" }]); + expect(screen.container.textContent).toContain("Current Babysit"); expect(document.body.textContent).not.toContain("Current short description"); expect(document.body.textContent).toContain("No description is available for this skill."); expect(document.body.textContent).not.toContain("Previous provider description"); @@ -431,18 +444,14 @@ describe("composer context undo lifetime", () => { return createElement(ComposerPromptEditor, editorProps); } - const render = async (targetKey: string) => { - await act(async () => root.render(createElement(Composer, { targetKey }))); - }; - const editor = () => { - const element = container.querySelector(".tiptap"); - if (!element) throw new Error("Composer did not mount"); - return element.editor; - }; - await render("a"); - return { + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render(createElement(Composer, { targetKey: "a" }))); + const editor = () => editorIn(screen.container); + const environment = stack.move(); + return Object.assign(screen, { + [Symbol.asyncDispose]: () => environment.disposeAsync(), initialValue, - render, + show: (targetKey: string) => screen.rerender(createElement(Composer, { targetKey })), editor, draft: () => current, retained: () => retained, @@ -459,11 +468,11 @@ describe("composer context undo lifetime", () => { editor().commands.undo(); }); }, - }; + }); } it("preserves typing focus across draft switches without taking focus from another control", async () => { - const composer = await mountComposer("Draft B"); + await using composer = await mountComposer("Draft B"); const previousEditor = composer.editor(); await act(async () => { previousEditor.commands.setTextSelection(previousEditor.state.doc.content.size - 1); @@ -471,7 +480,7 @@ describe("composer context undo lifetime", () => { }); expect(document.activeElement).toBe(previousEditor.view.dom); - await composer.render("b"); + await composer.show("b"); const nextEditor = composer.editor(); expect(nextEditor).not.toBe(previousEditor); expect(document.activeElement).toBe(nextEditor.view.dom); @@ -479,48 +488,47 @@ describe("composer context undo lifetime", () => { expect(composer.draft().value).toBe("xDraft B"); const button = document.createElement("button"); - container.append(button); + composer.container.append(button); button.focus(); - await composer.render("a"); + await composer.show("a"); expect(document.activeElement).toBe(button); expect(composer.draft().value).toBe(composer.initialValue); }); it("restores a deleted chip and its file bytes on same-draft undo, then releases them on unmount", async () => { - const composer = await mountComposer(); + await using composer = await mountComposer(); await composer.deleteDraft(); expect(composer.draft()).toEqual({ value: "", files: [] }); const retained = composer.retained(); expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file); - await composer.render("a"); + await composer.show("a"); await composer.undo(); expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(composer.initialValue); expect(composer.draft()).toEqual({ value: composer.initialValue, files: [file] }); expect(composer.draft().files[0]?.file).toBe(file.file); await composer.deleteDraft(); - await act(async () => root.unmount()); + await composer.unmount(); expect(retained.files.size).toBe(0); - root = createRoot(container); }); it.each(["", "Draft B"])( "releases deleted payloads and prevents undo into the previous draft when switching to %j", async (destinationValue) => { - const composer = await mountComposer(destinationValue); + await using composer = await mountComposer(destinationValue); await composer.deleteDraft(); expect(composer.draft()).toEqual({ value: "", files: [] }); const retained = composer.retained(); expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file); - await composer.render("b"); + await composer.show("b"); expect(retained.files.size).toBe(0); await composer.undo(); expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(destinationValue); expect(composer.draft()).toEqual({ value: destinationValue, files: [] }); - await composer.render("a"); + await composer.show("a"); await composer.undo(); expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(""); expect(composer.draft()).toEqual({ value: "", files: [] }); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.browser.test.tsx index 3f9f6ccbb23e..7d96839b6d53 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.browser.test.tsx @@ -1,52 +1,37 @@ import type { Editor } from "@tiptap/core"; import { TextSelection } from "@tiptap/pm/state"; -import { act, createRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createRef } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; import { serializeEditorDoc } from "~/composer-rich-text-doc"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; const draft = "> **Assistant and reasoning text still save intermediate full snapshots**, using the existing 50 ms batching. Compaction still preserves their `turn-item.updated` events. [Code](/Users/yashsingh/p/projects/t3code/apps/server/src/orchestration-v2/ProjectionMaintenance.ts:235)"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function mountEditor(value: string, richTextEnabled = true) { - await act(async () => - root.render( - ()} - value={value} - cursor={0} - richTextEnabled={richTextEnabled} - contextRecords={new Map()} - skills={[]} - disabled={false} - placeholder="" - onChange={() => {}} - onPaste={() => {}} - />, - ), +async function mountEditor(value: string, richTextEnabled = true, container?: HTMLElement) { + const screen = await render( + ()} + value={value} + cursor={0} + richTextEnabled={richTextEnabled} + contextRecords={new Map()} + skills={[]} + disabled={false} + placeholder="" + onChange={() => {}} + onPaste={() => {}} + />, + container ? { container } : {}, ); - const element = container.querySelector(".tiptap"); - if (!element) throw new Error("Composer did not mount"); - return element.editor; + const element = screen.container.querySelector(".tiptap"); + if (!element) { + await screen[Symbol.asyncDispose](); + throw new Error("Composer did not mount"); + } + return Object.assign(screen, { editor: element.editor }); } function press(editor: Editor, key: string, options: KeyboardEventInit = {}) { @@ -57,7 +42,8 @@ function press(editor: Editor, key: string, options: KeyboardEventInit = {}) { } it("keeps Up before the opening bold marker in a quote, including while typing", async () => { - const editor = await mountEditor(draft); + await using screen = await mountEditor(draft); + const { editor } = screen; const position = editor.state.selection.from; await act(async () => { editor.view.focus(); @@ -77,7 +63,8 @@ it("keeps Up before the opening bold marker in a quote, including while typing", }); it("keeps Down after a closing marker, including while typing", async () => { - const editor = await mountEditor("> **bold**"); + await using screen = await mountEditor("> **bold**"); + const { editor } = screen; await act(async () => { editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); expect(press(editor, "ArrowDown")).toBe(true); @@ -88,7 +75,8 @@ it("keeps Down after a closing marker, including while typing", async () => { }); it("leaves vertical navigation between paragraphs to the browser", async () => { - const editor = await mountEditor("**first**\n**second**"); + await using screen = await mountEditor("**first**\n**second**"); + const { editor } = screen; await act(async () => { const first = editor.state.doc.child(0); editor.commands.setTextSelection(first.nodeSize + 1); @@ -101,7 +89,8 @@ it("leaves vertical navigation between paragraphs to the browser", async () => { it.each([{ shiftKey: true }, { altKey: true }, { ctrlKey: true }, { isComposing: true }])( "preserves modified Up behavior for %j", async (options) => { - const editor = await mountEditor(draft); + await using screen = await mountEditor(draft); + const { editor } = screen; await act(async () => { expect(press(editor, "ArrowUp", options)).toBe(false); editor.commands.insertContent("x"); @@ -111,10 +100,11 @@ it.each([{ shiftKey: true }, { altKey: true }, { ctrlKey: true }, { isComposing: ); describe("macOS boundary navigation", () => { - beforeEach(() => { - vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + function macContainer() { + const container = document.createElement("div"); container.style.width = "600px"; - }); + return container; + } const markdown = String.raw`Fixed the top padding to 16px and updated the offline index and ZIP. @@ -129,7 +119,9 @@ The original Fall 2022, 2023, and 2024 sample exams also have circle-constrained it.each(["ArrowRight", "ArrowDown"])( "keeps Command-%s after the closing marker in the pasted draft", async (key) => { - const editor = await mountEditor(markdown); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using screen = await mountEditor(markdown, true, macContainer()); + const { editor } = screen; await act(async () => { editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); editor.view.focus(); @@ -156,7 +148,9 @@ The original Fall 2022, 2023, and 2024 sample exams also have circle-constrained it.each(["ArrowRight", "ArrowDown"])( "moves Command-%s from inside bold to outside its closing marker", async (key) => { - const editor = await mountEditor("**bold**"); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using screen = await mountEditor("**bold**", true, macContainer()); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection(3); editor.view.focus(); @@ -168,7 +162,9 @@ The original Fall 2022, 2023, and 2024 sample exams also have circle-constrained ); it.each(["ArrowLeft", "ArrowUp"])("moves Command-%s before the opening marker", async (key) => { - const editor = await mountEditor("**bold**"); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using screen = await mountEditor("**bold**", true, macContainer()); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection(3); editor.view.focus(); @@ -181,7 +177,9 @@ The original Fall 2022, 2023, and 2024 sample exams also have circle-constrained it.each(["ArrowRight", "ArrowDown"])( "extends Shift-Command-%s to the end without collapsing the selection", async (key) => { - const editor = await mountEditor("**bold**"); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using screen = await mountEditor("**bold**", true, macContainer()); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection(3); editor.view.focus(); @@ -195,8 +193,9 @@ The original Fall 2022, 2023, and 2024 sample exams also have circle-constrained ); it("leaves Command-arrow navigation to the browser on other platforms", async () => { - vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); - const editor = await mountEditor("**bold**"); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await mountEditor("**bold**", true, macContainer()); + const { editor } = screen; await act(async () => { editor.view.focus(); expect(press(editor, "ArrowDown", { metaKey: true })).toBe(false); @@ -205,7 +204,8 @@ The original Fall 2022, 2023, and 2024 sample exams also have circle-constrained }); it("leaves literal Markdown navigation to the browser in plain mode", async () => { - const editor = await mountEditor(draft, false); + await using screen = await mountEditor(draft, false); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection(5); expect(press(editor, "ArrowUp")).toBe(false); diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index f1d5a7a12238..0eb2c755ca1d 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -12,11 +12,11 @@ import { import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; +import { act, render } from "../../test/render"; + vi.mock("../hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => threadRef, useThreadRouteTarget: () => ({ ...threadRef, kind: "thread" }), @@ -112,65 +112,62 @@ const patchAtoms = Atom.family((path: string) => path === "e.txt" ? AsyncResult.initial() : AsyncResult.success(loadedPatch(path)), ), ); -let registry: AtomRegistry.AtomRegistry; -let container: HTMLDivElement; -let root: Root; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { localStorage.setItem("t3code.diffFileTreeOpen", "true"); useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); - registry = AtomRegistry.make(); - container = document.createElement("div"); + const registry = AtomRegistry.make(); + return { + registry, + [Symbol.dispose]() { + registry.dispose(); + localStorage.clear(); + }, + }; +} +function renderPanel(registry: AtomRegistry.AtomRegistry) { + const container = document.createElement("div"); container.style.cssText = "width: 1000px; height: 300px"; - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - registry.dispose(); - localStorage.clear(); - vi.unstubAllGlobals(); -}); -async function render() { - await act(async () => - root.render( - - - , - ), + return render( + + + , + { container }, ); } const toggle = (path: string, expanded: boolean) => page.getByRole("button", { name: `${expanded ? "Collapse" : "Expand"} ${path}`, exact: true }); -const viewport = () => container.querySelector(".diff-render-surface")!; +const viewport = (container: HTMLElement) => + container.querySelector(".diff-render-surface")!; async function selectTurn(path?: string, turn = turns[0]!) { await act(async () => useDiffPanelStore.getState().selectTurn(threadRef, turn.runId, path)); } -async function expectRevealed(path: string) { +async function expectRevealed(container: HTMLElement, path: string) { await expect.element(toggle(path, true)).toBeInTheDocument(); await expect .poll(() => { - const bounds = viewport().getBoundingClientRect(); + const bounds = viewport(container).getBoundingClientRect(); const header = toggle(path, true).element().getBoundingClientRect(); - return viewport().scrollTop > 0 && header.top >= bounds.top && header.bottom <= bounds.bottom; + return ( + viewport(container).scrollTop > 0 && + header.top >= bounds.top && + header.bottom <= bounds.bottom + ); }) .toBe(true); } -async function scrollToStart() { +async function scrollToStart(container: HTMLElement) { await act(async () => { - viewport().scrollTop = 0; - viewport().dispatchEvent(new Event("scroll")); + viewport(container).scrollTop = 0; + viewport(container).dispatchEvent(new Event("scroll")); await new Promise(requestAnimationFrame); }); } -async function selectTreeFile(path: string) { +async function selectTreeFile(container: HTMLElement, path: string) { const tree = container.querySelector("file-tree-container")!; const row = tree.shadowRoot!.querySelector(`[data-item-path="${path}"]`)!; await act(async () => @@ -181,30 +178,31 @@ async function selectTreeFile(path: string) { } it("reveals a delayed turn selection, preserves siblings, and repeats after scope changes", async () => { + using env = setup(); await selectTurn("e.txt"); - await render(); + await using screen = await renderPanel(env.registry); await expect .element(page.getByRole("status", { name: "Loading checkpoint diff..." })) .toBeInTheDocument(); - await act(async () => registry.set(checkpointAtom, paths.map(filePatch).join(""))); - await expectRevealed("e.txt"); + await act(async () => env.registry.set(checkpointAtom, paths.map(filePatch).join(""))); + await expectRevealed(screen.container, "e.txt"); await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "branch")); await expect.element(toggle("a.txt", false)).toBeInTheDocument(); await selectTurn("e.txt"); - await expectRevealed("e.txt"); - await scrollToStart(); + await expectRevealed(screen.container, "e.txt"); + await scrollToStart(screen.container); await toggle("a.txt", false).click(); await selectTurn("b.txt"); await expect.element(toggle("b.txt", true)).toBeInTheDocument(); await toggle("b.txt", true).click(); await selectTurn("e.txt"); - await expectRevealed("e.txt"); - await scrollToStart(); + await expectRevealed(screen.container, "e.txt"); + await scrollToStart(screen.container); await expect.element(toggle("a.txt", true)).toBeInTheDocument(); await expect.element(toggle("b.txt", false)).toBeInTheDocument(); await expect.element(toggle("c.txt", false)).toBeInTheDocument(); await selectTurn("e.txt"); - await expectRevealed("e.txt"); + await expectRevealed(screen.container, "e.txt"); await selectTurn(undefined, turns[1]!); await expect.element(toggle("a.txt", false)).toBeInTheDocument(); @@ -212,21 +210,22 @@ it("reveals a delayed turn selection, preserves siblings, and repeats after scop }); it("waits for a lazy Git patch while preserving sibling choices and expanded defaults", async () => { - registry.set(collapsedAtom, false); - await render(); + using env = setup(); + env.registry.set(collapsedAtom, false); + await using screen = await renderPanel(env.registry); await toggle("b.txt", true).click(); - await selectTreeFile("b.txt"); + await selectTreeFile(screen.container, "b.txt"); await expect.element(toggle("b.txt", true)).toBeInTheDocument(); await toggle("b.txt", true).click(); - await scrollToStart(); - await selectTreeFile("e.txt"); - expect(viewport().scrollTop).toBe(0); + await scrollToStart(screen.container); + await selectTreeFile(screen.container, "e.txt"); + expect(viewport(screen.container).scrollTop).toBe(0); await expect.element(toggle("e.txt", true)).not.toBeInTheDocument(); await act(async () => - registry.set(patchAtoms("e.txt"), AsyncResult.success(loadedPatch("e.txt"))), + env.registry.set(patchAtoms("e.txt"), AsyncResult.success(loadedPatch("e.txt"))), ); - await expectRevealed("e.txt"); - await scrollToStart(); + await expectRevealed(screen.container, "e.txt"); + await scrollToStart(screen.container); await expect.element(toggle("a.txt", true)).toBeInTheDocument(); await expect.element(toggle("b.txt", false)).toBeInTheDocument(); await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged")); diff --git a/apps/web/src/components/GitActionsControl.browser.test.tsx b/apps/web/src/components/GitActionsControl.browser.test.tsx index 5904d57f54df..224fee5d59fa 100644 --- a/apps/web/src/components/GitActionsControl.browser.test.tsx +++ b/apps/web/src/components/GitActionsControl.browser.test.tsx @@ -6,12 +6,12 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act, memo } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { memo } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { act, render } from "../../test/render"; type Branched = { readonly branch: string }; type GitActionInput = { featureBranch?: boolean; threadId?: string; action: string }; @@ -168,20 +168,15 @@ import GitActionsControl from "./GitActionsControl"; // ThreadDetailsPanel keeps this component mounted behind React.memo when threads change. const MemoGitActionsControl = memo(GitActionsControl); -let root: Root; -let container: HTMLDivElement; - -function render(thread = "thread", cwd = "/repo") { - return act(async () => - root.render( - , - ), +function control(thread = "thread", cwd = "/repo") { + return ( + ); } @@ -195,8 +190,8 @@ async function openCommitDialog() { await expect.element(page.getByRole("dialog", { name: "Commit changes" })).toBeVisible(); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +// The mocked modules read `state`, so every test starts by resetting all of it. +function setup() { state.scopes = new Set([AuthSourceControlWriteScope]); state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]); state.shell = { branch: "main" }; @@ -218,21 +213,12 @@ beforeEach(() => { if (state.draft && input.branch) state.draft = { ...state.draft, branch: input.branch }; }, }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); -}); +} describe("Git actions while thread details load", () => { it("does not offer a feature branch for a server thread without task permission", async () => { - await render(); + setup(); + await using _screen = await render(control()); await openCommitDialog(); // The primary environment's task grant must not leak into this thread's environment. @@ -241,8 +227,9 @@ describe("Git actions while thread details load", () => { }); it("checks the task grant again when the feature-branch commit runs", async () => { + setup(); state.scopes.add(AuthOrchestrationOperateScope); - await render(); + await using _screen = await render(control()); await openCommitDialog(); state.scopes.delete(AuthOrchestrationOperateScope); @@ -254,9 +241,10 @@ describe("Git actions while thread details load", () => { }); it("commits and synchronizes the server thread before details finish loading", async () => { + setup(); state.primaryScopes.clear(); state.scopes.add(AuthOrchestrationOperateScope); - await render(); + await using _screen = await render(control()); await openCommitDialog(); await userEvent.click(button("Commit on new branch")); @@ -267,11 +255,12 @@ describe("Git actions while thread details load", () => { }); it("synchronizes archived thread detail after its shell disappears", async () => { + setup(); state.scopes.add(AuthOrchestrationOperateScope); state.detail = { branch: "main" }; - await render(); + await using screen = await render(control()); state.shell = null; - await render(); + await screen.rerender(control()); await openCommitDialog(); await userEvent.click(button("Commit on new branch")); @@ -281,9 +270,10 @@ describe("Git actions while thread details load", () => { }); it("requires task permission before changing an archived thread's branch", async () => { + setup(); state.shell = null; state.detail = { branch: "main" }; - await render(); + await using _screen = await render(control()); await openCommitDialog(); await expect.element(button("Commit on new branch")).toBeDisabled(); @@ -291,9 +281,10 @@ describe("Git actions while thread details load", () => { }); it("uses the current shell branch when cached detail names the new branch", async () => { + setup(); state.scopes.add(AuthOrchestrationOperateScope); state.detail = { branch: "feature" }; - await render(); + await using _screen = await render(control()); await openCommitDialog(); await userEvent.click(button("Commit on new branch")); @@ -307,9 +298,10 @@ describe("Git actions while thread details load", () => { ] as const)( "uses the fresh task grant after Git finishes, revoking %s", async (revokedScope, syncsThread) => { + setup(); state.scopes.add(AuthOrchestrationOperateScope); state.afterGitAction = () => state.scopes.delete(revokedScope); - await render(); + await using _screen = await render(control()); await openCommitDialog(); await userEvent.click(button("Commit on new branch")); @@ -321,7 +313,8 @@ describe("Git actions while thread details load", () => { ); it("keeps ordinary commits available while details load", async () => { - await render(); + setup(); + await using _screen = await render(control()); await openCommitDialog(); await userEvent.click(button("Commit")); @@ -331,9 +324,10 @@ describe("Git actions while thread details load", () => { }); it("keeps feature-branch commits available for a local draft", async () => { + setup(); state.shell = null; state.draft = { branch: "main", worktreePath: null, envMode: "local" }; - await render(); + await using _screen = await render(control()); await openCommitDialog(); await userEvent.click(button("Commit on new branch")); @@ -344,17 +338,19 @@ describe("Git actions while thread details load", () => { }); describe("Changed-file editor targets", () => { - beforeEach(() => { + function setupNestedFiles() { + setup(); state.scopes.add(AuthOrchestrationOperateScope); state.files = [ { path: "nested/new.txt", insertions: 0, deletions: 0 }, { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, ]; - }); + } it("resolves repository-relative paths against the status root, not the project cwd", async () => { + setupNestedFiles(); state.workingTreeRoot = "/repo"; - await render("thread", "/repo/nested"); + await using _screen = await render(control("thread", "/repo/nested")); await openCommitDialog(); await userEvent.click(button("Open nested/new.txt in editor")); @@ -371,7 +367,8 @@ describe("Changed-file editor targets", () => { }); it("does not guess a file location when the server reports no root", async () => { - await render("thread", "/repo/nested"); + setupNestedFiles(); + await using _screen = await render(control("thread", "/repo/nested")); await openCommitDialog(); await expect.element(button("Open nested/new.txt in editor")).toBeDisabled(); @@ -380,13 +377,15 @@ describe("Changed-file editor targets", () => { }); describe("Default branch prompt", () => { - beforeEach(() => { + function setupDefaultRef() { + setup(); state.isDefaultRef = true; state.scopes.add(AuthOrchestrationOperateScope); - }); + } it("moves the commit to a feature branch when asked", async () => { - await render(); + setupDefaultRef(); + await using _screen = await render(control()); await userEvent.click(button(/^Commit & push/)); await expect .element(page.getByRole("dialog", { name: "Commit & push to default ref?" })) @@ -400,7 +399,8 @@ describe("Default branch prompt", () => { }); it("checks the task grant again when the feature branch is chosen", async () => { - await render(); + setupDefaultRef(); + await using _screen = await render(control()); await userEvent.click(button(/^Commit & push/)); await expect .element(page.getByRole("dialog", { name: "Commit & push to default ref?" })) @@ -418,12 +418,13 @@ describe("Git action targets across thread navigation", () => { const quickAction = () => button(/^Commit, push & /); it("runs a retained PR toast in the thread that produced it", async () => { - await render("first", "/first"); + setup(); + await using screen = await render(control("first", "/first")); await userEvent.click(quickAction()); await vi.waitFor(() => expect(state.toasts.some((toast) => toast.actionProps)).toBe(true)); const toast = state.toasts.find((toast) => toast.actionProps)!; - await render("second", "/second"); + await screen.rerender(control("second", "/second")); await act(async () => toast.actionProps!.onClick()); expect(state.actions.at(-1)).toMatchObject({ @@ -433,12 +434,14 @@ describe("Git action targets across thread navigation", () => { }); it("runs the memoized button in the current thread after navigation", async () => { + setup(); + await using screen = await render(control("first", "/first")); for (const [thread, cwd] of [ ["first", "/first"], ["second", "/second"], ["first", "/first"], ] as const) { - await render(thread, cwd); + await screen.rerender(control(thread, cwd)); await userEvent.click(quickAction()); await vi.waitFor(() => expect(state.actions.at(-1)).toMatchObject({ scope: { cwd }, input: { threadId: thread } }), diff --git a/apps/web/src/components/HostedEmptyState.browser.test.tsx b/apps/web/src/components/HostedEmptyState.browser.test.tsx index 333bcf57a0fd..8864e86a9967 100644 --- a/apps/web/src/components/HostedEmptyState.browser.test.tsx +++ b/apps/web/src/components/HostedEmptyState.browser.test.tsx @@ -2,9 +2,9 @@ import type { Discovery } from "@t3tools/client-runtime/relay"; import { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; type Availability = "checking" | "online"; @@ -83,11 +83,7 @@ async function publish(environments: DiscoveredEnvironments) { const registeredIds = () => discovery.register.mock.calls.map(([registration]) => registration.target.environmentId); -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { discovery.listeners.clear(); discovery.state = { environments: new Map(), @@ -96,21 +92,13 @@ beforeEach(() => { error: Option.none(), }; discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} // The first added computer replaces this screen with the app, so every // registration has to start in the same pass, after all health checks finish. it("adds every linked computer only after discovery finishes checking them", async () => { - await act(async () => root.render()); + setup(); + await using _screen = await render(); await publish(linked({ [laptopId]: "checking", [desktopId]: "checking" })); await publish(linked({ [laptopId]: "online", [desktopId]: "checking" })); diff --git a/apps/web/src/components/KeybindingsConfigWarning.browser.test.tsx b/apps/web/src/components/KeybindingsConfigWarning.browser.test.tsx index b3f00642ee0e..480e4c592209 100644 --- a/apps/web/src/components/KeybindingsConfigWarning.browser.test.tsx +++ b/apps/web/src/components/KeybindingsConfigWarning.browser.test.tsx @@ -6,9 +6,9 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; type SessionResult = AsyncResult.AsyncResult; const state = vi.hoisted(() => ({ @@ -40,8 +40,6 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { KeybindingsConfigWarning } from "./KeybindingsConfigWarning"; const environmentId = EnvironmentId.make("warning-environment"); -let root: Root; -let container: HTMLDivElement; const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ({ authenticated: true, scopes, @@ -54,10 +52,8 @@ const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ( }); const setScopes = (scopes: readonly AuthEnvironmentScope[]) => appAtomRegistry.set(state.sessions.get(environmentId)!, AsyncResult.success(session(scopes))); -const button = () => container.querySelector("button")!; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup(): Disposable { localStorage.setItem("t3code:last-editor", JSON.stringify("vscode")); state.sessions.set( environmentId, @@ -67,33 +63,28 @@ beforeEach(() => { ); state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.toast.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - state.sessions.clear(); - vi.unstubAllGlobals(); -}); + return { + [Symbol.dispose]() { + localStorage.clear(); + state.sessions.clear(); + }, + }; +} it("updates a visible warning on revocation and blocks a click that raced it until regrant", async () => { - await act(async () => - root.render( - - - , - ), + using _state = setup(); + await using screen = await render( + + + , ); - expect(container.textContent).toContain("Invalid shortcut"); + const button = () => screen.container.querySelector("button")!; + expect(screen.container.textContent).toContain("Invalid shortcut"); expect(button().disabled).toBe(false); // The click lands before React re-renders the revoked grant. diff --git a/apps/web/src/components/PermissionUpdateNotice.browser.test.tsx b/apps/web/src/components/PermissionUpdateNotice.browser.test.tsx index 58542edf62e9..6408f020e015 100644 --- a/apps/web/src/components/PermissionUpdateNotice.browser.test.tsx +++ b/apps/web/src/components/PermissionUpdateNotice.browser.test.tsx @@ -1,6 +1,6 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; type Session = { _tag: "Success" | "Failure"; @@ -37,12 +37,9 @@ vi.mock(import("./ui/toast"), async (original) => { }); import { PermissionUpdateNotice } from "./PermissionUpdateNotice"; -let root: Root; -let container: HTMLDivElement; const storageKey = () => `t3code:permission-update:v1:env-${state.id}`; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup(): Disposable { state.id++; state.session = { _tag: "Success", @@ -50,15 +47,9 @@ beforeEach(() => { value: { authenticated: true, permissions: ["orchestration:operate"] }, }; vi.clearAllMocks(); - container = document.createElement("div"); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - localStorage.clear(); - vi.unstubAllGlobals(); -}); -const render = () => act(async () => root.render()); + return { [Symbol.dispose]: () => localStorage.clear() }; +} + type Notice = { timeout: number; actionProps: { onClick: () => void }; @@ -67,7 +58,8 @@ type Notice = { const notice = () => state.add.mock.calls[0]![0] as Notice; it("keeps the notice visible and persists dismissal, with a route to pairing settings", async () => { - await render(); + using _state = setup(); + await using _screen = await render(); expect(notice().timeout).toBe(0); expect(localStorage.getItem(storageKey())).toBeNull(); notice().actionProps.onClick(); @@ -76,37 +68,41 @@ it("keeps the notice visible and persists dismissal, with a route to pairing set expect(localStorage.getItem(storageKey())).toBe("true"); }); it("does not repeat on session refresh or remount", async () => { - await render(); - await act(async () => root.unmount()); - root = createRoot(container); + using _state = setup(); + await using screen = await render(); + await screen.unmount(); state.session = { ...state.session }; - await render(); + await using _remounted = await render(); expect(state.add).toHaveBeenCalledTimes(1); }); it("skips a notice dismissed on an earlier launch", async () => { + using _state = setup(); localStorage.setItem(storageKey(), "true"); - await render(); + await using _screen = await render(); expect(state.add).not.toHaveBeenCalled(); }); it("waits for a successful, settled session check", async () => { + using _state = setup(); state.session._tag = "Failure"; - await render(); + await using screen = await render(); expect(state.add).not.toHaveBeenCalled(); state.session = { ...state.session, _tag: "Success", waiting: true }; - await render(); + await screen.rerender(); expect(state.add).not.toHaveBeenCalled(); state.session = { ...state.session, waiting: false }; - await render(); + await screen.rerender(); expect(state.add).toHaveBeenCalledTimes(1); }); it("does not warn for a granular grant", async () => { + using _state = setup(); state.session.value.permissions.push("filesystem:read"); - await render(); + await using _screen = await render(); expect(state.add).not.toHaveBeenCalled(); }); it("persists the secondary dismissal without relying on the toast close callback", async () => { - await render(); + using _state = setup(); + await using _screen = await render(); notice().data.secondaryActionProps.onClick(); expect(localStorage.getItem(storageKey())).toBe("true"); expect(state.close).toHaveBeenCalledWith("notice"); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index 547bde0896de..2c7942a7d96c 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -2,9 +2,9 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef } from "@t3tools/contra import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; const state = vi.hoisted(() => ({ keybindings: [] as ResolvedKeybindingsConfig, @@ -86,33 +86,9 @@ const snapshot = { canGoForward: false, updatedAt: "2026-09-22T12:00:00.000Z", } as const; -let root: Root; let menuAction: ((action: string) => void) | undefined; -async function render() { - await act(() => root.render()); -} - -function press(target: Element = document.body) { - const event = new KeyboardEvent("keydown", { - key: "T", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }); - target.dispatchEvent(event); - return event; -} - -function openCommandPalette() { - const palette = document.createElement("div"); - palette.dataset.commandPalette = ""; - document.body.append(palette); - return palette; -} - -beforeEach(() => { +function setup() { vi.clearAllMocks(); Object.assign(state, { keybindings: DEFAULT_RESOLVED_KEYBINDINGS, @@ -127,8 +103,11 @@ beforeEach(() => { terminalUiStateByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); +} + +async function renderShortcut() { + await using stack = new AsyncDisposableStack(); + stack.use(vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64")); window.desktopBridge = { preview: { setForwardedShortcuts: state.setShortcuts }, onMenuAction: (listener: (action: string) => void) => { @@ -138,23 +117,47 @@ beforeEach(() => { }; }, } as unknown as NonNullable; - root = createRoot(document.createElement("div")); -}); + stack.defer(() => { + delete window.desktopBridge; + }); + stack.use(await render()); + const environment = stack.move(); + return { + /** Attaches an element to the document until the test ends. */ + append(element: HTMLElement) { + document.body.append(element); + environment.defer(() => element.remove()); + return element; + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} -afterEach(async () => { - await act(() => root.unmount()); - delete window.desktopBridge; - document.body.replaceChildren(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); +function press(target: Element = document.body) { + const event = new KeyboardEvent("keydown", { + key: "T", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(event); + return event; +} + +function commandPalette() { + const palette = document.createElement("div"); + palette.dataset.commandPalette = ""; + return palette; +} describe("root reopen shortcut", () => { it("restores a tab from settings to its owning remote thread", async () => { + setup(); useClosedViewStore .getState() .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); - await render(); + await using _view = await renderShortcut(); await act(() => { expect(press().defaultPrevented).toBe(true); }); @@ -166,32 +169,35 @@ describe("root reopen shortcut", () => { }); it("leaves native shortcuts alone without history or while the palette is open", async () => { - await render(); + setup(); + await using view = await renderShortcut(); expect(press().defaultPrevented).toBe(false); await act(() => { useClosedViewStore .getState() .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); }); - openCommandPalette(); + view.append(commandPalette()); expect(press().defaultPrevented).toBe(false); expect(state.navigate).not.toHaveBeenCalled(); expect(useClosedViewStore.getState().entries).toHaveLength(1); }); it("lets the keybinding recorder capture the chord", async () => { + setup(); useClosedViewStore .getState() .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); - await render(); + await using view = await renderShortcut(); const recorder = document.createElement("input"); recorder.dataset.keybindingCapture = ""; - document.body.append(recorder); + view.append(recorder); expect(press(recorder).defaultPrevented).toBe(false); expect(useClosedViewStore.getState().entries).toHaveLength(1); }); it("serializes deliberate presses while a browser restore waits for RPC", async () => { + setup(); useClosedViewStore .getState() .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "files", id: "files" } }); @@ -202,7 +208,7 @@ describe("root reopen shortcut", () => { finish = resolve; }), ); - await render(); + await using _view = await renderShortcut(); await act(() => { press(); press(); @@ -217,6 +223,7 @@ describe("root reopen shortcut", () => { }); it("does not create a second browser when navigation fails after restoring it", async () => { + setup(); const store = useClosedViewStore.getState(); const older = store.remember({ kind: "panel-tab", @@ -226,7 +233,7 @@ describe("root reopen shortcut", () => { store.remember({ kind: "browser", threadRef: ref, snapshot }); state.openPreview.mockResolvedValue(AsyncResult.success({ tabId: "new-tab" })); state.navigate.mockRejectedValueOnce(new Error("navigation failed")); - await render(); + await using _view = await renderShortcut(); await act(() => { press(); }); @@ -250,6 +257,7 @@ describe("root reopen shortcut", () => { it.each(["files", "file", "browser"] as const)( "keeps a failed %s restore retryable without blocking older history", async (kind) => { + setup(); const store = useClosedViewStore.getState(); const older = store.remember({ kind: "panel-tab", @@ -276,7 +284,7 @@ describe("root reopen shortcut", () => { ); state.workspaceAvailable = false; state.openPreview.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("offline")))); - await render(); + await using _view = await renderShortcut(); await act(() => { press(); }); @@ -307,6 +315,7 @@ describe("root reopen shortcut", () => { ); it("forwards the chord to a focused desktop browser only while history exists", async () => { + setup(); const forwardedCommands = () => (state.setShortcuts.mock.lastCall as unknown as [Array<{ command: string }>])[0].map( ({ command }) => command, @@ -314,7 +323,7 @@ describe("root reopen shortcut", () => { useClosedViewStore .getState() .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); - await render(); + await using _view = await renderShortcut(); expect(forwardedCommands()).toContain("view.reopenClosed"); await act(() => { menuAction?.("view.reopenClosed"); @@ -324,7 +333,8 @@ describe("root reopen shortcut", () => { }); it("always forwards the layout toggles out of a focused desktop browser", async () => { - await render(); + setup(); + await using _view = await renderShortcut(); expect(state.setShortcuts).toHaveBeenLastCalledWith([ expect.objectContaining({ command: "sidebar.toggle" }), expect.objectContaining({ command: "rightPanel.toggle" }), diff --git a/apps/web/src/components/RightPanelTabs.browser.test.tsx b/apps/web/src/components/RightPanelTabs.browser.test.tsx index 56e6419a8854..729e9b1dc39e 100644 --- a/apps/web/src/components/RightPanelTabs.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browser.test.tsx @@ -1,8 +1,8 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; const setAudioMuted = vi.hoisted(() => vi.fn(async () => undefined)); @@ -80,107 +80,101 @@ function overlay( }; } -let root: Root; -let container: HTMLDivElement; const noop = () => undefined; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - setAudioMuted.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -async function renderTabs( +function tabs( first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon, audio?: { audible?: boolean; audioMuted?: boolean }, previewRuntimeTabId: ((tabId: string) => string) | null = (tabId) => `runtime:${tabId}`, ) { - await act(() => - root.render( - ({ - terminalFocus: false, - terminalOpen: false, - previewFocus: false, - previewOpen: false, - isWeb: true, - isDesktop: false, - })} - surfaces={second ? [previewSurface, secondSurface] : [previewSurface]} - environmentId={null} - activeSurfaceId={previewSurface.id} - pendingSurfaceIds={new Set()} - previewSessions={sessions} - desktopByTabId={{ - "tab-1": overlay(first, audio), - ...(second ? { "tab-2": overlay(second) } : {}), - }} - {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})} - terminalLabelsById={new Map()} - onActivate={noop} - onCloseSurface={noop} - onCloseOtherSurfaces={noop} - onCloseSurfacesToRight={noop} - onCloseAllSurfaces={noop} - onCopyFilePath={noop} - onAddBrowser={noop} - onAddBrowserInProfile={noop} - onAddTerminal={noop} - onAddPullRequest={noop} - onAddPullRequests={noop} - onAddDiff={noop} - onAddFiles={noop} - onAddDevice={noop} - browserAvailable - terminalAvailable={false} - diffAvailable={false} - filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} - > -
content
-
, - ), + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={second ? [previewSurface, secondSurface] : [previewSurface]} + environmentId={null} + activeSurfaceId={previewSurface.id} + pendingSurfaceIds={new Set()} + previewSessions={sessions} + desktopByTabId={{ + "tab-1": overlay(first, audio), + ...(second ? { "tab-2": overlay(second) } : {}), + }} + {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})} + terminalLabelsById={new Map()} + onActivate={noop} + onCloseSurface={noop} + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={noop} + onAddBrowser={noop} + onAddBrowserInProfile={noop} + onAddTerminal={noop} + onAddPullRequest={noop} + onAddPullRequests={noop} + onAddDiff={noop} + onAddFiles={noop} + onAddDevice={noop} + browserAvailable + terminalAvailable={false} + diffAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + > +
content
+
); } -const imageSources = () => [...container.querySelectorAll("img")].map((image) => image.src); -const button = (name: string) => +function setup() { + setAudioMuted.mockClear(); +} + +function renderTabs(...args: Parameters) { + return render(tabs(...args)); +} + +const imageSources = (container: HTMLElement) => + [...container.querySelectorAll("img")].map((image) => image.src); +const button = (container: HTMLElement, name: string) => [...container.querySelectorAll("button")].find((element) => element.ariaLabel === name); describe("preview tab favicon", () => { it("prefers a live capture and never asks a public provider about a private hostname", async () => { - await renderTabs(favicon(PIXEL_A, "http://24x.xf.local/")); - expect(imageSources()).toEqual([PIXEL_A]); + setup(); + await using screen = await renderTabs(favicon(PIXEL_A, "http://24x.xf.local/")); + expect(imageSources(screen.container)).toEqual([PIXEL_A]); - await renderTabs(null); - expect(imageSources()).toEqual([]); + await screen.rerender(tabs(null)); + expect(imageSources(screen.container)).toEqual([]); }); it("keeps route-specific captures isolated between live tabs on one origin", async () => { - await renderTabs( + setup(); + await using screen = await renderTabs( favicon(PIXEL_A, "http://24x.xf.local/"), favicon(PIXEL_B, "http://24x.xf.local/admin"), ); - expect(imageSources()).toEqual([PIXEL_A, PIXEL_B]); + expect(imageSources(screen.container)).toEqual([PIXEL_A, PIXEL_B]); }); it("hides a capture while the server session still describes another origin", async () => { - await renderTabs(favicon(PIXEL_A, "https://example.com/")); - expect(imageSources()).not.toContain(PIXEL_A); + setup(); + await using screen = await renderTabs(favicon(PIXEL_A, "https://example.com/")); + expect(imageSources(screen.container)).not.toContain(PIXEL_A); }); }); @@ -189,22 +183,25 @@ describe("preview tab audio toggle", () => { { audible: false, audioMuted: false }, { audible: false, audioMuted: true }, ])("is absent for a silent tab (muted=$audioMuted)", async (audio) => { - await renderTabs(null, undefined, audio); - expect(button("Mute Local site")).toBeUndefined(); - expect(button("Unmute Local site")).toBeUndefined(); + setup(); + await using screen = await renderTabs(null, undefined, audio); + expect(button(screen.container, "Mute Local site")).toBeUndefined(); + expect(button(screen.container, "Unmute Local site")).toBeUndefined(); }); it.each([ { audioMuted: false, label: "Mute Local site", nextMuted: true }, { audioMuted: true, label: "Unmute Local site", nextMuted: false }, ])("$label addresses the desktop by runtime tab id", async ({ audioMuted, label, nextMuted }) => { - await renderTabs(null, undefined, { audible: true, audioMuted }); - await act(() => button(label)!.click()); + setup(); + await using screen = await renderTabs(null, undefined, { audible: true, audioMuted }); + await act(() => button(screen.container, label)!.click()); expect(setAudioMuted).toHaveBeenCalledExactlyOnceWith("runtime:tab-1", nextMuted); }); it("hides the toggle when no runtime tab id can be resolved", async () => { - await renderTabs(null, undefined, { audible: true }, null); - expect(button("Mute Local site")).toBeUndefined(); + setup(); + await using screen = await renderTabs(null, undefined, { audible: true }, null); + expect(button(screen.container, "Mute Local site")).toBeUndefined(); }); }); diff --git a/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx index c036977acba3..52c9babc2cee 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx @@ -1,109 +1,108 @@ -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { compileResolvedKeybindingsConfig, DEFAULT_RESOLVED_KEYBINDINGS, } from "@t3tools/shared/keybindings"; +import { act, fakeTimers, render, stubGlobal } from "../../test/render"; import { RightPanelTabs } from "./RightPanelTabs"; vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) })); -let root: Root; -let container: HTMLDivElement; -const addFiles = vi.fn(); const noop = () => undefined; -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - disconnect() {} - unobserve() {} - }, - ); - Object.defineProperty(Element.prototype, "getAnimations", { - configurable: true, - value: () => [], - }); - vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); - addFiles.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); +async function settle() { + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersToNextFrame()); +} -async function renderPanel(overrides: Partial> = {}) { - await act(() => - root.render( - ({ - terminalFocus: false, - terminalOpen: false, - previewFocus: false, - previewOpen: false, - isWeb: true, - isDesktop: false, - })} - surfaces={[]} - environmentId={null} - activeSurfaceId={null} - pendingSurfaceIds={new Set()} - previewSessions={{}} - desktopByTabId={{}} - terminalLabelsById={new Map()} - onActivate={noop} - onCloseSurface={noop} - onCloseOtherSurfaces={noop} - onCloseSurfacesToRight={noop} - onCloseAllSurfaces={noop} - onCopyFilePath={noop} - onAddBrowser={noop} - onAddBrowserInProfile={noop} - onAddTerminal={noop} - onAddDiff={noop} - onAddFiles={addFiles} - onAddPullRequest={noop} - onAddPullRequests={noop} - onAddDevice={noop} - browserAvailable={false} - terminalAvailable={false} - diffAvailable={false} - filesAvailable - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} - {...overrides} - > - content - , +async function renderPanel( + overrides: Partial> = {}, + platform = "MacIntel", +) { + await using stack = new AsyncDisposableStack(); + stack.use(fakeTimers()); + stack.use( + stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + unobserve() {} + }, ), ); - await act(() => vi.advanceTimersByTimeAsync(0)); - await act(() => vi.advanceTimersToNextFrame()); + // Menus wait for exit animations to finish, which never happens under fake timers. + stack.use(vi.spyOn(Element.prototype, "getAnimations").mockReturnValue([])); + stack.use(vi.spyOn(navigator, "platform", "get").mockReturnValue(platform)); + const addFiles = vi.fn(); + const panel = (props: Partial>) => ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={noop} + onCloseSurface={noop} + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={noop} + onAddBrowser={noop} + onAddBrowserInProfile={noop} + onAddTerminal={noop} + onAddDiff={noop} + onAddFiles={addFiles} + onAddPullRequest={noop} + onAddPullRequests={noop} + onAddDevice={noop} + browserAvailable={false} + terminalAvailable={false} + diffAvailable={false} + filesAvailable + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + {...props} + > + content + + ); + // Nothing here navigates, and the test router would load on the fake clock too. + const screen = stack.use(await render(panel(overrides), { router: false })); + await settle(); + const environment = stack.move(); + return { + container: screen.container, + addFiles, + rerender: async (props: Partial>) => { + await screen.rerender(panel(props)); + await settle(); + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } async function press(key: string, options: KeyboardEventInit = {}) { const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options }); await act(() => (document.activeElement ?? document.body).dispatchEvent(event)); - await act(() => vi.advanceTimersByTimeAsync(0)); - await act(() => vi.advanceTimersToNextFrame()); + await settle(); return event; } @@ -111,8 +110,7 @@ describe("right panel new-tab shortcut", () => { it.each(["MacIntel", "Win32", "Linux x86_64"])( "opens the menu on %s and chooses a tab", async (platform) => { - vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); - await renderPanel(); + await using screen = await renderPanel({}, platform); const event = await press( "t", platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true }, @@ -132,35 +130,35 @@ describe("right panel new-tab shortcut", () => { ).defaultPrevented, ).toBe(true); await press("f"); - expect(addFiles).toHaveBeenCalledOnce(); + expect(screen.addFiles).toHaveBeenCalledOnce(); expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); }, ); it("opens Files while a closed popup remains mounted", async () => { - await renderPanel(); + await using screen = await renderPanel(); const popup = document.createElement("div"); popup.dataset.slot = "menu-popup"; popup.dataset.closed = ""; - container.append(popup); + screen.container.append(popup); expect((await press("f")).defaultPrevented).toBe(true); - expect(addFiles).toHaveBeenCalledOnce(); + expect(screen.addFiles).toHaveBeenCalledOnce(); }); it("opens the add-surface menu while a closed popup remains mounted", async () => { - await renderPanel(); + await using screen = await renderPanel(); const popup = document.createElement("div"); popup.dataset.slot = "menu-popup"; popup.dataset.closed = ""; - container.append(popup); + screen.container.append(popup); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(true); expect(document.querySelector('[role="menu"]:not([data-closed])')).not.toBeNull(); }); it("leaves the shortcut alone while the mounted panel is closed", async () => { - await renderPanel({ open: false }); + await using _screen = await renderPanel({ open: false }); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); }); @@ -168,21 +166,23 @@ describe("right panel new-tab shortcut", () => { it.each(["Win32", "Linux x86_64"])( "leaves Ctrl+T for the focused terminal on %s", async (platform) => { - vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); - await renderPanel({ - getShortcutContext: () => ({ - terminalFocus: true, - terminalOpen: true, - previewFocus: false, - previewOpen: false, - isWeb: true, - isDesktop: false, - }), - }); + await using screen = await renderPanel( + { + getShortcutContext: () => ({ + terminalFocus: true, + terminalOpen: true, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + }), + }, + platform, + ); const terminal = document.createElement("textarea"); const onKeyDown = vi.fn(); terminal.addEventListener("keydown", onKeyDown); - container.append(terminal); + screen.container.append(terminal); await act(() => terminal.focus()); const event = await press("t", { ctrlKey: true }); expect(event.defaultPrevented).toBe(false); @@ -193,10 +193,11 @@ describe("right panel new-tab shortcut", () => { it("uses a custom binding from a text field and closes on Escape", async () => { const input = document.createElement("input"); - await renderPanel({ + await using screen = await renderPanel({ keybindings: compileResolvedKeybindingsConfig([{ key: "mod+y", command: "rightPanel.new" }]), }); document.body.append(input); + using _input = { [Symbol.dispose]: () => input.remove() }; input.focus(); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); await press("y", { metaKey: true }); @@ -205,25 +206,24 @@ describe("right panel new-tab shortcut", () => { expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); // The menu hands focus back to its trigger once its close finishes, a few frames later. // Timers are fake here, so advance them instead of polling in real time. - const trigger = container.querySelector('[aria-label="Add panel surface"]'); + const trigger = screen.container.querySelector('[aria-label="Add panel surface"]'); for (let frame = 0; frame < 10 && document.activeElement !== trigger; frame++) { await act(() => vi.advanceTimersToNextFrame()); } expect(document.activeElement).toBe(trigger); - input.remove(); }); it("does not open over another popup or during text composition", async () => { - await renderPanel(); + await using screen = await renderPanel(); expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false); const dialog = document.createElement("div"); dialog.dataset.slot = "dialog-popup"; dialog.dataset.open = ""; - container.append(dialog); + screen.container.append(dialog); for (const state of ["data-open", "data-ending-style"]) { dialog.setAttribute(state, ""); expect((await press("f")).defaultPrevented, state).toBe(false); - expect(addFiles, state).not.toHaveBeenCalled(); + expect(screen.addFiles, state).not.toHaveBeenCalled(); expect((await press("t", { metaKey: true })).defaultPrevented, state).toBe(false); expect(document.querySelector('[role="menu"]'), state).toBeNull(); dialog.removeAttribute(state); @@ -231,16 +231,16 @@ describe("right panel new-tab shortcut", () => { }); it("blocks panel shortcuts until a mounted popup finishes closing", async () => { - await renderPanel(); + await using screen = await renderPanel(); const popover = document.createElement("div"); popover.dataset.slot = "popover-popup"; popover.dataset.open = ""; - container.append(popover); + screen.container.append(popover); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); expect((await press("f")).defaultPrevented).toBe(false); expect(document.querySelector('[role="menu"]')).toBeNull(); - expect(addFiles).not.toHaveBeenCalled(); + expect(screen.addFiles).not.toHaveBeenCalled(); delete popover.dataset.open; popover.dataset.closed = ""; @@ -248,12 +248,12 @@ describe("right panel new-tab shortcut", () => { expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); expect((await press("f")).defaultPrevented).toBe(false); expect(document.querySelector('[role="menu"]')).toBeNull(); - expect(addFiles).not.toHaveBeenCalled(); + expect(screen.addFiles).not.toHaveBeenCalled(); delete popover.dataset.endingStyle; popover.hidden = true; expect((await press("f")).defaultPrevented).toBe(true); - expect(addFiles).toHaveBeenCalledOnce(); + expect(screen.addFiles).toHaveBeenCalledOnce(); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(true); expect(document.querySelector('[role="menu"]')?.textContent).toContain("Files"); expect(document.activeElement?.closest('[role="menu"]')).not.toBeNull(); @@ -264,18 +264,18 @@ describe("right panel new-tab shortcut", () => { mode: "sheet" as const, surfaces: [{ id: "files" as const, kind: "files" as const }], }; - await renderPanel(panel); + await using screen = await renderPanel(panel); await press("t", { metaKey: true }); await press("ArrowDown"); await press("ArrowDown"); await press("ArrowDown"); expect(document.activeElement?.textContent).toContain("Files"); await press("Enter"); - expect(addFiles).toHaveBeenCalledOnce(); + expect(screen.addFiles).toHaveBeenCalledOnce(); await press("t", { metaKey: true }); - await renderPanel({ ...panel, open: false }); + await screen.rerender({ ...panel, open: false }); expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); - await renderPanel(panel); + await screen.rerender(panel); expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); }); }); @@ -285,8 +285,13 @@ describe("right panel tab middle click", () => { const onActivate = vi.fn(); const onCloseSurface = vi.fn(); const files = { id: "files" as const, kind: "files" as const }; - await renderPanel({ surfaces: [files], activeSurfaceId: files.id, onActivate, onCloseSurface }); - const tabLabel = container.querySelector("[data-active-tab] span.truncate")!; + await using screen = await renderPanel({ + surfaces: [files], + activeSurfaceId: files.id, + onActivate, + onCloseSurface, + }); + const tabLabel = screen.container.querySelector("[data-active-tab] span.truncate")!; const mouse = async (type: string, button: number) => { const event = new MouseEvent(type, { button, bubbles: true, cancelable: true }); await act(() => tabLabel.dispatchEvent(event)); diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 8a86d2d37bb0..f27263efb0a9 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -3,9 +3,10 @@ import { AuthSessionState, type EnvironmentId, type ServerInstallation } from "@ import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render, type Screen } from "../../test/render"; type SessionResult = AsyncResult.AsyncResult; const testState = vi.hoisted(() => ({ @@ -90,37 +91,42 @@ const currentSession = { const success = (method: "boot-service" | "desktop-app" = "boot-service", version = "0.0.31") => AsyncResult.success({ targetVersion: version, method }); -let root: Root; -let container: HTMLDivElement; let sessionAtom: Atom.Writable; function setSession(session: SessionResult) { appAtomRegistry.set(sessionAtom, session); } -async function render(element: ReactNode) { - await act(() => - root.render( - {element}, - ), +function setup(): Disposable { + testState.updateServer.mockReset(); + testState.toast.mockReset(); + testState.clipboard.mockReset(); + testState.continueThreadsAfterServerUpdate = false; + sessionAtom = Atom.make(AsyncResult.success(currentSession)); + testState.sessionAtom = sessionAtom; + resetConfirmDialogForTests(); + return { [Symbol.dispose]: resetConfirmDialogForTests }; +} + +function renderUpdate(element: ReactNode) { + return render( + {element}, ); } -function button(name?: string) { - const buttons = [...container.querySelectorAll("button")]; +function button(screen: Screen, name?: string) { + const buttons = [...screen.container.querySelectorAll("button")]; return name === undefined ? buttons[0] : buttons.find((element) => element.textContent?.trim() === name); } -async function click(name?: string) { - await act(async () => button(name)!.click()); +async function click(screen: Screen, name?: string) { + await act(async () => button(screen, name)!.click()); } -async function renderAction( - props: Partial[0]> = {}, -): Promise { - await render( +function renderAction(props: Partial[0]> = {}) { + return renderUpdate( { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - testState.updateServer.mockReset(); - testState.toast.mockReset(); - testState.clipboard.mockReset(); - testState.continueThreadsAfterServerUpdate = false; - sessionAtom = Atom.make(AsyncResult.success(currentSession)); - testState.sessionAtom = sessionAtom; - resetConfirmDialogForTests(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - resetConfirmDialogForTests(); - vi.unstubAllGlobals(); -}); - describe("ServerUpdateAction", () => { it.each([ { serverUpdateScope: undefined, scopes: ["orchestration:operate"], allowed: true }, @@ -169,6 +154,7 @@ describe("ServerUpdateAction", () => { ] as const)( "uses the advertised update scope $serverUpdateScope with grant $scopes", async ({ serverUpdateScope, scopes, allowed }) => { + using _state = setup(); setSession( AsyncResult.success( decodeSessionState({ @@ -183,29 +169,31 @@ describe("ServerUpdateAction", () => { ); testState.updateServer.mockResolvedValue(success()); - await renderAction(); - expect(button("Update")!.disabled).toBe(!allowed); - await click("Update"); + await using screen = await renderAction(); + expect(button(screen, "Update")!.disabled).toBe(!allowed); + await click(screen, "Update"); expect(testState.updateServer).toHaveBeenCalledTimes(allowed ? 1 : 0); }, ); it("keeps a known grant usable while the session refreshes", async () => { + using _state = setup(); setSession(AsyncResult.waiting(AsyncResult.success(currentSession))); testState.updateServer.mockResolvedValue(success()); - await renderAction(); - await click("Update"); + await using screen = await renderAction(); + await click(screen, "Update"); expect(testState.updateServer).toHaveBeenCalledOnce(); }); it("disables the action once maintenance access is removed", async () => { - await renderAction(); + using _state = setup(); + await using screen = await renderAction(); await act(() => setSession(AsyncResult.success({ ...currentSession, scopes: [] }))); - expect(button("Update")!.disabled).toBe(true); - await click("Update"); + expect(button(screen, "Update")!.disabled).toBe(true); + await click(screen, "Update"); expect(testState.updateServer).not.toHaveBeenCalled(); }); @@ -236,8 +224,13 @@ describe("ServerUpdateAction", () => { >)( "copies an honest manual command for %j without invoking remote update", async (installation, label, command, title, guidance) => { - await renderAction({ selfUpdate: null, installation, targetVersion: "0.0.45" }); - await click(label); + using _state = setup(); + await using screen = await renderAction({ + selfUpdate: null, + installation, + targetVersion: "0.0.45", + }); + await click(screen, label); expect(testState.clipboard).toHaveBeenCalledWith(command); expect(testState.toast).toHaveBeenCalledWith( expect.objectContaining({ @@ -250,10 +243,11 @@ describe("ServerUpdateAction", () => { ); it("reports success only after the shared update flow reconnects", async () => { + using _state = setup(); testState.updateServer.mockResolvedValue(success()); - await renderAction(); - await click("Update"); + await using screen = await renderAction(); + await click(screen, "Update"); expect(testState.updateServer).toHaveBeenCalledWith({ environmentId: "env-test", @@ -267,6 +261,7 @@ describe("ServerUpdateAction", () => { }); it("reports one result when the update action is double-clicked", async () => { + using _state = setup(); let finishUpdate: (() => void) | undefined; testState.updateServer.mockImplementation( () => @@ -275,10 +270,10 @@ describe("ServerUpdateAction", () => { }), ); - await renderAction(); + await using screen = await renderAction(); await act(async () => { - button("Update")!.click(); - button("Update")!.click(); + button(screen, "Update")!.click(); + button(screen, "Update")!.click(); }); expect(testState.updateServer).toHaveBeenCalledTimes(1); @@ -287,29 +282,35 @@ describe("ServerUpdateAction", () => { }); it("quietly releases the action when the operation is interrupted", async () => { + using _state = setup(); testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); - await renderAction(); - await click("Update"); + await using screen = await renderAction(); + await click(screen, "Update"); expect(testState.toast).not.toHaveBeenCalled(); }); it("keeps the manual instruction for desktop servers without remote update support", async () => { - await renderAction({ selfUpdate: "desktop-managed" }); + using _state = setup(); + await using screen = await renderAction({ selfUpdate: "desktop-managed" }); - expect(container.textContent).toBe( + expect(screen.container.textContent).toBe( "Update the desktop app on that machine to update this server.", ); - expect(button()).toBeUndefined(); + expect(button(screen)).toBeUndefined(); }); it("updates remote desktop apps through the shared update flow after confirmation", async () => { + using _state = setup(); registerConfirmDialogHost(); testState.updateServer.mockResolvedValue(success("desktop-app", "0.0.34")); - await renderAction({ selfUpdate: "desktop-managed", desktopAppUpdate: true }); - await click("Update"); + await using screen = await renderAction({ + selfUpdate: "desktop-managed", + desktopAppUpdate: true, + }); + await click(screen, "Update"); expect(readConfirmDialogState()).toEqual( expect.objectContaining({ status: "confirming", @@ -330,10 +331,11 @@ describe("ServerUpdateAction", () => { }); it("leaves thread continuation off by default", async () => { + using _state = setup(); testState.updateServer.mockResolvedValue(success()); - await renderAction({ threadContinuation: true }); - await click("Update"); + await using screen = await renderAction({ threadContinuation: true }); + await click(screen, "Update"); expect(testState.updateServer).toHaveBeenCalledWith({ environmentId: "env-test", @@ -342,11 +344,12 @@ describe("ServerUpdateAction", () => { }); it("applies the saved thread continuation preference automatically", async () => { + using _state = setup(); testState.updateServer.mockResolvedValue(success()); testState.continueThreadsAfterServerUpdate = true; - await renderAction({ threadContinuation: true }); - await click("Update"); + await using screen = await renderAction({ threadContinuation: true }); + await click(screen, "Update"); expect(testState.updateServer).toHaveBeenCalledWith({ environmentId: "env-test", @@ -381,12 +384,13 @@ describe("ServerUpdatesAction", () => { }, ]; - const mount = (batch = targets) => render(); + const mount = (batch = targets) => renderUpdate(); it("updates both supported machines with their own continuation preference and skips the manual machine", async () => { + using _state = setup(); testState.updateServer.mockResolvedValue(success()); - await mount(); - await click("Update all"); + await using screen = await mount(); + await click(screen, "Update all"); expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([ { @@ -402,11 +406,12 @@ describe("ServerUpdatesAction", () => { }); it("names a failed machine while letting the other machine complete", async () => { + using _state = setup(); testState.updateServer .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed")))) .mockResolvedValueOnce(success()); - await mount(); - await click("Update all"); + await using screen = await mount(); + await click(screen, "Update all"); expect(testState.updateServer).toHaveBeenCalledTimes(2); expect(testState.toast).toHaveBeenCalledWith({ @@ -417,10 +422,11 @@ describe("ServerUpdatesAction", () => { expect(testState.toast).toHaveBeenCalledWith( expect.objectContaining({ type: "success", title: "Office updated" }), ); - expect(button("Update all")!.disabled).toBe(false); + expect(button(screen, "Update all")!.disabled).toBe(false); }); it("starts each machine once when double-clicked and disables the action until both finish", async () => { + using _state = setup(); const completions: Array<() => void> = []; testState.updateServer.mockImplementation( () => @@ -428,28 +434,29 @@ describe("ServerUpdatesAction", () => { completions.push(() => resolve(success())); }), ); - await mount(); + await using screen = await mount(); await act(async () => { - button("Update all")!.click(); - button("Update all")!.click(); + button(screen, "Update all")!.click(); + button(screen, "Update all")!.click(); }); expect(testState.updateServer).toHaveBeenCalledTimes(2); - expect(button("Update all")!.disabled).toBe(true); + expect(button(screen, "Update all")!.disabled).toBe(true); await act(async () => completions[0]!()); - expect(button("Update all")!.disabled).toBe(true); + expect(button(screen, "Update all")!.disabled).toBe(true); await act(async () => completions[1]!()); - expect(button("Update all")!.disabled).toBe(false); + expect(button(screen, "Update all")!.disabled).toBe(false); expect(testState.toast).toHaveBeenCalledTimes(2); }); it("asks once for desktop machines and cancels the entire batch", async () => { + using _state = setup(); registerConfirmDialogHost(); - await mount( + await using screen = await mount( targets.map((target, index) => index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target, ), ); - await click("Update all"); + await click(screen, "Update all"); expect(readConfirmDialogState()).toEqual( expect.objectContaining({ status: "confirming", @@ -459,13 +466,14 @@ describe("ServerUpdatesAction", () => { expect(testState.updateServer).not.toHaveBeenCalled(); await act(async () => respondToConfirmDialog(false)); expect(testState.updateServer).not.toHaveBeenCalled(); - expect(button("Update all")!.disabled).toBe(false); + expect(button(screen, "Update all")!.disabled).toBe(false); }); }); describe("ServerUpdateProgress", () => { it("shows one calm status for the restart wait", async () => { - await render( + using _state = setup(); + await using screen = await renderUpdate( { }} />, ); - expect(container.textContent).toBe("Restarting…"); + expect(screen.container.textContent).toBe("Restarting…"); }); it("folds the sub-second installing handoff into the download phase", async () => { - await render( + using _state = setup(); + await using screen = await renderUpdate( { }} />, ); - expect(container.textContent).toBe("Downloading…"); + expect(screen.container.textContent).toBe("Downloading…"); }); it("keeps the failure visible as an alert with its error", async () => { - await render( + using _state = setup(); + await using screen = await renderUpdate( { }} />, ); - expect(container.querySelector("[role=alert]")?.textContent).toBe( + expect(screen.container.querySelector("[role=alert]")?.textContent).toBe( "The package could not be verified.", ); }); diff --git a/apps/web/src/components/Sidebar.pointer.browser.test.tsx b/apps/web/src/components/Sidebar.pointer.browser.test.tsx index 45ec1d60d177..6d4362f3db24 100644 --- a/apps/web/src/components/Sidebar.pointer.browser.test.tsx +++ b/apps/web/src/components/Sidebar.pointer.browser.test.tsx @@ -1,7 +1,7 @@ import type { SensorProps } from "@dnd-kit/core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { act, StrictMode } from "react"; -import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode } from "react"; +import { fakeTimers, render } from "../../test/render"; import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; const sensors: SidebarPointerSensor[] = []; @@ -42,24 +42,23 @@ function gesture( return { sensor, onFinish, ...callbacks }; } -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { + const stack = new DisposableStack(); + stack.use(fakeTimers()); hidden = false; - vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden); -}); -afterEach(() => { - for (const sensor of sensors.splice(0)) sensor.cancel(); - // Cancelled sensors keep suppressing clicks on the shared document until the next press. - document.dispatchEvent(pointer("pointerdown")); - vi.runAllTimers(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + stack.use(vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden)); + stack.defer(() => { + for (const sensor of sensors.splice(0)) sensor.cancel(); + // Cancelled sensors keep suppressing clicks on the shared document until the next press. + document.dispatchEvent(pointer("pointerdown")); + vi.runAllTimers(); + }); + return stack; +} describe("sidebar pointer lifecycle", () => { it("keeps a click idle and starts only after the drag threshold", () => { + using _environment = setup(); const click = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 16 })); expect(click.onStart).not.toHaveBeenCalled(); @@ -77,6 +76,7 @@ describe("sidebar pointer lifecycle", () => { }); it("moves to the pointer on the move that starts the drag", () => { + using _environment = setup(); const drag = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 90 })); expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); @@ -98,6 +98,7 @@ describe("sidebar pointer lifecycle", () => { }; for (const [name, interrupt] of Object.entries(interruptions)) { it.each([false, true])(`cancels on ${name}, started=%s, and ignores late events`, (started) => { + using _environment = setup(); const drag = gesture(); if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); interrupt(); @@ -120,6 +121,7 @@ describe("sidebar pointer lifecycle", () => { } it("hands claimed moves and drops to the context handlers and cancels the sort", () => { + using _environment = setup(); const onMove = vi.fn((point: { x: number; y: number }) => point.x > 50); const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); const drag = gesture({ onMove, onDrop }); @@ -143,6 +145,7 @@ describe("sidebar pointer lifecycle", () => { }); it("drops context when a zero-button move precedes pointerup", () => { + using _environment = setup(); const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); @@ -160,6 +163,7 @@ describe("sidebar pointer lifecycle", () => { }); it("cancels a missed sidebar release without committing a reorder", () => { + using _environment = setup(); const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); @@ -173,6 +177,7 @@ describe("sidebar pointer lifecycle", () => { }); it("does not drop context when the button is released before activation", () => { + using _environment = setup(); const onDrop = vi.fn(() => true); const drag = gesture({ onDrop }); document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 90, clientY: 30 })); @@ -185,6 +190,7 @@ describe("sidebar pointer lifecycle", () => { }); it("resumes reordering when a context drag returns to the sidebar", () => { + using _environment = setup(); const drag = gesture({ onMove: (point) => point.x > 50, onDrop: (point) => point.x > 50, @@ -204,6 +210,7 @@ describe("sidebar pointer lifecycle", () => { }); it.each([false, true])("suppresses a cancelled release click, started=%s", (started) => { + using _environment = setup(); const drag = gesture(); if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); @@ -220,6 +227,7 @@ describe("sidebar pointer lifecycle", () => { }); it.each([false, true])("allows a fresh click after an outside release, started=%s", (started) => { + using _environment = setup(); const drag = gesture(); if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); @@ -231,6 +239,7 @@ describe("sidebar pointer lifecycle", () => { }); it("does not move after cancellation during activation", () => { + using _environment = setup(); const drag = gesture(); drag.onStart.mockImplementation(() => drag.sensor.cancel()); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); @@ -240,6 +249,7 @@ describe("sidebar pointer lifecycle", () => { }); it("ignores events from other pointers", () => { + using _environment = setup(); const drag = gesture(); document.dispatchEvent(pointer("pointermove", { pointerId: 2, buttons: 0, clientY: 100 })); document.dispatchEvent(pointer("pointercancel", { pointerId: 2 })); @@ -250,19 +260,17 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onStart).toHaveBeenCalledOnce(); }); - it.each([false, true])("cancels when search unmounts the list, started=%s", (started) => { - const root = createRoot(document.createElement("div")); + it.each([false, true])("cancels when search unmounts the list, started=%s", async (started) => { + using _environment = setup(); let drag: ReturnType | undefined; - act(() => - root.render( - - drag?.sensor.cancel()} /> - , - ), + await using screen = await render( + + drag?.sensor.cancel()} /> + , ); drag = gesture(); if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - act(() => root.unmount()); + await screen.unmount(); document.dispatchEvent(pointer("pointermove", { clientY: 100 })); document.dispatchEvent(pointer("pointerup", { buttons: 0 })); expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0); @@ -272,6 +280,7 @@ describe("sidebar pointer lifecycle", () => { }); it("detaches a cancelled sensor before a replacement gesture starts", () => { + using _environment = setup(); const previous = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); previous.sensor.cancel(); diff --git a/apps/web/src/components/SidebarStageBackdrop.browser.test.tsx b/apps/web/src/components/SidebarStageBackdrop.browser.test.tsx index a8397d011916..c7b5fe2a58dd 100644 --- a/apps/web/src/components/SidebarStageBackdrop.browser.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.browser.test.tsx @@ -1,39 +1,20 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; +import { render } from "../../test/render"; import { StageBackdropArt } from "./SidebarStageBackdrop"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - // Gradients and patterns resolve url(#id) document-wide, so a repeated id would paint one // backdrop with another's definitions. it.each(["nightly", "dev"] as const)( "gives every %s backdrop its own SVG definitions when two are on the page", async (variant) => { - await act(() => - root.render( - <> - - - , - ), + await using screen = await render( + <> + + + , ); - const [first, second] = container.querySelectorAll("svg"); + const [first, second] = screen.container.querySelectorAll("svg"); const ids = (svg: Element | undefined) => [...svg!.querySelectorAll("[id]")].map((el) => el.id); expect(ids(first).length).toBeGreaterThan(0); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 3f8d6d1db57f..d18dedd9e418 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -3,9 +3,9 @@ import { EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { render, stubGlobal, type Screen } from "../../test/render"; const state = vi.hoisted(() => ({ mode: "notifications", @@ -85,7 +85,6 @@ const thread = { session: null as { status: string } | null, latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null }, }; -let root: Root; let focused = false; let visibility = "visible"; @@ -161,19 +160,14 @@ function complete(environment = "one", completedAt = "2026-09-13T08:00:00Z") { shell({ latestTurn: { turnId: "turn", state: "completed", completedAt } }), ); } -async function render() { - await act(async () => - root.render( - - - , - ), - ); -} - -beforeEach(() => { +// The mocked modules read `state`, the registry and the focus flags, so every test starts by +// resetting them. The first render is left to the test because some tests change state first. +function setup() { vi.clearAllMocks(); + const stack = new AsyncDisposableStack(); registry = AtomRegistry.make(); + const testRegistry = registry; + stack.defer(() => testRegistry.dispose()); state.mode = "notifications"; state.inApp = false; state.environmentIds = ["one", "two"]; @@ -183,25 +177,32 @@ beforeEach(() => { visibility = "visible"; TestNotification.permission = "granted"; TestNotification.sent = []; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("Notification", TestNotification); - vi.spyOn(window, "focus").mockImplementation(() => undefined); - vi.spyOn(document, "hasFocus").mockImplementation(() => focused); - vi.spyOn(document, "visibilityState", "get").mockImplementation( - () => visibility as DocumentVisibilityState, + stack.use(stubGlobal("Notification", TestNotification)); + stack.use(vi.spyOn(window, "focus").mockImplementation(() => undefined)); + stack.use(vi.spyOn(document, "hasFocus").mockImplementation(() => focused)); + stack.use( + vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility as DocumentVisibilityState), ); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - delete window.desktopBridge; - registry.dispose(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + const ui = () => ( + + + + ); + let screen: Screen | undefined; + return { + async render() { + if (screen) await screen.rerender(ui()); + else screen = stack.use(await render(ui())); + }, + unmount: () => screen!.unmount(), + [Symbol.asyncDispose]: () => stack.disposeAsync(), + }; +} it("uses bounded tags across threads and environments, replaces repeat alerts, and clears on focus", async () => { + await using app = setup(); const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90"; const otherEnvironmentId = "25b62992-b996-4ee3-8d82-d988840e45c1"; const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588"; @@ -213,19 +214,19 @@ it("uses bounded tags across threads and environments, replaces repeat alerts, a state.environmentIds = [environmentId, otherEnvironmentId]; state.shells.set(environmentId, shell({ id: threadId }, { id: otherThreadId })); state.shells.set(otherEnvironmentId, shell({ id: threadId })); - await render(); + await app.render(); state.shells.set(environmentId, shell(completed(threadId), { id: otherThreadId })); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(1); state.shells.set(environmentId, shell(completed(threadId), completed(otherThreadId))); state.shells.set(otherEnvironmentId, shell(completed(threadId))); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(3); state.shells.set( environmentId, shell(completed(threadId, "2026-09-13T08:01:00Z"), completed(otherThreadId)), ); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(3); expect(TestNotification.sent).toHaveLength(4); const [first, otherThread, otherEnvironment, replacement] = TestNotification.sent; @@ -246,62 +247,62 @@ it("uses bounded tags across threads and environments, replaces repeat alerts, a ).toBe(true); focused = false; state.shells.set(otherEnvironmentId, shell(completed(threadId, "2026-09-13T08:02:00Z"))); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(1); }); it("does not badge old completions on first load or reconnect", async () => { + await using app = setup(); complete(); - await render(); + await app.render(); state.shells.set("one", { status: "connecting", snapshot: Option.none() }); - await render(); + await app.render(); complete("one", "2026-09-13T08:01:00Z"); - await render(); + await app.render(); expect(TestNotification.sent).toHaveLength(0); expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); }); it("removes alerts only from environments that leave the client", async () => { - await render(); + await using app = setup(); + await app.render(); complete("one"); complete("two"); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(2); const [removed, retained] = TestNotification.sent; state.environmentIds = ["two"]; - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(1); expect(removed!.close).toHaveBeenCalledOnce(); expect(retained!.close).not.toHaveBeenCalled(); - await render(); + await app.render(); expect(removed!.close).toHaveBeenCalledOnce(); state.environmentIds = []; - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(0); expect(retained!.close).toHaveBeenCalledOnce(); }); it("starts a fresh count after another native app window gains focus", async () => { + await using app = setup(); let clear: (() => void) | undefined; const unsubscribe = vi.fn(); - Object.assign(window, { - desktopBridge: { - onNotificationBadgeClear: (listener: () => void) => { - clear = listener; - return unsubscribe; - }, + using _bridge = stubGlobal("desktopBridge", { + onNotificationBadgeClear: (listener: () => void) => { + clear = listener; + return unsubscribe; }, }); - await render(); + await app.render(); complete(); - await render(); + await app.render(); clear!(); expect(state.badge).toHaveBeenLastCalledWith(0); complete("two"); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(1); - await act(async () => root.unmount()); - root = createRoot(document.createElement("div")); + await app.unmount(); expect(unsubscribe).toHaveBeenCalledOnce(); expect(state.badge).toHaveBeenLastCalledWith(0); }); @@ -309,10 +310,11 @@ it("starts a fresh count after another native app window gains focus", async () it.each(["off", "sound", "focused", "denied", "archived"])( "does not show visual alerts when %s", async (condition) => { + await using app = setup(); if (condition === "off" || condition === "sound") state.mode = condition; if (condition === "focused") focused = true; if (condition === "denied") TestNotification.permission = "denied"; - await render(); + await app.render(); complete(); if (condition === "archived") state.shells.set( @@ -322,7 +324,7 @@ it.each(["off", "sound", "focused", "denied", "archived"])( hasPendingApprovals: true, }), ); - await render(); + await app.render(); expect(TestNotification.sent).toHaveLength(0); expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); }, @@ -331,9 +333,10 @@ it.each(["off", "sound", "focused", "denied", "archived"])( it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( "badges %s and clears when notifications are disabled", async (flag) => { - await render(); + await using app = setup(); + await app.render(); state.shells.set("one", shell({ [flag]: true })); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(1); const notification = TestNotification.sent[0]!; notification.dispatchEvent(new Event("click")); @@ -342,28 +345,30 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, }); state.mode = "sound"; - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(0); expect(notification.close).toHaveBeenCalled(); }, ); it("shows in-app alerts without adding a badge while focused", async () => { + await using app = setup(); state.inApp = true; focused = true; - await render(); + await app.render(); complete(); - await render(); + await app.render(); expect(state.toast).toHaveBeenCalledOnce(); expect(TestNotification.sent).toHaveLength(0); expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); }); it("badges background failures with in-app notifications enabled", async () => { + await using app = setup(); state.inApp = true; - await render(); + await app.render(); state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "error" } })); - await render(); + await app.render(); expect(TestNotification.sent[0]?.title).toBe("Thread failed"); expect(state.badge).toHaveBeenLastCalledWith(1); expect(state.toast).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx index 5c066f03628d..2046a3fc5aa0 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx @@ -1,9 +1,9 @@ import type { ClientSettings } from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render, stubGlobal, type Screen } from "../../test/render"; const state = vi.hoisted(() => ({ mode: "off" as ClientSettings["notificationMode"], @@ -150,18 +150,9 @@ vi.mock(import("./ui/toast"), async (original) => { import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; -let root: Root; - -async function render() { - await act(() => root.render()); -} - -async function complete() { - state.completedAt = "2026-09-13T10:00:00.000Z"; - await render(); -} - -beforeEach(() => { +// The mocked modules read `state`, so every test starts by resetting all of it. The first +// render is left to the test because some tests change state before the coordinator mounts. +function setup() { vi.clearAllMocks(); Object.assign(state, { mode: "off", @@ -182,42 +173,54 @@ beforeEach(() => { delegatedChild: null, }); stableParent = null; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(document, "visibilityState", "get").mockImplementation( - () => state.visible as DocumentVisibilityState, + const stack = new AsyncDisposableStack(); + stack.use( + vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => state.visible as DocumentVisibilityState), ); - vi.spyOn(document, "hasFocus").mockImplementation(() => state.focused); - vi.stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" })); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + stack.use(vi.spyOn(document, "hasFocus").mockImplementation(() => state.focused)); + stack.use( + stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" })), + ); + let screen: Screen | undefined; + const renderCoordinator = async () => { + if (screen) await screen.rerender(); + else screen = stack.use(await render()); + }; + return { + render: renderCoordinator, + complete: async () => { + state.completedAt = "2026-09-13T10:00:00.000Z"; + await renderCoordinator(); + }, + [Symbol.asyncDispose]: () => stack.disposeAsync(), + }; +} describe("thread notifications", () => { it.each([true, false])("keeps subagents silent with focus=%s", async (focused) => { + await using app = setup(); state.subagent = true; state.focused = focused; state.mode = "notifications-and-sound"; - await render(); - await complete(); + await app.render(); + await app.complete(); state.input = true; - await render(); + await app.render(); expect(state.sound).not.toHaveBeenCalled(); expect(state.add).not.toHaveBeenCalled(); expect(state.notification).not.toHaveBeenCalled(); }); it("alerts on the parent when its delegated subagent asks for approval", async () => { + await using app = setup(); state.mode = "notifications-and-sound"; state.delegatedChild = "running"; - await render(); + await app.render(); state.delegatedChild = "approval"; - await render(); - await render(); + await app.render(); + await app.render(); expect(state.add).toHaveBeenCalledTimes(1); expect(state.add).toHaveBeenLastCalledWith( expect.objectContaining({ title: "Approval needed", description: "Fix the login form" }), @@ -225,9 +228,10 @@ describe("thread notifications", () => { }); it("alerts once with system alerts off and opens the completed thread", async () => { - await render(); - await complete(); - await render(); + await using app = setup(); + await app.render(); + await app.complete(); + await app.render(); expect(state.add).toHaveBeenCalledTimes(1); const toast = state.add.mock.calls[0]?.[0]; expect(toast?.title).toBe("Thread completed"); @@ -244,13 +248,14 @@ describe("thread notifications", () => { it.each(["active", "blurred", "hidden", "archived", "disabled"])( "does not show a completion toast for %s threads", async (condition) => { - await render(); + await using app = setup(); + await app.render(); if (condition === "active") state.active.threadId = "thread-1"; if (condition === "blurred") state.focused = false; if (condition === "hidden") state.visible = "hidden"; if (condition === "archived") state.archivedAt = "2026-09-13T09:00:00.000Z"; if (condition === "disabled") state.inApp = false; - await complete(); + await app.complete(); expect(state.add).not.toHaveBeenCalled(); }, ); @@ -262,22 +267,23 @@ describe("thread notifications", () => { ["turnError", "Thread failed"], ["limited", "Usage limit reached"], ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => { + await using app = setup(); state.mode = "notifications-and-sound"; - await render(); + await app.render(); state[event] = true; - await render(); - await render(); + await app.render(); + await app.render(); expect(state.add).toHaveBeenCalledTimes(1); expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title })); expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); expect(state.notification).not.toHaveBeenCalled(); state[event] = false; - await render(); + await app.render(); state.focused = false; state[event] = true; - await render(); - await render(); + await app.render(); + await app.render(); expect(state.add).toHaveBeenCalledTimes(1); expect(state.notification).toHaveBeenCalledTimes(1); expect(state.notification).toHaveBeenCalledWith(title, { @@ -288,12 +294,13 @@ describe("thread notifications", () => { }); it("alerts when only a dev server is left running, not while a monitor can wake the agent", async () => { - await render(); + await using app = setup(); + await app.render(); state.background = [{ taskId: "watch", kind: "monitor" }]; - await complete(); + await app.complete(); expect(state.add).not.toHaveBeenCalled(); state.background = [{ taskId: "dev", kind: "command" }]; - await render(); + await app.render(); expect(state.add).toHaveBeenCalledTimes(1); expect(state.add).toHaveBeenLastCalledWith( expect.objectContaining({ title: "Thread completed" }), @@ -301,57 +308,63 @@ describe("thread notifications", () => { }); it("keeps background desktop alerts when in-app notifications are disabled", async () => { + await using app = setup(); state.focused = false; state.inApp = false; state.mode = "notifications"; - await render(); - await complete(); + await app.render(); + await app.complete(); expect(state.add).not.toHaveBeenCalled(); expect(state.notification).toHaveBeenCalledTimes(1); state.inApp = true; - await render(); + await app.render(); expect(state.add).not.toHaveBeenCalled(); }); it("does not replay a completion when opting in from all alerts off", async () => { + await using app = setup(); state.inApp = false; - await render(); - await complete(); + await app.render(); + await app.complete(); state.inApp = true; - await render(); + await app.render(); expect(state.add).not.toHaveBeenCalled(); }); it("compares the environment as well as the thread", async () => { + await using app = setup(); state.active = { environmentId: "env-2", threadId: "thread-1" }; - await render(); - await complete(); + await app.render(); + await app.complete(); expect(state.add).toHaveBeenCalledTimes(1); }); it("does not replay completed threads on first load or reconnect", async () => { - await complete(); + await using app = setup(); + await app.complete(); state.live = false; - await render(); + await app.render(); state.live = true; - await render(); + await app.render(); expect(state.add).not.toHaveBeenCalled(); }); it("keeps sound but replaces the system popup when showing a toast", async () => { + await using app = setup(); state.mode = "notifications-and-sound"; - await render(); - await complete(); + await app.render(); + await app.complete(); expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); expect(state.add).toHaveBeenCalledTimes(1); expect(state.notification).not.toHaveBeenCalled(); }); it("keeps system alerts when the app is in the background", async () => { + await using app = setup(); state.mode = "notifications"; state.focused = false; - await render(); - await complete(); + await app.render(); + await app.complete(); expect(state.add).not.toHaveBeenCalled(); expect(state.notification).toHaveBeenCalledWith("Thread completed", { body: "Fix the login form", @@ -361,12 +374,13 @@ describe("thread notifications", () => { }); it("reveals the desktop window and opens the thread when a system alert is clicked", async () => { + await using app = setup(); const revealWindow = vi.fn(() => Promise.resolve()); - vi.stubGlobal("desktopBridge", { revealWindow }); + using _bridge = stubGlobal("desktopBridge", { revealWindow }); state.mode = "notifications"; state.focused = false; - await render(); - await complete(); + await app.render(); + await app.complete(); const notification: EventTarget = state.notification.mock.results[0]?.value; notification.dispatchEvent(new Event("click")); expect(revealWindow).toHaveBeenCalledTimes(1); diff --git a/apps/web/src/components/ThreadStatusIndicators.subscriptions.browser.test.tsx b/apps/web/src/components/ThreadStatusIndicators.subscriptions.browser.test.tsx index 09d3c3823105..9e2aa3f0cc35 100644 --- a/apps/web/src/components/ThreadStatusIndicators.subscriptions.browser.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.subscriptions.browser.test.tsx @@ -4,14 +4,14 @@ import { type ThreadPullRequestLink, type VcsStatusResult, } from "@t3tools/contracts"; -import { act, type MouseEvent } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import type { MouseEvent } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "../index.css"; import { Button } from "./ui/button"; +import { act, render } from "../../test/render"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { makeThreadFixture } from "../test-fixtures"; @@ -86,17 +86,12 @@ vi.mock(import("../state/pullRequests"), async (importOriginal) => { }); import { ThreadPullRequestBadgeControl, ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; -afterEach(() => { - vi.unstubAllGlobals(); -}); - // The lease overscans 160px around a 20px viewport, a 340px band. With 45px rows the band // covers exactly 8 rows at each scroll position below. const ROW_HEIGHT = 45; const SPACER = 1000; it("leases only visible palette results while retaining observed and cached PR badges", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const threads = Array.from({ length: 128 }, (_, index) => makeThreadFixture({ id: ThreadId.make(`palette-thread-${index}`), @@ -113,12 +108,9 @@ it("leases only visible palette results while retaining observed and cached PR b }, }), ); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); state.queries.length = 0; const viewport = () => - container.querySelector('[data-slot="scroll-area-viewport"]')!; + screen.container.querySelector('[data-slot="scroll-area-viewport"]')!; const scrollTo = async (top: number) => { await act(async () => { viewport().scrollTop = top; @@ -126,68 +118,63 @@ it("leases only visible palette results while retaining observed and cached PR b }); }; const badgeCount = () => - [...container.querySelectorAll("[aria-label]")].filter((node) => + [...screen.container.querySelectorAll("[aria-label]")].filter((node) => node.getAttribute("aria-label")!.startsWith("PR #"), ).length; - try { - await act(async () => - root.render( - -
-
- {threads.map((thread, index) => ( -
- - {thread.title} -
- ))} + await using screen = await render( + +
+
+ {threads.map((thread, index) => ( +
+ + {thread.title}
- , - ), - ); - await scrollTo(0); - expect(state.queries).toEqual([]); - expect(badgeCount()).toBe(1); + ))} +
+ , + ); + await scrollTo(0); + expect(state.queries).toEqual([]); + expect(badgeCount()).toBe(1); - // Band [1000, 1340]: rows 0..7. - await scrollTo(SPACER + 160); - expect(state.queries).toHaveLength(8); - expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4); - expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4); - expect(badgeCount()).toBe(9); + // Band [1000, 1340]: rows 0..7. + await scrollTo(SPACER + 160); + expect(state.queries).toHaveLength(8); + expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4); + expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4); + expect(badgeCount()).toBe(9); - // Band [1370, 1710]: rows 8..15. - await scrollTo(SPACER + 8 * ROW_HEIGHT + 10 + 160); - expect(state.queries).toHaveLength(16); - expect(badgeCount()).toBe(17); - } finally { - await act(async () => root.unmount()); - container.remove(); - } + // Band [1370, 1710]: rows 8..15. + await scrollTo(SPACER + 8 * ROW_HEIGHT + 10 + 160); + expect(state.queries).toHaveLength(16); + expect(badgeCount()).toBe(17); }); it("keeps a long PR hover list in the viewport and lets the last row be selected", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const previousViewport = { width: window.innerWidth, height: window.innerHeight }; await page.viewport(800, 480); + await using _viewport = { + [Symbol.asyncDispose]: () => page.viewport(previousViewport.width, previousViewport.height), + }; const pullRequests = Array.from({ length: 40 }, (_, index): ThreadPullRequestLink => ({ host: "github.com", repository: "example/repo", @@ -199,50 +186,39 @@ it("keeps a long PR hover list in the viewport and lets the last row be selected stack: null, })); const onOpenPullRequest = vi.fn((event: MouseEvent) => event.preventDefault()); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - try { - await act(async () => - root.render( -
- } - badge={{ kind: "pull-request", others: 39, state: "open" }} - pullRequests={pullRequests} - number={1} - url={pullRequests[0]!.url} - status={null} - onOpenList={() => {}} - onOpenPullRequest={onOpenPullRequest} - /> -
, - ), - ); - await act(() => userEvent.hover(container.querySelector("button")!)); - const popup = () => document.querySelector('[data-slot="tooltip-popup"]')!; - await expect.poll(() => popup()?.querySelectorAll("a").length).toBe(40); - await expect.poll(() => popup().getBoundingClientRect().top).toBeGreaterThanOrEqual(0); - await expect - .poll(() => popup().getBoundingClientRect().bottom) - .toBeLessThanOrEqual(window.innerHeight); + await using screen = await render( +
+ } + badge={{ kind: "pull-request", others: 39, state: "open" }} + pullRequests={pullRequests} + number={1} + url={pullRequests[0]!.url} + status={null} + onOpenList={() => {}} + onOpenPullRequest={onOpenPullRequest} + /> +
, + ); + await act(() => userEvent.hover(screen.container.querySelector("button")!)); + const popup = () => document.querySelector('[data-slot="tooltip-popup"]')!; + await expect.poll(() => popup()?.querySelectorAll("a").length).toBe(40); + await expect.poll(() => popup().getBoundingClientRect().top).toBeGreaterThanOrEqual(0); + await expect + .poll(() => popup().getBoundingClientRect().bottom) + .toBeLessThanOrEqual(window.innerHeight); - const lastRow = popup().querySelector("li:last-child a")!; - expect(lastRow.getBoundingClientRect().top).toBeGreaterThan( - popup().getBoundingClientRect().bottom, - ); - await act(() => userEvent.wheel(popup(), { delta: { y: 10_000 } })); - await expect - .poll(() => lastRow.getBoundingClientRect().bottom) - .toBeLessThanOrEqual(popup().getBoundingClientRect().bottom); - expect(lastRow.getBoundingClientRect().top).toBeGreaterThanOrEqual( - popup().getBoundingClientRect().top, - ); - await act(() => userEvent.click(lastRow)); - expect(onOpenPullRequest).toHaveBeenCalledExactlyOnceWith(expect.anything(), lastRow.href); - } finally { - await act(async () => root.unmount()); - container.remove(); - await page.viewport(previousViewport.width, previousViewport.height); - } + const lastRow = popup().querySelector("li:last-child a")!; + expect(lastRow.getBoundingClientRect().top).toBeGreaterThan( + popup().getBoundingClientRect().bottom, + ); + await act(() => userEvent.wheel(popup(), { delta: { y: 10_000 } })); + await expect + .poll(() => lastRow.getBoundingClientRect().bottom) + .toBeLessThanOrEqual(popup().getBoundingClientRect().bottom); + expect(lastRow.getBoundingClientRect().top).toBeGreaterThanOrEqual( + popup().getBoundingClientRect().top, + ); + await act(() => userEvent.click(lastRow)); + expect(onOpenPullRequest).toHaveBeenCalledExactlyOnceWith(expect.anything(), lastRow.href); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx index 8883e768f672..be661c1316d1 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx @@ -7,9 +7,7 @@ import { import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { nextTerminalAttachSeedState } from "@t3tools/client-runtime/state/terminal"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import type { GhosttyTerminalSurface, GhosttyTerminalSurfaceOptions, @@ -82,6 +80,7 @@ vi.mock(import("../state/session"), async (original) => { }; }); +import { act, render } from "../../test/render"; import ThreadTerminalDrawer, { TerminalViewport } from "./ThreadTerminalDrawer"; const threadRef = { @@ -89,10 +88,8 @@ const threadRef = { threadId: ThreadId.make("thread"), }; const session = { ...nextTerminalAttachSeedState(), status: "running" as const, version: 1 }; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { +// The mocked modules read `state`, so every test starts by resetting all of it. +function setup() { state.allowed = true; state.listeners.clear(); state.resize.mockReset().mockResolvedValue(AsyncResult.success(undefined)); @@ -100,38 +97,26 @@ beforeEach(() => { // A surface can report its initial grid while asynchronous WASM setup is // pending. Keep that unrelated setup pending while exercising its callback. state.createSurface.mockReset().mockReturnValue(new Promise(() => undefined)); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); +} it("rechecks the target terminal grant when a retained surface callback reports a resize", async () => { - await act(async () => - root.render( - , - ), + setup(); + await using _screen = await render( + , ); expect(state.createSurface).toHaveBeenCalledOnce(); const onResize = state.createSurface.mock.calls[0]![1].onResize; @@ -157,6 +142,7 @@ it("rechecks the target terminal grant when a retained surface callback reports }); it("keeps held Kitty keys and the original surface through split, focus, and unsplit", async () => { + setup(); const { GhosttyTerminalSurface } = await vi.importActual< typeof import("~/terminal/ghostty/surface") >("~/terminal/ghostty/surface"); @@ -180,78 +166,79 @@ it("keeps held Kitty keys and the original surface through split, focus, and uns }; // ChatView installs its capture listener before the terminal mounts. window.addEventListener("keydown", consumeShortcut, { capture: true }); + using _shortcut = { + [Symbol.dispose]: () => + window.removeEventListener("keydown", consumeShortcut, { capture: true }), + }; const composer = document.createElement("textarea"); document.body.append(composer); - const render = async (terminalIds: string[]) => { - await act(async () => - root.render( - , - ), - ); - await act(async () => { + using _composer = { [Symbol.dispose]: () => composer.remove() }; + const drawer = (terminalIds: string[]) => ( + + ); + const surfacesCreated = () => + act(async () => { await Promise.all(state.createSurface.mock.results.map((result) => result.value)); }); - }; - try { - await render(["original"]); - const original = await state.createSurface.mock.results[0]!.value; - const disposeOriginal = vi.spyOn(original, "dispose"); - original.write("\x1b[>11u"); - const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => - original.input.dispatchEvent( - new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }), - ); - original.focus(); - key("keydown", { key: "Control", code: "ControlLeft", ctrlKey: true }); - key("keydown", { key: "d", code: "KeyD", ctrlKey: true }); - await render(["original", "split"]); - expect(state.createSurface).toHaveBeenCalledTimes(2); - expect(container.querySelector("textarea")).toBe(original.input); - expect(disposeOriginal).not.toHaveBeenCalled(); - const split = await state.createSurface.mock.results[1]!.value; - const disposeSplit = vi.spyOn(split, "dispose"); - original.focus(); - key("keyup", { key: "d", code: "KeyD", ctrlKey: true }); - key("keyup", { key: "Control", code: "ControlLeft" }); + await using screen = await render(drawer(["original"])); + const { container } = screen; + await surfacesCreated(); + const original = await state.createSurface.mock.results[0]!.value; + const disposeOriginal = vi.spyOn(original, "dispose"); + original.write("\x1b[>11u"); + const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => + original.input.dispatchEvent( + new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }), + ); + original.focus(); + key("keydown", { key: "Control", code: "ControlLeft", ctrlKey: true }); + key("keydown", { key: "d", code: "KeyD", ctrlKey: true }); + await screen.rerender(drawer(["original", "split"])); + await surfacesCreated(); + expect(state.createSurface).toHaveBeenCalledTimes(2); + expect(container.querySelector("textarea")).toBe(original.input); + expect(disposeOriginal).not.toHaveBeenCalled(); + const split = await state.createSurface.mock.results[1]!.value; + const disposeSplit = vi.spyOn(split, "dispose"); + original.focus(); + key("keyup", { key: "d", code: "KeyD", ctrlKey: true }); + key("keyup", { key: "Control", code: "ControlLeft" }); - key("keydown", { key: "b", code: "KeyB" }); - composer.focus(); - original.focus(); - key("keydown", { key: "b", code: "KeyB", repeat: true }); - key("keyup", { key: "b", code: "KeyB" }); + key("keydown", { key: "b", code: "KeyB" }); + composer.focus(); + original.focus(); + key("keydown", { key: "b", code: "KeyB", repeat: true }); + key("keyup", { key: "b", code: "KeyB" }); - key("keydown", { key: "c", code: "KeyC" }); - await render(["original"]); - expect(state.createSurface).toHaveBeenCalledTimes(2); - expect(container.querySelector("textarea")).toBe(original.input); - expect(disposeOriginal).not.toHaveBeenCalled(); - expect(disposeSplit).toHaveBeenCalledOnce(); - original.focus(); - key("keyup", { key: "c", code: "KeyC" }); - expect(output).toEqual([ - ["\x1b[57442;5u", "\x1b[57442;1:3u", "\x1b[98u", "\x1b[98;1:3u", "\x1b[99u", "\x1b[99;1:3u"], - [], - ]); - } finally { - window.removeEventListener("keydown", consumeShortcut, { capture: true }); - composer.remove(); - } + key("keydown", { key: "c", code: "KeyC" }); + await screen.rerender(drawer(["original"])); + await surfacesCreated(); + expect(state.createSurface).toHaveBeenCalledTimes(2); + expect(container.querySelector("textarea")).toBe(original.input); + expect(disposeOriginal).not.toHaveBeenCalled(); + expect(disposeSplit).toHaveBeenCalledOnce(); + original.focus(); + key("keyup", { key: "c", code: "KeyC" }); + expect(output).toEqual([ + ["\x1b[57442;5u", "\x1b[57442;1:3u", "\x1b[98u", "\x1b[98;1:3u", "\x1b[99u", "\x1b[99;1:3u"], + [], + ]); }); diff --git a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx index 790fb481174e..645c7f0e3a80 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx @@ -4,11 +4,11 @@ import { MessageId, ThreadId, } from "@t3tools/contracts"; -import { act, useState, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useState, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { act, render } from "../../../test/render"; const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() })); vi.mock(import("./AssistantCitationSource"), async (importOriginal) => ({ @@ -35,11 +35,20 @@ const citation = { suffix: "", }; -let root: Root; -let container: HTMLDivElement; -let sourceAnchor: AssistantCitationSourceAnchor; +// The mocked source observer is shared, so every test starts by resetting it. +function setup() { + mocks.observeSource.mockReset().mockReturnValue(mocks.dispose); + mocks.dispose.mockClear(); + const source = document.createElement("p"); + source.textContent = "hello"; + document.body.append(source); + const range = document.createRange(); + range.selectNodeContents(source); + const sourceAnchor = { source, range, viewport: document.body } as AssistantCitationSourceAnchor; + return { sourceAnchor, [Symbol.dispose]: () => source.remove() }; +} -async function mount(onSave = vi.fn(() => true)) { +function mount(sourceAnchor: AssistantCitationSourceAnchor, onSave: () => boolean) { function Composer() { const [open, setOpen] = useState(true); return ( @@ -50,8 +59,7 @@ async function mount(onSave = vi.fn(() => true)) { /> ); } - await act(async () => root.render()); - return onSave; + return render(); } const textarea = () => @@ -81,30 +89,11 @@ async function clickButton(label: string) { await act(async () => button!.click()); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - mocks.observeSource.mockReset().mockReturnValue(mocks.dispose); - mocks.dispose.mockClear(); - const source = document.createElement("p"); - source.textContent = "hello"; - document.body.append(source); - const range = document.createRange(); - range.selectNodeContents(source); - sourceAnchor = { source, range, viewport: document.body } as AssistantCitationSourceAnchor; - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); - describe("citation comment source disappearance", () => { it("preserves an over-length draft at the composer until it can be shortened and saved", async () => { - const onSave = await mount(); + using source = setup(); + const onSave = vi.fn(() => true); + await using _screen = await mount(source.sourceAnchor, onSave); const draft = "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1); await typeComment(draft); await removeSource(); @@ -122,7 +111,9 @@ describe("citation comment source disappearance", () => { }); it("preserves a rejected save and allows a later retry", async () => { - const onSave = await mount(vi.fn(() => false)); + using source = setup(); + const onSave = vi.fn(() => false); + await using _screen = await mount(source.sourceAnchor, onSave); await typeComment("keep this draft"); await removeSource(); @@ -135,7 +126,9 @@ describe("citation comment source disappearance", () => { }); it("still allows explicit cancellation after source disappearance", async () => { - const onSave = await mount(vi.fn(() => false)); + using source = setup(); + const onSave = vi.fn(() => false); + await using _screen = await mount(source.sourceAnchor, onSave); await typeComment("discard this draft"); await removeSource(); onSave.mockClear(); @@ -145,7 +138,9 @@ describe("citation comment source disappearance", () => { }); it("saves and closes when the source disappears with a valid draft", async () => { - const onSave = await mount(); + using source = setup(); + const onSave = vi.fn(() => true); + await using _screen = await mount(source.sourceAnchor, onSave); await typeComment("saved comment"); await removeSource(); expect(onSave).toHaveBeenCalledWith("saved comment"); diff --git a/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx index 6e40307bb524..d271bea184cd 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx @@ -2,40 +2,20 @@ import "~/index.css"; import { RunId } from "@t3tools/contracts"; import { LegendList } from "@legendapp/list/react"; -import { act, type ReactNode, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; import type { TurnDiffFileChange } from "../../types"; import { ChangedFilesCard, ChangedFilesTree } from "./ChangedFilesTree"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -async function render(node: ReactNode) { - await act(async () => root.render(node)); -} - function file(path: string, additions = 1, deletions = 0): TurnDiffFileChange { return { path, kind: "modified", additions, deletions }; } -const text = () => container.textContent ?? ""; +const text = (container: HTMLElement) => container.textContent ?? ""; -function buttonWithText(label: string) { +function buttonWithText(container: HTMLElement, label: string) { const button = [...container.querySelectorAll("button")].find((element) => element.textContent?.includes(label), ); @@ -53,7 +33,7 @@ async function scrollTo(element: HTMLElement, top: number) { describe("ChangedFilesCard", () => { it("summarizes a single file with a singular label", async () => { - await render( + await using screen = await render( { onOpenTurnDiff={() => {}} />, ); + const { container } = screen; - expect(text()).toContain("1 changed file"); - expect(text()).not.toContain("1 changed files"); + expect(text(container)).toContain("1 changed file"); + expect(text(container)).not.toContain("1 changed files"); }); it("shows collapsed folders and root files together, and opens a folder on click", async () => { const onOpenTurnDiff = vi.fn(); - await render( + await using screen = await render( { onOpenTurnDiff={onOpenTurnDiff} />, ); + const { container } = screen; - expect(text()).toContain("apps/web/src"); - expect(text()).toContain("packages/shared/src"); - expect(text()).toContain("README.md"); - expect(text()).not.toContain("App.tsx"); - expect(text()).not.toContain("git.ts"); + expect(text(container)).toContain("apps/web/src"); + expect(text(container)).toContain("packages/shared/src"); + expect(text(container)).toContain("README.md"); + expect(text(container)).not.toContain("App.tsx"); + expect(text(container)).not.toContain("git.ts"); - await act(async () => buttonWithText("apps/web/src").click()); - expect(text()).toContain("App.tsx"); - expect(text()).toContain("App.test.tsx"); - expect(text()).not.toContain("git.ts"); + await act(async () => buttonWithText(container, "apps/web/src").click()); + expect(text(container)).toContain("App.tsx"); + expect(text(container)).toContain("App.test.tsx"); + expect(text(container)).not.toContain("git.ts"); - await act(async () => buttonWithText("App.test.tsx").click()); + await act(async () => buttonWithText(container, "App.test.tsx").click()); expect(onOpenTurnDiff).toHaveBeenCalledWith(RunId.make("run-1"), "apps/web/src/App.test.tsx"); }); }); @@ -108,7 +90,7 @@ describe("ChangedFilesTree", () => { ); it("virtualizes above 250 visible rows and disables it when folders collapse below that count", async () => { - await render( + await using screen = await render(
{ />
, ); + const { container } = screen; expect(container.querySelectorAll("button").length).toBe(2); - await act(async () => buttonWithText("src").click()); + await act(async () => buttonWithText(container, "src").click()); expect(container.querySelectorAll("button").length).toBe(250); - expect(text()).toContain("file-00247.ts"); - expect(text()).not.toContain("overflow.ts"); + expect(text(container)).toContain("file-00247.ts"); + expect(text(container)).not.toContain("overflow.ts"); - await act(async () => buttonWithText("extra").click()); + await act(async () => buttonWithText(container, "extra").click()); expect(container.querySelectorAll("button").length).toBeLessThan(60); - expect(text()).not.toContain("file-00247.ts"); + expect(text(container)).not.toContain("file-00247.ts"); - await act(async () => buttonWithText("extra").click()); + await act(async () => buttonWithText(container, "extra").click()); expect(container.querySelectorAll("button").length).toBe(250); - expect(text()).toContain("file-00247.ts"); - expect(text()).not.toContain("overflow.ts"); + expect(text(container)).toContain("file-00247.ts"); + expect(text(container)).not.toContain("overflow.ts"); }); it("keeps 1,000 collapsed folders virtualized and reachable after scrolling", async () => { @@ -151,21 +134,22 @@ describe("ChangedFilesTree", () => { />
); - await render(content(true)); + await using screen = await render(content(true)); + const { container } = screen; expect(container.querySelectorAll("button").length).toBeLessThan(60); - await render(content(false)); + await screen.rerender(content(false)); expect(container.querySelectorAll("button").length).toBeLessThan(60); - expect(text()).toContain("folder-0000"); - expect(text()).not.toContain("index.ts"); - expect(text()).not.toContain("folder-0999"); + expect(text(container)).toContain("folder-0000"); + expect(text(container)).not.toContain("index.ts"); + expect(text(container)).not.toContain("folder-0999"); const viewport = container.firstElementChild as HTMLElement; await scrollTo(viewport, viewport.scrollHeight); - expect(text()).toContain("folder-0999"); + expect(text(container)).toContain("folder-0999"); expect(container.querySelectorAll("button").length).toBeLessThan(60); - await act(async () => buttonWithText("folder-0999").click()); - expect(text()).toContain("index.ts"); + await act(async () => buttonWithText(container, "folder-0999").click()); + expect(text(container)).toContain("index.ts"); }); it("keeps expand-all bounded and opens files after scrolling the surrounding viewport", async () => { @@ -185,12 +169,13 @@ describe("ChangedFilesTree", () => { /> ); } - await render( + await using screen = await render(
, ); + const { container } = screen; const viewport = container.firstElementChild as HTMLElement; await scrollTo(viewport, 420); const collapsedHeight = viewport.scrollHeight; @@ -200,20 +185,20 @@ describe("ChangedFilesTree", () => { expect(container.querySelectorAll("button").length).toBeLessThan(60); expect(viewport.scrollHeight).toBeGreaterThan(collapsedHeight + 400_000); - expect(text()).toContain("file-00000.ts"); - expect(text()).not.toContain("file-08000.ts"); + expect(text(container)).toContain("file-00000.ts"); + expect(text(container)).not.toContain("file-08000.ts"); await scrollTo(viewport, 420 + 8_001 * 28); - expect(text()).toContain("file-08000.ts"); - expect(text()).not.toContain("file-00000.ts"); + expect(text(container)).toContain("file-08000.ts"); + expect(text(container)).not.toContain("file-00000.ts"); expect(container.querySelectorAll("button").length).toBeLessThan(60); await scrollTo(viewport, viewport.scrollHeight); - expect(text()).toContain("file-16184.ts"); - await act(async () => buttonWithText("file-16184.ts").click()); + expect(text(container)).toContain("file-16184.ts"); + await act(async () => buttonWithText(container, "file-16184.ts").click()); expect(onOpenTurnDiff).toHaveBeenCalledWith(RunId.make("run-1"), "src/file-16184.ts"); await act(async () => - buttonWithText("file-16184.ts").dispatchEvent( + buttonWithText(container, "file-16184.ts").dispatchEvent( new MouseEvent("contextmenu", { bubbles: true, cancelable: true }), ), ); @@ -223,11 +208,11 @@ describe("ChangedFilesTree", () => { container.querySelector('[aria-label="Collapse all folders"]')!.click(), ); expect(viewport.scrollHeight).toBe(collapsedHeight); - expect(text()).not.toContain("file-16184.ts"); + expect(text(container)).not.toContain("file-16184.ts"); }); it("virtualizes a single expanded folder inside a LegendList timeline item", async () => { - await render( + await using screen = await render( item} @@ -248,25 +233,26 @@ describe("ChangedFilesTree", () => { } />, ); + const { container } = screen; const viewport = container.firstElementChild as HTMLElement; await scrollTo(viewport, 420); - await act(async () => buttonWithText("src").click()); + await act(async () => buttonWithText(container, "src").click()); expect(container.querySelectorAll("button").length).toBeLessThan(60); await scrollTo(viewport, 420 + 8_001 * 28); - expect(text()).toContain("file-08000.ts"); + expect(text(container)).toContain("file-08000.ts"); expect(container.querySelectorAll("button").length).toBeLessThan(60); await scrollTo(viewport, viewport.scrollHeight); - expect(text()).toContain("file-16184.ts"); + expect(text(container)).toContain("file-16184.ts"); await scrollTo(viewport, 420); - await act(async () => buttonWithText("src").click()); - expect(text()).not.toContain("file-00000.ts"); + await act(async () => buttonWithText(container, "src").click()); + expect(text(container)).not.toContain("file-00000.ts"); expect(container.querySelectorAll("button").length).toBe(1); }); it("keeps a focused file mounted when scrolling it out of view", async () => { - await render( + await using screen = await render(
{ />
, ); + const { container } = screen; const viewport = container.firstElementChild as HTMLElement; - const focusedFile = buttonWithText("file-00000.ts"); + const focusedFile = buttonWithText(container, "file-00000.ts"); await act(async () => focusedFile.focus()); await scrollTo(viewport, 8_001 * 28); - expect(text()).toContain("file-08000.ts"); + expect(text(container)).toContain("file-08000.ts"); expect(document.activeElement).toBe(focusedFile); expect(container.querySelectorAll("button").length).toBeLessThan(60); await act(async () => focusedFile.blur()); - expect(text()).not.toContain("file-00000.ts"); + expect(text(container)).not.toContain("file-00000.ts"); }); it("remeasures the visible rows when the viewport or preceding content resizes", async () => { @@ -304,15 +291,16 @@ describe("ChangedFilesTree", () => {
); - await render(content(140, 0)); + await using screen = await render(content(140, 0)); + const { container } = screen; const viewport = container.firstElementChild as HTMLElement; await scrollTo(viewport, 8_001 * 28); const beforeResize = container.querySelectorAll("button").length; - await render(content(560, 560)); + await screen.rerender(content(560, 560)); await expect .poll(() => container.querySelectorAll("button").length) .toBeGreaterThan(beforeResize); - expect(text()).toContain("file-07980.ts"); + expect(text(container)).toContain("file-07980.ts"); expect(container.querySelectorAll("button").length).toBeLessThan(60); }); @@ -369,7 +357,8 @@ describe("ChangedFilesTree", () => { onOpenTurnDiff={() => {}} /> ); - await render(tree(false)); + await using screen = await render(tree(false)); + const { container } = screen; const visibleWhenCollapsed = [...container.querySelectorAll("button")].map((button) => button.textContent?.trim(), ); @@ -379,8 +368,8 @@ describe("ChangedFilesTree", () => { ); } - await render(tree(true)); - for (const label of expanded) expect(text()).toContain(label); + await screen.rerender(tree(true)); + for (const label of expanded) expect(text(container)).toContain(label); }, ); }); diff --git a/apps/web/src/components/chat/ChatCanvas.browser.test.tsx b/apps/web/src/components/chat/ChatCanvas.browser.test.tsx index 048c16802fc5..49c171f0962d 100644 --- a/apps/web/src/components/chat/ChatCanvas.browser.test.tsx +++ b/apps/web/src/components/chat/ChatCanvas.browser.test.tsx @@ -1,6 +1,7 @@ -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; import { ChatCanvas } from "./ChatCanvas"; import { useChatCanvas } from "./ChatCanvasContext"; @@ -12,8 +13,6 @@ vi.mock("../../lib/observeResize", () => ({ }, })); -let root: Root; -let container: HTMLDivElement; let fontSize = 16; let canvas: ReturnType; @@ -35,76 +34,71 @@ function Preview({ x }: { x: number }) { return null; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function mockLayout() { + const stack = new DisposableStack(); fontSize = 16; - vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1800); - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(1000); - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( - this: HTMLElement, - ) { - if (this.hasAttribute("data-thread-find-bar")) { - const gap = 0.75 * fontSize; - return new DOMRect( - 200 + 1800 - gap - 17.5 * fontSize, - 100 + gap, - 17.5 * fontSize, - 2.25 * fontSize, - ); - } - return new DOMRect(200, 100, 1800, 1000); - }); - vi.spyOn(window, "getComputedStyle").mockReturnValue({ - paddingLeft: "48px", - width: "736px", - minWidth: "640px", - } as CSSStyleDeclaration); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - resize.callbacks.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + stack.use(vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1800)); + stack.use(vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(1000)); + stack.use( + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + if (this.hasAttribute("data-thread-find-bar")) { + const gap = 0.75 * fontSize; + return new DOMRect( + 200 + 1800 - gap - 17.5 * fontSize, + 100 + gap, + 17.5 * fontSize, + 2.25 * fontSize, + ); + } + return new DOMRect(200, 100, 1800, 1000); + }), + ); + stack.use( + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + paddingLeft: "48px", + width: "736px", + minWidth: "640px", + } as CSSStyleDeclaration), + ); + stack.defer(() => resize.callbacks.clear()); + return stack; +} -async function renderFind(open: boolean, previewX = 1548) { - await act(() => - root.render( - - {open ?
: null} - - , - ), +function find(open: boolean, previewX = 1548) { + return ( + + {open ?
: null} + + ); } it.each([16, 24])( "keeps a preview below Find and releases its space on close at a %spx root font", async (size) => { + using _layout = mockLayout(); fontSize = size; - await renderFind(false); + await using screen = await render(find(false)); expect(canvas?.layout.frame?.y).toBe(12); - await renderFind(true); + await screen.rerender(find(true)); expect(canvas?.detailsCardTopInset).toBe(3 * size); expect(canvas?.layout.frame?.y).toBe(3 * size + 12); - await renderFind(false); + await screen.rerender(find(false)); expect(canvas?.detailsCardTopInset).toBe(0); expect(canvas?.layout.frame?.y).toBe(12); - await renderFind(true); + await screen.rerender(find(true)); expect(canvas?.layout.frame?.y).toBe(3 * size + 12); }, ); it("remeasures the open bar when its font size changes without resizing the canvas", async () => { - await renderFind(true, 1200); + using _layout = mockLayout(); + await using _screen = await render(find(true, 1200)); expect(canvas?.layout.frame?.y).toBe(12); fontSize = 24; diff --git a/apps/web/src/components/chat/ChatOverlayHosts.browser.test.tsx b/apps/web/src/components/chat/ChatOverlayHosts.browser.test.tsx index 2e36c714761a..55c51472788c 100644 --- a/apps/web/src/components/chat/ChatOverlayHosts.browser.test.tsx +++ b/apps/web/src/components/chat/ChatOverlayHosts.browser.test.tsx @@ -1,27 +1,10 @@ import { MessageId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; +import { act, render } from "../../../test/render"; import { openRevertConfirm, RevertConfirmHost } from "./ChatOverlayHosts"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - const messageId = MessageId.make("message-1"); const dialog = () => document.querySelector('[role="alertdialog"]'); @@ -33,7 +16,7 @@ describe("RevertConfirmHost", () => { ); - await act(() => root.render(host("env:a"))); + await using screen = await render(host("env:a")); await act(() => openRevertConfirm("env:b", { turnCount: 2, messageId })); expect(dialog()).toBeNull(); @@ -48,7 +31,7 @@ describe("RevertConfirmHost", () => { await act(() => openRevertConfirm("env:a", { turnCount: 3, messageId })); expect(dialog()).not.toBeNull(); - await act(() => root.render(host("env:b"))); + await screen.rerender(host("env:b")); await vi.waitFor(() => expect(dialog()).toBeNull()); }); }); diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index ecb56eab5deb..51bb2d4e6422 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -1,57 +1,43 @@ import "../../index.css"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +import { act, render } from "../../../test/render"; import { ComposerBannerStack } from "./ComposerBannerStack"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +function sized(width: number) { + const container = document.createElement("div"); + container.style.width = `${width}px`; + return container; +} const nextFrame = () => act(() => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)))); -async function render(width: number, description: string) { - container.style.width = `${width}px`; - await act(async () => - root.render( - , - ), - ); - await nextFrame(); -} - -const hasDetails = () => container.querySelector('[aria-label="Show notice details"]') !== null; +const usage = (description: string) => ( + +); it("only offers notice details when the description cannot fit", async () => { - await render(700, "OpenCode"); + await using screen = await render(usage("OpenCode"), { container: sized(700) }); + const { container } = screen; + const hasDetails = () => container.querySelector('[aria-label="Show notice details"]') !== null; + await nextFrame(); expect(hasDetails()).toBe(false); - await render(700, "OpenCode ".repeat(40)); + await screen.rerender(usage("OpenCode ".repeat(40))); + await nextFrame(); expect(hasDetails()).toBe(true); container.style.width = "4000px"; await nextFrame(); expect(hasDetails()).toBe(false); - await render(4000, "OpenCode"); + await screen.rerender(usage("OpenCode")); + await nextFrame(); expect(hasDetails()).toBe(false); // A narrow composer hides the description entirely, so details are the only way to read it. @@ -61,29 +47,28 @@ it("only offers notice details when the description cannot fit", async () => { }); it("keeps only the usage limit attached while an active goal waits in the collapsible stack", async () => { - await act(async () => - root.render( - , - ), + await using screen = await render( + , ); + const { container } = screen; const visibleBanners = () => Array.from(container.querySelectorAll('[data-slot="composer-banner"]')).filter((banner) => @@ -113,21 +98,19 @@ it("keeps only the usage limit attached while an active goal waits in the collap it("reveals the full running command only while its title is clipped", async () => { const command = "Running: /usr/bin/zsh -lc 'sleep 600 > /tmp/t3-background-command-with-a-long-log-filename-for-truncation.log 2>&1'"; - container.style.width = "240px"; - await act(async () => - root.render( - , - ), + await using screen = await render( + , + { container: sized(240) }, ); const details = page.getByRole("button", { name: "Show title details", exact: true }); @@ -137,7 +120,7 @@ it("reveals the full running command only while its title is clipped", async () await expect.element(popup).toBeVisible(); await expect.element(popup).toHaveTextContent(command); - container.style.width = "4000px"; + screen.container.style.width = "4000px"; await expect.element(details).not.toBeInTheDocument(); await expect.element(popup).not.toBeInTheDocument(); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.browser.test.tsx index d6cbd23d0266..8bb9d4158096 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.browser.test.tsx @@ -1,48 +1,30 @@ import { RuntimeRequestId, type ProviderApprovalOption } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; -let root: Root; -let container: HTMLDivElement; -const onRespond = vi.fn(async () => undefined); - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - onRespond.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); - -async function render(options?: ReadonlyArray) { - await act(async () => - root.render( - , - ), +function renderActions( + onRespond: () => Promise, + options?: ReadonlyArray, +) { + return render( + , ); } -const rowLabels = () => +const rowLabels = (container: HTMLElement) => [...container.querySelectorAll("button")] .map((button) => button.textContent?.trim()) .filter(Boolean); -async function openMore() { +async function openMore(container: HTMLElement) { await act(async () => container.querySelector('[aria-label="More approval options"]')!.click(), ); @@ -51,10 +33,11 @@ async function openMore() { describe("ComposerPendingApprovalActions", () => { it("keeps the main decisions in the row and sends a secondary decision from the menu", async () => { - await render(); - expect(rowLabels()).toEqual(["Decline", "Approve"]); + const onRespond = vi.fn(async () => undefined); + await using screen = await renderActions(onRespond); + expect(rowLabels(screen.container)).toEqual(["Decline", "Approve"]); - const items = await openMore(); + const items = await openMore(screen.container); expect(items.map((item) => item.textContent?.trim())).toEqual([ "Cancel", "Always allow this session", @@ -64,18 +47,19 @@ describe("ComposerPendingApprovalActions", () => { }); it("keeps provider labels, including secondary ones, where the user can find them", async () => { - await render([ + const onRespond = vi.fn(async () => undefined); + await using screen = await renderActions(onRespond, [ { decision: "decline", label: "Deny" }, { decision: "acceptAlways", label: "Always allow Safari" }, { decision: "accept", label: "Allow once" }, ]); - expect(rowLabels()).toEqual(["Deny", "Allow once"]); - expect((await openMore()).map((item) => item.textContent?.trim())).toEqual([ + expect(rowLabels(screen.container)).toEqual(["Deny", "Allow once"]); + expect((await openMore(screen.container)).map((item) => item.textContent?.trim())).toEqual([ "Always allow Safari", ]); await act(async () => - [...container.querySelectorAll("button")] + [...screen.container.querySelectorAll("button")] .find((button) => button.textContent?.trim() === "Allow once")! .click(), ); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index b0d14d504726..f1076f246b09 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -6,9 +6,8 @@ import { TurnItemId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; -import { act, useLayoutEffect, useRef, useState, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { togglePendingUserInputOptionSelection, @@ -20,6 +19,7 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ default: (({ text }: { text: string }) =>

{text}

) as never, })); +import { act, fakeTimers, render } from "../../../test/render"; import { makeThreadProjectionFixture } from "../../test-fixtures"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { usePendingThreadRequests } from "./usePendingThreadRequests"; @@ -75,22 +75,6 @@ function Questions({ initialIndex = 0 }: { initialIndex?: number }) { ); } -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); - function buttonWithText(text: string) { const button = [...document.querySelectorAll("button")].find((element) => element.textContent?.includes(text), @@ -108,11 +92,12 @@ async function navigate(label: string) { } function activeIndex() { - return container.querySelector("output")!.textContent; + return document.querySelector("output")!.textContent; } it("does not skip the next unanswered question after manual advance", async () => { - await act(async () => root.render()); + using _timers = fakeTimers(); + await using _screen = await render(); await chooseOption(); await navigate("Next"); expect(activeIndex()).toBe("1"); @@ -125,7 +110,8 @@ it("does not skip the next unanswered question after manual advance", async () = }); it("does not undo previous-question navigation with a stale timer", async () => { - await act(async () => root.render()); + using _timers = fakeTimers(); + await using _screen = await render(); await chooseOption(); await navigate("Previous"); await act(async () => vi.advanceTimersByTime(200)); @@ -133,7 +119,8 @@ it("does not undo previous-question navigation with a stale timer", async () => }); it("still auto-advances once after the answer draft rerenders the same question", async () => { - await act(async () => root.render()); + using _timers = fakeTimers(); + await using _screen = await render(); await chooseOption(); await act(async () => vi.advanceTimersByTime(200)); expect(activeIndex()).toBe("1"); @@ -211,19 +198,13 @@ function PendingRequestsProbe({ } it("retains answers, selections and question position after leaving chat, including while the projection reloads", async () => { + using _timers = fakeTimers(); const environmentId = EnvironmentId.make("remount-environment"); const projection = questionProjection(threadRef.threadId); - const render = (loading = false) => - act(() => - root.render( - , - ), - ); - await render(); + const probe = (loading = false) => ( + + ); + await using screen = await render(probe()); await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", "Keep my text"), ); @@ -240,18 +221,19 @@ it("retains answers, selections and question position after leaving chat, includ expect(requests).toMatchObject(expected); // New thread and Settings both unmount the chat that owns this hook. - await act(() => root.render(null)); - await render(); + await screen.rerender(null); + await screen.rerender(probe()); expect(requests).toMatchObject(expected); - await act(() => root.render(null)); - await render(true); + await screen.rerender(null); + await screen.rerender(probe(true)); expect(requests.activePendingUserInput).toBeNull(); - await render(); + await screen.rerender(probe()); expect(requests).toMatchObject(expected); }); it("isolates drafts by environment, thread and request and discards only resolved requests", async () => { + using _timers = fakeTimers(); const environmentId = EnvironmentId.make("scope-environment"); const projection = questionProjection(threadRef.threadId); const scopes = [ @@ -260,8 +242,9 @@ it("isolates drafts by environment, thread and request and discards only resolve { environmentId, projection: questionProjection(ThreadId.make("other-thread")) }, { environmentId, projection: questionProjection(threadRef.threadId, ["second", "first"]) }, ]; + await using screen = await render(null); const show = (scope: ComponentProps) => - act(() => root.render()); + screen.rerender(); for (const [index, scope] of scopes.entries()) { await show(scope); expect(requests.activePendingQuestionIndex).toBe(0); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.browser.test.tsx index 478ac9ed0db6..bd722de8c9fb 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.browser.test.tsx @@ -1,6 +1,4 @@ import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ @@ -8,13 +6,13 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ default: (({ text }: { text: string }) =>

{text}

) as never, })); +import { act, fakeTimers, render } from "../../../test/render"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + using _timers = fakeTimers(); const onToggleOption = vi.fn(); const onAdvance = vi.fn(); const onDismiss = vi.fn(); @@ -47,39 +45,29 @@ it("cancels an answer's pending auto-submit on revocation and resumes after a ne onDismiss={onDismiss} /> ); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); + await using screen = await render(panel(false)); // A disabled button swallows the click, as it would for a user. const chooseOption = () => - [...container.querySelectorAll("button")] + [...screen.container.querySelectorAll("button")] .find((button) => button.textContent?.includes("Incremental"))! .click(); - try { - await act(async () => root.render(panel(false))); - await act(async () => chooseOption()); - expect(onToggleOption).toHaveBeenCalledWith("approach", "Incremental"); - expect(onAdvance).not.toHaveBeenCalled(); + await act(async () => chooseOption()); + expect(onToggleOption).toHaveBeenCalledWith("approach", "Incremental"); + expect(onAdvance).not.toHaveBeenCalled(); - await act(async () => root.render(panel(true))); - await act(async () => { - chooseOption(); - vi.runAllTimers(); - }); - expect(onToggleOption).toHaveBeenCalledTimes(1); - expect(onAdvance).not.toHaveBeenCalled(); + await screen.rerender(panel(true)); + await act(async () => { + chooseOption(); + vi.runAllTimers(); + }); + expect(onToggleOption).toHaveBeenCalledTimes(1); + expect(onAdvance).not.toHaveBeenCalled(); - await act(async () => root.render(panel(false))); - await act(async () => { - chooseOption(); - vi.runAllTimers(); - }); - expect(onToggleOption).toHaveBeenCalledTimes(2); - expect(onAdvance).toHaveBeenCalledTimes(1); - } finally { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - } + await screen.rerender(panel(false)); + await act(async () => { + chooseOption(); + vi.runAllTimers(); + }); + expect(onToggleOption).toHaveBeenCalledTimes(2); + expect(onAdvance).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.browser.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.browser.test.tsx index 97a97174c0e9..1f124ddfbd5f 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.browser.test.tsx @@ -1,7 +1,5 @@ import "~/index.css"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -13,52 +11,36 @@ vi.mock("../SidebarStageBackdrop", () => ({ useSidebarStageBackdropVariant: () => null, })); +import { render } from "../../../test/render"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; it("opens implementation actions with a mouse while the composer is resting", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); const onImplementPlanInNewThread = vi.fn(); + await using _screen = await render( + {}} + onInterrupt={() => {}} + onImplementPlanInNewThread={onImplementPlanInNewThread} + />, + ); - try { - await act(async () => { - root.render( - {}} - onInterrupt={() => {}} - onImplementPlanInNewThread={onImplementPlanInNewThread} - />, - ); - }); - - await act(async () => { - await userEvent.click(page.getByRole("button", { name: "Implementation actions" })); - }); - const item = page.getByRole("menuitem", { name: "Implement in a new thread" }); - await expect.element(item).toBeVisible(); - await act(async () => { - await userEvent.click(item); - }); - expect(onImplementPlanInNewThread).toHaveBeenCalledTimes(1); - } finally { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - } + await userEvent.click(page.getByRole("button", { name: "Implementation actions" })); + const item = page.getByRole("menuitem", { name: "Implement in a new thread" }); + await expect.element(item).toBeVisible(); + await userEvent.click(item); + expect(onImplementPlanInNewThread).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx b/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx index 7e7cc267d757..752ec8af46f0 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx @@ -1,8 +1,8 @@ -import { act, useRef, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useRef, useState } from "react"; +import { expect, it } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +import { act, render } from "../../../test/render"; import { Dialog, DialogPopup } from "../ui/dialog"; import { ComposerStashMenu } from "./ComposerStashMenu"; @@ -62,22 +62,6 @@ function Composer({ dialog = false }: { dialog?: boolean }) { ); } -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - const editor = () => page.getByRole("textbox", { name: "Message" }); const stash = () => page.getByRole("list", { name: "Stashed prompts" }); const restore = (prompt: string) => @@ -86,7 +70,7 @@ const press = (keys: string) => act(async () => userEvent.keyboard(keys)); const focus = (element: Element) => act(async () => (element as HTMLElement).focus()); it("leaves dialog, unrelated input and question button shortcuts alone while the stash is open", async () => { - await act(async () => root.render()); + await using _screen = await render(); const input = page.getByRole("textbox", { name: "Search commands" }).element(); await focus(input); @@ -128,7 +112,7 @@ it("leaves dialog, unrelated input and question button shortcuts alone while the }); it("navigates, deletes and restores stashed prompts from the composer and drawer", async () => { - await act(async () => root.render()); + await using _screen = await render(); await focus(restore("first prompt").element()); await press("{ArrowDown}"); await expect.element(restore("second prompt")).toHaveFocus(); diff --git a/apps/web/src/components/chat/ComposerSurface.browser.test.tsx b/apps/web/src/components/chat/ComposerSurface.browser.test.tsx index 18414f8ee595..accfe1d7d5ac 100644 --- a/apps/web/src/components/chat/ComposerSurface.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerSurface.browser.test.tsx @@ -1,33 +1,19 @@ import "~/index.css"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, vi } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; +import { render } from "../../../test/render"; import { ComposerSurface } from "./ComposerSurface"; it("masks the toolbar overlap and reveals its surface one pixel before the input edge", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); + await using screen = await render( + + Message + Workspace + , + ); - try { - await act(async () => { - root.render( - - Message - Workspace - , - ); - }); - - const strip = container.querySelector('[data-slot="composer-context-strip"]')!; - const mask = getComputedStyle(strip, "::before").maskImage; - expect(mask).toContain("rgba(0, 0, 0, 0) 15px, rgb(0, 0, 0) 15px"); - } finally { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - } + const strip = screen.container.querySelector('[data-slot="composer-context-strip"]')!; + const mask = getComputedStyle(strip, "::before").maskImage; + expect(mask).toContain("rgba(0, 0, 0, 0) 15px, rgb(0, 0, 0) 15px"); }); diff --git a/apps/web/src/components/chat/DraftProjectName.browser.test.tsx b/apps/web/src/components/chat/DraftProjectName.browser.test.tsx index 5672901a7717..c9d36b55bd4a 100644 --- a/apps/web/src/components/chat/DraftProjectName.browser.test.tsx +++ b/apps/web/src/components/chat/DraftProjectName.browser.test.tsx @@ -1,77 +1,81 @@ import "~/index.css"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; +import { act, render as renderUi } from "../../../test/render"; import { InlineButton } from "../ui/button"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { DraftProjectName } from "./DraftProjectName"; -let root: Root; -let host: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - host = document.body.appendChild(document.createElement("div")); +async function setup() { + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await renderUi(null)); + const container = screen.container; + const host = container; host.style.width = "700px"; host.style.maxWidth = "100%"; - root = createRoot(host); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - host.remove(); - vi.unstubAllGlobals(); -}); - -async function render(value: string, placement: "headline" | "row" = "headline") { - await act(async () => { - root.render( - placement === "headline" ? ( -
-
- - - - - + async function render(value: string, placement: "headline" | "row" = "headline") { + await act(async () => { + await screen.rerender( + placement === "headline" ? ( +
+
+ + + + + +
-
- ) : ( -
- - - - -
- ), - ); - }); -} + ) : ( +
+ + + + +
+ ), + ); + }); + } -const label = () => host.querySelector("span[aria-label]")!; -const title = () => host.querySelector("h1 > span")!; + const label = () => host.querySelector("span[aria-label]")!; + const title = () => host.querySelector("h1 > span")!; -function expectWithinHost(element: Element) { - const bounds = element.getBoundingClientRect(); - const hostBounds = host.getBoundingClientRect(); - expect(bounds.left).toBeGreaterThanOrEqual(hostBounds.left); - expect(bounds.right).toBeLessThanOrEqual(hostBounds.right); -} + function expectWithinHost(element: Element) { + const bounds = element.getBoundingClientRect(); + const hostBounds = host.getBoundingClientRect(); + expect(bounds.left).toBeGreaterThanOrEqual(hostBounds.left); + expect(bounds.right).toBeLessThanOrEqual(hostBounds.right); + } -function expectAboveComposer() { - const heading = host.querySelector("h1")!; - const composer = host.querySelector('[data-testid="composer"]')!; - expectWithinHost(heading); - expectWithinHost(label()); - expect(heading.getBoundingClientRect().bottom).toBeLessThan(composer.getBoundingClientRect().top); -} + function expectAboveComposer() { + const heading = host.querySelector("h1")!; + const composer = host.querySelector('[data-testid="composer"]')!; + expectWithinHost(heading); + expectWithinHost(label()); + expect(heading.getBoundingClientRect().bottom).toBeLessThan( + composer.getBoundingClientRect().top, + ); + } + const environment = stack.move(); + return { + host, + render, + label, + title, + expectAboveComposer, + expectWithinHost, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} it.each([320, 390, 822])( "places the full short project name below the title in smaller text at %ipx", async (width) => { + await using fixture = await setup(); + const { host, render, label, title, expectAboveComposer } = fixture; await page.viewport(width, 800); host.style.width = `${width - 32}px`; for (const value of ["t3code", "obsidian-arr", "No project"]) { @@ -92,6 +96,8 @@ it.each([320, 390, 822])( ); it("uses the inline heading only when the viewport and chat lane both have room", async () => { + await using fixture = await setup(); + const { host, render, label, title, expectAboveComposer } = fixture; await page.viewport(1440, 900); await render("obsidian-arr"); expect(title().innerText).toBe("What should we build in"); @@ -109,6 +115,8 @@ it("uses the inline heading only when the viewport and chat lane both have room" it.each([390, 1440])( "uses a single smaller row and truncates only its remaining overflow at %ipx", async (width) => { + await using fixture = await setup(); + const { host, render, label, title, expectAboveComposer } = fixture; await page.viewport(width, 900); host.style.width = `${Math.min(width - 32, 700)}px`; const value = "ExampleCompany-CustomerPortal-Production-".repeat(5); @@ -131,6 +139,8 @@ it.each([390, 1440])( ); it("keeps short picker rows intact and bounds long rows at fractional zoom", async () => { + await using fixture = await setup(); + const { host, render, label, expectWithinHost } = fixture; await page.viewport(1280, 800); host.style.width = "240px"; host.style.zoom = "1.31"; @@ -145,6 +155,8 @@ it("keeps short picker rows intact and bounds long rows at fractional zoom", asy }); it("moves a name below the heading when it stops fitting and returns it inline when space recovers", async () => { + await using fixture = await setup(); + const { host, render, label, title, expectAboveComposer } = fixture; await page.viewport(1440, 900); host.style.width = "900px"; const value = "CustomerPortal-Production"; @@ -169,6 +181,8 @@ it("moves a name below the heading when it stops fitting and returns it inline w }); it("rechecks inline fit when the selected project changes", async () => { + await using fixture = await setup(); + const { render, label, title, expectAboveComposer } = fixture; await page.viewport(1440, 900); await render("ExampleCompany-CustomerPortal-Production-Environment"); expect(title().innerText).toBe("What should we build?"); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index 3557bc0bb200..d7624742d4b9 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -1,13 +1,9 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; +import { act, render as renderUi } from "../../../test/render"; import { ExpandedImageDialogHost, openExpandedImagePreview } from "./ExpandedImageDialog"; -let root: Root; -let container: HTMLDivElement; - const preview = { images: [ { src: "data:image/gif;base64,R0lGODlhAQABAAAAACw=", name: "one.png" }, @@ -16,19 +12,6 @@ const preview = { index: 0, }; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - const host = (threadKey: string) => ( @@ -41,7 +24,8 @@ describe("ExpandedImageDialogHost", () => { it("opens for its thread, steps with arrows, and closes on Escape with focus back on the opener", async () => { const opener = document.createElement("button"); document.body.append(opener); - await act(() => root.render(host("env:a"))); + using _opener = { [Symbol.dispose]: () => opener.remove() }; + await using _screen = await renderUi(host("env:a")); opener.focus(); await act(() => openExpandedImagePreview("env:a", preview)); @@ -57,18 +41,17 @@ describe("ExpandedImageDialogHost", () => { }); await vi.waitFor(() => expect(dialog()).toBeNull()); expect(document.activeElement).toBe(opener); - opener.remove(); }); it("ignores another thread's preview and drops its own when the thread changes", async () => { - await act(() => root.render(host("env:a"))); + await using screen = await renderUi(host("env:a")); await act(() => openExpandedImagePreview("env:b", preview)); expect(dialog()).toBeNull(); await act(() => openExpandedImagePreview("env:a", preview)); expect(dialog()).not.toBeNull(); - await act(() => root.render(host("env:b"))); + await screen.rerender(host("env:b")); await vi.waitFor(() => expect(dialog()).toBeNull()); }); }); diff --git a/apps/web/src/components/chat/HighlightedCodeLines.browser.test.tsx b/apps/web/src/components/chat/HighlightedCodeLines.browser.test.tsx index b4c1af4bd9e2..b199790da2b8 100644 --- a/apps/web/src/components/chat/HighlightedCodeLines.browser.test.tsx +++ b/apps/web/src/components/chat/HighlightedCodeLines.browser.test.tsx @@ -1,16 +1,11 @@ import { getSharedHighlighter } from "@pierre/diffs"; import { toHtml } from "hast-util-to-html"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; +import { render as renderUi } from "../../../test/render"; import { createIncrementalHighlightedDocument } from "../../lib/incrementalHighlighting"; import { HighlightedCodeLines } from "./HighlightedCodeLines"; -afterEach(() => { - vi.unstubAllGlobals(); -}); - // React writes inline styles through CSSOM, so normalize Shiki's style strings the same way. function normalizedHtml(html: string) { const host = document.createElement("div"); @@ -26,7 +21,6 @@ function normalizedHtml(html: string) { } it("streams Shiki HTML into the DOM, keeping finished lines mounted", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const highlighter = await getSharedHighlighter({ langs: ["typescript"], themes: ["pierre-dark", "pierre-light"], @@ -36,12 +30,12 @@ it("streams Shiki HTML into the DOM, keeping finished lines mounted", async () = 'const html = "";\n\n/* multi\nline */\n\tconst x = 1;\n'; for (const theme of ["pierre-dark", "pierre-light"] as const) { const highlight = createIncrementalHighlightedDocument(highlighter, "typescript", theme); - const container = document.createElement("div"); - const root = createRoot(container); + await using screen = await renderUi(null); + const { container } = screen; let firstLine: Element | null = null; for (let end = 0; end <= code.length; end++) { const tree = highlight(code.slice(0, end)); - await act(async () => root.render()); + await screen.rerender(); expect(normalizedHtml(container.innerHTML)).toBe(normalizedHtml(toHtml(tree))); if (end > code.indexOf("\n")) { firstLine ??= container.querySelector("code > .line"); @@ -49,6 +43,5 @@ it("streams Shiki HTML into the DOM, keeping finished lines mounted", async () = } } expect(container.querySelector("img")).toBeNull(); - await act(async () => root.unmount()); } }); diff --git a/apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx b/apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx index 275f50ca37ec..c7fd2121238d 100644 --- a/apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx +++ b/apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx @@ -1,6 +1,4 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { htmlRenderTheme, HTML_RENDER_DEFAULT_FONTS } from "@t3tools/shared/htmlRender"; import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; @@ -8,6 +6,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import "~/index.css"; +import { act, render as renderUi } from "../../../test/render"; import { HtmlRenderDialog } from "./HtmlRenderDialog"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; @@ -45,35 +44,22 @@ vi.mock(import("~/hooks/useHtmlRenderTheme"), async (original) => ({ htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS), })); -let root: Root; -let container: HTMLDivElement; const onClose = vi.fn(); -beforeEach(async () => { +function setup() { refresh.mockReset(); cachedUrl.lifeMs = 60 * 60_000; openLink.mockReset().mockResolvedValue(undefined); openChangeRequestLink.mockReset().mockReturnValue(false); showError.mockClear(); onClose.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await act(() => - root.render( - , - ), - ); -}); +} -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); -}); +function renderDialog() { + return renderUi( + , + ); +} function button(label: string) { return page.getByRole("button", { name: label, exact: true }); @@ -85,6 +71,8 @@ async function click(label: string) { } it("zooms the loaded page within bounds and resets without reloading it", async () => { + setup(); + await using _screen = await renderDialog(); const frame = document.querySelector("iframe")!; const src = frame.src; for (let step = 0; step < 4; step += 1) await click("Zoom in"); @@ -101,6 +89,8 @@ it("zooms the loaded page within bounds and resets without reloading it", async }); it("closes with the close button or Escape", async () => { + setup(); + await using _screen = await renderDialog(); await click("Close"); expect(onClose).toHaveBeenCalledOnce(); onClose.mockClear(); @@ -109,23 +99,21 @@ it("closes with the close button or Escape", async () => { }); async function renderInline() { - // Async so a URL minted at mount lands inside act. - await act(async () => - root.render( - , - ), + return renderUi( + , ); } it("offers only full screen and opens with the inline URL while keeping the inline page loaded", async () => { - await renderInline(); + setup(); + await using _screen = await renderInline(); expect(document.querySelector('[aria-label="Open in panel"]')).toBeNull(); const inlineFrame = document.querySelector("iframe")!; const originalSrc = inlineFrame.src; @@ -148,12 +136,15 @@ it("offers only full screen and opens with the inline URL while keeping the inli async function renderInlineNearExpiry() { cachedUrl.lifeMs = 60_000; refresh.mockResolvedValueOnce("https://environment.test/inline.html"); - await renderInline(); + await using stack = new AsyncDisposableStack(); + stack.use(await renderInline()); expect(refresh).toHaveBeenCalledOnce(); + return stack.move(); } it("opens full screen with a fresh URL when the inline one is near expiry", async () => { - await renderInlineNearExpiry(); + setup(); + await using _screen = await renderInlineNearExpiry(); refresh.mockResolvedValue("https://environment.test/renewed.html"); await click("Open full screen"); expect(refresh).toHaveBeenCalledTimes(2); @@ -163,7 +154,8 @@ it("opens full screen with a fresh URL when the inline one is near expiry", asyn }); it("routes links from the expanded page through its thread", async () => { - await renderInline(); + setup(); + await using _screen = await renderInline(); openChangeRequestLink.mockReturnValue(true); await click("Open full screen"); const frame = document.querySelector('[role="dialog"] iframe')!; @@ -189,7 +181,8 @@ it("routes links from the expanded page through its thread", async () => { it.each([null, new Error("Disconnected")])( "reports an unavailable URL instead of opening a blank page: %s", async (failure) => { - await renderInlineNearExpiry(); + setup(); + await using _screen = await renderInlineNearExpiry(); if (failure === null) refresh.mockResolvedValue(null); else refresh.mockRejectedValue(failure); await click("Open full screen"); diff --git a/apps/web/src/components/chat/HtmlRenderFrame.browser.test.tsx b/apps/web/src/components/chat/HtmlRenderFrame.browser.test.tsx index 55e8a627690c..616b0f49f17c 100644 --- a/apps/web/src/components/chat/HtmlRenderFrame.browser.test.tsx +++ b/apps/web/src/components/chat/HtmlRenderFrame.browser.test.tsx @@ -1,6 +1,4 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; import { htmlRenderTheme, @@ -11,6 +9,7 @@ import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; import "~/index.css"; +import { render as renderUi } from "../../../test/render"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; const page = vi.hoisted(() => ({ url: "" })); @@ -29,33 +28,26 @@ vi.mock(import("~/hooks/useHtmlRenderTheme"), async (original) => ({ htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS), })); -let root: Root | undefined; -let container: HTMLDivElement | undefined; - -afterEach(async () => { - await act(() => root?.unmount()); - container?.remove(); - URL.revokeObjectURL(page.url); -}); +function setup() { + page.url = ""; +} it("grows past the agent's height to fit the page the client lays out", async () => { + setup(); page.url = URL.createObjectURL( new Blob([injectHtmlRenderBootstrap('
')], { type: "text/html", }), ); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await act(() => - root!.render( - , - ), + using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(page.url) }; + await using screen = await renderUi( + , ); + const { container } = screen; const box = container.firstElementChild as HTMLElement; await expect.poll(() => box.style.height).toBe("2345px"); diff --git a/apps/web/src/components/chat/McpAppFrame.browser.test.tsx b/apps/web/src/components/chat/McpAppFrame.browser.test.tsx index 7af9130dc131..7883c6eba15d 100644 --- a/apps/web/src/components/chat/McpAppFrame.browser.test.tsx +++ b/apps/web/src/components/chat/McpAppFrame.browser.test.tsx @@ -1,7 +1,5 @@ import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -75,55 +73,38 @@ vi.mock(import("../../hooks/useHtmlRenderTheme"), async (original) => ({ useHtmlRenderTheme: (() => ({ appearance: "light", variables: {} })) as never, })); +import { render as renderUi } from "../../../test/render"; import { McpAppFrame } from "./McpAppFrame"; -let root: Root | undefined; -let skew = 0; -let answers: string[] = []; -let answered: (() => void) | undefined; -const onProbe = (event: MessageEvent) => { - const probe = (event.data as { readonly probe?: unknown } | null)?.probe; - if (typeof probe !== "string") return; - answers.push(probe); - answered?.(); -}; -const nextAnswer = async (action: () => Promise) => { - const seen = answers.length; - const answer = new Promise((resolve) => { - answered = () => { - if (answers.length > seen) resolve(answers[seen]!); - }; - }); - await action(); - return answer; -}; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - answers = []; - answered = undefined; - skew = 0; +function setup() { + let skew = 0; // Advance the host cooldown without manufacturing browser user activation. const now = performance.now.bind(performance); - vi.spyOn(performance, "now").mockImplementation(() => now() + skew); + const clock = vi.spyOn(performance, "now").mockImplementation(() => now() + skew); + const answers: string[] = []; + let answered: (() => void) | undefined; + const onProbe = (event: MessageEvent) => { + const probe = (event.data as { readonly probe?: unknown } | null)?.probe; + if (typeof probe !== "string") return; + answers.push(probe); + answered?.(); + }; window.addEventListener("message", onProbe); -}); -afterEach(async () => { - window.removeEventListener("message", onProbe); - if (root !== undefined) await act(async () => root?.unmount()); - root = undefined; - document.body.replaceChildren(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + const nextAnswer = async (action: () => Promise) => { + const seen = answers.length; + const answer = new Promise((resolve) => { + answered = () => { + if (answers.length > seen) resolve(answers[seen]!); + }; + }); + await action(); + return answer; + }; -const isFullscreen = () => document.querySelector("[data-mcp-app-fullscreen]") !== null; + const isFullscreen = () => document.querySelector("[data-mcp-app-fullscreen]") !== null; -const renderApp = async () => { - const container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - await act(async () => - root?.render( + const renderApp = async () => { + return renderUi( { app={{ attachmentId: "app", server: "srv", tool: "show", resourceUri: "ui://srv/app" }} onSendMessage={undefined} />, - ), - ); -}; + ); + }; -const retry = () => - nextAnswer(async () => { - document.querySelector("iframe")!.contentWindow!.postMessage("retry", "*"); - }); + const retry = () => + nextAnswer(async () => { + document.querySelector("iframe")!.contentWindow!.postMessage("retry", "*"); + }); + + return { + renderApp, + nextAnswer, + retry, + isFullscreen, + advance: (ms: number) => { + skew += ms; + }, + [Symbol.dispose]() { + window.removeEventListener("message", onProbe); + clock.mockRestore(); + }, + }; +} it("keeps load and idle retries inline even after the page-input cooldown", async () => { - expect(await nextAnswer(renderApp)).toBe("load:inline"); - skew += 5_000; + using fixture = setup(); + await using screens = new AsyncDisposableStack(); + const { renderApp, nextAnswer, retry, isFullscreen, advance } = fixture; + expect( + await nextAnswer(async () => { + screens.use(await renderApp()); + }), + ).toBe("load:inline"); + advance(5_000); expect(await retry()).toBe("retry:inline"); expect(await retry()).toBe("retry:inline"); expect(isFullscreen()).toBe(false); }); it("allows an app click immediately after loading on an idle page", async () => { - expect(await nextAnswer(renderApp)).toBe("load:inline"); + using fixture = setup(); + await using screens = new AsyncDisposableStack(); + const { renderApp, nextAnswer, isFullscreen } = fixture; + expect( + await nextAnswer(async () => { + screens.use(await renderApp()); + }), + ).toBe("load:inline"); const appButton = page .frameLocator(page.getByTitle("srv app")) .getByRole("button", { name: "Open full screen" }); @@ -160,13 +169,17 @@ it("allows an app click immediately after loading on an idle page", async () => }); it("opens full screen only from a tap in the app, never from activation the page got elsewhere", async () => { + using fixture = setup(); + await using screens = new AsyncDisposableStack(); + const { renderApp, nextAnswer, isFullscreen, advance } = fixture; const appButton = page .frameLocator(page.getByTitle("srv app")) .getByRole("button", { name: "Open full screen" }); const elsewhere = document.body.appendChild(document.createElement("button")); + using _elsewhere = { [Symbol.dispose]: () => elsewhere.remove() }; elsewhere.textContent = "Elsewhere"; const tapApp = async () => { - skew += 5_000; + advance(5_000); expect(await nextAnswer(() => userEvent.click(appButton))).toBe("tap:fullscreen"); expect(isFullscreen()).toBe(true); }; @@ -174,7 +187,7 @@ it("opens full screen only from a tap in the app, never from activation the page // The click that opens a thread still activates the page as its app loads. const load = nextAnswer(async () => { await userEvent.click(elsewhere); - await renderApp(); + screens.use(await renderApp()); }); expect(await load).toBe("load:inline"); expect(isFullscreen()).toBe(false); @@ -184,7 +197,7 @@ it("opens full screen only from a tap in the app, never from activation the page expect(await nextAnswer(() => userEvent.click(exit))).toBe("exit:inline"); expect(isFullscreen()).toBe(false); - skew += 5_000; + advance(5_000); expect(await nextAnswer(() => userEvent.click(elsewhere))).toBe("blur:inline"); expect(isFullscreen()).toBe(false); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 810c62d32469..7b99f41dad7c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -14,9 +14,8 @@ import { decodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; import type { LegendListRef } from "@legendapp/list/react"; -import { act, createRef, Profiler, useState, type ComponentProps, type ReactElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createRef, Profiler, useState, type ComponentProps, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -24,6 +23,7 @@ import "~/index.css"; import { serverEnvironment } from "../../state/server"; import { PullRequestLinkPreview } from "../pullRequest/PullRequestLinkPreview"; import { Tooltip, TooltipPopup, TooltipScrollDismissArea, TooltipTrigger } from "../ui/tooltip"; +import { act, render } from "../../../test/render"; import { MessagesTimeline, remarkThoughtPreview } from "./MessagesTimeline"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { readTimelinePosition } from "./timelineScrollAnchoring"; @@ -51,10 +51,7 @@ type Entry = TimelineProps["timelineEntries"][number]; // The timeline remembers scroll position and open disclosures per thread key, // so every render gets a fresh thread. -let threadCount = 0; - function baseProps(): Omit { - threadCount += 1; return { isWorking: false, activeTurnInProgress: false, @@ -62,7 +59,7 @@ function baseProps(): Omit { latestRun: null, turnDiffSummaries: [], runs: [], - routeThreadKey: `environment-local:thread-${threadCount}`, + routeThreadKey: `environment-local:thread-${Math.random().toString(36).slice(2)}`, onOpenTurnDiff: () => {}, onOpenThread: () => {}, onForkFromRun: async () => {}, @@ -152,27 +149,24 @@ function longText(tail = "deep hidden detail only after expand") { ).join("\n"); } -const roots: Root[] = []; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - window.getSelection()?.removeAllRanges(); - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); - async function renderElement(element: ReactElement) { - container = document.body.appendChild(document.createElement("div")); + const container = document.createElement("div"); container.style.cssText = "height: 1200px; width: 900px; display: flex; flex-direction: column;"; - const root = createRoot(container); - roots.push(root); - await act(async () => root.render(element)); - return container; + const screen = await render(element, { container }); + const buttons = (name: string | RegExp) => + [...container.querySelectorAll("button, [role=button]")].filter((button) => { + const label = button.getAttribute("aria-label") ?? button.textContent ?? ""; + return typeof name === "string" ? label === name : name.test(label); + }); + return Object.assign(screen, { + text: () => container.textContent ?? "", + buttons, + button: (name: string | RegExp) => { + const [match] = buttons(name); + if (!match) throw new Error(`Missing button: ${String(name)}`); + return match; + }, + }); } function renderTimeline(entries: ReadonlyArray, props: Partial = {}) { @@ -197,23 +191,6 @@ async function copyRange(range: Range) { } } -function text() { - return container.textContent ?? ""; -} - -function buttons(name: string | RegExp) { - return [...container.querySelectorAll("button, [role=button]")].filter((button) => { - const label = button.getAttribute("aria-label") ?? button.textContent ?? ""; - return typeof name === "string" ? label === name : name.test(label); - }); -} - -function button(name: string | RegExp) { - const [match] = buttons(name); - if (!match) throw new Error(`Missing button: ${String(name)}`); - return match; -} - async function click(element: HTMLElement) { await act(async () => element.click()); } @@ -224,7 +201,7 @@ describe("MessagesTimeline work rows", () => { ["Space", " "], ])("opens a nested diff with %s without collapsing the tool row", async (_name, key) => { const onOpenTurnDiff = vi.fn(); - await renderTimeline( + await using screen = await renderTimeline( [ work("file-change-1", { label: "Updated src/index.ts", @@ -236,7 +213,7 @@ describe("MessagesTimeline work rows", () => { ], { onOpenTurnDiff }, ); - const row = container.querySelector('[data-v2-item-type="file_change"]')!; + const row = screen.container.querySelector('[data-v2-item-type="file_change"]')!; const press = async (target: HTMLElement) => { await act(async () => { target.focus(); @@ -246,7 +223,7 @@ describe("MessagesTimeline work rows", () => { expect(row.getAttribute("aria-expanded")).toBe("false"); await press(row); expect(row.getAttribute("aria-expanded")).toBe("true"); - const openDiff = button("Open diff"); + const openDiff = screen.button("Open diff"); await press(openDiff); expect(onOpenTurnDiff).toHaveBeenCalledWith("run-1", "src/index.ts"); expect(onOpenTurnDiff).toHaveBeenCalledTimes(1); @@ -256,7 +233,7 @@ describe("MessagesTimeline work rows", () => { }); it("shows dynamic tool input without cached output when the row is expanded", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ work("tool-with-cached-output", { label: "Example tool", toolTitle: "Example tool", @@ -269,15 +246,15 @@ describe("MessagesTimeline work rows", () => { }, }), ]); - await click(button("Example tool")); - expect(text()).toContain("KEEP_TOOL_INPUT"); - expect(text()).not.toContain("RAW_CACHED_TOOL_OUTPUT"); - await click(button("Example tool")); - await vi.waitFor(() => expect(text()).not.toContain("KEEP_TOOL_INPUT")); + await click(screen.button("Example tool")); + expect(screen.text()).toContain("KEEP_TOOL_INPUT"); + expect(screen.text()).not.toContain("RAW_CACHED_TOOL_OUTPUT"); + await click(screen.button("Example tool")); + await vi.waitFor(() => expect(screen.text()).not.toContain("KEEP_TOOL_INPUT")); }); it("expands and collapses a tool call through its header", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ work("work-standalone", { toolCallId: "call-standalone", label: "Run lint", @@ -287,7 +264,7 @@ describe("MessagesTimeline work rows", () => { toolLifecycleStatus: "completed", }), ]); - const header = () => container.querySelector("[aria-expanded]")!; + const header = () => screen.container.querySelector("[aria-expanded]")!; expect(header().getAttribute("aria-expanded")).toBe("false"); await click(header()); expect(header().getAttribute("aria-expanded")).toBe("true"); @@ -296,7 +273,7 @@ describe("MessagesTimeline work rows", () => { }); it("leads an unanswered question row with the question text", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ work("question-work", { label: "User input requested", tone: "tool", @@ -308,16 +285,16 @@ describe("MessagesTimeline work rows", () => { }, }), ]); - const toggle = button("Which repository?"); + const toggle = screen.button("Which repository?"); expect(toggle.getAttribute("aria-expanded")).toBe("false"); - const before = text().match(/Which repository\?/g)?.length ?? 0; + const before = screen.text().match(/Which repository\?/g)?.length ?? 0; expect(before).toBeGreaterThan(0); await click(toggle); - expect(text().match(/Which repository\?/g)?.length).toBe(before + 1); + expect(screen.text().match(/Which repository\?/g)?.length).toBe(before + 1); }); it("collapses settled tool runs behind a generated summary and names a failure", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ work("work-1", { label: "Ran command", command: "vp lint", @@ -333,15 +310,15 @@ describe("MessagesTimeline work rows", () => { toolLifecycleStatus: "failed", }), ]); - const summary = button(/^Ran 2 commands/); + const summary = screen.button(/^Ran 2 commands/); expect(summary.getAttribute("aria-label")).toContain("tool call failed"); - expect(text()).not.toContain("vp lint"); + expect(screen.text()).not.toContain("vp lint"); await click(summary); - await vi.waitFor(() => expect(text()).toContain("vp lint")); + await vi.waitFor(() => expect(screen.text()).toContain("vp lint")); }); it("keeps mixed work logs neutral after a later tool call succeeds", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ work("work-failed", { label: "Run search", tone: "tool", @@ -356,11 +333,11 @@ describe("MessagesTimeline work rows", () => { toolLifecycleStatus: "completed", }), ]); - expect(button("Ran 2 commands and received 1 update")).toBeDefined(); + expect(screen.button("Ran 2 commands and received 1 update")).toBeDefined(); }); it("marks a failed tool inside an expanded work log", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ work("work-info", { label: "Status updated", tone: "info" }), work("work-1", { label: "Glob", @@ -369,19 +346,21 @@ describe("MessagesTimeline work rows", () => { detail: "No files found", }), ]); - await click(button(/^Received 1 update/)); + await click(screen.button(/^Received 1 update/)); await vi.waitFor(() => - expect(container.querySelector('[aria-label="Tool call failed"]')).not.toBeNull(), + expect(screen.container.querySelector('[aria-label="Tool call failed"]')).not.toBeNull(), ); }); it("renders context compaction entries in the normal work log", async () => { - await renderTimeline([work("work-1", { label: "Context compacted", tone: "info" })]); - expect(text()).toContain("Context compacted"); + await using screen = await renderTimeline([ + work("work-1", { label: "Context compacted", tone: "info" }), + ]); + expect(screen.text()).toContain("Context compacted"); }); it("formats changed file paths from the workspace root", async () => { - await renderTimeline( + await using screen = await renderTimeline( [ work("context-info", { label: "Session started", tone: "info" }), work("work-1", { @@ -394,9 +373,11 @@ describe("MessagesTimeline work rows", () => { ], { workspaceRoot: "C:/Users/mike/dev-stuff/t3code" }, ); - await click(button(/^Received 1 update/)); - await vi.waitFor(() => expect(text()).toContain("t3code/apps/web/src/session-logic.ts")); - expect(text()).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); + await click(screen.button(/^Received 1 update/)); + await vi.waitFor(() => expect(screen.text()).toContain("t3code/apps/web/src/session-logic.ts")); + expect(screen.text()).not.toContain( + "C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts", + ); }); it("renders T3 MCP dynamic tools with their pretty name", async () => { @@ -407,7 +388,7 @@ describe("MessagesTimeline work rows", () => { input: { threadId: "thread-child" }, output: { messages: [] }, }; - await renderTimeline([ + await using screen = await renderTimeline([ work("context-info", { label: "Session started", tone: "info" }), work(item.id, { runId: null, @@ -421,9 +402,9 @@ describe("MessagesTimeline work rows", () => { projectedItem: v2Item("dynamic_tool", item), }), ]); - await click(button(/^Received 1 update/)); - await vi.waitFor(() => expect(text()).toContain("Read a T3 thread")); - expect(text()).not.toContain("mcp__t3-code__t3_thread_read"); + await click(screen.button(/^Received 1 update/)); + await vi.waitFor(() => expect(screen.text()).toContain("Read a T3 thread")); + expect(screen.text()).not.toContain("mcp__t3-code__t3_thread_read"); }); it("keeps a provider retry collapsed in the work log", async () => { @@ -439,7 +420,7 @@ describe("MessagesTimeline work rows", () => { }, retry: { attempt: 2, maxAttempts: 5, retryDelayMs: null }, }; - await renderTimeline( + await using screen = await renderTimeline( [ work("provider-error", { runId: "run-1", @@ -462,8 +443,8 @@ describe("MessagesTimeline work rows", () => { }, }, ); - expect(text()).toContain("Retrying provider (2/5)"); - expect(text()).not.toContain("Structured details"); + expect(screen.text()).toContain("Retrying provider (2/5)"); + expect(screen.text()).not.toContain("Structured details"); }); it.each([ @@ -481,7 +462,7 @@ describe("MessagesTimeline work rows", () => { ] as const)( "shows plain text for a V2 reasoning preview: %s", async (markdown, expected, strongCount) => { - await renderTimeline([ + await using screen = await renderTimeline([ work("reasoning-preview", { label: markdown, detail: markdown, @@ -490,15 +471,16 @@ describe("MessagesTimeline work rows", () => { toolLifecycleStatus: "completed", }), ]); - const row = () => container.querySelector("[role=button][aria-expanded]")!; + const row = () => + screen.container.querySelector("[role=button][aria-expanded]")!; const preview = () => row().textContent!.replace(/\s+/g, " "); expect(preview()).toContain(expected); - expect(container.querySelectorAll("strong")).toHaveLength(0); + expect(screen.container.querySelectorAll("strong")).toHaveLength(0); await click(row()); - expect(container.querySelectorAll("strong")).toHaveLength(strongCount); + expect(screen.container.querySelectorAll("strong")).toHaveLength(strongCount); await click(row()); expect(preview()).toContain(expected); - await vi.waitFor(() => expect(container.querySelectorAll("strong")).toHaveLength(0)); + await vi.waitFor(() => expect(screen.container.querySelectorAll("strong")).toHaveLength(0)); }, ); }); @@ -515,12 +497,15 @@ describe("MessagesTimeline messages", () => { async ({ role, selection, findExpanded }) => { const code = "flowchart LR\n A[Select message] --> B[Copy Markdown]"; const source = `\`\`\`mermaid\n${code}\n\`\`\``; - await renderTimeline([message(`Before diagram.\n\n${source}\n\nAfter diagram.`, { role })], { - findExpanded, - }); + await using screen = await renderTimeline( + [message(`Before diagram.\n\n${source}\n\nAfter diagram.`, { role })], + { + findExpanded, + }, + ); const diagram = await vi.waitFor( () => { - const svg = container.querySelector('[aria-label="Expand diagram"] svg'); + const svg = screen.container.querySelector('[aria-label="Expand diagram"] svg'); expect(svg).not.toBeNull(); return svg!; }, @@ -529,7 +514,7 @@ describe("MessagesTimeline messages", () => { const range = document.createRange(); let expected = source; if (selection === "across") { - const paragraphs = [...container.querySelectorAll("p")]; + const paragraphs = [...screen.container.querySelectorAll("p")]; const before = paragraphs.find((p) => p.textContent === "Before diagram.")!.firstChild!; const after = paragraphs.find((p) => p.textContent === "After diagram.")!.firstChild!; range.setStart(before, 2); @@ -543,8 +528,8 @@ describe("MessagesTimeline messages", () => { range.setStart(labelText, 2); range.setEnd(labelText, 5); } else { - await click(button("Show code")); - const code = container.querySelector("pre code")!; + await click(screen.button("Show code")); + const code = screen.container.querySelector("pre code")!; const firstToken = document.createTreeWalker(code, NodeFilter.SHOW_TEXT).nextNode()!; range.setStart(firstToken, 0); range.setEnd(firstToken, 9); @@ -575,7 +560,7 @@ describe("MessagesTimeline messages", () => { const link = `[shot.png](t3-context://v1/image/${record.contextId})`; const code = `flowchart LR\n A --> B\n%% ${link}`; const source = `\`\`\`mermaid\n${code}\n\`\`\``; - await renderTimeline([ + await using screen = await renderTimeline([ message(`Before\n\n${source}\n\nAfter\n\n${link}`, { context: { version: 1, records: [record] }, attachments: [ @@ -590,13 +575,14 @@ describe("MessagesTimeline messages", () => { }), ]); await vi.waitFor( - () => expect(container.querySelector('[aria-label="Expand diagram"] svg')).not.toBeNull(), + () => + expect(screen.container.querySelector('[aria-label="Expand diagram"] svg')).not.toBeNull(), { timeout: 10_000 }, ); - const paragraphs = [...container.querySelectorAll("p")]; + const paragraphs = [...screen.container.querySelectorAll("p")]; const before = paragraphs.find((p) => p.textContent === "Before")!.firstChild!; const after = paragraphs.find((p) => p.textContent === "After")!.firstChild!; - const chip = button(/^Image attachment, shot\.png,/); + const chip = screen.button(/^Image attachment, shot\.png,/); const range = document.createRange(); range.setStart(before, 0); range.setEnd(after, 5); @@ -626,13 +612,13 @@ describe("MessagesTimeline messages", () => { " in your context?", "Comparison: 2 < 3 and 5 > 4.", ]; - await renderTimeline([message(source.join("\n"))]); - for (const line of source) expect(text()).toContain(line); - expect(container.querySelector("global-agent-instructions, nested")).toBeNull(); + await using screen = await renderTimeline([message(source.join("\n"))]); + for (const line of source) expect(screen.text()).toContain(line); + expect(screen.container.querySelector("global-agent-instructions, nested")).toBeNull(); }); it("preserves XML-like source inside user code spans and fences", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ message( [ 'Inline ``', @@ -643,32 +629,36 @@ describe("MessagesTimeline messages", () => { ].join("\n"), ), ]); - expect(container.querySelector("code[data-inline-code]")?.textContent).toBe(''); - expect(text()).toContain(''); + expect(screen.container.querySelector("code[data-inline-code]")?.textContent).toBe( + '', + ); + expect(screen.text()).toContain(''); }); it("does not show markdown titles in user messages", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ message( '[link](https://example.com "link tip") ![image](https://example.com/image.png "image tip")', ), ]); - expect(container.querySelector('a[href="https://example.com"]')).not.toBeNull(); - expect(container.querySelector('img[src="https://example.com/image.png"]')).not.toBeNull(); - expect(container.querySelector('[title="link tip"], [title="image tip"]')).toBeNull(); + expect(screen.container.querySelector('a[href="https://example.com"]')).not.toBeNull(); + expect( + screen.container.querySelector('img[src="https://example.com/image.png"]'), + ).not.toBeNull(); + expect(screen.container.querySelector('[title="link tip"], [title="image tip"]')).toBeNull(); }); it("renders unsafe user HTML as inert source text", async () => { const source = ''; - await renderTimeline([message(source)]); - expect(text()).toContain(source); - expect(container.querySelector("script, img")).toBeNull(); + await using screen = await renderTimeline([message(source)]); + expect(screen.text()).toContain(source); + expect(screen.container.querySelector("script, img")).toBeNull(); expect((globalThis as { __t3Xss?: number }).__t3Xss).toBeUndefined(); }); it("renders sanitized raw HTML in assistant messages", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ message( [ '
', @@ -681,48 +671,53 @@ describe("MessagesTimeline messages", () => { { role: "assistant" }, ), ]); - expect(container.querySelector("[data-markdown-details]")?.textContent).toContain( + expect(screen.container.querySelector("[data-markdown-details]")?.textContent).toContain( "Safe details", ); - expect(text()).not.toContain(" { - await renderTimeline([message(longText())]); - const toggle = button("Show full message"); + await using screen = await renderTimeline([message(longText())]); + const toggle = screen.button("Show full message"); expect(toggle.getAttribute("aria-expanded")).toBe("false"); - expect(buttons("Copy message")).toHaveLength(1); + expect(screen.buttons("Copy message")).toHaveLength(1); await click(toggle); - expect(button("Show less").getAttribute("aria-expanded")).toBe("true"); + expect(screen.button("Show less").getAttribute("aria-expanded")).toBe("true"); }); it("does not offer a collapse toggle for short user messages", async () => { - await renderTimeline([message("Short prompt.")]); - expect(buttons(/Show full message|Show less/)).toHaveLength(0); + await using screen = await renderTimeline([message("Short prompt.")]); + expect(screen.buttons(/Show full message|Show less/)).toHaveLength(0); }); it("identifies user-role messages sent by another agent", async () => { - await renderTimeline([ - message("Review this area", { createdBy: "agent", creationSource: "provider" }), - ]); - expect(text()).toContain("Sent by another agent"); - await act(async () => roots.pop()!.unmount()); - await renderTimeline([message("Review this area")]); - expect(text()).not.toContain("Sent by another agent"); + { + await using screen = await renderTimeline([ + message("Review this area", { createdBy: "agent", creationSource: "provider" }), + ]); + expect(screen.text()).toContain("Sent by another agent"); + } + await using screen = await renderTimeline([message("Review this area")]); + expect(screen.text()).not.toContain("Sent by another agent"); }); it("shows steer intent ahead of committed user messages", async () => { - await renderTimeline([message("Adjust the current turn", { inputIntent: "steer" })]); - expect(text().indexOf("Steer")).toBeGreaterThanOrEqual(0); - expect(text().indexOf("Steer")).toBeLessThan(text().indexOf("Adjust the current turn")); + await using screen = await renderTimeline([ + message("Adjust the current turn", { inputIntent: "steer" }), + ]); + expect(screen.text().indexOf("Steer")).toBeGreaterThanOrEqual(0); + expect(screen.text().indexOf("Steer")).toBeLessThan( + screen.text().indexOf("Adjust the current turn"), + ); }); it("renders inline terminal labels as chips", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ message( [ longText("yoo what's @terminal-1:1-5 mean"), @@ -735,14 +730,14 @@ describe("MessagesTimeline messages", () => { ].join("\n"), ), ]); - expect(text()).toContain("Terminal 1 lines 1-5"); - expect(text()).toContain("yoo what's"); - expect(text()).not.toContain("terminal_context"); - expect(buttons("Show full message")).toHaveLength(1); + expect(screen.text()).toContain("Terminal 1 lines 1-5"); + expect(screen.text()).toContain("yoo what's"); + expect(screen.text()).not.toContain("terminal_context"); + expect(screen.buttons("Show full message")).toHaveLength(1); }); it("renders chips for standalone element-pick context messages", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ message( [ "", @@ -756,12 +751,12 @@ describe("MessagesTimeline messages", () => { ].join("\n"), ), ]); - expect(text()).toContain("SubmitButton"); - expect(text()).not.toContain("element_context"); + expect(screen.text()).toContain("SubmitButton"); + expect(screen.text()).not.toContain("element_context"); }); it("renders review comment contexts as structured cards instead of raw tags", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ message( [ '', @@ -775,12 +770,12 @@ describe("MessagesTimeline messages", () => { ].join("\n"), ), ]); - expect(text()).toContain("contextWindow.test.ts L47 to L58"); - expect(text()).not.toContain("review_comment"); + expect(screen.text()).toContain("contextWindow.test.ts L47 to L58"); + expect(screen.text()).not.toContain("review_comment"); }); it("renders file review comments as source code", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ message( [ '', @@ -793,8 +788,8 @@ describe("MessagesTimeline messages", () => { ].join("\n"), ), ]); - expect(text()).toContain("plan.md L1 to L2"); - expect(text()).not.toContain("review_comment"); + expect(screen.text()).toContain("plan.md L1 to L2"); + expect(screen.text()).not.toContain("review_comment"); }); it("collapses superseded attempt output behind a disclosure", async () => { @@ -808,7 +803,7 @@ describe("MessagesTimeline messages", () => { status, completedAt: null, }) as never; - await renderTimeline( + await using screen = await renderTimeline( [ { ...message( @@ -831,17 +826,17 @@ describe("MessagesTimeline messages", () => { latestRun: { runId, status: "running", startedAt: CREATED_AT, completedAt: null }, }, ); - expect(text()).toContain("Current response remains visible"); - expect(text()).not.toContain("Partial response from the old attempt"); - const disclosure = button(/Superseded attempt/); + expect(screen.text()).toContain("Current response remains visible"); + expect(screen.text()).not.toContain("Partial response from the old attempt"); + const disclosure = screen.button(/Superseded attempt/); expect(disclosure.textContent).toContain("Cut off by a steer"); await click(disclosure); - expect(text()).toContain("Partial response from the old attempt"); + expect(screen.text()).toContain("Partial response from the old attempt"); }); it("forks from a completed assistant response", async () => { const onForkFromRun = vi.fn(async () => {}); - await renderTimeline( + await using screen = await renderTimeline( [ { ...message("Done", { role: "assistant", runId: RunId.make("run-1") }, "assistant-1"), @@ -855,7 +850,7 @@ describe("MessagesTimeline messages", () => { ], { onForkFromRun }, ); - await click(button("Fork from this response")); + await click(screen.button("Fork from this response")); expect(onForkFromRun).toHaveBeenCalledWith({ sourceThreadId: "thread-1", runId: "run-1" }); }); @@ -863,7 +858,7 @@ describe("MessagesTimeline messages", () => { const assistantMessageId = MessageId.make("assistant-with-files"); const runId = RunId.make("run-with-files"); const onOpenTurnDiff = vi.fn(); - await renderTimeline( + await using screen = await renderTimeline( [message("Updated the fixture.", { role: "assistant", runId }, assistantMessageId)], { onOpenTurnDiff, @@ -881,8 +876,8 @@ describe("MessagesTimeline messages", () => { ], }, ); - expect(text()).toContain("1 changed file"); - await click(button("Open diff")); + expect(screen.text()).toContain("1 changed file"); + await click(screen.button("Open diff")); expect(onOpenTurnDiff).toHaveBeenCalledWith(runId, "README.md"); }); }); @@ -902,31 +897,33 @@ describe("MessagesTimeline attachments", () => { }); it("offers preview and download actions for PDF attachments", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ withAttachment({ id: "attachment-report-pdf", previewUrl: "https://environment.test/api/assets/report.pdf", }), ]); - expect(buttons("Preview report.pdf")).toHaveLength(1); - expect(buttons("Download report.pdf")).toHaveLength(1); - expect(container.querySelector('img[alt="report.pdf"]')).toBeNull(); + expect(screen.buttons("Preview report.pdf")).toHaveLength(1); + expect(screen.buttons("Download report.pdf")).toHaveLength(1); + expect(screen.container.querySelector('img[alt="report.pdf"]')).toBeNull(); }); it("does not create a download URL before the download is requested", async () => { - await renderTimeline([withAttachment({ id: "attachment-report-pdf" })]); - expect(buttons("Download report.pdf")).toHaveLength(1); - expect(container.querySelector("a[href]")).toBeNull(); + await using screen = await renderTimeline([withAttachment({ id: "attachment-report-pdf" })]); + expect(screen.buttons("Download report.pdf")).toHaveLength(1); + expect(screen.container.querySelector("a[href]")).toBeNull(); }); it("does not download an optimistic file before the server supplies its attachment ID", async () => { - await renderTimeline([withAttachment({ id: "composer-local-report", downloadable: false })]); - expect(text()).toContain("report.pdf"); - expect(buttons("Download report.pdf")).toHaveLength(0); + await using screen = await renderTimeline([ + withAttachment({ id: "composer-local-report", downloadable: false }), + ]); + expect(screen.text()).toContain("report.pdf"); + expect(screen.buttons("Download report.pdf")).toHaveLength(0); }); it("renders unknown attachment types as inert rows instead of crashing", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ withAttachment({ type: "recording", id: "attachment-voice-memo", @@ -934,9 +931,9 @@ describe("MessagesTimeline attachments", () => { mimeType: "audio/ogg", }), ]); - expect(text()).toContain("voice-memo.ogg"); - expect(buttons("Download voice-memo.ogg")).toHaveLength(0); - expect(container.querySelector("a[href], img")).toBeNull(); + expect(screen.text()).toContain("voice-memo.ogg"); + expect(screen.buttons("Download voice-memo.ogg")).toHaveLength(0); + expect(screen.container.querySelector("a[href], img")).toBeNull(); }); it("shows SnapShot window details only after the preview resolves", async () => { @@ -960,13 +957,14 @@ describe("MessagesTimeline attachments", () => { }, ] as never, }); - await renderTimeline([snapShot()]); - expect(text()).toContain("screenshot.png"); - expect(text()).not.toContain("t3code — Tests"); - await act(async () => roots.pop()!.unmount()); - await renderTimeline([snapShot("data:image/png;base64,iVBORw0KGgo=")]); - expect(text()).toContain("Terminal"); - expect(text()).toContain("t3code — Tests"); + { + await using screen = await renderTimeline([snapShot()]); + expect(screen.text()).toContain("screenshot.png"); + expect(screen.text()).not.toContain("t3code — Tests"); + } + await using screen = await renderTimeline([snapShot("data:image/png;base64,iVBORw0KGgo=")]); + expect(screen.text()).toContain("Terminal"); + expect(screen.text()).toContain("t3code — Tests"); }); }); @@ -984,33 +982,33 @@ describe("MessagesTimeline history and thread links", () => { message("Investigation complete.", { role: "assistant", runId }, "citation-answer"), ], }; - await renderElement(); + await using screen = await renderElement(); const source = () => - container.querySelector('[data-assistant-citation-source="citation-source"]'); - await vi.waitFor(() => expect(text()).toContain("Investigation complete.")); - expect(buttons(/^Worked/)).toHaveLength(1); + screen.container.querySelector( + '[data-assistant-citation-source="citation-source"]', + ); + await vi.waitFor(() => expect(screen.text()).toContain("Investigation complete.")); + expect(screen.buttons(/^Worked/)).toHaveLength(1); expect(source()).toBeNull(); - await act(async () => - roots.at(-1)!.render( - , - ), + await screen.rerender( + , ); await vi.waitFor(() => { @@ -1019,7 +1017,9 @@ describe("MessagesTimeline history and thread links", () => { expect(highlight).toBeDefined(); const range = [...highlight!][0] as Range; expect(range.toString()).toBe(quote); - const viewport = container.querySelector("[data-assistant-citation-viewport]")!; + const viewport = screen.container.querySelector( + "[data-assistant-citation-viewport]", + )!; const bounds = range.getBoundingClientRect(); expect(bounds.height).toBeGreaterThan(0); expect(bounds.top).toBeGreaterThanOrEqual(viewport.getBoundingClientRect().top); @@ -1029,7 +1029,7 @@ describe("MessagesTimeline history and thread links", () => { it("loads earlier turns from a control ahead of the bounded timeline", async () => { const onLoadEarlier = vi.fn(); - await renderTimeline([message("Recent activity")], { + await using screen = await renderTimeline([message("Recent activity")], { historyControls: { hasMoreHistory: true, loading: false, @@ -1037,14 +1037,16 @@ describe("MessagesTimeline history and thread links", () => { onLoadEarlier, }, }); - expect(text()).toContain("Earlier activity could not be loaded."); - expect(text().indexOf("Load earlier turns")).toBeLessThan(text().indexOf("Recent activity")); - await click(button("Load earlier turns")); + expect(screen.text()).toContain("Earlier activity could not be loaded."); + expect(screen.text().indexOf("Load earlier turns")).toBeLessThan( + screen.text().indexOf("Recent activity"), + ); + await click(screen.button("Load earlier turns")); expect(onLoadEarlier).toHaveBeenCalledOnce(); }); it("keeps an empty bounded timeline busy while earlier history loads", async () => { - await renderTimeline([], { + await using screen = await renderTimeline([], { historyControls: { hasMoreHistory: true, loading: true, @@ -1052,21 +1054,21 @@ describe("MessagesTimeline history and thread links", () => { onLoadEarlier: () => {}, }, }); - expect(text()).toContain("Loading earlier turns…"); - expect(button("Load earlier turns")).toHaveProperty("disabled", true); - expect(text()).not.toContain("Send a message to start the conversation."); + expect(screen.text()).toContain("Loading earlier turns…"); + expect(screen.button("Load earlier turns")).toHaveProperty("disabled", true); + expect(screen.text()).not.toContain("Send a message to start the conversation."); }); it("opens the parent thread from the top of an empty subagent timeline", async () => { const onOpenThread = vi.fn(); - await renderTimeline([], { + await using screen = await renderTimeline([], { onOpenThread, parentThreadLink: { threadId: ThreadId.make("thread-parent"), title: "Architecture audit" }, }); - expect(text()).toContain("Subagent of"); - expect(text()).toContain("Architecture audit"); - expect(text()).not.toContain("Send a message to start the conversation"); - await click(button("Open parent thread")); + expect(screen.text()).toContain("Subagent of"); + expect(screen.text()).toContain("Architecture audit"); + expect(screen.text()).not.toContain("Send a message to start the conversation"); + await click(screen.button("Open parent thread")); expect(onOpenThread).toHaveBeenCalledWith("thread-parent"); }); @@ -1079,37 +1081,31 @@ describe("MessagesTimeline history and thread links", () => { `message-${index}`, ), ); - await renderTimeline([], { + await using screen = await renderTimeline([], { ...props, routeThreadKey: `${props.routeThreadKey}-loading`, hideEmptyPlaceholder: true, }); // Each row is much taller than LegendList's estimate. - await act(async () => - roots.at(-1)!.render(), - ); + await screen.rerender(); const viewport = props.listRef.current!.getScrollableNode(); await vi.waitFor(() => { expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); - expect(text()).toContain("Paragraph 11.59"); + expect(screen.text()).toContain("Paragraph 11.59"); expect( viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, ).toBeLessThanOrEqual(1); }); const otherThreadKey = `${props.routeThreadKey}-other`; - await act(async () => - roots - .at(-1)! - .render( - , - ), + await screen.rerender( + , ); await vi.waitFor(() => expect( @@ -1125,11 +1121,9 @@ describe("MessagesTimeline history and thread links", () => { ); }); - await act(async () => - roots.at(-1)!.render(), - ); + await screen.rerender(); await vi.waitFor(() => { - expect(text()).toContain("Paragraph 11.59"); + expect(screen.text()).toContain("Paragraph 11.59"); expect( viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, ).toBeLessThanOrEqual(1); @@ -1137,7 +1131,7 @@ describe("MessagesTimeline history and thread links", () => { }); it("does not render the transient V2 interruption request", async () => { - await renderTimeline([ + await using screen = await renderTimeline([ { id: "interrupt-request", kind: "event", @@ -1148,13 +1142,13 @@ describe("MessagesTimeline history and thread links", () => { }), } as Entry, ]); - expect(text()).not.toContain("Interrupt requested"); - expect(text()).not.toContain("Waiting for the provider to stop."); + expect(screen.text()).not.toContain("Interrupt requested"); + expect(screen.text()).not.toContain("Waiting for the provider to stop."); }); it("opens created threads from a lean row", async () => { const onOpenThread = vi.fn(); - await renderTimeline( + await using screen = await renderTimeline( [ { id: "thread-created", @@ -1174,9 +1168,9 @@ describe("MessagesTimeline history and thread links", () => { ], { onOpenThread }, ); - expect(text()).toContain("Claude research thread"); - expect(text()).not.toContain("Work Log"); - await click(button("Open Claude research thread")); + expect(screen.text()).toContain("Claude research thread"); + expect(screen.text()).not.toContain("Work Log"); + await click(screen.button("Open Claude research thread")); expect(onOpenThread).toHaveBeenCalledWith("thread-2"); }); @@ -1217,50 +1211,52 @@ describe("MessagesTimeline history and thread links", () => { ...fields, }), }) as Entry; - const show = async (entry: Entry, props: Partial = {}) => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - await renderElement( + const show = (entry: Entry, props: Partial = {}) => + renderElement( , ); - }; - await show( - handoff({ - fromModelSelections: [{ instanceId: "codex_personal", model: "gpt-5.6-sol" }], - toModel: "claude-fable-5", - }), - ); - expect(text()).toContain("Context handoff"); - expect(text()).toContain("GPT 5.6 Sol"); - expect(text()).toContain("Claude Fable 5"); - expect(text()).not.toContain("Full conversation context"); + { + await using screen = await show( + handoff({ + fromModelSelections: [{ instanceId: "codex_personal", model: "gpt-5.6-sol" }], + toModel: "claude-fable-5", + }), + ); + expect(screen.text()).toContain("Context handoff"); + expect(screen.text()).toContain("GPT 5.6 Sol"); + expect(screen.text()).toContain("Claude Fable 5"); + expect(screen.text()).not.toContain("Full conversation context"); + } - await show(handoff({}), { - runs: [ - { - id: "run-1", - ordinal: 1, - providerInstanceId: "codex_personal", - modelSelection: { instanceId: "codex_personal", model: "gpt-5.6-sol" }, - }, - { - id: "run-2", - ordinal: 2, - providerInstanceId: "claudeAgent", - modelSelection: { instanceId: "claudeAgent", model: "claude-fable-5" }, - }, - ] as never, - }); - expect(text()).toContain("GPT 5.6 Sol"); - expect(text()).toContain("Claude Fable 5"); + { + await using screen = await show(handoff({}), { + runs: [ + { + id: "run-1", + ordinal: 1, + providerInstanceId: "codex_personal", + modelSelection: { instanceId: "codex_personal", model: "gpt-5.6-sol" }, + }, + { + id: "run-2", + ordinal: 2, + providerInstanceId: "claudeAgent", + modelSelection: { instanceId: "claudeAgent", model: "claude-fable-5" }, + }, + ] as never, + }); + expect(screen.text()).toContain("GPT 5.6 Sol"); + expect(screen.text()).toContain("Claude Fable 5"); + } - await show(handoff({})); - expect(text()).toContain("Codex Personal"); - expect(text()).not.toContain("Full conversation context"); + await using screen = await show(handoff({})); + expect(screen.text()).toContain("Codex Personal"); + expect(screen.text()).not.toContain("Full conversation context"); }); }); @@ -1310,7 +1306,7 @@ describe("MessagesTimeline subagents", () => { "shows $count $status subagents with '$preview', grouping only multiple agents", async ({ status, progress, result, preview, count }) => { const onOpenThread = vi.fn(); - await renderTimeline( + await using screen = await renderTimeline( Array.from( { length: count }, (_, index) => @@ -1339,25 +1335,25 @@ describe("MessagesTimeline subagents", () => { ), { onOpenThread }, ); - const children = () => buttons("Open Package audit"); + const children = () => screen.buttons("Open Package audit"); // A settled run folds its work behind a "Worked for" summary. - const fold = buttons(/^Worked for/)[0]; + const fold = screen.buttons(/^Worked for/)[0]; if (fold) await click(fold); if (count > 1) { expect(children()).toHaveLength(0); - await click(button(`${count} subagents`)); + await click(screen.button(`${count} subagents`)); } else { - expect(buttons(`${count} subagents`)).toHaveLength(0); + expect(screen.buttons(`${count} subagents`)).toHaveLength(0); } expect(children()).toHaveLength(count); - expect(text()).toContain(preview); - expect(text()).not.toContain("Inspect the package"); - if (result?.trim() && result !== preview) expect(text()).not.toContain(result); - if (progress && progress !== preview) expect(text()).not.toContain(progress); + expect(screen.text()).toContain(preview); + expect(screen.text()).not.toContain("Inspect the package"); + if (result?.trim() && result !== preview) expect(screen.text()).not.toContain(result); + if (progress && progress !== preview) expect(screen.text()).not.toContain(progress); await click(children()[0]!); expect(onOpenThread).toHaveBeenCalledWith("thread-subagent-1"); if (count > 1) { - await click(button(`${count} subagents`)); + await click(screen.button(`${count} subagents`)); await vi.waitFor(() => expect(children()).toHaveLength(0)); } }, @@ -1384,7 +1380,7 @@ describe("timeline tooltip scroll dismissal", () => { https://example.com ); - await renderElement( + const screen = await renderElement( <>
@@ -1395,10 +1391,10 @@ describe("timeline tooltip scroll dismissal", () => { {outside ? tooltip : null} , ); - return { - trigger: container.querySelector("button")!, - scrollable: container.querySelector('[data-testid="scrollable"]')!, - }; + return Object.assign(screen, { + trigger: screen.container.querySelector("button")!, + scrollable: screen.container.querySelector('[data-testid="scrollable"]')!, + }); } async function scroll(scrollable: HTMLElement) { @@ -1411,7 +1407,8 @@ describe("timeline tooltip scroll dismissal", () => { it.each([false, true])( "closes a hovered tooltip on scroll but keeps a focused one (pull request=%s)", async (pullRequest) => { - const { trigger, scrollable } = await renderTooltip(pullRequest, false); + await using screen = await renderTooltip(pullRequest, false); + const { trigger, scrollable } = screen; await userEvent.hover(trigger); await vi.waitFor(() => expect(openPopup()).toBe("https://example.com")); await scroll(scrollable); @@ -1428,7 +1425,8 @@ describe("timeline tooltip scroll dismissal", () => { ); it("leaves tooltips outside the timeline open when it scrolls", async () => { - const { trigger, scrollable } = await renderTooltip(false, true); + await using screen = await renderTooltip(false, true); + const { trigger, scrollable } = screen; await userEvent.hover(trigger); await vi.waitFor(() => expect(openPopup()).toBe("https://example.com")); await scroll(scrollable); @@ -1436,7 +1434,8 @@ describe("timeline tooltip scroll dismissal", () => { }); it("keeps a hovered tooltip open on a wheel that does not scroll", async () => { - const { trigger, scrollable } = await renderTooltip(false, false); + await using screen = await renderTooltip(false, false); + const { trigger, scrollable } = screen; await userEvent.hover(trigger); await vi.waitFor(() => expect(openPopup()).toBe("https://example.com")); await act(async () => { @@ -1486,7 +1485,7 @@ describe("MessagesTimeline scroll position", () => { ); - await renderElement(timeline(long)); + await using screen = await renderElement(timeline(long)); const viewport = await waitForList(props); await act(async () => { viewport.scrollTop = 0; @@ -1494,13 +1493,13 @@ describe("MessagesTimeline scroll position", () => { }); maxNestedCommits = 0; - await act(async () => roots.at(-1)!.render(timeline([...short, ...long]))); + await screen.rerender(timeline([...short, ...long])); await vi.waitFor(async () => { await act(async () => { viewport.scrollTop = 0; viewport.dispatchEvent(new Event("scroll")); }); - expect(text()).toContain("Short reply 0"); + expect(screen.text()).toContain("Short reply 0"); }); expect(maxNestedCommits).toBeLessThan(8); }); @@ -1520,14 +1519,16 @@ describe("MessagesTimeline scroll position", () => {
); } - await renderElement(); - const viewport = await waitForList(props); - await vi.waitFor(() => expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true)); - - await userEvent.wheel(viewport, { delta: { y: -600 } }); - await vi.waitFor(() => expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(false)); - const offset = viewport.scrollTop; - await act(async () => roots.pop()!.unmount()); + let offset: number; + { + await using _chat = await renderElement(); + const viewport = await waitForList(props); + await vi.waitFor(() => expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true)); + + await userEvent.wheel(viewport, { delta: { y: -600 } }); + await vi.waitFor(() => expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(false)); + offset = viewport.scrollTop; + } expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ atEnd: false, scrollOffset: offset, @@ -1536,19 +1537,19 @@ describe("MessagesTimeline scroll position", () => { const timeline = (routeThreadKey: string) => ( ); - await renderElement(timeline(props.routeThreadKey)); + await using screen = await renderElement(timeline(props.routeThreadKey)); const reopenedViewport = await waitForList(props); await vi.waitFor(() => expect(Math.abs(reopenedViewport.scrollTop - offset)).toBeLessThanOrEqual(1), ); - await act(async () => roots.at(-1)!.render(timeline(`${props.routeThreadKey}-other`))); + await screen.rerender(timeline(`${props.routeThreadKey}-other`)); await vi.waitFor(() => expect( reopenedViewport.scrollHeight - reopenedViewport.clientHeight - reopenedViewport.scrollTop, ).toBeLessThanOrEqual(1), ); - await act(async () => roots.at(-1)!.render(timeline(props.routeThreadKey))); + await screen.rerender(timeline(props.routeThreadKey)); await vi.waitFor(() => expect(Math.abs(reopenedViewport.scrollTop - offset)).toBeLessThanOrEqual(1), ); @@ -1578,16 +1579,16 @@ describe("MessagesTimeline scroll position", () => { ); } - await renderElement(); - const composerState = () => container.querySelector("output")!.textContent; + await using screen = await renderElement(); + const composerState = () => screen.container.querySelector("output")!.textContent; const viewport = await waitForList(props); - await click(button("Collapse")); - const toggle = () => button("Command output"); + await click(screen.button("Collapse")); + const toggle = () => screen.button("Command output"); await click(toggle()); await click(toggle()); await vi.waitFor(() => expect(composerState()).toBe("resting")); - await click(button("Collapse")); + await click(screen.button("Collapse")); await userEvent.wheel(viewport, { delta: { y: -2000 } }); await click(toggle()); await click(toggle()); @@ -1600,11 +1601,14 @@ describe("MessagesTimeline send anchor", () => { it("reports the anchored sent message once it is laid out", async () => { const onAnchorReady = vi.fn(); const sent = message("Newest prompt.", {}, "message-2"); - await renderTimeline([message("Earlier reply.", { role: "assistant" }), sent], { - anchorMessageId: MessageId.make("message-2"), - onAnchorReady, - contentInsetEndAdjustment: 144, - }); + await using _screen = await renderTimeline( + [message("Earlier reply.", { role: "assistant" }), sent], + { + anchorMessageId: MessageId.make("message-2"), + onAnchorReady, + contentInsetEndAdjustment: 144, + }, + ); await vi.waitFor(() => expect(onAnchorReady).toHaveBeenCalledWith(MessageId.make("message-2"), 1), ); @@ -1612,10 +1616,13 @@ describe("MessagesTimeline send anchor", () => { it("does not anchor a follow-up user message that is not the newest turn", async () => { const onAnchorReady = vi.fn(); - await renderTimeline([message("First prompt."), message("Newest prompt.", {}, "message-2")], { - anchorMessageId: MessageId.make("message-2"), - onAnchorReady, - }); + await using _screen = await renderTimeline( + [message("First prompt."), message("Newest prompt.", {}, "message-2")], + { + anchorMessageId: MessageId.make("message-2"), + onAnchorReady, + }, + ); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); expect(onAnchorReady).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 82603fc3fca0..ff50964f00c6 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -1,10 +1,9 @@ import { AuthOrchestrationOperateScope, EnvironmentId, type EditorId } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render as renderUi } from "../../../test/render"; import type { RemoteOpenState } from "../../remoteOpen"; const state = vi.hoisted(() => ({ @@ -71,10 +70,8 @@ import { useOpenFavoriteEditorShortcut } from "./OpenInPickerShortcut"; const primary = EnvironmentId.make("primary"); const selected = EnvironmentId.make("selected"); const editors: readonly EditorId[] = ["vscode"]; -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { +async function setup() { state.allowed.clear(); state.remote = { mode: "local-exec" }; state.clientEditors = editors; @@ -84,94 +81,103 @@ beforeEach(() => { state.markHintSeen.mockReset(); state.listeners.clear(); window.localStorage.clear(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - document.body.replaceChildren(); - window.localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + await using stack = new AsyncDisposableStack(); + const _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); + stack.use(_platform); + const _storage = { [Symbol.dispose]: () => window.localStorage.clear() }; + stack.use(_storage); + const screen = stack.use(await renderUi(null)); + async function renderPicker( + availableEditors: readonly EditorId[] = editors, + panel = false, + { openInCwd = "/work/project", revealFileManager = false } = {}, + ) { + await act(async () => { + for (const listener of state.listeners) listener(); + const node = ( + + ); + await screen.rerender(node); + }); + } -async function renderPicker( - availableEditors: readonly EditorId[] = editors, - panel = false, - { openInCwd = "/work/project", revealFileManager = false } = {}, -) { - await act(async () => { - for (const listener of state.listeners) listener(); - const node = ( - - ); - root.render(node); - }); -} + function byLabel(label: string) { + const element = document.querySelector(`[aria-label="${label}"]`); + expect(element, label).not.toBeNull(); + return element!; + } -function byLabel(label: string) { - const element = document.querySelector(`[aria-label="${label}"]`); - expect(element, label).not.toBeNull(); - return element!; -} + const primaryButton = () => byLabel("Open file in preferred editor"); + const menuItems = () => [...document.querySelectorAll('[role="menuitem"]')]; -const primaryButton = () => byLabel("Open file in preferred editor"); -const menuItems = () => [...document.querySelectorAll('[role="menuitem"]')]; + async function click(element: HTMLElement) { + await act(async () => element.click()); + } -async function click(element: HTMLElement) { - await act(async () => element.click()); -} + async function pressOpenFavorite(mac = false) { + const event = new KeyboardEvent("keydown", { + key: "o", + ctrlKey: !mac, + metaKey: mac, + bubbles: true, + cancelable: true, + }); + await act(async () => document.body.dispatchEvent(event)); + return event; + } -async function pressOpenFavorite(mac = false) { - const event = new KeyboardEvent("keydown", { - key: "o", - ctrlKey: !mac, - metaKey: mac, - bubbles: true, - cancelable: true, - }); - await act(async () => document.body.dispatchEvent(event)); - return event; -} + function FavoriteEditorFallback() { + useOpenFavoriteEditorShortcut({ + enabled: true, + environmentId: selected, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + availableEditors: editors, + openInCwd: "/work/project", + }); + return null; + } -function FavoriteEditorFallback() { - useOpenFavoriteEditorShortcut({ - enabled: true, - environmentId: selected, - keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - availableEditors: editors, - openInCwd: "/work/project", - }); - return null; + const environment = stack.move(); + return { + screen, + renderPicker, + byLabel, + primaryButton, + menuItems, + click, + pressOpenFavorite, + FavoriteEditorFallback, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } - it.each(["local-exec", "remote-links"] as const)( "opens exactly one %s target as the file preview and workspace pickers mount and unmount", async (mode) => { + await using fixture = await setup(); + const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture; state.allowed.add(selected); state.remote = mode === "local-exec" ? { mode } : { mode, host: { kind: "ssh-alias", host: "test-host" } }; const mac = mode === "remote-links"; - vi.spyOn(navigator, "platform", "get").mockReturnValue(mac ? "MacIntel" : "Linux"); + using _platform = vi + .spyOn(navigator, "platform", "get") + .mockReturnValue(mac ? "MacIntel" : "Linux"); const surfaces = ["workspace", "file", "workspace"]; if (mode === "local-exec") surfaces.push("fallback"); for (const surface of surfaces) { const previewVisible = surface === "file"; await act(async () => { - root.render( + await screen.rerender( <> {/* Mount the fallback first so picker priority cannot rely on listener order. */} @@ -221,10 +227,12 @@ it.each(["local-exec", "remote-links"] as const)( it.each(["remote-links", "remote-unavailable"] as const)( "does not launch an editor from the %s fallback when no picker is mounted", async (mode) => { + await using fixture = await setup(); + const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture; state.allowed.add(selected); state.remote = mode === "remote-links" ? { mode, host: { kind: "ssh-alias", host: "test-host" } } : { mode }; - await act(async () => root.render()); + await act(async () => screen.rerender()); await pressOpenFavorite(); @@ -236,6 +244,8 @@ it.each(["remote-links", "remote-unavailable"] as const)( it.each(["local-exec", "remote-links"] as const)( "selects the preferred editor without launching a %s workspace", async (mode) => { + await using fixture = await setup(); + const { renderPicker, byLabel, menuItems, click } = fixture; state.allowed.add(selected); state.clientEditors = ["cursor", "vscode"]; state.remote = @@ -265,6 +275,8 @@ it.each(["local-exec", "remote-links"] as const)( describe("host editor access", () => { it("uses the selected grant for both the button and a retained shortcut", async () => { + await using fixture = await setup(); + const { renderPicker, byLabel, primaryButton, click, pressOpenFavorite } = fixture; state.allowed.add(selected); await renderPicker(); // Revoke without rerendering: the mounted button and shortcut must re-check the grant. @@ -283,6 +295,8 @@ describe("host editor access", () => { }); it("closes an open picker on revocation and launches only after a fresh grant", async () => { + await using fixture = await setup(); + const { renderPicker, byLabel, primaryButton, menuItems, click } = fixture; state.allowed.add(selected); await renderPicker(); await click(byLabel("Choose editor")); @@ -303,6 +317,8 @@ describe("host editor access", () => { }); it("reveals a file in the file manager instead of opening it", async () => { + await using fixture = await setup(); + const { renderPicker, primaryButton, click } = fixture; state.allowed.add(selected); await renderPicker(["file-manager"], false, { openInCwd: "/work/project/README.md", @@ -317,6 +333,8 @@ it("reveals a file in the file manager instead of opening it", async () => { describe("client editor links", () => { it("opens an SSH editor URL without host operate permission", async () => { + await using fixture = await setup(); + const { renderPicker, primaryButton, click } = fixture; state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; await renderPicker(); expect(primaryButton().disabled).toBe(false); @@ -330,6 +348,8 @@ describe("client editor links", () => { }); it("does not change preferences when the client rejects the SSH URL", async () => { + await using fixture = await setup(); + const { renderPicker, pressOpenFavorite } = fixture; state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; state.openUrl.mockResolvedValue(false); await renderPicker(); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx index d6632a5d2c3a..21a39de1f767 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -6,13 +6,13 @@ import { } from "@t3tools/contracts"; import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { act, render as renderUi } from "../../../test/render"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { ProviderModelPicker } from "./ProviderModelPicker"; import type { ModelEsque } from "./providerIconUtils"; @@ -37,14 +37,6 @@ vi.mock(import("~/hooks/useSettings"), async (original) => ({ }, })); -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - useNavigate: () => vi.fn(), -})); - -let pickerRoot: Root | undefined; -let pickerRegistry: AtomRegistry.AtomRegistry | undefined; - function providerEntry(instanceId: string, driver: string) { const provider: ServerProvider = { instanceId: ProviderInstanceId.make(instanceId), @@ -62,25 +54,10 @@ function providerEntry(instanceId: string, driver: string) { return deriveProviderInstanceEntries([provider])[0]!; } -afterEach(async () => { - if (pickerRoot) await act(async () => pickerRoot!.unmount()); - pickerRoot = undefined; - pickerRegistry?.dispose(); - pickerRegistry = undefined; - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); - // Returns the trigger's visible text, which is what the user reads before opening the picker. async function triggerText(node: ReactNode) { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - await act(async () => root.render(node)); - const text = container.querySelector("button")?.textContent ?? ""; - await act(async () => root.unmount()); - return text; + await using screen = await renderUi(node); + return screen.container.querySelector("button")?.textContent ?? ""; } function renderPicker(input: { @@ -112,16 +89,16 @@ async function click(target: Parameters[0]) { } async function openLegacyPicker(activeModel: string) { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const entry = providerEntry("codex", "codex"); - pickerRegistry = AtomRegistry.make(); + await using stack = new AsyncDisposableStack(); + const pickerRegistry = AtomRegistry.make(); + stack.defer(() => pickerRegistry.dispose()); pickerRegistry.set(favoritesAtom, [ { provider: entry.instanceId, model: "current-favorite" }, { provider: entry.instanceId, model: "legacy-favorite" }, ]); - pickerRoot = createRoot(document.body.appendChild(document.createElement("div"))); - await act(async () => - pickerRoot!.render( + stack.use( + await renderUi( { it("hoists legacy favorites in provider order and returns an unfavorited model to legacy", async () => { - await openLegacyPicker("current"); + await using _screen = await openLegacyPicker("current"); const legacyFavorite = page.getByRole("option", { name: /Legacy favorite/ }); const currentFavorite = page.getByRole("option", { name: /Current favorite/ }); const current = page.getByRole("option", { name: /Current model/ }); @@ -183,7 +161,7 @@ describe("ProviderModelPicker", () => { }); it("keeps unrelated legacy models collapsed when the active model is a favorite", async () => { - await openLegacyPicker("legacy-favorite"); + await using _screen = await openLegacyPicker("legacy-favorite"); await expect.element(page.getByRole("option", { name: /Legacy favorite/ })).toBeVisible(); await expect diff --git a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx index 18d136620e8b..14a68afcb434 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx @@ -1,8 +1,7 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; const state = vi.hoisted(() => ({ @@ -31,17 +30,13 @@ vi.mock(import("../../assets/assetUrls"), async (importOriginal) => ({ resources.map((resource) => `https://assets.test/${resource.attachmentId}`)) as never, })); +import { act, render as renderUi } from "../../../test/render"; import { QueuedRunsControl } from "./QueuedRunsControl"; -const roots: Root[] = []; - -afterEach(async () => { - await act(async () => { - for (const root of roots.splice(0)) root.unmount(); - }); - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); +function setup() { + state.projection = null; + state.workflow = null; +} const baseProps = { environmentId: "environment:test" as never, @@ -52,14 +47,8 @@ const baseProps = { onCancelEdit: () => undefined, } satisfies ComponentProps; -async function render(props: Partial> = {}) { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.push(root); - await act(async () => root.render()); - return container; +async function renderQueue(props: Partial> = {}) { + return renderUi(); } const labels = (container: HTMLElement) => @@ -69,6 +58,7 @@ const labels = (container: HTMLElement) => describe("QueuedRunsControl automatic completion delivery", () => { it("does not render a queue control when only hidden delivery remains", async () => { + setup(); state.projection = { projection: { messages: [ @@ -90,9 +80,9 @@ describe("QueuedRunsControl automatic completion delivery", () => { queuedRuns: [], }; - const view = await render(); + await using screen = await renderQueue(); - expect(view.childElementCount).toBe(0); + expect(screen.container.childElementCount).toBe(0); }); }); @@ -119,19 +109,25 @@ describe("QueuedRunsControl attachments and edit mode", () => { }); it("renders an attachment thumbnail on the queued row", async () => { + setup(); state.projection = { projection: { messages: [] } }; state.workflow = workflowWithAttachment(); - const view = await render(); - - expect(view.querySelector("img")?.getAttribute("src")).toBe("https://assets.test/attachment-1"); - expect(view.textContent).toContain("Queued with a screenshot"); - expect(labels(view)).toContain("Edit queued message"); - expect(labels(view)).toContain("Reorder queued message (drag, or press the arrow keys)"); - expect(labels(view)).not.toContain("Move queued message up"); + await using screen = await renderQueue(); + + expect(screen.container.querySelector("img")?.getAttribute("src")).toBe( + "https://assets.test/attachment-1", + ); + expect(screen.container.textContent).toContain("Queued with a screenshot"); + expect(labels(screen.container)).toContain("Edit queued message"); + expect(labels(screen.container)).toContain( + "Reorder queued message (drag, or press the arrow keys)", + ); + expect(labels(screen.container)).not.toContain("Move queued message up"); }); it("drops the optimistic pending row once the projection holds its message", async () => { + setup(); state.projection = { projection: { messages: [{ id: "message:acknowledged", text: "hello" }] }, }; @@ -142,7 +138,7 @@ describe("QueuedRunsControl attachments and edit mode", () => { queuedRuns: [], }; - const view = await render({ + await using screen = await renderQueue({ optimisticMessages: [ { id: "message:acknowledged" as never, @@ -153,20 +149,22 @@ describe("QueuedRunsControl attachments and edit mode", () => { ], }); - expect(view.childElementCount).toBe(0); + expect(screen.container.childElementCount).toBe(0); }); it("keeps the original queued message visible while editing", async () => { + setup(); state.projection = { projection: { messages: [] } }; state.workflow = workflowWithAttachment(); - const view = await render({ editingRunId: "run:queued" as never }); + await using screen = await renderQueue({ editingRunId: "run:queued" as never }); - expect(view.textContent).toContain("Queued with a screenshot"); + expect(screen.container.textContent).toContain("Queued with a screenshot"); }); }); it("previews assistant quotes as readable text and preserves the citation when editing", async () => { + setup(); const quotedText = "Cache the parsed config per worktree."; const quote = serializeAssistantCitation({ version: 1, @@ -199,7 +197,7 @@ it("previews assistant quotes as readable text and preserves the citation when e ], }; const onEditQueuedRun = vi.fn(); - await render({ onEditQueuedRun }); + await using _screen = await renderQueue({ onEditQueuedRun }); await expect.element(page.getByText(quotedText, { exact: true })).toBeVisible(); await expect diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx index e780a6350756..3a25b7196b22 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx @@ -1,7 +1,5 @@ import { EnvironmentId, ThreadId, ScheduledTaskId, type ScheduledTask } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ allowed: true, toggle: vi.fn(), run: vi.fn() })); vi.mock(import("../../state/session"), async (importOriginal) => ({ @@ -31,6 +29,7 @@ vi.mock(import("../../state/query"), async (importOriginal) => ({ useEnvironmentQuery: (() => ({ data: { tasks: [task] }, error: null })) as never, })); +import { act, render as renderUi } from "../../../test/render"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; const environmentId = EnvironmentId.make("remote"); const threadId = ThreadId.make("thread"); @@ -42,32 +41,33 @@ const task = { schedule: { type: "interval", everyMs: 60000 }, lastRunStatus: "never", } as ScheduledTask; -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +async function setup() { state.allowed = true; state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); state.run.mockReset().mockResolvedValue({ _tag: "Success" }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); -async function renderPanel() { - await act(async () => - root.render(), - ); -} + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await renderUi(null)); + const container = screen.container; + async function renderPanel() { + await act( + async () => + await screen.rerender( + , + ), + ); + } -const byLabel = (label: string) => container.querySelector(`[aria-label="${label}"]`)!; + const byLabel = (label: string) => + container.querySelector(`[aria-label="${label}"]`)!; + const environment = stack.move(); + return { renderPanel, byLabel, [Symbol.asyncDispose]: () => environment.disposeAsync() }; +} it.each([true, false])("runs scheduled work only with task permission (%s)", async (allowed) => { + await using fixture = await setup(); + const { renderPanel, byLabel } = fixture; state.allowed = allowed; await renderPanel(); await act(async () => byLabel("Run Daily review now").click()); @@ -75,6 +75,8 @@ it.each([true, false])("runs scheduled work only with task permission (%s)", asy }); it("rejects a retained toggle after task permission is revoked", async () => { + await using fixture = await setup(); + const { renderPanel, byLabel } = fixture; await renderPanel(); state.allowed = false; await act(async () => byLabel("Pause Daily review").click()); diff --git a/apps/web/src/components/chat/ThreadDetailsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsControl.browser.test.tsx index e47123e957e1..804b2b6dd17b 100644 --- a/apps/web/src/components/chat/ThreadDetailsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsControl.browser.test.tsx @@ -1,36 +1,22 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, vi } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; +import { act, render as renderUi } from "../../../test/render"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; it("keeps the row label and one chevron when used as a menu trigger", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - try { - await act(async () => { - root.render( - - }> - Run on this machine - - - New worktree - - , - ); - }); - const trigger = container.querySelector("button")!; - expect(trigger.textContent).toBe("Run on this machine"); - expect(trigger.querySelectorAll(".lucide-chevron-down")).toHaveLength(1); - await act(async () => trigger.click()); - expect(document.querySelector('[role="menuitem"]')?.textContent).toBe("New worktree"); - } finally { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - } + await using screen = await renderUi( + + }>Run on this machine + + New worktree + + , + ); + const { container } = screen; + const trigger = container.querySelector("button")!; + expect(trigger.textContent).toBe("Run on this machine"); + expect(trigger.querySelectorAll(".lucide-chevron-down")).toHaveLength(1); + await act(async () => trigger.click()); + expect(document.querySelector('[role="menuitem"]')?.textContent).toBe("New worktree"); }); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx index 48fef8ee4a8e..f220577026ce 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx @@ -9,9 +9,7 @@ import { import * as Cause from "effect/Cause"; import { DraftId } from "~/composerDraftStore"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; const state = vi.hoisted(() => ({ @@ -106,33 +104,12 @@ vi.mock(import("../ui/toast"), async (original) => { }; }); +import { act, render as renderUi } from "../../../test/render"; import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - state.checkoutError = null; - state.newThread.mockReset().mockResolvedValue({ - draftId: DraftId.make("new-draft"), - threadId: ThreadId.make("new-thread"), - }); - state.prepareThread.mockReset(); - state.addToast.mockReset().mockReturnValue("checkout-toast"); - state.updateToast.mockReset(); -}); -afterEach(() => { - act(() => root.unmount()); - document.body.replaceChildren(); - vi.unstubAllGlobals(); - state.status = "success"; - state.extraStatus = null; -}); - -it("requires a new merge click after passing checks become pending and pass again", () => { +it("requires a new merge click after passing checks become pending and pass again", async () => { + await using screen = await setup(); + const { container } = screen; const render = () => ( ); - const clickMerge = () => - act(() => { + const clickMerge = async () => + await act(async () => { [...container.querySelectorAll("button")] .find((button) => button.textContent?.trim() === "Merge")! .click(); }); const dialogs = () => [...document.querySelectorAll('[role="alertdialog"]')]; - act(() => root.render(render())); - clickMerge(); + await act(async () => await screen.rerender(render())); + await clickMerge(); expect(dialogs()).toHaveLength(1); - act(() => { + await act(async () => { state.status = "pending"; - root.render(render()); + await screen.rerender(render()); }); expect(dialogs()).toHaveLength(0); - act(() => { + await act(async () => { state.status = "success"; - root.render(render()); + await screen.rerender(render()); }); expect(dialogs()).toHaveLength(0); - clickMerge(); + await clickMerge(); expect(dialogs()).toHaveLength(1); expect(state.perform).not.toHaveBeenCalled(); }); @@ -182,11 +159,13 @@ it.each<[PullRequestCheck["status"], PullRequestCheck["status"], string]>([ ["neutral", "pending", "1/2"], ["failure", "pending", "1/2"], ["cancelled", "pending", "1/2"], -])("counts finished checks while work remains (%s, %s)", (status, extraStatus, count) => { +])("counts finished checks while work remains (%s, %s)", async (status, extraStatus, count) => { + await using screen = await setup(); + const { container } = screen; state.status = status; state.extraStatus = extraStatus; - act(() => { - root.render( + await act(async () => { + await screen.rerender( ([ }); it("shows the current checkout recovery detail when fixing checks fails and is retried", async () => { + await using screen = await setup(); + const { container } = screen; state.status = "failure"; const first = new GitManagerError({ cwd: "/workspace", @@ -219,7 +200,7 @@ it("shows the current checkout recovery detail when fixing checks fails and is r detail: "The worktree directory already exists. Choose another directory.", }); await act(async () => { - root.render( + await screen.rerender( { - act(() => - root.render( - , - ), + await using screen = await setup(); + await screen.rerender( + , ); - await act(() => userEvent.hover(container.querySelector('button[aria-label="Open PR"]')!)); + await userEvent.hover(screen.getByRole("button", { name: "Open PR" })); await expect .poll(() => document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent) .toMatch(/Docs\+7-3/); }); + +async function setup() { + state.perform.mockReset(); + state.checkoutError = null; + state.newThread.mockReset().mockResolvedValue({ + draftId: DraftId.make("new-draft"), + threadId: ThreadId.make("new-thread"), + }); + state.prepareThread.mockReset(); + state.addToast.mockReset().mockReturnValue("checkout-toast"); + state.updateToast.mockReset(); + + state.status = "success"; + state.extraStatus = null; + return renderUi(null); +} diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx index 82dda7318692..34a607b9f115 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx @@ -1,7 +1,6 @@ import { EnvironmentId, ProjectId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import type { ComponentProps } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -79,6 +78,7 @@ vi.mock(import("../BranchToolbarBranchSelector"), () => ({ ), })); +import { act, render as renderUi } from "../../../test/render"; import { PopoverCreateHandle } from "../ui/popover"; import { ThreadDetailsPanel } from "./ThreadDetailsPanel"; import { ThreadDetailsPrRows } from "./ThreadDetailsPrRows"; @@ -121,79 +121,91 @@ const bottom = link(1, "layer-one", "main", "2026-01-01T00:00:10.000Z"); const top = link(2, "layer-two", "layer-one", "2026-01-01T00:00:20.000Z"); const other = link(3, "unrelated", "main", "2026-01-01T00:00:05.000Z"); -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); +async function setup() { fixture.realRows = false; -}); + watchCommand.mockReset(); + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await renderUi(null)); + const container = screen.container; + async function render( + links: ReadonlyArray, + current: ThreadPullRequestLink, + ) { + await act(async () => { + await screen.rerender( + , + ); + }); + } -function render(links: ReadonlyArray, current: ThreadPullRequestLink) { - act(() => { - root.render( - , + const rows = () => + [...container.querySelectorAll("[data-row]")].map((node) => node.getAttribute("data-row")); + const toggleButton = () => + [...container.querySelectorAll("button")].find((button) => + /^(Show \d+ more|Show less)$/.test(button.textContent?.trim() ?? ""), ); - }); -} + const toggleLabel = () => toggleButton()?.textContent?.trim(); -const rows = () => - [...container.querySelectorAll("[data-row]")].map((node) => node.getAttribute("data-row")); -const toggleButton = () => - [...container.querySelectorAll("button")].find((button) => - /^(Show \d+ more|Show less)$/.test(button.textContent?.trim() ?? ""), - ); -const toggleLabel = () => toggleButton()?.textContent?.trim(); + async function toggle() { + await act(() => toggleButton()!.click()); + } -function toggle() { - act(() => toggleButton()!.click()); + const environment = stack.move(); + return { + screen, + container, + render, + rows, + toggleButton, + toggleLabel, + toggle, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } - -it("shows only the current pull request until the rest are asked for", () => { - render([other, bottom, top], top); +it("shows only the current pull request until the rest are asked for", async () => { + await using test = await setup(); + const { render, rows, toggleLabel, toggle } = test; + await render([other, bottom, top], top); expect(rows()).toEqual(["2"]); expect(toggleLabel()).toBe("Show 2 more"); - toggle(); + await toggle(); expect(rows()).toEqual(["2", "1", "3"]); expect(toggleLabel()).toBe("Show less"); - toggle(); + await toggle(); expect(rows()).toEqual(["2"]); expect(toggleLabel()).toBe("Show 2 more"); }); -it("keeps the single row untouched when the thread links one pull request", () => { - render([bottom], bottom); +it("keeps the single row untouched when the thread links one pull request", async () => { + await using test = await setup(); + const { render, rows, toggleLabel } = test; + await render([bottom], bottom); expect(rows()).toEqual(["1"]); expect(toggleLabel()).toBeUndefined(); }); -it("lets only watched pull requests stop their watch", () => { +it("lets only watched pull requests stop their watch", async () => { + await using test = await setup(); + const { container, render, toggle } = test; const watched: ThreadPullRequestLink = { ...bottom, watch: { @@ -208,14 +220,14 @@ it("lets only watched pull requests stop their watch", () => { wakes: 0, }, }; - render([other, watched, top], top); - toggle(); + await render([other, watched, top], top); + await toggle(); const stopButtons = [...container.querySelectorAll("button")].filter((button) => button.textContent?.startsWith("Stop watching"), ); expect(stopButtons.map((button) => button.textContent)).toEqual(["Stop watching #1"]); - act(() => stopButtons[0]!.click()); + await act(() => stopButtons[0]!.click()); expect(watchCommand).toHaveBeenCalledWith({ environmentId: EnvironmentId.make("environment"), input: { @@ -229,10 +241,12 @@ it("lets only watched pull requests stop their watch", () => { }); it("keeps workspace controls after expanding and collapsing 70 linked pull requests", async () => { + await using test = await setup(); + const { screen, container } = test; fixture.realRows = true; container.style.cssText = "position:relative;width:1800px;height:500px"; await act(async () => { - root.render( + await screen.rerender( { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); const error = ' {"error": {"message":"Turn failed", "code":"unsupported_value"}}\t '; - try { - await act(async () => root.render()); + await using screen = await renderUi(); + const { container } = screen; - await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); - expect(container.textContent).not.toContain("unsupported_value"); + await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); + expect(container.textContent).not.toContain("unsupported_value"); - await act(async () => page.getByRole("button", { name: "Show full error" }).click()); + await act(async () => page.getByRole("button", { name: "Show full error" }).click()); - const diagnostics = page.getByText(error, { exact: true }); - await expect.element(diagnostics).toBeVisible(); - expect(diagnostics.element().textContent).toBe(error); + const diagnostics = page.getByText(error, { exact: true }); + await expect.element(diagnostics).toBeVisible(); + expect(diagnostics.element().textContent).toBe(error); - await act(async () => page.getByRole("button", { name: "Show less" }).click()); + await act(async () => page.getByRole("button", { name: "Show less" }).click()); - await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); - expect(container.textContent).not.toContain("unsupported_value"); - } finally { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - } + await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); + expect(container.textContent).not.toContain("unsupported_value"); }); diff --git a/apps/web/src/components/chat/ThreadFindProvider.browser.test.tsx b/apps/web/src/components/chat/ThreadFindProvider.browser.test.tsx index 16924d250f20..0a1ecbd6c4ca 100644 --- a/apps/web/src/components/chat/ThreadFindProvider.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadFindProvider.browser.test.tsx @@ -1,6 +1,5 @@ -import { act, createRef, useContext, useState, type ReactNode } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { createRef, useContext, useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ThreadId, @@ -12,6 +11,7 @@ import { ThreadFindTimelineContext, type ThreadFindControls, } from "./ThreadFindProvider"; +import { act, render as renderUi } from "../../../test/render"; import type { ThreadFindBar } from "./ThreadFindBar"; const query = vi.hoisted(() => ({ @@ -51,23 +51,13 @@ vi.mock("~/state/query", async () => { }); vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); -type Rendered = { update: (node: ReactNode) => void; unmount: () => void }; -function render(node: ReactNode): Rendered { - const root = createRoot(document.createElement("div")); - root.render(node); - return { update: (next) => root.render(next), unmount: () => root.unmount() }; -} - -let renderer: Rendered | undefined; -afterEach(async () => { - if (renderer) await act(async () => renderer?.unmount()); - renderer = undefined; +function setup() { query.listeners.clear(); - vi.unstubAllGlobals(); -}); + query.value = { data: null, isPending: false, error: null, refresh: vi.fn() }; +} it("keeps navigation and pending responses out of the surrounding chat and unrelated children", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + setup(); const controls = createRef(); const parentRender = vi.fn(); const composerRender = vi.fn(); @@ -104,9 +94,7 @@ it("keeps navigation and pending responses out of the surrounding chat and unrel match: { entryId: "message", runId: null, occurrence: index }, }); query.value = { ...query.value, data: response(0), isPending: false }; - await act(async () => { - renderer = render(); - }); + await using _screen = await renderUi(); await act(async () => controls.current?.open()); await act(async () => bar.onQueryChange("COD4")); const parentCount = parentRender.mock.calls.length; diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx index efe5ab0f742e..401c9e53e824 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx @@ -1,5 +1,4 @@ -import { act, cloneElement, type ReactElement, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { cloneElement, type ReactElement, type ReactNode } from "react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { EnvironmentId, @@ -10,7 +9,7 @@ import { type OrchestrationV2ContextTransfer, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import "~/index.css"; @@ -96,64 +95,10 @@ vi.mock(import("../ui/tooltip"), async () => { }; }); +import { act, render as renderUi } from "../../../test/render"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; -let root: Root; -let container: HTMLDivElement; -let current: ReactElement; - -async function render(element: ReactElement) { - current = element; - await act(async () => root.render(element)); -} -// The mocked hooks read `state` while rendering, so a new element picks up changes. -const update = () => act(async () => root.render(cloneElement(current))); -const click = (element: HTMLElement) => act(async () => element.click()); -const buttons = () => [...container.querySelectorAll("button")]; -const allByLabel = (label: string) => - buttons().filter((button) => button.getAttribute("aria-label") === label); -function byLabel(label: string) { - const matches = allByLabel(label); - if (matches.length !== 1) - throw new Error(`Expected one "${label}" button, got ${matches.length}`); - return matches[0]!; -} -const sectionToggles = (expanded: boolean) => - buttons().filter( - (button) => - button.getAttribute("aria-expanded") === String(expanded) && - !button.hasAttribute("aria-label"), - ); -function sectionToggle(expanded: boolean) { - const [toggle] = sectionToggles(expanded); - if (!toggle) throw new Error(`No ${expanded ? "expanded" : "collapsed"} section toggle`); - return toggle; -} -const heading = () => container.querySelector("h3")?.textContent; -const buttonsWithLabel = (matches: (label: string) => boolean) => - buttons().filter((button) => matches(button.getAttribute("aria-label") ?? "")); -// Joins rendered text nodes; `visibleOnly` drops screen-reader-only text. -function textNodes(separator: string, visibleOnly = false) { - const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - const parts: string[] = []; - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - if (visibleOnly && node.parentElement?.closest(".sr-only")) continue; - parts.push(node.textContent ?? ""); - } - return parts.join(separator); -} - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); +async function setup() { state.shells = []; state.projects = []; state.configs.clear(); @@ -164,9 +109,73 @@ afterEach(async () => { state.toast.mockClear(); state.projection = null; state.projections = null; -}); + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await renderUi(null)); + const container = screen.container; + let current: ReactElement; + + async function render(element: ReactElement) { + current = element; + await act(async () => screen.rerender(element)); + } + // The mocked hooks read `state` while rendering, so a new element picks up changes. + const update = async () => await act(async () => screen.rerender(cloneElement(current))); + const click = async (element: HTMLElement) => await act(async () => element.click()); + const buttons = () => [...container.querySelectorAll("button")]; + const allByLabel = (label: string) => + buttons().filter((button) => button.getAttribute("aria-label") === label); + function byLabel(label: string) { + const matches = allByLabel(label); + if (matches.length !== 1) + throw new Error(`Expected one "${label}" button, got ${matches.length}`); + return matches[0]!; + } + const sectionToggles = (expanded: boolean) => + buttons().filter( + (button) => + button.getAttribute("aria-expanded") === String(expanded) && + !button.hasAttribute("aria-label"), + ); + function sectionToggle(expanded: boolean) { + const [toggle] = sectionToggles(expanded); + if (!toggle) throw new Error(`No ${expanded ? "expanded" : "collapsed"} section toggle`); + return toggle; + } + const heading = () => container.querySelector("h3")?.textContent; + const buttonsWithLabel = (matches: (label: string) => boolean) => + buttons().filter((button) => matches(button.getAttribute("aria-label") ?? "")); + // Joins rendered text nodes; `visibleOnly` drops screen-reader-only text. + function textNodes(separator: string, visibleOnly = false) { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const parts: string[] = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (visibleOnly && node.parentElement?.closest(".sr-only")) continue; + parts.push(node.textContent ?? ""); + } + return parts.join(separator); + } + + const environment = stack.move(); + return { + container, + render, + update, + click, + buttons, + allByLabel, + byLabel, + sectionToggles, + sectionToggle, + heading, + buttonsWithLabel, + textNodes, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => { + await using fixture = await setup(); + const { render, click, byLabel, heading, buttonsWithLabel } = fixture; const coordinator = { id: "workflow", origin: "provider_native", @@ -264,6 +273,8 @@ it.each([ ["codex", "provider_native", "running"], ["claudeAgent", "app_owned", "running"], ])("does not offer workflow stop for %s %s %s", async (driver, origin, status) => { + await using fixture = await setup(); + const { render, click, allByLabel, byLabel, sectionToggles } = fixture; state.projection = { thread: { id: "parent", lineage: { relationshipToParent: null } }, runs: [], @@ -300,6 +311,8 @@ it.each([ }); it("opens the correct chat for every workflow phase and unphased member", async () => { + await using fixture = await setup(); + const { render, update, click, byLabel, buttonsWithLabel, textNodes } = fixture; state.showTooltips = true; state.configs.set("remote", { providers: [ @@ -433,6 +446,8 @@ it("opens the correct chat for every workflow phase and unphased member", async }); it("groups and counts a workflow by its run status instead of its coordinator chat", async () => { + await using fixture = await setup(); + const { render, update, click, allByLabel, byLabel, sectionToggle, heading, textNodes } = fixture; state.showTooltips = true; const coordinator = { id: "workflow", @@ -512,6 +527,8 @@ it.each([ ])( "offers individual Stop for supported $origin $driver subagents with run $runId", async ({ driver, origin, runId, canStop }) => { + await using fixture = await setup(); + const { render, update, click, allByLabel, byLabel } = fixture; const parent = { id: "parent", lineage: { relationshipToParent: null }, @@ -623,6 +640,9 @@ it.each([ ); it("shows the matching child agent details and refreshes them when the agent settles", async () => { + await using fixture = await setup(); + const { render, update, click, buttons, sectionToggles, sectionToggle, heading, textNodes } = + fixture; const agent = { id: "agent-1", driver: "codex", @@ -740,6 +760,8 @@ it("shows the matching child agent details and refreshes them when the agent set }); it("keeps active supervisor status, progress, and task timing after its child turn ends", async () => { + await using fixture = await setup(); + const { render, buttons, heading, textNodes, container } = fixture; using _now = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-16T12:05:15Z")); state.showTooltips = true; // The child request follows task creation by milliseconds, but finishing that @@ -812,6 +834,8 @@ it("keeps active supervisor status, progress, and task timing after its child tu }); it("shows readable models and only differing workspace details in agent tooltips", async () => { + await using fixture = await setup(); + const { render, update, click, sectionToggle, textNodes } = fixture; state.showTooltips = true; const parent = { id: "parent", @@ -1229,6 +1253,8 @@ it("shows readable models and only differing workspace details in agent tooltips }); it("shows the parent's own visible status as parent and child activity change", async () => { + await using fixture = await setup(); + const { render, update, textNodes } = fixture; const environmentId = EnvironmentId.make("test"); const parent = { id: "parent", @@ -1273,6 +1299,8 @@ it("shows the parent's own visible status as parent and child activity change", }); it("shows the native coordinator status from its member and gives follow-up activity precedence", async () => { + await using fixture = await setup(); + const { render, update, buttons } = fixture; const environmentId = EnvironmentId.make("remote"); const coordinator = { id: "coordinator", @@ -1341,6 +1369,8 @@ it("shows the native coordinator status from its member and gives follow-up acti it.each(["source", "target"])( "shows transfer lifecycle states when viewing the %s thread", async (currentThreadId) => { + await using fixture = await setup(); + const { render, update, textNodes } = fixture; const environmentId = EnvironmentId.make("test"); const threads = ["source", "target"].map((id) => ({ id, diff --git a/apps/web/src/components/chat/WorkLog.browser.test.tsx b/apps/web/src/components/chat/WorkLog.browser.test.tsx index 9ab6c17a067e..1a6eb321276a 100644 --- a/apps/web/src/components/chat/WorkLog.browser.test.tsx +++ b/apps/web/src/components/chat/WorkLog.browser.test.tsx @@ -1,51 +1,26 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; +import { act, render as renderUi } from "../../../test/render"; import { WorkLogDetails } from "./WorkLog"; // The scroller's geometry is simulated so the test controls it: a 384px // viewport over content whose height the test grows like a streamed trace. const VIEWPORT = 384; -let contentHeight = 0; -let scrollTop = 0; - -let root: Root; -let host: HTMLDivElement; - -function render(followLive: boolean, text: string) { - act(() => { - root.render( - -

{text}

-
, - ); - }); -} - -function scroller() { - const element = host.firstElementChild; - if (!(element instanceof HTMLDivElement)) throw new Error("missing scroller"); - return element; -} - -function stream(followLive: boolean, text: string) { - contentHeight += 200; - render(followLive, text); -} - -function readerScrollsTo(top: number) { - scroller().scrollTop = top; - act(() => { - scroller().dispatchEvent(new Event("scroll")); - }); -} - -const maxScrollTop = () => contentHeight - VIEWPORT; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - contentHeight = 1000; - scrollTop = 0; +async function setup() { + let contentHeight = 1000; + let scrollTop = 0; + const maxScrollTop = () => contentHeight - VIEWPORT; + const keys = ["clientHeight", "scrollHeight", "scrollTop"] as const; + const original = keys.map((key) => + Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, key), + ); + await using stack = new AsyncDisposableStack(); + stack.defer(() => + keys.forEach((key, i) => { + const descriptor = original[i]; + if (descriptor) Object.defineProperty(HTMLDivElement.prototype, key, descriptor); + else Reflect.deleteProperty(HTMLDivElement.prototype, key); + }), + ); Object.defineProperties(HTMLDivElement.prototype, { clientHeight: { configurable: true, get: () => VIEWPORT }, scrollHeight: { configurable: true, get: () => contentHeight }, @@ -57,45 +32,68 @@ beforeEach(() => { }, }, }); - host = document.createElement("div"); - document.body.append(host); - root = createRoot(host); -}); - -afterEach(() => { - act(() => root.unmount()); - host.remove(); - for (const key of ["clientHeight", "scrollHeight", "scrollTop"]) { - Reflect.deleteProperty(HTMLDivElement.prototype, key); - } - vi.unstubAllGlobals(); -}); + const screen = stack.use(await renderUi(null)); + const render = async (followLive: boolean, text: string) => + await screen.rerender( + +

{text}

+
, + ); + const scroller = () => { + const element = screen.container.firstElementChild; + if (!(element instanceof HTMLDivElement)) throw new Error("missing scroller"); + return element; + }; + const stream = async (followLive: boolean, text: string) => { + contentHeight += 200; + await render(followLive, text); + }; + const readerScrollsTo = async (top: number) => { + scroller().scrollTop = top; + await act(() => scroller().dispatchEvent(new Event("scroll"))); + }; + const environment = stack.move(); + return { + render, + stream, + readerScrollsTo, + maxScrollTop, + getScrollTop: () => scrollTop, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} describe("WorkLogDetails followLive", () => { - it("keeps the newest streamed text in view", () => { - render(true, "a"); - expect(scrollTop).toBe(maxScrollTop()); + it("keeps the newest streamed text in view", async () => { + await using fixture = await setup(); + const { render, stream, maxScrollTop, getScrollTop } = fixture; + await render(true, "a"); + expect(getScrollTop()).toBe(maxScrollTop()); - stream(true, "ab"); - stream(true, "abc"); - expect(scrollTop).toBe(maxScrollTop()); + await stream(true, "ab"); + await stream(true, "abc"); + expect(getScrollTop()).toBe(maxScrollTop()); }); - it("stops following when the reader scrolls up and resumes at the end", () => { - render(true, "a"); - readerScrollsTo(100); + it("stops following when the reader scrolls up and resumes at the end", async () => { + await using fixture = await setup(); + const { render, stream, readerScrollsTo, maxScrollTop, getScrollTop } = fixture; + await render(true, "a"); + await readerScrollsTo(100); - stream(true, "ab"); - expect(scrollTop).toBe(100); + await stream(true, "ab"); + expect(getScrollTop()).toBe(100); - readerScrollsTo(maxScrollTop()); - stream(true, "abc"); - expect(scrollTop).toBe(maxScrollTop()); + await readerScrollsTo(maxScrollTop()); + await stream(true, "abc"); + expect(getScrollTop()).toBe(maxScrollTop()); }); - it("does not move a trace that is not streaming", () => { - render(false, "a"); - stream(false, "ab"); - expect(scrollTop).toBe(0); + it("does not move a trace that is not streaming", async () => { + await using fixture = await setup(); + const { render, stream, getScrollTop } = fixture; + await render(false, "a"); + await stream(false, "ab"); + expect(getScrollTop()).toBe(0); }); }); diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx index 8f28e93b0081..eb8165061d14 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx @@ -1,10 +1,9 @@ import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import "~/index.css"; +import { act, render } from "../../../test/render"; import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; @@ -63,76 +62,72 @@ function Find(props: { readonly container: HTMLElement | null; readonly query: s return null; } -let root: Root; -let host: HTMLElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - host = document.createElement("div"); - host.style.width = "720px"; - document.body.append(host); - root = createRoot(host); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - host.remove(); - CSS.highlights.clear(); - vi.unstubAllGlobals(); -}); - -async function render(script: string, query = "") { - await act(async () => - root.render( - - 0}> -
- undefined} - /> -
-
- -
, - ), +async function renderOpening(script: string, query = "") { + const container = document.createElement("div"); + container.style.width = "720px"; + const block = (script: string, query: string) => ( + + 0}> +
+ undefined} + /> +
+
+ +
); - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - }); + const nextFrame = () => + act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + await using stack = new AsyncDisposableStack(); + stack.defer(() => CSS.highlights.clear()); + const screen = stack.use(await render(block(script, query), { container })); + await nextFrame(); + const environment = stack.move(); + return { + container, + rerender: async (script: string, query: string) => { + await screen.rerender(block(script, query)); + await nextFrame(); + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } -const foldToggle = () => +const foldToggle = (host: HTMLElement) => [...host.querySelectorAll("button")].find((button) => /Show full script|Show less/.test(button.textContent ?? ""), ); it("shows a short script as highlighted code with nothing to open", async () => { - await render(SHORT_SCRIPT); - await act(async () => { - await getSyntaxHighlighterPromise("javascript"); - }); - expect(host.querySelector('[data-language="javascript"]')?.textContent).toContain("SCRIPTONLY"); - await vi.waitFor(() => expect(host.querySelector(".chat-markdown-shiki")).not.toBeNull()); - expect(foldToggle()).toBeUndefined(); + await using screen = await renderOpening(SHORT_SCRIPT); + await act(() => getSyntaxHighlighterPromise("javascript")); + expect(screen.container.querySelector('[data-language="javascript"]')?.textContent).toContain( + "SCRIPTONLY", + ); + await vi.waitFor(() => + expect(screen.container.querySelector(".chat-markdown-shiki")).not.toBeNull(), + ); + expect(foldToggle(screen.container)).toBeUndefined(); }); it("folds a long script until it is opened", async () => { - await render(LONG_SCRIPT); - expect(foldToggle()?.getAttribute("aria-expanded")).toBe("false"); - expect(host.querySelector("[data-thread-find-fold]")).not.toBeNull(); - await act(async () => foldToggle()?.click()); - expect(foldToggle()?.getAttribute("aria-expanded")).toBe("true"); - expect(host.querySelector("[data-thread-find-fold]")).toBeNull(); + await using screen = await renderOpening(LONG_SCRIPT); + expect(foldToggle(screen.container)?.getAttribute("aria-expanded")).toBe("false"); + expect(screen.container.querySelector("[data-thread-find-fold]")).not.toBeNull(); + await act(() => foldToggle(screen.container)?.click()); + expect(foldToggle(screen.container)?.getAttribute("aria-expanded")).toBe("true"); + expect(screen.container.querySelector("[data-thread-find-fold]")).toBeNull(); }); it("finds the workflow's name and opens a folded script on a match below the fold", async () => { - await render(LONG_SCRIPT, "repo-check"); + await using screen = await renderOpening(LONG_SCRIPT, "repo-check"); expect(CSS.highlights.get("t3-thread-find-active")?.size).toBe(1); - expect(foldToggle()?.getAttribute("aria-expanded")).toBe("false"); + expect(foldToggle(screen.container)?.getAttribute("aria-expanded")).toBe("false"); - await render(LONG_SCRIPT, "TAILONLY"); + await screen.rerender(LONG_SCRIPT, "TAILONLY"); expect(CSS.highlights.get("t3-thread-find-active")?.size).toBe(1); - expect(foldToggle()?.getAttribute("aria-expanded")).toBe("true"); + expect(foldToggle(screen.container)?.getAttribute("aria-expanded")).toBe("true"); }); diff --git a/apps/web/src/components/chat/composerEventScope.browser.test.tsx b/apps/web/src/components/chat/composerEventScope.browser.test.tsx index 46523cdcd1ce..53e6e3c6d47b 100644 --- a/apps/web/src/components/chat/composerEventScope.browser.test.tsx +++ b/apps/web/src/components/chat/composerEventScope.browser.test.tsx @@ -1,7 +1,7 @@ -import { act, useLayoutEffect } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { render } from "../../../test/render"; import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; import { isInsideCollapsedComposerControls, @@ -10,18 +10,11 @@ import { useComposerMenuProps, } from "./composerEventScope"; -function elementInside(html: string) { - const host = document.createElement("div"); - host.innerHTML = html; - document.body.append(host); +function elementInside(html: string, host = document.createElement("div")) { + host.insertAdjacentHTML("beforeend", html); return host.querySelector("[data-target]")!; } -afterEach(() => { - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); - describe("composer menu focus", () => { it.each([ [ @@ -32,14 +25,14 @@ describe("composer menu focus", () => { ["an unmounted menu", null, true], ["another control", "", false], ])("closes while focus is on %s", async (_label, html, shouldFocusComposer) => { - const editor = document.createElement("textarea"); - document.body.append(editor); - const focused = html === null ? null : (elementInside(html) as HTMLElement); + const host = document.body.appendChild(document.createElement("div")); + using _host = { [Symbol.dispose]: () => host.remove() }; + const editor = host.appendChild(document.createElement("textarea")); + const focused = html === null ? null : (elementInside(html, host) as HTMLElement); focused?.focus(); const composerRef = { current: { focusAtEnd: () => editor.focus() }, } as unknown as ComposerHandleRef; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); let menuProps: ReturnType | undefined; function Probe() { const props = useComposerMenuProps(); @@ -48,20 +41,13 @@ describe("composer menu focus", () => { }, [props]); return null; } - const root = createRoot(document.createElement("div")); - await act(async () => - root.render( - - - , - ), + await using _screen = await render( + + + , ); - try { - expect(menuProps?.finalFocus?.()).toBe(false); - expect(document.activeElement).toBe(shouldFocusComposer ? editor : focused); - } finally { - await act(async () => root.unmount()); - } + expect(menuProps?.finalFocus?.()).toBe(false); + expect(document.activeElement).toBe(shouldFocusComposer ? editor : focused); }); }); diff --git a/apps/web/src/components/chat/composerThreadImport.browser.test.tsx b/apps/web/src/components/chat/composerThreadImport.browser.test.tsx index c0dbf9af16cd..69c5d77df224 100644 --- a/apps/web/src/components/chat/composerThreadImport.browser.test.tsx +++ b/apps/web/src/components/chat/composerThreadImport.browser.test.tsx @@ -1,7 +1,8 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { act, useRef } from "react"; -import { createRoot } from "react-dom/client"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { useRef } from "react"; +import { describe, expect, it } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; import { importComposerThreadAttachment } from "./composerThreadImport"; import { THREAD_CONTEXT_DROP_EVENT, useThreadContextDrop } from "./threadContextDrag"; @@ -59,46 +60,39 @@ describe("importComposerThreadAttachment from a thread drop", () => { targetKey: "draft", expanded: false, }; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const root = createRoot(document.createElement("div")); - try { - act(() => root.render()); - act(() => target.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: [] }))); - expect(pendingImports.get("draft")).toBe(1); - if (change === "unmount") { - act(() => root.unmount()); - } else { - act(() => - root.render( - , - ), - ); - if (change !== "expand") { - act(() => root.render()); - } - } - transcript.resolve(new File(["transcript"], "thread.txt")); - await act(async () => importing); - expect(attached).toEqual(change === "expand" ? ["thread.txt"] : []); - expect(pendingImports.size).toBe(0); - if (change === "expand") { - act(() => - target.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: [] })), - ); - await act(async () => importing); - expect(expandedAtDrop).toEqual([false, true]); - expect(attached).toEqual(["thread.txt", "thread.txt"]); + await using screen = await render(); + await act(() => + target.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: [] })), + ); + expect(pendingImports.get("draft")).toBe(1); + if (change === "unmount") { + await screen.unmount(); + } else { + await screen.rerender( + , + ); + if (change !== "expand") { + await screen.rerender(); } - } finally { - if (change !== "unmount") act(() => root.unmount()); - vi.unstubAllGlobals(); + } + transcript.resolve(new File(["transcript"], "thread.txt")); + await act(() => importing); + expect(attached).toEqual(change === "expand" ? ["thread.txt"] : []); + expect(pendingImports.size).toBe(0); + if (change === "expand") { + await act(() => + target.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: [] })), + ); + await act(() => importing); + expect(expandedAtDrop).toEqual([false, true]); + expect(attached).toEqual(["thread.txt", "thread.txt"]); } }, ); diff --git a/apps/web/src/components/chat/threadContextDrag.browser.test.tsx b/apps/web/src/components/chat/threadContextDrag.browser.test.tsx index 04aff4dbc2ff..398299e05b0e 100644 --- a/apps/web/src/components/chat/threadContextDrag.browser.test.tsx +++ b/apps/web/src/components/chat/threadContextDrag.browser.test.tsx @@ -1,28 +1,12 @@ -import { act, memo, useRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { memo, useRef } from "react"; +import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { act, render } from "../../../test/render"; import { THREAD_CONTEXT_DROP_EVENT, useThreadContextDrop } from "./threadContextDrag"; const ENVIRONMENT_ID = "environment-local" as EnvironmentId; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - // ChatComposer is memoized and reused across thread navigation; each render's // drop callback must write into the draft of the thread it last rendered. const ComposerDropTarget = memo(function ComposerDropTarget({ @@ -61,16 +45,16 @@ describe("useThreadContextDrop", () => { ["thread-b", []], ]); - await act(() => root.render()); - await act(() => root.render()); + await using screen = await render(); + await screen.rerender(); const dropped: ScopedThreadRef = { environmentId: ENVIRONMENT_ID, threadId: "thread-dropped" as ThreadId, }; - const target = container.querySelector("[data-drop-target]"); + const target = screen.container.querySelector("[data-drop-target]"); expect(target).not.toBeNull(); - act(() => { + await act(() => { target!.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: [dropped] })); }); @@ -82,12 +66,12 @@ describe("useThreadContextDrop", () => { const first: Array = []; const latest: Array = []; - await act(() => root.render()); - await act(() => root.render()); + await using screen = await render(); + await screen.rerender(); - const target = container.querySelector("[data-drop-target]"); + const target = screen.container.querySelector("[data-drop-target]"); expect(target).not.toBeNull(); - act(() => { + await act(() => { target!.dispatchEvent( new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: [{ environmentId: ENVIRONMENT_ID, threadId: "thread-dropped" as ThreadId }], diff --git a/apps/web/src/components/chat/threadFindHighlights.browser.test.tsx b/apps/web/src/components/chat/threadFindHighlights.browser.test.tsx index 05fbf603ac5b..ff46f9a799ea 100644 --- a/apps/web/src/components/chat/threadFindHighlights.browser.test.tsx +++ b/apps/web/src/components/chat/threadFindHighlights.browser.test.tsx @@ -1,6 +1,6 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; import { collectThreadFindRanges, useThreadFindHighlights } from "./threadFindHighlights"; function Probe(props: Parameters[0]) { @@ -8,44 +8,39 @@ function Probe(props: Parameters[0]) { return null; } -let root: Root; -let host: HTMLElement; -let container: HTMLElement; -const onActiveRange = vi.fn(); const activeRanges = () => [ ...(CSS.highlights.get("t3-thread-find-active") as unknown as Set), ]; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("CSS", { highlights: new Map() }); - vi.stubGlobal( - "Highlight", - class extends Set { - constructor(...ranges: Range[]) { - super(ranges); - } - }, +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + +function setup() { + using stack = new DisposableStack(); + stack.use(stubGlobal("CSS", { highlights: new Map() })); + stack.use( + stubGlobal( + "Highlight", + class extends Set { + constructor(...ranges: Range[]) { + super(ranges); + } + }, + ), ); - host = document.createElement("div"); - container = document.createElement("div"); + const container = document.createElement("div"); container.innerHTML = '

COD4 and COD4

' + '

COD4

'; - document.body.append(host, container); - root = createRoot(host); -}); -afterEach(async () => { - await act(async () => root.unmount()); - host.remove(); - container.remove(); - vi.restoreAllMocks(); - vi.clearAllMocks(); - vi.unstubAllGlobals(); -}); + document.body.append(container); + stack.defer(() => container.remove()); + const environment = stack.move(); + return { container, onActiveRange: vi.fn(), [Symbol.dispose]: () => environment.dispose() }; +} describe("find highlight caching", () => { it("checks only the old and new occurrence when cycling a message with many clipped matches", async () => { + using find = setup(); + const { container, onActiveRange } = find; container.innerHTML = '

' + "COD4 ".repeat(1500) + @@ -55,6 +50,9 @@ describe("find highlight caching", () => { configurable: true, value: bounds, }); + using _bounds = { + [Symbol.dispose]: () => Reflect.deleteProperty(Range.prototype, "getBoundingClientRect"), + }; container.querySelector("[data-thread-find-fold]")!.getBoundingClientRect = () => new DOMRect(0, 0, 800, 100); const props = { @@ -64,117 +62,108 @@ describe("find highlight caching", () => { activeOccurrence: 0, onActiveRange, }; - try { - await act(async () => root.render()); - const inactive = CSS.highlights.get("t3-thread-find"); - const first = activeRanges()[0]; - bounds.mockClear(); - await act(async () => root.render()); - expect(bounds).toHaveBeenCalledTimes(2); - expect(CSS.highlights.get("t3-thread-find")).toBe(inactive); - expect(inactive?.size).toBe(1499); - expect(inactive?.has(first!)).toBe(true); - expect(activeRanges()[0]?.startOffset).toBe(5); - expect(inactive?.has(activeRanges()[0]!)).toBe(false); - } finally { - Reflect.deleteProperty(Range.prototype, "getBoundingClientRect"); - } + await using screen = await render(); + const inactive = CSS.highlights.get("t3-thread-find"); + const first = activeRanges()[0]; + bounds.mockClear(); + await screen.rerender(); + expect(bounds).toHaveBeenCalledTimes(2); + expect(CSS.highlights.get("t3-thread-find")).toBe(inactive); + expect(inactive?.size).toBe(1499); + expect(inactive?.has(first!)).toBe(true); + expect(activeRanges()[0]?.startOffset).toBe(5); + expect(inactive?.has(activeRanges()[0]!)).toBe(false); }); it("repaints for searchable text changes but not for timers outside it", async () => { + using find = setup(); + const { container, onActiveRange } = find; const timer = document.createElement("span"); container.firstElementChild!.append(timer); - await act(async () => - root.render( - , - ), + await using _screen = await render( + , ); onActiveRange.mockClear(); await act(async () => { timer.textContent = "0:01"; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(onActiveRange).not.toHaveBeenCalled(); await act(async () => { container.querySelector("p")!.append(" COD4"); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(onActiveRange).toHaveBeenCalled(); }); it("opens only the selected fold while reusing its cached text", async () => { + using find = setup(); + const { container, onActiveRange } = find; container.innerHTML = '

' + '
'; const folds = [...container.querySelectorAll("[hidden]")]; for (const fold of folds) fold.addEventListener("beforematch", () => fold.removeAttribute("hidden")); - const createRange = vi.spyOn(document, "createRange"); - await act(async () => - root.render( - , - ), + using createRange = vi.spyOn(document, "createRange"); + await using screen = await render( + , ); - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - }); + await act(nextFrame); expect(folds[0]?.hasAttribute("hidden")).toBe(false); expect(folds[1]?.hasAttribute("hidden")).toBe(true); expect(activeRanges()[0]?.toString()).toBe("COD4"); expect(CSS.highlights.get("t3-thread-find")?.size).toBe(1); const scanned = createRange.mock.calls.length; - await act(async () => - root.render( - , - ), + await screen.rerender( + , ); expect(createRange).toHaveBeenCalledTimes(scanned); expect(activeRanges()[0]?.startOffset).toBe(5); }); it("observes rows mounted synchronously while revealing the first match", async () => { + using find = setup(); + const { container, onActiveRange } = find; container.replaceChildren(); onActiveRange.mockImplementationOnce(() => { container.innerHTML = '

COD4

'; }); - await act(async () => { - root.render( - , - ); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - }); - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - }); + await using _screen = await render( + , + ); + await act(nextFrame); expect(activeRanges()[0]?.toString()).toBe("COD4"); }); it("rebuilds ranges when the list moves or recycles an existing row", async () => { + using find = setup(); + const { container, onActiveRange } = find; const props = { container, query: "COD4", @@ -182,27 +171,29 @@ describe("find highlight caching", () => { activeOccurrence: 0, onActiveRange, }; - await act(async () => root.render()); + await using screen = await render(); const initial = activeRanges()[0]; const row = container.querySelector('[data-timeline-row-id="first"]')!; await act(async () => { row.remove(); container.append(row); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(activeRanges()[0]).not.toBe(initial); expect(activeRanges()[0]?.toString()).toBe("COD4"); await act(async () => { row.dataset.timelineRowId = "recycled"; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(activeRanges()).toHaveLength(0); - await act(async () => root.render()); + await screen.rerender(); expect(activeRanges()[0]?.toString()).toBe("COD4"); }); it("reuses unchanged text ranges on navigation and scans only the streaming row", async () => { - const createRange = vi.spyOn(document, "createRange"); + using find = setup(); + const { container, onActiveRange } = find; + using createRange = vi.spyOn(document, "createRange"); const props = { container, query: "COD4", @@ -210,35 +201,33 @@ describe("find highlight caching", () => { activeOccurrence: 0, onActiveRange, }; - await act(async () => root.render()); + await using screen = await render(); expect(createRange).toHaveBeenCalledTimes(3); const initial = activeRanges()[0]; - await act(async () => root.render()); + await screen.rerender(); expect(createRange).toHaveBeenCalledTimes(3); expect(activeRanges()[0]?.startOffset).toBe(9); - await act(async () => root.render()); + await screen.rerender(); expect(activeRanges()[0]).toBe(initial); await act(async () => { container.querySelector('[data-timeline-row-id="second"] p')!.firstChild!.nodeValue = "COD4 COD4"; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(createRange).toHaveBeenCalledTimes(5); expect(activeRanges()[0]).toBe(initial); - await act(async () => - root.render(), - ); + await screen.rerender(); expect(createRange).toHaveBeenCalledTimes(5); expect(activeRanges()[0]?.toString()).toBe("COD4"); expect(activeRanges()[0]?.startOffset).toBe(5); await act(async () => { container.querySelector('[data-timeline-row-id="second"]')!.remove(); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(activeRanges()).toHaveLength(0); expect(onActiveRange).toHaveBeenLastCalledWith(null); expect(CSS.highlights.get("t3-thread-find")?.size).toBe(2); - await act(async () => root.render()); + await screen.rerender(); expect(CSS.highlights.get("t3-thread-find")?.size).toBe(0); }); }); diff --git a/apps/web/src/components/chat/useComposerFocusState.browser.test.tsx b/apps/web/src/components/chat/useComposerFocusState.browser.test.tsx index da35cf05d39d..1ddca3d1c2ec 100644 --- a/apps/web/src/components/chat/useComposerFocusState.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerFocusState.browser.test.tsx @@ -1,11 +1,10 @@ -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { act, render } from "../../../test/render"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; -let root: Root; let composer: ReturnType; let isResting: boolean; @@ -25,19 +24,9 @@ function ComposerProbe() { return null; } -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - root = createRoot(document.createElement("div")); - await act(() => root.render()); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); - describe("composer focus state", () => { it("stays expanded when the composer loses focus", async () => { + await using _screen = await render(); await act(() => composer.setIsComposerFocused(true)); expect(isResting).toBe(false); @@ -46,6 +35,7 @@ describe("composer focus state", () => { }); it("can collapse again on the next scroll after returning to the end", async () => { + await using _screen = await render(); await act(() => composer.setIsComposerScrollCollapsed(true)); expect(isResting).toBe(true); @@ -57,6 +47,7 @@ describe("composer focus state", () => { }); it("does not move focus into the composer when the timeline reaches the end", async () => { + await using _screen = await render(); await act(() => composer.setIsComposerScrollCollapsed(true)); await act(() => composer.restoreAfterTimelineReachedEnd()); expect(isResting).toBe(false); diff --git a/apps/web/src/components/chat/useComposerMenuState.browser.test.tsx b/apps/web/src/components/chat/useComposerMenuState.browser.test.tsx index b444497032a3..04337f86f560 100644 --- a/apps/web/src/components/chat/useComposerMenuState.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerMenuState.browser.test.tsx @@ -1,10 +1,9 @@ -import { act, StrictMode, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, useLayoutEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; import { useComposerMenuState } from "./useComposerMenuState"; -let root: Root; let setOpen: ReturnType[1]; let committedOpen: boolean[]; let closePopup = vi.fn<() => void>(); @@ -19,40 +18,30 @@ function MenuStateProbe({ hidden }: { hidden: boolean }) { return null; } -async function renderMenu(hidden: boolean) { - await act(() => { - root.render( - - , - ); - }); -} +const menu = (hidden: boolean) => ( + + +); -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { committedOpen = []; closePopup = vi.fn(); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); +} describe("useComposerMenuState", () => { it("closes an open popup when its trigger hides and does not reopen when it returns", async () => { - await renderMenu(false); + setup(); + await using screen = await render(menu(false)); await act(() => setOpen(true)); expect(committedOpen.at(-1)).toBe(true); - await renderMenu(true); + await screen.rerender(menu(true)); expect(committedOpen.at(-1)).toBe(false); expect(closePopup).toHaveBeenCalledTimes(1); const afterClose = committedOpen.length; - await renderMenu(false); + await screen.rerender(menu(false)); expect(committedOpen).toHaveLength(afterClose); expect(committedOpen.at(-1)).toBe(false); @@ -64,9 +53,10 @@ describe("useComposerMenuState", () => { }); it("does not commit an open popup requested while its trigger is hidden", async () => { - await renderMenu(true); + setup(); + await using screen = await render(menu(true)); await act(() => setOpen(true)); - await renderMenu(false); + await screen.rerender(menu(false)); expect(committedOpen.every((open) => !open)).toBe(true); expect(closePopup).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx index 03225f5249be..665391e1dc40 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx @@ -1,13 +1,12 @@ -import { act, StrictMode, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, useLayoutEffect } from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { act, render } from "../../../test/render"; import { detectComposerTrigger } from "../../composer-logic"; import { useComposerTriggerState } from "./useComposerTriggerState"; const command = "pnpm install -g @openai/codex@latest"; const initialPrompt = "pnpm install -g @openai"; -let root: Root; let composer: ReturnType; function ComposerProbe() { @@ -24,25 +23,16 @@ async function updatePrompt(text: string, cursor = text.length) { await act(() => composer.setTrigger(detectComposerTrigger(text, cursor))); } -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - root = createRoot(document.createElement("div")); - await act(() => - root.render( - - - , - ), +const renderComposer = () => + render( + + + , ); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); describe("composer suggestion dismissal", () => { it("closes suggestions and rejects keyboard selection before the next render", async () => { + await using _screen = await renderComposer(); const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); expect(composer.trigger).toEqual(candidate); @@ -54,6 +44,7 @@ describe("composer suggestion dismissal", () => { }); it("stays dismissed while typing a scoped package, including its second @", async () => { + await using _screen = await renderComposer(); await act(() => composer.dismissTrigger(composer.trigger)); for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) { @@ -65,6 +56,7 @@ describe("composer suggestion dismissal", () => { }); it("stays dismissed while deleting characters or moving within the same word", async () => { + await using _screen = await renderComposer(); await act(() => composer.dismissTrigger(composer.trigger)); for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) { @@ -76,6 +68,7 @@ describe("composer suggestion dismissal", () => { }); it("opens suggestions for a new @ word after a space", async () => { + await using _screen = await renderComposer(); await act(() => composer.dismissTrigger(composer.trigger)); await updatePrompt(`${command} `); await updatePrompt(`${command} @src`); @@ -85,6 +78,7 @@ describe("composer suggestion dismissal", () => { }); it("opens a different token when the caret moves directly to it", async () => { + await using _screen = await renderComposer(); await act(() => composer.dismissTrigger(composer.trigger)); await updatePrompt(`${command} @src`); @@ -92,6 +86,7 @@ describe("composer suggestion dismissal", () => { }); it("can reopen after the caret leaves the dismissed word", async () => { + await using _screen = await renderComposer(); await act(() => composer.dismissTrigger(composer.trigger)); await updatePrompt(initialPrompt, 0); await updatePrompt(initialPrompt); @@ -100,6 +95,7 @@ describe("composer suggestion dismissal", () => { }); it("can reopen at the same position after deleting and retyping @", async () => { + await using _screen = await renderComposer(); await act(() => composer.dismissTrigger(composer.trigger)); const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@")); await updatePrompt(prefix); @@ -109,6 +105,7 @@ describe("composer suggestion dismissal", () => { }); it("clears dismissal when switching drafts or pending questions", async () => { + await using _screen = await renderComposer(); await act(() => composer.dismissTrigger(composer.trigger)); const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); await act(() => composer.resetTrigger(candidate)); @@ -118,6 +115,7 @@ describe("composer suggestion dismissal", () => { }); it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { + await using _screen = await renderComposer(); await updatePrompt(text); await act(() => composer.dismissTrigger(composer.trigger)); await updatePrompt(`${text}x`); diff --git a/apps/web/src/components/chat/useThreadFind.browser.test.tsx b/apps/web/src/components/chat/useThreadFind.browser.test.tsx index 1b4080ba998d..3129a70af0b0 100644 --- a/apps/web/src/components/chat/useThreadFind.browser.test.tsx +++ b/apps/web/src/components/chat/useThreadFind.browser.test.tsx @@ -1,6 +1,5 @@ -import { act, useLayoutEffect, type ReactNode } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, RunId, @@ -8,6 +7,7 @@ import { type OrchestrationV2SearchThreadInput, type OrchestrationV2SearchThreadResult, } from "@t3tools/contracts"; +import { act, fakeTimers, render } from "../../../test/render"; import { useThreadFind } from "./useThreadFind"; import { orchestrationEnvironment } from "~/state/orchestration"; import { requestThreadFindOpen } from "./threadFindActionBus"; @@ -39,14 +39,6 @@ vi.mock("~/state/query", () => ({ vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); -type Rendered = { update: (node: ReactNode) => void; unmount: () => void }; -function render(node: ReactNode): Rendered { - const root = createRoot(document.createElement("div")); - root.render(node); - return { update: (next) => root.render(next), unmount: () => root.unmount() }; -} - -let renderer: Rendered | undefined; let find: ReturnType; const a = EnvironmentId.make("environment:a"); const b = EnvironmentId.make("environment:b"); @@ -74,18 +66,11 @@ function Probe({ }); return null; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); -}); -afterEach(async () => { - if (renderer) await act(async () => renderer?.unmount()); - renderer = undefined; +function setup() { queries.results.clear(); queries.pending = false; vi.clearAllMocks(); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); +} describe("V2 find state", () => { function messageResult(index: number, snapshotSequence = 9): OrchestrationV2SearchThreadResult { @@ -98,9 +83,8 @@ describe("V2 find state", () => { } it("reveals an early match and updates its count without requesting another jump", async () => { - await act(async () => { - renderer = render(); - }); + setup(); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("needle")); const partial: OrchestrationV2SearchThreadResult = { @@ -112,7 +96,7 @@ describe("V2 find state", () => { }; queries.pending = true; queries.results.set(a, partial); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.barProps.counting).toBe(true); expect(find.barProps.status).toBeNull(); expect(find.timelineProps.activeFindMatch).toEqual(partial.match); @@ -125,7 +109,7 @@ describe("V2 find state", () => { }); expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests); queries.results.set(a, { ...partial, complete: true, totalMatches: 123, activeIndex: 42 }); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.barProps.counting).toBe(false); expect(find.barProps.matchCount).toBe(123); expect(find.barProps.activeIndex).toBe(42); @@ -133,33 +117,28 @@ describe("V2 find state", () => { expect(find.timelineProps.findNavigationId).toBe(navigationId); expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled(); queries.pending = false; - await act(async () => renderer?.update()); + await screen.rerender(); await act(async () => find.barProps.onNext()); expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests + 1); }); it("coalesces incoming pages until the progressive scan finishes", async () => { - vi.useFakeTimers(); + setup(); + using _timers = fakeTimers(); queries.pending = true; - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("needle")); queries.results.set(a, { ...messageResult(0), complete: false, totalMatches: 1 }); const content = { visibleTurnItems: [], runs: [] }; - await act(async () => - renderer?.update(), - ); + await screen.rerender(); const requests = vi.mocked(orchestrationEnvironment.threadFindProgressive).mock.calls.length; await act(async () => vi.advanceTimersByTime(300)); expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests); expect(queries.refresh).not.toHaveBeenCalled(); queries.results.set(a, messageResult(0)); queries.pending = false; - await act(async () => - renderer?.update(), - ); + await screen.rerender(); expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests + 1); expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenLastCalledWith({ environmentId: a, @@ -174,10 +153,9 @@ describe("V2 find state", () => { }); it("requests a fresh jump when the same search wraps to the same match from a new position", async () => { + setup(); queries.results.set(a, messageResult(0)); - await act(async () => { - renderer = render(); - }); + await using _screen = await render(); await act(async () => find.open()); find.timelineProps.findPositionReaderRef.current = () => ({ entryId: "reading", @@ -214,6 +192,7 @@ describe("V2 find state", () => { }); it("cycles cached entries immediately, wraps, and accumulates clicks in one event", async () => { + setup(); const initial = { ...messageResult(0), totalMatches: 5, @@ -224,9 +203,7 @@ describe("V2 find state", () => { ], }; queries.results.set(a, initial); - await act(async () => { - renderer = render(); - }); + await using _screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); const requests = vi.mocked(orchestrationEnvironment.threadFind).mock.calls.length; @@ -252,15 +229,14 @@ describe("V2 find state", () => { }); it("does not let an older background response undo a cached step", async () => { + setup(); const initial = { ...messageResult(0), match: { entryId: "first", runId, occurrence: 0 }, navigation: [{ entryId: "first", runId, startIndex: 0, count: 3 }], }; queries.results.set(a, initial); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); const requests = vi.mocked(orchestrationEnvironment.threadFind).mock.calls.length; @@ -272,7 +248,7 @@ describe("V2 find state", () => { activeIndex: 1, navigation: [{ entryId: "first", runId, startIndex: 1, count: 3 }], }); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "first", runId, occurrence: 1 }); expect(find.barProps.activeIndex).toBe(2); expect(find.barProps.matchCount).toBe(11); @@ -284,15 +260,14 @@ describe("V2 find state", () => { }); it("fetches the next window from the locally selected match and retains it while pending", async () => { + setup(); const initial = { ...messageResult(0), match: { entryId: "first", runId, occurrence: 0 }, navigation: [{ entryId: "first", runId, startIndex: 0, count: 2 }], }; queries.results.set(a, initial); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); await act(async () => find.barProps.onNext()); @@ -317,13 +292,14 @@ describe("V2 find state", () => { expect(find.timelineProps.findNavigationId).toBe(2); queries.pending = false; queries.results.set(a, messageResult(3)); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.barProps.activeIndex).toBe(3); expect(find.timelineProps.findNavigationId).toBe(4); }); it("refreshes around the cached selection when live matches are inserted before it", async () => { - vi.useFakeTimers(); + setup(); + using _timers = fakeTimers(); const initial = { ...messageResult(0), match: { entryId: "first", runId, occurrence: 0 }, @@ -333,15 +309,13 @@ describe("V2 find state", () => { ], }; queries.results.set(a, initial); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); await act(async () => find.barProps.onNext()); expect(find.timelineProps.activeFindMatch?.entryId).toBe("second"); const content = { runs: [], visibleTurnItems: [] }; - await act(async () => renderer?.update()); + await screen.rerender(); await act(async () => vi.advanceTimersByTime(300)); expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({ environmentId: a, @@ -358,7 +332,7 @@ describe("V2 find state", () => { { entryId: "second", runId, startIndex: 2, count: 1 }, ], }); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.barProps.matchCount).toBe(11); expect(find.barProps.activeIndex).toBe(2); expect(find.timelineProps.activeFindMatch?.entryId).toBe("second"); @@ -368,19 +342,16 @@ describe("V2 find state", () => { }); it("asks the server rather than using stale navigation counts during live updates", async () => { + setup(); const initial = { ...messageResult(0), navigation: [{ entryId: "message:2", runId, startIndex: 0, count: 10 }], }; queries.results.set(a, initial); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); - await act(async () => - renderer?.update(), - ); + await screen.rerender(); await act(async () => find.barProps.onNext()); expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({ environmentId: a, @@ -395,10 +366,9 @@ describe("V2 find state", () => { }); it("starts from the viewport and steps from the server-selected match", async () => { + setup(); queries.results.set(a, messageResult(4)); - await act(async () => { - renderer = render(); - }); + await using _screen = await render(); await act(async () => find.open()); find.timelineProps.findPositionReaderRef.current = () => ({ entryId: "message:6", @@ -444,10 +414,9 @@ describe("V2 find state", () => { }); it("accumulates rapid navigation against the same match until the response arrives", async () => { + setup(); queries.results.set(a, messageResult(4)); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); queries.pending = true; @@ -467,22 +436,21 @@ describe("V2 find state", () => { expect(find.timelineProps.findNavigationId).toBe(1); queries.pending = false; queries.results.set(a, messageResult(6)); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.timelineProps.findNavigationId).toBe(3); expect(find.barProps.activeIndex).toBe(6); }); it("refreshes continuous updates without shifting the current match or showing Searching", async () => { - vi.useFakeTimers(); + setup(); + using _timers = fakeTimers(); queries.results.set(a, messageResult(4)); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); for (let i = 0; i < 3; i++) { - await act(async () => - renderer?.update(), + await screen.rerender( + , ); await act(async () => vi.advanceTimersByTime(100)); } @@ -497,7 +465,7 @@ describe("V2 find state", () => { }); const updated = { ...messageResult(5, 10), totalMatches: 11, match: messageResult(4).match }; queries.results.set(a, updated); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.barProps.matchCount).toBe(11); expect(find.barProps.activeIndex).toBe(5); expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:6"); @@ -517,19 +485,16 @@ describe("V2 find state", () => { }); it("keeps pending navigation when incoming content triggers a refresh", async () => { - vi.useFakeTimers(); + setup(); + using _timers = fakeTimers(); queries.results.set(a, messageResult(4)); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); queries.pending = true; queries.results.delete(a); await act(async () => find.barProps.onNext()); - await act(async () => - renderer?.update(), - ); + await screen.rerender(); await act(async () => vi.advanceTimersByTime(300)); expect(queries.refresh).toHaveBeenCalledTimes(1); expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({ @@ -544,17 +509,16 @@ describe("V2 find state", () => { }); queries.pending = false; queries.results.set(a, { ...messageResult(6, 10), totalMatches: 11 }); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:8"); expect(find.timelineProps.findNavigationId).toBe(2); expect(find.barProps.matchCount).toBe(11); }); it("does not navigate the previous result while a new match is loading", async () => { + setup(); queries.results.set(a, messageResult(0)); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); queries.pending = true; @@ -567,16 +531,15 @@ describe("V2 find state", () => { expect(find.barProps.matchCount).toBe(10); queries.pending = false; queries.results.set(a, messageResult(1)); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:3"); expect(find.timelineProps.findNavigationId).toBe(2); }); it("keeps the reading-position start when Enter is pressed before results arrive", async () => { + setup(); queries.pending = true; - await act(async () => { - renderer = render(); - }); + await using _screen = await render(); await act(async () => find.open()); find.timelineProps.findPositionReaderRef.current = () => ({ entryId: "message:6", @@ -596,16 +559,15 @@ describe("V2 find state", () => { }); it("shows Searching only when the query has no result yet", async () => { + setup(); queries.pending = true; - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("COD4")); expect(find.barProps.status).toBe("loading"); queries.pending = false; queries.results.set(a, messageResult(0)); - await act(async () => renderer?.update()); + await screen.rerender(); expect(find.barProps.status).toBeNull(); queries.pending = true; queries.results.delete(a); @@ -620,9 +582,8 @@ describe("V2 find state", () => { }); it("keeps search unavailable when the server does not support it", async () => { - await act(async () => { - renderer = render(); - }); + setup(); + await using _screen = await render(); await act(async () => { find.open(); requestThreadFindOpen(); @@ -633,38 +594,32 @@ describe("V2 find state", () => { }); it("closes search when support disappears without restoring stale queries", async () => { - await act(async () => { - renderer = render(); - }); + setup(); + await using screen = await render(); await act(async () => find.open()); await act(async () => find.barProps.onQueryChange("needle")); expect(find.isOpen).toBe(true); vi.mocked(orchestrationEnvironment.threadFind).mockClear(); - await act(async () => { - renderer?.update(); - }); + await screen.rerender(); expect(find.isOpen).toBe(false); expect(find.timelineProps.activeFindMatch).toBeNull(); expect(find.timelineProps.findQuery).toBe(""); expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled(); - await act(async () => { - renderer?.update(); - }); + await screen.rerender(); expect(find.isOpen).toBe(false); await act(async () => find.open()); expect(find.barProps.query).toBe(""); }); it("preserves plan item ID and run ownership for navigation", async () => { + setup(); queries.results.set(a, { snapshotSequence: 9, totalMatches: 1, activeIndex: 0, match: { entryId: "plan-item", runId, occurrence: 0 }, }); - await act(async () => { - renderer = render(); - }); + await using _screen = await render(); await act(async () => { find.open(); }); @@ -684,6 +639,7 @@ describe("V2 find state", () => { }); it("drops results on environment changes even when the thread IDs are identical", async () => { + setup(); queries.results.set(a, { snapshotSequence: 1, totalMatches: 3, @@ -696,9 +652,7 @@ describe("V2 find state", () => { activeIndex: 0, match: null, }); - await act(async () => { - renderer = render(); - }); + await using screen = await render(); await act(async () => { find.open(); }); @@ -706,9 +660,7 @@ describe("V2 find state", () => { find.barProps.onQueryChange("needle"); }); expect(find.barProps.matchCount).toBe(3); - await act(async () => { - renderer?.update(); - }); + await screen.rerender(); expect(find.isOpen).toBe(false); expect(find.timelineProps.activeFindMatch).toBeNull(); await act(async () => { @@ -718,9 +670,7 @@ describe("V2 find state", () => { find.barProps.onQueryChange("needle"); }); expect(find.barProps.matchCount).toBe(0); - await act(async () => { - renderer?.update(); - }); + await screen.rerender(); expect(find.isOpen).toBe(false); }); }); diff --git a/apps/web/src/components/chat/useThreadFindNavigation.browser.test.tsx b/apps/web/src/components/chat/useThreadFindNavigation.browser.test.tsx index f27a7df7bb71..3d06bfe82eeb 100644 --- a/apps/web/src/components/chat/useThreadFindNavigation.browser.test.tsx +++ b/apps/web/src/components/chat/useThreadFindNavigation.browser.test.tsx @@ -1,7 +1,8 @@ import type { LegendListRef } from "@legendapp/list/react"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; import { useThreadFindNavigation } from "./useThreadFindNavigation"; const renders = vi.fn(); @@ -11,37 +12,39 @@ function Probe(props: Parameters[0]) { return null; } -let root: Root; -let host: HTMLDivElement; let container: HTMLDivElement; let rect: DOMRect; const scrollToIndex = vi.fn(); const scrollToOffset = vi.fn(); let props: ComponentProps; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("CSS", { highlights: new Map() }); - vi.stubGlobal( - "Highlight", - class extends Set { - constructor(...ranges: Range[]) { - super(ranges); - } - }, +function setup() { + vi.clearAllMocks(); + using stack = new DisposableStack(); + stack.use(stubGlobal("CSS", { highlights: new Map() })); + stack.use( + stubGlobal( + "Highlight", + class extends Set { + constructor(...ranges: Range[]) { + super(ranges); + } + }, + ), ); rect = new DOMRect(0, 200, 40, 20); Object.defineProperty(Range.prototype, "getBoundingClientRect", { configurable: true, value: () => rect, }); - host = document.createElement("div"); + stack.defer(() => Reflect.deleteProperty(Range.prototype, "getBoundingClientRect")); container = document.createElement("div"); container.innerHTML = '

COD4 and COD4

'; container.getBoundingClientRect = () => new DOMRect(0, 0, 800, 600); - document.body.append(host, container); - root = createRoot(host); + document.body.append(container); + const ownContainer = container; + stack.defer(() => ownContainer.remove()); scrollToIndex.mockResolvedValue(); props = { container, @@ -60,19 +63,14 @@ beforeEach(() => { contentInsetEndAdjustment: 100, listReady: true, }; -}); + return stack.move(); +} -afterEach(async () => { - await act(async () => root.unmount()); - host.remove(); - container.remove(); - Reflect.deleteProperty(Range.prototype, "getBoundingClientRect"); - vi.clearAllMocks(); - vi.unstubAllGlobals(); -}); +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); describe("find result navigation", () => { it("retargets a pending first jump when progressive history moves the matching row", async () => { + using _environment = setup(); container.replaceChildren(); let first!: () => void; let second!: () => void; @@ -89,9 +87,9 @@ describe("find result navigation", () => { second = resolve; }), ); - await act(async () => root.render()); - await act(async () => - root.render(), + await using screen = await render(); + await screen.rerender( + , ); expect(scrollToIndex.mock.calls.map(([request]) => request.index)).toEqual([2, 6]); await act(async () => first()); @@ -101,59 +99,58 @@ describe("find result navigation", () => { '

COD4

'; rect = new DOMRect(0, 450, 40, 20); second(); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false }); }); it("keeps the highlighted text at the same position when progressive activity arrives", async () => { + using _environment = setup(); const historyControls = { hasMoreHistory: true, loading: true, error: null, onLoadEarlier: vi.fn(), }; - await act(async () => root.render()); + await using screen = await render(); rect = new DOMRect(0, 368, 40, 20); scrollToOffset.mockImplementationOnce(async () => { rect = new DOMRect(0, 200, 40, 20); }); - await act(async () => - root.render( - , - ), + await screen.rerender( + , ); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 468, animated: false }); expect(scrollToIndex).not.toHaveBeenCalled(); }); it("respects scrolling away from a partial match before activity arrives", async () => { + using _environment = setup(); const historyControls = { hasMoreHistory: true, loading: true, error: null, onLoadEarlier: vi.fn(), }; - await act(async () => root.render()); + await using screen = await render(); rect = new DOMRect(0, -600, 40, 20); container.dispatchEvent(new Event("scroll")); - await act(async () => - root.render( - , - ), + await screen.rerender( + , ); expect(scrollToOffset).not.toHaveBeenCalled(); }); it("reveals settled virtual text without another timeline render", async () => { + using _environment = setup(); let complete!: () => void; scrollToIndex.mockImplementationOnce( () => @@ -162,19 +159,20 @@ describe("find result navigation", () => { }), ); rect = new DOMRect(0, 1800, 40, 20); - await act(async () => root.render()); + await using _screen = await render(); expect(renders).toHaveBeenCalledTimes(1); expect(scrollToOffset).not.toHaveBeenCalled(); rect = new DOMRect(0, 450, 40, 20); await act(async () => { complete(); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false }); expect(renders).toHaveBeenCalledTimes(1); }); it("ignores an older scroll completion after navigation moves to another occurrence", async () => { + using _environment = setup(); let first!: () => void; let second!: () => void; scrollToIndex @@ -191,72 +189,69 @@ describe("find result navigation", () => { }), ); rect = new DOMRect(0, 1800, 40, 20); - await act(async () => root.render()); - await act(async () => - root.render( - , - ), + await using screen = await render(); + await screen.rerender( + , ); rect = new DOMRect(0, 450, 40, 20); await act(async () => first()); expect(scrollToOffset).not.toHaveBeenCalled(); await act(async () => { second(); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false }); }); it("loads history until a distant match can be revealed in the conversation", async () => { + using _environment = setup(); const onLoadEarlier = vi.fn(); const historyControls = { hasMoreHistory: true, loading: false, error: null, onLoadEarlier }; const pending = { ...props, rowIndex: -1, entries: [{ id: "recent" }], historyControls }; - await act(async () => root.render()); + await using screen = await render(); expect(onLoadEarlier).toHaveBeenCalledTimes(1); expect(scrollToIndex).not.toHaveBeenCalled(); - await act(async () => - root.render(), + await screen.rerender( + , ); expect(onLoadEarlier).toHaveBeenCalledTimes(1); - await act(async () => - root.render(), - ); + await screen.rerender(); expect(onLoadEarlier).toHaveBeenCalledTimes(2); rect = new DOMRect(0, 1800, 40, 20); scrollToIndex.mockImplementationOnce(async () => { rect = new DOMRect(0, 200, 40, 20); }); - await act(async () => root.render()); + await screen.rerender(); expect(onLoadEarlier).toHaveBeenCalledTimes(2); expect(scrollToIndex).toHaveBeenCalledTimes(1); }); it("waits for list bootstrap before positioning the first result", async () => { + using _environment = setup(); rect = new DOMRect(0, 1800, 40, 20); - await act(async () => root.render()); + await using screen = await render(); expect(scrollToIndex).not.toHaveBeenCalled(); expect(scrollToOffset).not.toHaveBeenCalled(); scrollToIndex.mockImplementationOnce(async () => { rect = new DOMRect(0, 200, 40, 20); }); - await act(async () => root.render()); + await screen.rerender(); expect(scrollToIndex).toHaveBeenCalledTimes(1); expect(scrollToOffset).not.toHaveBeenCalled(); }); it("leaves the scroll position unchanged between already visible occurrences", async () => { - await act(async () => root.render()); - await act(async () => - root.render( - , - ), + using _environment = setup(); + await using screen = await render(); + await screen.rerender( + , ); expect(scrollToIndex).not.toHaveBeenCalled(); expect(scrollToOffset).not.toHaveBeenCalled(); @@ -268,14 +263,15 @@ describe("find result navigation", () => { }); it("reveals the same match again when search restarts after scrolling away", async () => { - await act(async () => root.render()); + using _environment = setup(); + await using screen = await render(); rect = new DOMRect(0, -1800, 40, 20); props.listRef.current = { scrollToIndex, scrollToOffset, getState: () => ({ scroll: 2400 }), } as unknown as LegendListRef; - await act(async () => root.render()); + await screen.rerender(); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 504, animated: false }); expect(scrollToIndex).not.toHaveBeenCalled(); expect( @@ -284,38 +280,37 @@ describe("find result navigation", () => { }); it("moves only far enough to reveal text below the composer", async () => { - await act(async () => root.render()); + using _environment = setup(); + await using screen = await render(); rect = new DOMRect(0, 450, 40, 20); - await act(async () => - root.render( - , - ), + await screen.rerender( + , ); expect(scrollToIndex).not.toHaveBeenCalled(); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false }); }); it("moves only far enough to reveal text above the viewport margin", async () => { - await act(async () => root.render()); + using _environment = setup(); + await using screen = await render(); rect = new DOMRect(0, 40, 40, 20); - await act(async () => - root.render( - , - ), + await screen.rerender( + , ); expect(scrollToIndex).not.toHaveBeenCalled(); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 244, animated: false }); }); it("keeps the selected text visible when newly measured rows shift during scrolling", async () => { + using _environment = setup(); let scroll = 300; props.listRef.current = { scrollToIndex, @@ -330,16 +325,14 @@ describe("find result navigation", () => { scroll = offset; rect = new DOMRect(0, 200, 40, 20); }); - await act(async () => root.render()); + await using screen = await render(); rect = new DOMRect(0, 450, 40, 20); - await act(async () => - root.render( - , - ), + await screen.rerender( + , ); await act(async () => { await new Promise((resolve) => @@ -355,23 +348,21 @@ describe("find result navigation", () => { }); it("waits for a new context window to settle before revealing an offscreen result", async () => { + using _environment = setup(); rect = new DOMRect(0, 1800, 40, 20); scrollToIndex.mockImplementationOnce(async () => { rect = new DOMRect(0, 450, 40, 20); }); - await act(async () => { - root.render(); - }); - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - }); + await using _screen = await render(); + await act(nextFrame); expect(scrollToIndex).toHaveBeenCalledTimes(1); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false }); }); it("materializes a virtualized message before revealing its occurrence", async () => { + using _environment = setup(); container.replaceChildren(); - await act(async () => root.render()); + await using _screen = await render(); expect(scrollToIndex).toHaveBeenCalledExactlyOnceWith({ index: 4, animated: false, @@ -382,7 +373,7 @@ describe("find result navigation", () => { await act(async () => { container.innerHTML = '

COD4

'; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await nextFrame(); }); expect(scrollToIndex).toHaveBeenCalledTimes(1); expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false }); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx index 550f77addd45..9d3c29932974 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.browser.test.tsx @@ -3,9 +3,10 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; -import { act, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useState, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render } from "../../../test/render"; type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; @@ -67,7 +68,6 @@ const linkedMachines: DiscoveredEnvironments = new Map([ ], ]); -let root: Root | null; let container: HTMLDivElement; let visibility: DocumentVisibilityState; @@ -76,13 +76,10 @@ function publish(state: Discovery.RelayEnvironmentDiscoveryState) { for (const listener of discovery.listeners) listener(); } -async function render(element: React.ReactElement) { - root ??= createRoot(container); - await act(async () => root!.render(element)); -} +const renderRows = (element: ReactElement) => render(element, { container }); -async function mount(refreshWhileEmpty = true) { - await render( +function mount(refreshWhileEmpty = true) { + return renderRows( { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { + using stack = new DisposableStack(); + stack.use(fakeTimers()); visibility = "visible"; - vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); + stack.use(vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility)); container = document.createElement("div"); - document.body.append(container); - root = null; discovery.listeners.clear(); discovery.state = { environments: new Map(), @@ -133,18 +128,12 @@ beforeEach(() => { publish({ environments, refreshing: false, offline: false, error: Option.none() }); return AsyncResult.success(undefined); }); -}); - -afterEach(async () => { - await act(async () => root?.unmount()); - container.remove(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + return stack.move(); +} describe("cloud environment offline reasons", () => { it("tells the user to update a computer whose idle tunnel was removed", async () => { + using _environment = setup(); const base = linkedMachines.get(newMachineId)!; const offlineEntry = (offlineReason?: "tunnel_released") => ({ ...base, @@ -160,7 +149,7 @@ describe("cloud environment offline reasons", () => { }); discovery.listEnvironments.mockResolvedValue(new Map([[newMachineId, offlineEntry()]])); - await mount(); + await using _screen = await mount(); expect(text()).toContain("T3 Connect · Not added · Relay offline"); await act(async () => { @@ -177,6 +166,7 @@ describe("cloud environment offline reasons", () => { describe("cloud onboarding discovery", () => { it("signals that the section can expand after initial discovery settles", async () => { + using _environment = setup(); let finishDiscovery!: (environments: DiscoveredEnvironments) => void; discovery.listEnvironments.mockReturnValue( new Promise((resolve) => { @@ -184,7 +174,7 @@ describe("cloud onboarding discovery", () => { }), ); const onDiscoveryReady = vi.fn(); - await render( + await using _screen = await renderRows( { }); it("keeps incompatible discoveries unselected until the user enables a compatible server", async () => { + using _environment = setup(); const base = linkedMachines.get(newMachineId)!; const entry = (protocolVersion: number) => ({ ...base, @@ -248,7 +239,7 @@ describe("cloud onboarding discovery", () => { /> ); } - await render(); + await using _screen = await renderRows(); expect(discovery.register).not.toHaveBeenCalled(); expect(onSelectionChange).toHaveBeenCalledWith(newMachineId, false); expect(checked()).toBe(false); @@ -271,6 +262,7 @@ describe("cloud onboarding discovery", () => { }); it("connects and selects discovered computers by default without overwriting deselection", async () => { + using _environment = setup(); discovery.listEnvironments.mockResolvedValue(linkedMachines); const autoSelectedComputers = new Set(); function Setup() { @@ -294,7 +286,7 @@ describe("cloud onboarding discovery", () => { /> ); } - await render(); + await using _screen = await renderRows(); expect(discovery.register).toHaveBeenCalledTimes(1); expect(checked()).toBe(true); @@ -307,10 +299,11 @@ describe("cloud onboarding discovery", () => { }); it("shows a newly linked computer without remounting and stops polling once found", async () => { + using _environment = setup(); discovery.listEnvironments .mockResolvedValueOnce(new Map()) .mockResolvedValueOnce(linkedMachines); - await mount(); + await using _screen = await mount(); expect(text()).toBe("Waiting for your computer to connect."); await advance(5_000); @@ -322,10 +315,11 @@ describe("cloud onboarding discovery", () => { }); it("keeps a discovered computer visible when it is added to the browser", async () => { + using _environment = setup(); discovery.listEnvironments.mockResolvedValue(linkedMachines); - await mount(); + await using screen = await mount(); expect(button()?.textContent).toBe("Add"); - await render( + await screen.rerender( { }); it("waits while hidden and refreshes immediately when visible again", async () => { + using _environment = setup(); visibility = "hidden"; - await mount(); + await using _screen = await mount(); await advance(30_000); expect(discovery.listEnvironments).not.toHaveBeenCalled(); @@ -359,12 +354,13 @@ describe("cloud onboarding discovery", () => { }); it("does not overlap a slow refresh or restart polling after unmount", async () => { + using _environment = setup(); let resolveRefresh!: (environments: DiscoveredEnvironments) => void; const pending = new Promise((resolve) => { resolveRefresh = resolve; }); discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending); - await mount(); + await using screen = await mount(); await advance(5_000); expect(text()).toBe("Waiting for your computer to connect."); @@ -373,15 +369,15 @@ describe("cloud onboarding discovery", () => { await advance(30_000); expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); - await act(async () => root!.unmount()); - root = null; + await screen.unmount(); await act(async () => resolveRefresh(new Map())); await advance(30_000); expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); }); it("pauses while offline and resumes when discovery is online", async () => { - await mount(); + using _environment = setup(); + await using _screen = await mount(); await act(async () => publish({ ...discovery.state!, offline: true })); await advance(30_000); expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); @@ -392,7 +388,8 @@ describe("cloud onboarding discovery", () => { }); it("does not add polling to other cloud lists", async () => { - await mount(false); + using _environment = setup(); + await using _screen = await mount(false); await advance(30_000); expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx index 63c73d52d056..aa5ccc87c799 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx @@ -1,8 +1,8 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; const state = vi.hoisted(() => ({ projects: [] as never[], @@ -87,44 +87,43 @@ const pullRequestView = { }, }; -let root: Root; +function setup() { + vi.clearAllMocks(); + state.projects = []; + appAtomRegistry.set(discoverySettled, false); +} /** Mounts the coordinator behind a desktop bridge that hands over `urls` once the renderer is ready. */ async function mount(...urls: string[]) { let deliver: ((url: string) => void) | undefined; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("desktopBridge", { - getClientSettings: async () => null, - webLinks: { - onOpen: (listener: (url: string) => void) => { - deliver = listener; - return () => undefined; - }, - setReady: async (ready: boolean) => { - if (ready) for (const url of urls.splice(0)) deliver?.(url); + await using stack = new AsyncDisposableStack(); + stack.use( + stubGlobal("desktopBridge", { + getClientSettings: async () => null, + webLinks: { + onOpen: (listener: (url: string) => void) => { + deliver = listener; + return () => undefined; + }, + setReady: async (ready: boolean) => { + if (ready) for (const url of urls.splice(0)) deliver?.(url); + }, }, - }, - }); - root = createRoot(document.createElement("div")); - await act(async () => - root.render( + }), + ); + stack.use( + await render( , ), ); + return stack.move(); } -afterEach(async () => { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - state.projects = []; - appAtomRegistry.set(discoverySettled, false); -}); - it("holds a launch PR link until project discovery settles, then opens the PR view", async () => { - await mount(pullRequestUrl); + setup(); + await using _coordinator = await mount(pullRequestUrl); expect(state.navigate).not.toHaveBeenCalled(); expect(state.openScratchProject).not.toHaveBeenCalled(); @@ -141,7 +140,8 @@ it("holds a launch PR link until project discovery settles, then opens the PR vi }); it("opens an ordinary link in the browser while a PR link is still held", async () => { - await mount(pullRequestUrl, "https://example.com/docs"); + setup(); + await using _coordinator = await mount(pullRequestUrl, "https://example.com/docs"); expect(state.openScratchProject).toHaveBeenCalledOnce(); expect(state.navigate).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index a6aaef3dd97a..9f836aef1d59 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -4,12 +4,13 @@ import { type DeviceServiceState, type ScopedThreadRef, } from "@t3tools/contracts"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { RightPanelSurface } from "~/rightPanelStore"; +import { act, render } from "../../../test/render"; + import { DevicePanel } from "./DevicePanel"; import "~/index.css"; @@ -120,22 +121,18 @@ const deferred = () => { return { promise, settle }; }; -let root: Root; let container: HTMLDivElement; // Fixed key and a changing threadRef, as RightPanelSurfaceContent mounts it. -const show = (threadRef: ScopedThreadRef, surface = picker) => - act(async () => - root.render( - undefined} - />, - ), - ); +const devicePanel = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined} + /> +); const startButton = () => container.querySelector('[aria-label="Start Phone"]'); const click = (button: HTMLButtonElement | null) => act(async () => { @@ -143,48 +140,42 @@ const click = (button: HTMLButtonElement | null) => }); const errors = () => container.querySelectorAll('[role="alert"]'); -beforeEach(() => { +function setup() { vi.resetAllMocks(); currentState = deviceState; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); container.style.width = "480px"; container.style.height = "800px"; - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} describe("DevicePanel notices", () => { it("keeps discovery warnings dismissed until the diagnostic or environment changes", async () => { + setup(); currentState = { ...deviceState, hostStatusDetail: "Could not read simulator usage timestamps.", }; - await show(threadA); + await using screen = await render(devicePanel(threadA), { container }); expect(errors()).toHaveLength(1); expect(startButton()?.disabled).toBe(false); await click(container.querySelector('[aria-label="Dismiss device warning"]')); expect(errors()).toHaveLength(0); - await show(threadA); + await screen.rerender(devicePanel(threadA)); expect(errors()).toHaveLength(0); currentState = { ...currentState, hostStatusDetail: "A different discovery warning." }; - await show(threadA); + await screen.rerender(devicePanel(threadA)); expect(errors()).toHaveLength(1); await click(container.querySelector('[aria-label="Dismiss device warning"]')); - await show(otherEnvironment); + await screen.rerender(devicePanel(otherEnvironment)); expect(errors()).toHaveLength(1); }); it("keeps the loading content centered when discovery and boot notices arrive or are dismissed", async () => { + setup(); const pick = deferred(); mocks.open.mockReturnValue(pick.promise); - await show(threadA); + await using screen = await render(devicePanel(threadA), { container }); await click(startButton()); const loadingBounds = () => container @@ -200,7 +191,7 @@ describe("DevicePanel notices", () => { hostStatusDetail: "Could not read CoreSimulator plist.\n".repeat(8), bootingDevices: [{ ...phone, threadId: threadA.threadId }], }; - await show(threadA); + await screen.rerender(devicePanel(threadA)); expect(loadingBounds().top).toBe(before.top); expect(loadingBounds().left).toBe(before.left); await click(container.querySelector('[aria-label="Dismiss device warning"]')); @@ -212,24 +203,25 @@ describe("DevicePanel notices", () => { describe("DevicePanel thread switches", () => { it.each(switches)("does not carry a pending or failed pick to %s", async (_, target) => { + setup(); const pick = deferred(); mocks.open.mockReturnValue(pick.promise); - await show(threadA); + await using screen = await render(devicePanel(threadA), { container }); await click(startButton()); expect(startButton()).toBeNull(); - await show(target); + await screen.rerender(devicePanel(target)); expect(startButton()?.disabled).toBe(false); await act(async () => pick.settle(failed)); expect(errors()).toHaveLength(0); expect(mocks.openDevice).not.toHaveBeenCalled(); - await show(threadA); + await screen.rerender(devicePanel(threadA)); expect(startButton()?.disabled).toBe(false); expect(errors()).toHaveLength(1); - await show(target); + await screen.rerender(devicePanel(target)); expect(startButton()?.disabled).toBe(false); expect(errors()).toHaveLength(0); }); @@ -252,12 +244,13 @@ describe("DevicePanel thread switches", () => { ] as const)( "applies a late %s to its starting thread after switching environments with the same thread id", async (_, surface, command, start, expectApplied) => { + setup(); const late = deferred(); command().mockReturnValue(late.promise); - await show(threadA, surface); + await using screen = await render(devicePanel(threadA, surface), { container }); await start(); - await show(otherEnvironment, surface); + await screen.rerender(devicePanel(otherEnvironment, surface)); await act(async () => late.settle(opened)); expectApplied(); @@ -265,21 +258,23 @@ describe("DevicePanel thread switches", () => { ); it("keeps a late power-off error in the environment that started it", async () => { + setup(); const powerOff = deferred(); mocks.close.mockReturnValue(powerOff.promise); - await show(threadA, streaming); + await using screen = await render(devicePanel(threadA, streaming), { container }); await click(container.querySelector("button")); - await show(otherEnvironment, streaming); + await screen.rerender(devicePanel(otherEnvironment, streaming)); await act(async () => powerOff.settle(failed)); expect(errors()).toHaveLength(0); expect(mocks.closeSurface).not.toHaveBeenCalled(); - await show(threadA, streaming); + await screen.rerender(devicePanel(threadA, streaming)); expect(errors()).toHaveLength(1); }); it("keeps the latest pick pending across switches away and back", async () => { + setup(); const first = deferred(); const second = deferred(); const latest = deferred(); @@ -287,11 +282,11 @@ describe("DevicePanel thread switches", () => { .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) .mockReturnValueOnce(latest.promise); - await show(threadA); + await using screen = await render(devicePanel(threadA), { container }); await click(startButton()); - await show(threadB); + await screen.rerender(devicePanel(threadB)); await click(startButton()); - await show(threadA); + await screen.rerender(devicePanel(threadA)); await click(startButton()); await act(async () => first.settle(failed)); diff --git a/apps/web/src/components/device/DeviceStreamView.browser.test.tsx b/apps/web/src/components/device/DeviceStreamView.browser.test.tsx index c0efd1c06898..7a214cc69dd1 100644 --- a/apps/web/src/components/device/DeviceStreamView.browser.test.tsx +++ b/apps/web/src/components/device/DeviceStreamView.browser.test.tsx @@ -1,7 +1,8 @@ -import { act, useEffect, useSyncExternalStore } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { useEffect, useSyncExternalStore } from "react"; import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render, stubGlobal } from "../../../test/render"; const accessStore = { value: { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }, @@ -88,98 +89,93 @@ async function publishFrame(image: HTMLImageElement) { framedImages.add(image); await act(async () => image.dispatchEvent(new Event("load"))); } -let root: Root; let container: HTMLDivElement; let primes = 0; -beforeEach(() => { + +async function setup(h264 = false, platform: "ios" | "android" = "ios") { primes = 0; viewerMounts = 0; viewerUnmounts = 0; images = []; + await using stack = new AsyncDisposableStack(); stubImages(); - container = document.createElement("div"); - container.style.cssText = "width: 400px; height: 800px; display: flex"; - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - restoreImages(); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -async function setup(h264 = false, platform: "ios" | "android" = "ios") { + stack.defer(restoreImages); + stack.use(fakeTimers()); const touches: unknown[] = []; - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); let videoBody: ReadableStreamDefaultController | undefined; let output: VideoFrameOutputCallback | undefined; if (h264) { - vi.stubGlobal( - "VideoDecoder", - class { - static isConfigSupported = async () => ({ supported: true }); - state = "unconfigured"; - constructor(callbacks: VideoDecoderInit) { - output = callbacks.output; - } - configure() { - this.state = "configured"; - } - close() { - this.state = "closed"; - } - }, + stack.use( + stubGlobal( + "VideoDecoder", + class { + static isConfigSupported = async () => ({ supported: true }); + state = "unconfigured"; + constructor(callbacks: VideoDecoderInit) { + output = callbacks.output; + } + configure() { + this.state = "configured"; + } + close() { + this.state = "closed"; + } + }, + ), ); - vi.stubGlobal("EncodedVideoChunk", vi.fn()); + stack.use(stubGlobal("EncodedVideoChunk", vi.fn())); } else { // Without WebCodecs the iOS client streams MJPEG. const videoDecoder = Object.getOwnPropertyDescriptor(globalThis, "VideoDecoder"); if (videoDecoder) { Reflect.deleteProperty(globalThis, "VideoDecoder"); - onTestFinished(() => { + stack.defer(() => { Object.defineProperty(globalThis, "VideoDecoder", videoDecoder); }); } } - vi.stubGlobal("fetch", (url: string, init: RequestInit) => { - if (url.endsWith("stream.avcc")) { - return Promise.resolve( - new Response( - new ReadableStream({ - start(controller) { - videoBody = controller; - init.signal?.addEventListener("abort", () => controller.error(new Error("aborted"))); - }, - }), - ), - ); - } - primes++; - return Promise.resolve(new Response("prime")); - }); + stack.use( + stubGlobal("fetch", (url: string, init: RequestInit) => { + if (url.endsWith("stream.avcc")) { + return Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + videoBody = controller; + init.signal?.addEventListener("abort", () => + controller.error(new Error("aborted")), + ); + }, + }), + ), + ); + } + primes++; + return Promise.resolve(new Response("prime")); + }), + ); const sockets: Array<{ onmessage?: (event: { data: ArrayBuffer }) => void }> = []; const sent: unknown[] = []; - vi.stubGlobal( - "WebSocket", - class { - static OPEN = 1; - readyState = 1; - constructor() { - sockets.push(this); - } - onmessage?: (event: { data: ArrayBuffer }) => void; - send(message: string | Uint8Array) { - if (typeof message === "string") { - sent.push(message); - } else if (message[0] === 0x03) { - touches.push(JSON.parse(new TextDecoder().decode(message.subarray(1)))); + stack.use( + stubGlobal( + "WebSocket", + class { + static OPEN = 1; + readyState = 1; + constructor() { + sockets.push(this); } - } - close() {} - }, + onmessage?: (event: { data: ArrayBuffer }) => void; + send(message: string | Uint8Array) { + if (typeof message === "string") { + sent.push(message); + } else if (message[0] === 0x03) { + touches.push(JSON.parse(new TextDecoder().decode(message.subarray(1)))); + } + } + close() {} + }, + ), ); const view = (visible: boolean) => ( ); - await act(async () => root.render(view(true))); + container = document.createElement("div"); + container.style.cssText = "width: 400px; height: 800px; display: flex"; + const screen = stack.use(await render(view(true), { container })); + const environment = stack.move(); return { images, + show: (visible: boolean) => screen.rerender(view(visible)), + [Symbol.asyncDispose]: () => environment.disposeAsync(), sent, - view, touches, configure() { const json = new TextEncoder().encode( @@ -228,7 +228,8 @@ it.each([ { name: "middle", button: 1, buttons: 4 }, { name: "right", button: 2, buttons: 2 }, ])("ignores $name-button drags and forwards the next primary drag", async ({ button, buttons }) => { - const { touches, configure } = await setup(); + await using stream = await setup(); + const { touches, configure } = stream; await act(async () => configure()); const viewport = container.querySelector("canvas")!.parentElement!; viewport.style.width = "400px"; @@ -270,18 +271,20 @@ it.each([ }); it("removes MJPEG requests while hidden and reconnects when shown", async () => { - const { images, view } = await setup(); + await using stream = await setup(); + const { images, show } = stream; expect(images[0]!.src).toContain("stream.mjpeg"); - await act(async () => root.render(view(false))); + await show(false); expect(container.querySelectorAll("img")).toHaveLength(0); expect(images[0]!.src).toBe(""); expect(vi.getTimerCount()).toBe(0); - await act(async () => root.render(view(true))); + await show(true); expect(images.at(-1)!.src).toContain("stream.mjpeg"); }); it("offers Reconnect after the shared timeout and receives a frame after retry with unchanged access", async () => { - const { images } = await setup(); + await using stream = await setup(); + const { images } = stream; await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); @@ -297,7 +300,7 @@ it("offers Reconnect after the shared timeout and receives a frame after retry w }); it("starts exactly one new stream per Reconnect press", async () => { - await setup(); + await using _stream = await setup(); await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); @@ -307,7 +310,8 @@ it("starts exactly one new stream per Reconnect press", async () => { }); it("keeps the 3D viewer mounted across iOS video recovery and releases it when hidden", async () => { - const { configure, frame, view } = await setup(true); + await using stream = await setup(true); + const { configure, frame, show } = stream; await act(async () => configure()); expect(viewerMounts).toBe(0); await act(async () => frame()); @@ -323,13 +327,14 @@ it("keeps the 3D viewer mounted across iOS video recovery and releases it when h }); await act(async () => frame()); expect(viewerMounts).toBe(1); - await act(async () => root.render(view(false))); + await show(false); expect(viewerUnmounts).toBe(1); expect(vi.getTimerCount()).toBe(0); }); it("types a paste on the Android screen into the device and lets Ctrl+V reach the paste", async () => { - const { sent } = await setup(true, "android"); + await using stream = await setup(true, "android"); + const { sent } = stream; const screen = container.querySelector('[role="application"]')!; screen.focus(); expect(document.activeElement).toBe(screen); @@ -348,7 +353,9 @@ it("types a paste on the Android screen into the device and lets Ctrl+V reach th const capturePaste = (event: ClipboardEvent) => composerPastes.push(pasteTextToFocusComposer(event)); window.addEventListener("paste", capturePaste, true); - onTestFinished(() => window.removeEventListener("paste", capturePaste, true)); + using _paste = { + [Symbol.dispose]: () => window.removeEventListener("paste", capturePaste, true), + }; const paste = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); screen.dispatchEvent(paste); expect(paste.defaultPrevented).toBe(true); diff --git a/apps/web/src/components/device/useDeviceControls.browser.test.tsx b/apps/web/src/components/device/useDeviceControls.browser.test.tsx index cd5df4f7c064..9447a96e5471 100644 --- a/apps/web/src/components/device/useDeviceControls.browser.test.tsx +++ b/apps/web/src/components/device/useDeviceControls.browser.test.tsx @@ -1,9 +1,10 @@ import { EnvironmentId, type DeviceDetail, type DeviceSummary } from "@t3tools/contracts"; import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; import * as Cause from "effect/Cause"; -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; type Result = { _tag: "Success"; value: DeviceDetail } | { _tag: "Failure"; cause: unknown }; const { read, action, subscribe } = vi.hoisted(() => ({ @@ -54,7 +55,6 @@ function deferred() { }); return { promise, resolve }; } -let root: Root; let controls: DeviceControls; function Probe({ visible, access = null }: { visible: boolean; access?: DeviceHubAccess | null }) { const next = useDeviceControls({ @@ -68,27 +68,17 @@ function Probe({ visible, access = null }: { visible: boolean; access?: DeviceHu }, [next]); return null; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { read.mockReset(); action.mockReset(); subscribe.mockReset(); subscribe.mockReturnValue(() => {}); read.mockResolvedValue(snapshot("light")); - root = createRoot(document.createElement("div")); -}); -afterEach(async () => { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); -}); -async function mount() { - await act(async () => { - root.render(); - }); } it("serializes rapid actions and retains confirmed settings while pending or after failure", async () => { - await mount(); + setup(); + await using _screen = await render(); const response = deferred(); action.mockReturnValueOnce(response.promise); let pending!: Promise; @@ -112,16 +102,17 @@ it("serializes rapid actions and retains confirmed settings while pending or aft }); it("serializes host actions across hide/reopen and discards detached results", async () => { - await mount(); + setup(); + await using screen = await render(); const response = deferred(); action.mockReturnValueOnce(response.promise); let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); - await act(async () => root.render()); + await screen.rerender(); expect(controls.disabled).toBe(true); - await act(async () => root.render()); + await screen.rerender(); await act(async () => controls.act({ type: "setAppearance", value: "light" })); expect(action).toHaveBeenCalledOnce(); expect(controls.disabled).toBe(true); @@ -146,15 +137,16 @@ it("serializes host actions across hide/reopen and discards detached results", a }); it("keeps actions disabled when settings cannot be confirmed after a detached command", async () => { - await mount(); + setup(); + await using screen = await render(); const response = deferred(); action.mockReturnValueOnce(response.promise); let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); - await act(async () => root.render()); - await act(async () => root.render()); + await screen.rerender(); + await screen.rerender(); read.mockResolvedValueOnce({ _tag: "Failure", cause: offline }); await act(async () => { response.resolve(snapshot("dark")); @@ -165,35 +157,37 @@ it("keeps actions disabled when settings cannot be confirmed after a detached co expect(controls.error).toBe("Device offline"); await act(async () => controls.act({ type: "setAppearance", value: "light" })); expect(action).toHaveBeenCalledOnce(); - await act(async () => root.render()); - await act(async () => root.render()); + await screen.rerender(); + await screen.rerender(); expect(controls.disabled).toBe(false); expect(controls.error).toBeNull(); }); it("disables stale settings after a failed reopen read and recovers on a successful read", async () => { - await mount(); - await act(async () => root.render()); + setup(); + await using screen = await render(); + await screen.rerender(); read.mockResolvedValueOnce({ _tag: "Failure", cause: offline }); - await act(async () => root.render()); + await screen.rerender(); expect(controls.detail).toBeNull(); expect(controls.disabled).toBe(true); expect(controls.error).toBe("Device offline"); await act(async () => controls.act({ type: "setAppearance", value: "dark" })); expect(action).not.toHaveBeenCalled(); - await act(async () => root.render()); - await act(async () => root.render()); + await screen.rerender(); + await screen.rerender(); expect(controls.detail?.settings.appearance).toBe("light"); expect(controls.disabled).toBe(false); expect(controls.error).toBeNull(); }); it("does not let an older refresh overwrite a newer confirmed action", async () => { - await mount(); - await act(async () => root.render()); + setup(); + await using screen = await render(); + await screen.rerender(); const refresh = deferred(); read.mockReturnValueOnce(refresh.promise); - await act(async () => root.render()); + await screen.rerender(); action.mockResolvedValueOnce(snapshot("dark")); await act(async () => controls.act({ type: "setAppearance", value: "dark" })); await act(async () => { @@ -206,6 +200,7 @@ it("does not let an older refresh overwrite a newer confirmed action", async () it.each(["hidden", "access renewed"] as const)( "uses the detail snapshot until a new foreground event after the subscription is %s", async (restart) => { + setup(); const access: DeviceHubAccess = { httpBase: "https://remote.test/api/device-hub", wsBase: "wss://remote.test/api/device-hub", @@ -218,19 +213,15 @@ it.each(["hidden", "access renewed"] as const)( ...detail, value: { ...detail.value, foregroundApp: { id: "snapshot.app" } }, }); - await act(async () => { - root.render(); - }); + await using screen = await render(); const oldEvent = subscribe.mock.calls[0]![1]; await act(async () => oldEvent({ id: "previous.live.app" })); expect(controls.foregroundApp?.id).toBe("previous.live.app"); if (restart === "hidden") { - await act(async () => root.render()); - await act(async () => root.render()); + await screen.rerender(); + await screen.rerender(); } else { - await act(async () => - root.render(), - ); + await screen.rerender(); } expect(controls.foregroundApp?.id).toBe("snapshot.app"); await act(async () => oldEvent({ id: "late.previous.app" })); diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx index de0f62949963..bfc697e47ed2 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx @@ -1,8 +1,9 @@ import type { CodeViewDiffItem, FileDiffMetadata, SelectedLineRange } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; -import { act, StrictMode, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, type ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileDiffIdentityKey } from "~/lib/diffRendering"; @@ -51,7 +52,6 @@ import type { StyledDiffCodeView } from "./StyledDiffCodeView"; const target = DraftId.make("diff-draft-test"); const range: SelectedLineRange = { start: 1, end: 1, side: "additions" }; -let root: Root; let container: HTMLDivElement; function file( @@ -75,28 +75,26 @@ function file( }; } -function render( +function view( files: ReturnType[], codeViewKey = "initial", pendingFiles: FileDiffMetadata[] = [], ) { - act(() => - root.render( - - null} - renderHeaderPrefix={() => null} - onRevealSearchMatch={() => {}} - /> - , - ), + return ( + + null} + renderHeaderPrefix={() => null} + onRevealSearchMatch={() => {}} + /> + ); } @@ -105,7 +103,7 @@ function click(label: string) { (node) => node.textContent === label, ); if (!button) throw new Error(`Missing button: ${label}`); - act(() => button.click()); + return act(() => button.click()); } function editor() { @@ -115,7 +113,7 @@ function editor() { } function typeComment(text: string) { - act(() => { + return act(() => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call( editor(), text, @@ -124,32 +122,25 @@ function typeComment(text: string) { }); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true); +function setup(): Disposable { + const reset = () => useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true); + reset(); container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(() => { - act(() => root.unmount()); - container.remove(); - useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true); - vi.unstubAllGlobals(); -}); + return { [Symbol.dispose]: reset }; +} describe("diff comment drafts", () => { it.each(["removed file", "replaced rename"] as const)( "allows a fresh comment after a %s loses its draft", - (scenario) => { + async (scenario) => { + using _drafts = setup(); const original = scenario === "removed file" ? file("a.ts") : file("shared.ts", 1, "rename-changed", "old.ts"); - render([original, file("b.ts")]); - click(`Comment on ${original.filePath}`); - typeComment("Discard this draft"); + await using screen = await render(view([original, file("b.ts")]), { container }); + await click(`Comment on ${original.filePath}`); + await typeComment("Discard this draft"); const pending = scenario === "removed file" ? [] @@ -161,12 +152,12 @@ describe("diff comment drafts", () => { }, ]; - render([file("b.ts")], "refreshed", pending); + await screen.rerender(view([file("b.ts")], "refreshed", pending)); expect(container.querySelector("textarea")).toBeNull(); - click("Comment on b.ts"); + await click("Comment on b.ts"); expect(editor().value).toBe(""); - typeComment(" Review b.ts "); - click("Comment"); + await typeComment(" Review b.ts "); + await click("Comment"); expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([ expect.objectContaining({ @@ -175,31 +166,34 @@ describe("diff comment drafts", () => { selection: { ...range, endSide: "additions" }, }), ]); - render([original, file("b.ts")], "returned"); + await screen.rerender(view([original, file("b.ts")], "returned")); expect(container.querySelector("textarea")).toBeNull(); expect(container.querySelector("mark")).toBeNull(); expect(container.querySelector("p")?.textContent).toBe("Review b.ts"); }, ); - it("keeps the text and selected line through refresh and a provisional lazy patch", () => { + it("keeps the text and selected line through refresh and a provisional lazy patch", async () => { + using _drafts = setup(); const original = file("shared.ts", 1, "rename-pure", "old.ts"); - render([original]); - click("Comment on shared.ts"); - typeComment("Keep this draft"); + await using screen = await render(view([original]), { container }); + await click("Comment on shared.ts"); + await typeComment("Keep this draft"); const refreshed = file("shared.ts", 2, "rename-pure", "old.ts"); - render([refreshed], "refreshed"); + await screen.rerender(view([refreshed], "refreshed")); expect(editor().value).toBe("Keep this draft"); expect(container.querySelector("mark")?.textContent).toBe("Line 1"); - render([], "loading", [ - { ...refreshed.fileDiff, type: "rename-changed", hunks: [], cacheKey: "shared:pending" }, - ]); + await screen.rerender( + view([], "loading", [ + { ...refreshed.fileDiff, type: "rename-changed", hunks: [], cacheKey: "shared:pending" }, + ]), + ); expect(container.querySelector("textarea")).toBeNull(); - render([refreshed], "loaded"); + await screen.rerender(view([refreshed], "loaded")); expect(editor().value).toBe("Keep this draft"); expect(container.querySelector("mark")?.textContent).toBe("Line 1"); - click("Comment"); + await click("Comment"); expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([ expect.objectContaining({ filePath: "shared.ts", diff --git a/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx b/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx index e3ab74f2d51c..8ce914137053 100644 --- a/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx @@ -1,11 +1,12 @@ import type { CodeViewScrollTarget } from "@pierre/diffs"; import type { FileTree as FileTreeModel } from "@pierre/trees"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { FileDiffMetadata } from "@pierre/diffs"; +import { act, render } from "../../../test/render"; + import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; import { diffFileTreeEntries } from "./diffFileTree.logic"; import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; @@ -31,7 +32,6 @@ const entries: DiffFileTreeEntry[] = [ ]; describe("diff tree file activation", () => { - let root: Root | undefined; let container: HTMLDivElement; const targets: CodeViewScrollTarget[] = []; const viewer = { @@ -62,9 +62,10 @@ describe("diff tree file activation", () => { const model = (): FileTreeModel => captured.model as FileTreeModel; - async function mount(props: Parameters[0] = {}) { - root ??= createRoot(container); - await act(async () => root!.render()); + function setup() { + targets.length = 0; + container = document.createElement("div"); + container.style.cssText = "display: flex; flex-direction: column; width: 320px; height: 480px"; } function row(path: string): HTMLElement { @@ -99,29 +100,14 @@ describe("diff tree file activation", () => { }); } - beforeEach(() => { - targets.length = 0; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - container.style.cssText = "display: flex; flex-direction: column; width: 320px; height: 480px"; - document.body.append(container); - }); - - afterEach(async () => { - await act(async () => root?.unmount()); - root = undefined; - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - it("mounts a file-to-symlink type change as one tree row", async () => { + setup(); const typeChange = diffFileTreeEntries([ { type: "deleted", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, { type: "new", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, { type: "change", name: "CLAUDE.md", prevName: "CLAUDE.md" } as FileDiffMetadata, ]); - await mount({ files: [...typeChange] }); + await using _screen = await render(, { container }); const tree = model(); expect(tree.getItem("AGENTS.md")?.isDirectory()).toBe(false); @@ -131,7 +117,8 @@ describe("diff tree file activation", () => { }); it("reissues the reveal when the sole selected file is activated again", async () => { - await mount(); + setup(); + await using _screen = await render(, { container }); await activate("02-short.ts"); expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); await activate("02-short.ts"); @@ -142,7 +129,8 @@ describe("diff tree file activation", () => { }); it("reveals newly selected files once in either direction", async () => { - await mount(); + setup(); + await using _screen = await render(, { container }); await activate("02-short.ts"); await activate("01-tall.ts"); await activate("03-medium.ts"); @@ -152,7 +140,8 @@ describe("diff tree file activation", () => { }); it("keeps focus-only navigation separate from button activation", async () => { - await mount(); + setup(); + await using _screen = await render(, { container }); await activate("02-short.ts"); await act(async () => model().getItem("01-tall.ts")!.focus()); expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); @@ -165,7 +154,8 @@ describe("diff tree file activation", () => { it.each(["ctrlKey", "metaKey"] as const)( "does not reveal a selected file that a %s click deselects", async (modifier) => { - await mount(); + setup(); + await using _screen = await render(, { container }); await activate("02-short.ts"); await activate("02-short.ts", { [modifier]: true }); expect(model().getSelectedPaths()).toEqual([]); @@ -174,7 +164,8 @@ describe("diff tree file activation", () => { ); it("lets a click narrow multiple selected files without a second reveal", async () => { - await mount(); + setup(); + await using _screen = await render(, { container }); await activate("02-short.ts"); await act(async () => model().getItem("01-tall.ts")!.select()); expect(model().getSelectedPaths()).toHaveLength(2); @@ -185,7 +176,11 @@ describe("diff tree file activation", () => { }); it("leaves directory selection and expansion to the tree", async () => { - await mount({ files: [{ path: "src/app.ts", status: "modified" }] }); + setup(); + await using _screen = await render( + , + { container }, + ); const directory = model().getItem("src/")!; if (!("isExpanded" in directory)) throw new Error("Expected the directory handle"); expect(directory.isExpanded()).toBe(true); @@ -197,28 +192,28 @@ describe("diff tree file activation", () => { }); it("reorders refreshed files without reopening a collapsed folder", async () => { + setup(); const files: DiffFileTreeEntry[] = [ { path: "src/state/shell.ts", status: "modified" }, { path: "src/features/route.ts", status: "added" }, ]; - await mount({ files }); + await using screen = await render(, { container }); const initialFolder = model().getItem("src/features/")!; if (!("collapse" in initialFolder)) throw new Error("Expected the directory handle"); await act(async () => initialFolder.collapse()); - await act(async () => { - root!.render(); - }); + await screen.rerender(); const folder = model().getItem("src/features/")!; if (!("isExpanded" in folder)) throw new Error("Expected the directory handle"); expect(folder.isExpanded()).toBe(false); }); it("reveals real diff paths and statuses when a directory is replaced by a file", async () => { + setup(); const files: DiffFileTreeEntry[] = [ { path: "office/config.ts", status: "deleted" }, { path: "office", status: "added" }, ]; - await mount({ files, selectedPath: "office" }); + await using screen = await render(, { container }); expect(fileRows().map((row) => row.getAttribute("data-item-git-status"))).toEqual([ "deleted", "added", @@ -233,25 +228,26 @@ describe("diff tree file activation", () => { { type: "item", id: "office/config.ts\0office/config.ts", align: "start" }, ]); expect(row("office/").getAttribute("aria-expanded")).toBe("false"); - await mount({ files, selectedPath: "office/config.ts" }); + await screen.rerender(); expect(row("office/").getAttribute("aria-expanded")).toBe("true"); expect(fileRows()[0]!.getAttribute("aria-selected")).toBe("true"); expect(targets).toHaveLength(2); }); it("keeps selection, focus, and collapsed folders through a colliding slice and refresh", async () => { + setup(); const files: DiffFileTreeEntry[] = [ { path: "office", status: "deleted" }, { path: "src/route.ts", status: "modified" }, ]; - await mount({ files }); + await using screen = await render(, { container }); await activate("src/"); await activate("office"); await act(async () => model().focusPath("office")); expect(model().getFocusedPath()).toBe("office"); expect(row("office").getAttribute("aria-selected")).toBe("true"); const appended: DiffFileTreeEntry[] = [...files, { path: "office/config.ts", status: "added" }]; - await mount({ files: appended }); + await screen.rerender(); expect(row("src/").getAttribute("aria-expanded")).toBe("false"); expect(fileRows()[0]!.getAttribute("aria-selected")).toBe("true"); expect(row("office/").getAttribute("aria-selected")).toBe("false"); @@ -265,7 +261,7 @@ describe("diff tree file activation", () => { { type: "item", id: "office/config.ts\0office/config.ts", align: "start" }, ]); await activate("office/"); - await mount({ files: appended.map((file) => ({ ...file })) }); + await screen.rerender( ({ ...file }))} />); expect(row("src/").getAttribute("aria-expanded")).toBe("false"); expect(row("office/").getAttribute("aria-expanded")).toBe("false"); await activate(fileRows()[0]!); @@ -274,6 +270,7 @@ describe("diff tree file activation", () => { }); it("preserves filename and extension icons when files become directory prefixes", async () => { + setup(); const files: DiffFileTreeEntry[] = [ { path: "AGENTS.md", status: "deleted" }, { path: "config.ts", status: "deleted" }, @@ -282,25 +279,30 @@ describe("diff tree file activation", () => { fileRows() .filter((row) => row.getAttribute("aria-level") === "1") .map((row) => row.querySelector('[data-item-section="icon"] use')?.getAttribute("href")); - await mount({ files }); + await using screen = await render(, { container }); const icons = rootFileIcons(); expect(icons).toEqual(["#t3-file-icon-agents", "#file-tree-builtin-typescript"]); - await mount({ - files: [ - ...files, - { path: "AGENTS.md/note.txt", status: "added" }, - { path: "config.ts/note.txt", status: "added" }, - ], - }); + await screen.rerender( + , + ); expect(rootFileIcons()).toEqual(icons); }); it("preserves a collapsed controlled selection through paging until explicitly revealed", async () => { + setup(); const files: DiffFileTreeEntry[] = [ { path: "src/office", status: "deleted" }, { path: "src/route.ts", status: "modified" }, ]; - await mount({ files, selectedPath: "src/office" }); + await using screen = await render(, { + container, + }); expect(model().getSelectedPaths()).toEqual(["src/office"]); expect(targets).toEqual([]); await activate("src/office"); @@ -310,9 +312,9 @@ describe("diff tree file activation", () => { if (!("collapse" in folder)) throw new Error("Expected the directory handle"); await act(async () => folder.collapse()); expect(row("src/").getAttribute("aria-expanded")).toBe("false"); - const scroll = vi.spyOn(model(), "scrollToPath"); + using scroll = vi.spyOn(model(), "scrollToPath"); const appended: DiffFileTreeEntry[] = [...files, { path: "src/office/x.ts", status: "added" }]; - await mount({ files: appended, selectedPath: "src/office" }); + await screen.rerender(); expect.soft(row("src/").getAttribute("aria-expanded")).toBe("false"); expect.soft(scroll).not.toHaveBeenCalled(); const selected = model().getSelectedPaths(); @@ -322,7 +324,7 @@ describe("diff tree file activation", () => { await act(async () => folder.collapse()); scroll.mockClear(); - await mount({ files: appended, selectedPath: "src/office", revealRequestId: 1 }); + await screen.rerender(); expect(row("src/").getAttribute("aria-expanded")).toBe("true"); expect(model().getSelectedPaths()).toEqual(selected); expect(row(selected[0]!).getAttribute("aria-selected")).toBe("true"); @@ -335,7 +337,9 @@ describe("diff tree file activation", () => { ]); await act(async () => folder.collapse()); - await mount({ files: appended, selectedPath: "src/route.ts", revealRequestId: 1 }); + await screen.rerender( + , + ); expect(row("src/").getAttribute("aria-expanded")).toBe("true"); expect(model().getSelectedPaths()).toEqual(["src/route.ts"]); expect(targets).toHaveLength(2); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx index efd47ece4e8d..8fbd98a35860 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render, fakeTimers } from "../../../test/render"; import { CodeView, FileDiff, @@ -12,18 +13,16 @@ import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pie import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import { getDiffLineStat, getRenderablePatch } from "../../lib/diffRendering"; import { - act, StrictMode, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, - type ReactElement, + type ReactNode, type Ref, } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ workers: [] as Worker[], @@ -147,31 +146,12 @@ function renderViews(count: number) { ); } -const roots: Root[] = []; - -function mount(element: ReactElement) { - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.push(root); - return { - root, - render: (next: ReactElement) => act(async () => root.render(next)), - initial: act(async () => root.render(element)), - }; -} - -async function unmountAll() { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); -} - const codeFile = (name: string) => document.querySelector(`[data-code-file="${name}"]`)?.textContent ?? ""; const loadingViews = () => document.querySelectorAll('[role="status"]').length; describe("code-view worker lifecycle", () => { - beforeEach(() => { + function setup() { testState.workers = []; testState.terminations = 0; testState.requests = []; @@ -181,38 +161,54 @@ describe("code-view worker lifecycle", () => { testState.holdInitialization = false; testState.heldResponses = []; testState.onInitializationHeld = undefined; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + } + // Worker startup runs on real time, so only the idle expiry checks use this clock. + // Faking startup too would let advancing the idle clock time out a worker startup. + const idleClock = () => fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + async function workerEnvironment() { + await using stack = new AsyncDisposableStack(); // Pins the pool at two workers whatever machine runs the suite. - Object.defineProperty(navigator, "hardwareConcurrency", { configurable: true, value: 2 }); + stack.use(vi.spyOn(navigator, "hardwareConcurrency", "get").mockReturnValue(2)); const initialize = WorkerPoolManager.prototype.initialize; - vi.spyOn(WorkerPoolManager.prototype, "initialize").mockImplementation(function ( - this: WorkerPoolManager, - ...args - ) { - const pending = initialize.apply(this, args); - testState.pools.set(this, pending); - return pending; + stack.use( + vi.spyOn(WorkerPoolManager.prototype, "initialize").mockImplementation(function ( + this: WorkerPoolManager, + ...args + ) { + const pending = initialize.apply(this, args); + testState.pools.set(this, pending); + return pending; + }), + ); + // Runs out the idle timer each view's unmount scheduled, releasing its pool. + stack.defer(async () => { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); }); - }); - - afterEach(async () => { - if (!vi.isFakeTimers()) vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - await unmountAll(); - await vi.runOnlyPendingTimersAsync(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - delete (navigator as { hardwareConcurrency?: number }).hardwareConcurrency; - }); + const environment = stack.move(); + return { + async render(ui: ReactNode) { + const view = environment.use(await render(ui, { router: false })); + // Disposed before the view, so its unmount schedules the idle timer on a fake clock + // that the run-out above then restores. + environment.defer(() => { + if (!vi.isFakeTimers()) idleClock(); + }); + return view; + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; + } it("starts on demand, keeps quick reopens warm, and expires the last closed pool", async () => { - const view = mount(renderViews(0)); - await view.initial; + setup(); + await using workers = await workerEnvironment(); + const view = await workers.render(renderViews(0)); const button = document.querySelector("button")!; expect(testState.workers).toHaveLength(0); expect(testState.pools.size).toBe(0); await act(async () => button.click()); - await view.render(renderViews(1)); + await view.rerender(renderViews(1)); const pool = [...testState.pools.keys()][0]!; await act(async () => testState.pools.get(pool)); expect(pool.isInitialized()).toBe(true); @@ -220,7 +216,7 @@ describe("code-view worker lifecycle", () => { await vi.waitFor(() => expect(codeFile("notes.txt")).toContain("Plain text is ready.")); expect(testState.requests.filter((request) => request.type === "file")).toHaveLength(0); - await view.render(renderViews(2)); + await view.rerender(renderViews(2)); await act(async () => pool.primeFileHighlightCache(files[1]!)); expect(testState.pools.size).toBe(1); expect(testState.workers).toHaveLength(2); @@ -228,32 +224,31 @@ describe("code-view worker lifecycle", () => { expect(pool.getFileResultCache(files[1]!)).toBeDefined(); await vi.waitFor(() => expect(codeFile("answer.ts")).toContain("answer")); - // Worker startup uses real time. Only advance the clock once highlighting - // has finished, so idle expiry cannot also expire a worker startup timer. - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - await view.render(renderViews(1)); - expect(pool.getStats().totalWorkers).toBe(2); - expect(testState.terminations).toBe(0); - await view.render(renderViews(0)); - await vi.advanceTimersByTimeAsync(29_999); - expect(pool.getStats().totalWorkers).toBe(2); - expect(testState.terminations).toBe(0); - - await view.render(renderViews(1)); - expect(testState.pools.size).toBe(1); - expect(testState.workers).toHaveLength(2); - expect(loadingViews()).toBe(0); - expect(pool.getFileResultCache(files[1]!)).toBeDefined(); - await vi.advanceTimersByTimeAsync(30_000); - expect(pool.getStats().totalWorkers).toBe(2); - - await view.render(renderViews(0)); - await vi.advanceTimersByTimeAsync(30_000); - expect(pool.getStats().totalWorkers).toBe(0); - expect(testState.terminations).toBe(2); + { + using _idleClock = idleClock(); + await view.rerender(renderViews(1)); + expect(pool.getStats().totalWorkers).toBe(2); + expect(testState.terminations).toBe(0); + await view.rerender(renderViews(0)); + await vi.advanceTimersByTimeAsync(29_999); + expect(pool.getStats().totalWorkers).toBe(2); + expect(testState.terminations).toBe(0); + + await view.rerender(renderViews(1)); + expect(testState.pools.size).toBe(1); + expect(testState.workers).toHaveLength(2); + expect(loadingViews()).toBe(0); + expect(pool.getFileResultCache(files[1]!)).toBeDefined(); + await vi.advanceTimersByTimeAsync(30_000); + expect(pool.getStats().totalWorkers).toBe(2); + + await view.rerender(renderViews(0)); + await vi.advanceTimersByTimeAsync(30_000); + expect(pool.getStats().totalWorkers).toBe(0); + expect(testState.terminations).toBe(2); + } - vi.useRealTimers(); - await view.render(renderViews(1)); + await view.rerender(renderViews(1)); const reopenedPool = [...testState.pools.keys()][1]!; await act(async () => testState.pools.get(reopenedPool)); expect(reopenedPool.isInitialized()).toBe(true); @@ -264,8 +259,10 @@ describe("code-view worker lifecycle", () => { }); it("renders through Pierre's main-thread fallback when worker creation fails", async () => { + setup(); + await using workers = await workerEnvironment(); testState.failWorkers = true; - await mount(renderViews(1)).initial; + await workers.render(renderViews(1)); const pool = [...testState.pools.keys()][0]!; await act(async () => { await expect(testState.pools.get(pool)).rejects.toMatchObject({ _tag: "DiffWorkerError" }); @@ -281,13 +278,14 @@ describe("code-view worker lifecycle", () => { }); it("keeps an initializing pool until its last view closes and ignores late responses", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + setup(); + using _idleClock = idleClock(); + await using workers = await workerEnvironment(); testState.holdInitialization = true; const held = new Promise((resolve) => { testState.onInitializationHeld = resolve; }); - const view = mount(renderViews(2)); - await view.initial; + const view = await workers.render(renderViews(2)); const pool = [...testState.pools.keys()][0]!; const pending = testState.pools.get(pool)!; await held; @@ -296,10 +294,10 @@ describe("code-view worker lifecycle", () => { expect(loadingViews()).toBe(2); expect(testState.renderPools).toHaveLength(0); - await view.render(renderViews(1)); + await view.rerender(renderViews(1)); expect(pool.getStats().totalWorkers).toBe(2); expect(testState.terminations).toBe(0); - await view.render(renderViews(0)); + await view.rerender(renderViews(0)); // Pierre gives up on a held startup after 10 seconds; the idle timer then tears the pool down. const timedOut = expect(pending).rejects.toThrow("worker initialization timed out"); await vi.advanceTimersByTimeAsync(30_000); @@ -316,10 +314,6 @@ describe("code-view worker lifecycle", () => { }); describe("collapsed diff content", () => { - let pool: WorkerPoolManager; - let container: HTMLDivElement; - let viewer: FileDiff | CodeView | undefined; - const loadedDiff = () => { const parsed = getRenderablePatch( [ @@ -339,41 +333,45 @@ describe("collapsed diff content", () => { if (parsed?.kind !== "files" || !parsed.files[0]) throw new Error("Expected diff"); return parsed.files[0]; }; - const code = () => + const codeIn = (container: HTMLElement) => [...container.querySelectorAll("diffs-container")] .flatMap((element) => [...(element.shadowRoot?.querySelectorAll("[data-line]") ?? [])]) .map((line) => line.textContent) .join("\n"); - beforeEach(async () => { + async function setup() { + await using stack = new AsyncDisposableStack(); testState.requests = []; testState.failWorkers = false; testState.holdInitialization = false; - pool = new WorkerPoolManager( + const pool = new WorkerPoolManager( { workerFactory: () => new DiffsWorker(), poolSize: 1 }, { theme: "pierre-dark", langs: ["typescript"], preferredHighlighter: "shiki-wasm" }, ); + stack.defer(() => pool.terminate()); await pool.initialize(); - container = document.createElement("div"); + const container = document.createElement("div"); container.style.cssText = "height: 400px; width: 800px; overflow: auto"; document.body.append(container); - }); - - afterEach(() => { - viewer?.cleanUp(); - viewer = undefined; - pool.terminate(); - container.remove(); - vi.restoreAllMocks(); - }); + stack.defer(() => container.remove()); + const environment = stack.move(); + return { + pool, + container, + code: () => codeIn(container), + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; + } it("defers plain AST and highlight work across collapsed renders until expansion", async () => { + await using environment = await setup(); + const { pool, container, code } = environment; const fileDiff = loadedDiff(); - const highlight = vi.spyOn(pool, "highlightDiffAST"); - const plainAST = vi.spyOn(pool, "getPlainDiffAST"); + using highlight = vi.spyOn(pool, "highlightDiffAST"); + using plainAST = vi.spyOn(pool, "getPlainDiffAST"); const options = { theme: "pierre-dark", collapsed: true, disableErrorHandling: true } as const; const file = new FileDiff(options, pool); - viewer = file; + using _viewer = { [Symbol.dispose]: () => file.cleanUp() }; file.render({ fileDiff, containerWrapper: container }); file.render({ fileDiff, forceRender: true }); expect.soft(highlight.mock.calls.length).toBe(0); @@ -393,6 +391,8 @@ describe("collapsed diff content", () => { }); it("finds newly loaded collapsed lines and unfolds the match with its annotation and header", async () => { + await using environment = await setup(); + const { pool, container, code } = environment; const fileDiff = loadedDiff(); const item: CodeViewItem = { type: "diff", @@ -422,7 +422,7 @@ describe("collapsed diff content", () => { }, pool, ); - viewer = view; + using _viewer = { [Symbol.dispose]: () => view.cleanUp() }; view.setup(container); view.openSearch(); const search = container.querySelector("[data-diffs-search]")!.shadowRoot!; @@ -447,8 +447,8 @@ describe("collapsed diff content", () => { await pool.primeDiffHighlightCache(fileDiff); view.render(true); - const highlight = vi.spyOn(pool, "highlightDiffAST"); - const plainAST = vi.spyOn(pool, "getPlainDiffAST"); + using highlight = vi.spyOn(pool, "highlightDiffAST"); + using plainAST = vi.spyOn(pool, "getPlainDiffAST"); const updatedDiff = { ...fileDiff, name: "updated.ts", cacheKey: "collapsed-update" }; view.setItems([{ ...item, fileDiff: updatedDiff, version: 2 }]); view.render(true); @@ -568,27 +568,22 @@ function RevealViews({ } describe("code-view file reveals", () => { - let view: ReturnType; const reveal = () => act(async () => document.querySelector("button")!.click()); - beforeEach(() => { + function setup() { revealCalls.length = 0; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - }); - - afterEach(async () => { - await unmountAll(); - vi.unstubAllGlobals(); - }); + } it("does not replay a handled tree click or scroll an old handle when a selected file remounts the view", async () => { - view = mount(); - await view.initial; + setup(); + await using view = await render( + , + ); await reveal(); expect(revealCalls.at(-1)?.fileKey).toBe("tree-file"); revealCalls.length = 0; - await view.render( + await view.rerender( , ); expect(revealCalls).toEqual([ @@ -597,16 +592,18 @@ describe("code-view file reveals", () => { }); it("waits for a live viewer and applies each pending tree click only once", async () => { - view = mount(); - await view.initial; + setup(); + await using view = await render( + , + ); await reveal(); expect(revealCalls).toEqual([]); - await view.render(); + await view.rerender(); expect(revealCalls.map((call) => call.fileKey)).toEqual(["external", "tree-file"]); revealCalls.length = 0; - await view.render(); - await view.render(); + await view.rerender(); + await view.rerender(); expect(revealCalls).toEqual([ { scope: "diff", fileKey: "external", mounted: true, collapsed: false }, ]); @@ -615,10 +612,10 @@ describe("code-view file reveals", () => { // A click that lands in the same update that hides the viewer waits for it to return. await act(async () => { document.querySelector("button")!.click(); - view.root.render(); + await view.rerender(); }); expect(revealCalls).toEqual([]); - await view.render(); + await view.rerender(); expect(revealCalls).toEqual([ { scope: "diff", fileKey: "external", mounted: true, collapsed: false }, { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false }, @@ -626,25 +623,27 @@ describe("code-view file reveals", () => { }); it("cancels pending tree clicks when an external selection or diff scope changes", async () => { - view = mount(); - await view.initial; + setup(); + await using view = await render( + , + ); await reveal(); - await view.render( + await view.rerender( , ); expect(revealCalls.map((call) => call.fileKey)).toEqual(["external-b"]); revealCalls.length = 0; - await view.render(); + await view.rerender(); await reveal(); - await view.render(); - await view.render(); + await view.rerender(); + await view.rerender(); expect(revealCalls).toEqual([]); }); it("reveals after a collapsed file expands and honors another click on the same file", async () => { - view = mount(); - await view.initial; + setup(); + await using _view = await render(); await reveal(); await reveal(); expect(revealCalls).toEqual([ diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 3ae2c3d998a8..f8ac76cb2fb9 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -1,7 +1,6 @@ +import { act, render, stubGlobal, type Screen } from "../../../test/render"; import { EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; const { refresh } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>() })); @@ -20,101 +19,100 @@ import { AttachmentFilePreview } from "./AttachmentFilePreview"; describe("attachment HTML preview recovery", () => { const originalUrl = "data:text/html,original"; const renewedUrl = "data:text/html,renewed"; - let now = 0; - let root: Root; - let container: HTMLDivElement; - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - now = 0; - vi.spyOn(Date, "now").mockImplementation(() => now); + function setup() { refresh.mockReset().mockResolvedValueOnce(originalUrl).mockResolvedValue(renewedUrl); - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("

Captured HTML

")), - ); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + } const openRemote = () => - act(async () => - root.render( - , - ), + render( + , ); - const toggleMode = async (label: string) => { - const button = container.querySelector(`button[aria-label="${label}"]`); + const toggleMode = async (screen: Screen, label: string) => { + const button = screen.container.querySelector( + `button[aria-label="${label}"]`, + ); expect(button, label).not.toBeNull(); await act(async () => button!.click()); }; - const alert = () => container.querySelector('[role="alert"]'); + const alert = (screen: Screen) => screen.container.querySelector('[role="alert"]'); it("keeps a renewed URL for subsequent rendered and source views", async () => { - await openRemote(); - expect(container.querySelector("iframe")?.getAttribute("src")).toBe(originalUrl); + setup(); + let now = 0; + using _now = vi.spyOn(Date, "now").mockImplementation(() => now); + using _fetch = stubGlobal( + "fetch", + vi.fn(async () => new Response("

Captured HTML

")), + ); + await using screen = await openRemote(); + expect(screen.container.querySelector("iframe")?.getAttribute("src")).toBe(originalUrl); now = 61 * 60_000; - await toggleMode("Show HTML source"); + await toggleMode(screen, "Show HTML source"); await act(async () => { await import("./ReadOnlySourcePreview"); }); expect(fetch).toHaveBeenCalledExactlyOnceWith(renewedUrl, expect.any(Object)); await vi.waitFor(() => - expect(container.querySelector("pre")?.textContent).toBe("

Captured HTML

"), + expect(screen.container.querySelector("pre")?.textContent).toBe("

Captured HTML

"), ); - await toggleMode("Show rendered page"); - expect(container.querySelector("iframe")?.getAttribute("src")).toBe(renewedUrl); - await toggleMode("Show HTML source"); + await toggleMode(screen, "Show rendered page"); + expect(screen.container.querySelector("iframe")?.getAttribute("src")).toBe(renewedUrl); + await toggleMode(screen, "Show HTML source"); expect(refresh).toHaveBeenCalledTimes(2); expect(vi.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([renewedUrl, renewedUrl]); }); it("does not fetch or mark an expired URL fresh when reauthorization is unavailable", async () => { - await openRemote(); + setup(); + let now = 0; + using _now = vi.spyOn(Date, "now").mockImplementation(() => now); + using _fetch = stubGlobal( + "fetch", + vi.fn(async () => new Response("

Captured HTML

")), + ); + await using screen = await openRemote(); now = 61 * 60_000; refresh.mockResolvedValue(null); - await toggleMode("Show HTML source"); + await toggleMode(screen, "Show HTML source"); expect(fetch).not.toHaveBeenCalled(); - expect(alert()?.textContent).toContain("Reconnect to the environment and try again."); + expect(alert(screen)?.textContent).toContain("Reconnect to the environment and try again."); - await toggleMode("Show rendered page"); - await toggleMode("Show HTML source"); + await toggleMode(screen, "Show rendered page"); + await toggleMode(screen, "Show HTML source"); expect(refresh).toHaveBeenCalledTimes(3); expect(fetch).not.toHaveBeenCalled(); }); it("can return to rendered HTML after local source decoding fails", async () => { + setup(); + let now = 0; + using _now = vi.spyOn(Date, "now").mockImplementation(() => now); + using _fetch = stubGlobal( + "fetch", + vi.fn(async () => new Response("

Captured HTML

")), + ); const file = new Blob([new Uint8Array([0x3c, 0x70, 0x3e, 0xe9])], { type: "text/html" }); - await act(async () => - root.render( - , - ), + await using screen = await render( + , ); - await toggleMode("Show HTML source"); - await vi.waitFor(() => expect(alert()?.textContent).toContain("not UTF-8")); + await toggleMode(screen, "Show HTML source"); + await vi.waitFor(() => expect(alert(screen)?.textContent).toContain("not UTF-8")); - await toggleMode("Show rendered page"); - expect(alert()).toBeNull(); - expect(container.querySelector("iframe")?.title).toBe("document.html"); + await toggleMode(screen, "Show rendered page"); + expect(alert(screen)).toBeNull(); + expect(screen.container.querySelector("iframe")?.title).toBe("document.html"); }); }); diff --git a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx index 5f91fb96ad71..9215eb1735ba 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx @@ -1,39 +1,27 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, onTestFinished, vi } from "vite-plus/test"; +import { render } from "../../../test/render"; +import { expect, it } from "vite-plus/test"; import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; it("keeps the loaded HTML page while its signed URL renews, and reloads on a new revision", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - onTestFinished(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - }); - const render = (src: string, documentKey?: string) => - act(async () => - root.render( - , - ), - ); + const frameUi = (src: string, documentKey?: string) => ( + + ); - await render("about:blank#token-a", "review.html:1"); + await using screen = await render(frameUi("about:blank#token-a", "review.html:1")); + const { container } = screen; const frame = container.querySelector("iframe"); - await render("about:blank#token-b", "review.html:1"); + await screen.rerender(frameUi("about:blank#token-b", "review.html:1")); expect(container.querySelector("iframe")).toBe(frame); expect(frame?.getAttribute("src")).toBe("about:blank#token-a"); - await render("about:blank#token-c", "review.html:2"); + await screen.rerender(frameUi("about:blank#token-c", "review.html:2")); expect(container.querySelector("iframe")).not.toBe(frame); expect(container.querySelector("iframe")?.getAttribute("src")).toBe("about:blank#token-c"); - await render("about:blank#first"); + await screen.rerender(frameUi("about:blank#first")); const urlDrivenFrame = container.querySelector("iframe"); - await render("about:blank#second"); + await screen.rerender(frameUi("about:blank#second")); expect(container.querySelector("iframe")).not.toBe(urlDrivenFrame); expect(container.querySelector("iframe")?.getAttribute("src")).toBe("about:blank#second"); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 118d1cf0321b..010b1ca4a767 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -1,9 +1,9 @@ +import { act, render } from "../../../test/render"; import "../../index.css"; import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import FileBrowserPanel from "./FileBrowserPanel"; @@ -53,8 +53,6 @@ vi.mock("~/components/ui/tooltip", () => ({ })); describe("Files panel", () => { - let root: Root | undefined; - let container: HTMLDivElement | undefined; const props = { environmentId: EnvironmentId.make("test-environment"), cwd: "/workspace", @@ -65,67 +63,75 @@ describe("Files panel", () => { workspaceMutationId: null, }; - async function render() { - if (!container) { - container = document.body.appendChild(document.createElement("div")); - // The tree virtualizes its rows, so it needs a real viewport to render any. - container.style.cssText = - "display: flex; flex-direction: column; width: 320px; height: 480px;"; - root = createRoot(container); + async function renderPanel() { + const container = document.createElement("div"); + // The tree virtualizes its rows, so it needs a real viewport to render any. + container.style.cssText = "display: flex; flex-direction: column; width: 320px; height: 480px;"; + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render(, { container })); + const rerender = () => screen.rerender(); + function searchInput() { + const input = container!.querySelector('input[type="search"]'); + expect(input).not.toBeNull(); + return input!; } - await act(async () => root!.render()); - } - function searchInput() { - const input = container!.querySelector('input[type="search"]'); - expect(input).not.toBeNull(); - return input!; - } + async function search(value: string) { + const input = searchInput(); + // React tracks the last value it saw on the element, so set it through the native setter. + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + await act(async () => { + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } - async function search(value: string) { - const input = searchInput(); - // React tracks the last value it saw on the element, so set it through the native setter. - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); - await act(async () => { - input.dispatchEvent(new Event("input", { bubbles: true })); - }); - } + function visiblePaths() { + const tree = container!.querySelector("file-tree-container"); + if (!tree) return []; + return [...tree.shadowRoot!.querySelectorAll("[data-item-path]")].map((row) => + row.getAttribute("data-item-path"), + ); + } - function visiblePaths() { - const tree = container!.querySelector("file-tree-container"); - if (!tree) return []; - return [...tree.shadowRoot!.querySelectorAll("[data-item-path]")].map((row) => - row.getAttribute("data-item-path"), - ); - } + function hasMessage(message: string) { + return [...container!.querySelectorAll("div")].some( + (node) => node.childNodes.length === 1 && node.textContent === message, + ); + } - function hasMessage(message: string) { - return [...container!.querySelectorAll("div")].some( - (node) => node.childNodes.length === 1 && node.textContent === message, - ); - } + function expectVisibleLoadingStatus() { + const status = container!.querySelector('[role="status"]'); + expect(status?.textContent).toBe("Loading files…"); + expect(status!.checkVisibility()).toBe(true); + const bounds = status!.getBoundingClientRect(); + expect(bounds.width).toBeGreaterThan(1); + expect(bounds.height).toBeGreaterThan(1); + } - function expectVisibleLoadingStatus() { - const status = container!.querySelector('[role="status"]'); - expect(status?.textContent).toBe("Loading files…"); - expect(status!.checkVisibility()).toBe(true); - const bounds = status!.getBoundingClientRect(); - expect(bounds.width).toBeGreaterThan(1); - expect(bounds.height).toBeGreaterThan(1); - } + function rowTop(path: string) { + const row = container! + .querySelector("file-tree-container") + ?.shadowRoot!.querySelector(`[data-item-path="${CSS.escape(path)}"]`); + expect(row, `visible row ${path}`).not.toBeNull(); + const bounds = row!.getBoundingClientRect(); + expect(bounds.height).toBeGreaterThan(0); + return bounds.top; + } - function rowTop(path: string) { - const row = container! - .querySelector("file-tree-container") - ?.shadowRoot!.querySelector(`[data-item-path="${CSS.escape(path)}"]`); - expect(row, `visible row ${path}`).not.toBeNull(); - const bounds = row!.getBoundingClientRect(); - expect(bounds.height).toBeGreaterThan(0); - return bounds.top; + const environment = stack.move(); + return { + container, + searchInput, + search, + visiblePaths, + hasMessage, + expectVisibleLoadingStatus, + rowTop, + rerender, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } - - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + function setup() { files.directoryEntries = [ { path: "src", kind: "directory" }, { path: "src/app.ts", kind: "file" }, @@ -136,34 +142,32 @@ describe("Files panel", () => { files.searchEntries = []; files.searchPending = false; files.searchError = null; - }); - - afterEach(async () => { - await act(async () => root?.unmount()); - container?.remove(); - root = undefined; - container = undefined; - vi.unstubAllGlobals(); - }); + files.load.mockReset(); + files.refresh.mockReset(); + files.loadingDirectories = new Set(); + props.onOpenFile.mockReset(); + } it("keeps visible rows stationary during loading while showing initial loading status", async () => { + setup(); const entries = files.directoryEntries; files.directoryEntries = []; files.directoryReady = false; files.directoryPending = true; - await render(); + await using screen = await renderPanel(); + const { search, visiblePaths, expectVisibleLoadingStatus, rowTop } = screen; expect(visiblePaths()).toEqual([]); expectVisibleLoadingStatus(); files.directoryEntries = entries; files.directoryReady = true; files.directoryPending = false; - await render(); + await screen.rerender(); expect(visiblePaths()).toEqual(["src/", "package.json"]); const initialTop = rowTop("package.json"); files.directoryPending = true; - await render(); + await screen.rerender(); expect(rowTop("package.json")).toBe(initialTop); files.directoryPending = false; @@ -177,12 +181,14 @@ describe("Files panel", () => { expect(rowTop("package.json")).toBe(filteredTop); files.searchPending = false; - await render(); + await screen.rerender(); expect(rowTop("package.json")).toBe(filteredTop); }); it("shows no matching files instead of the full tree when a filename is absent", async () => { - await render(); + setup(); + await using screen = await renderPanel(); + const { search, visiblePaths, hasMessage } = screen; expect(visiblePaths()).toEqual(["src/", "package.json"]); await search(".env.local"); @@ -198,15 +204,19 @@ describe("Files panel", () => { }); it("still finds ignored files already loaded from a directory", async () => { + setup(); files.directoryEntries.push({ path: ".env.local", kind: "file", ignored: true }); - await render(); + await using screen = await renderPanel(); + const { search, visiblePaths, hasMessage } = screen; await search(".env.local"); expect(visiblePaths()).toEqual([".env.local"]); expect(hasMessage("No matching files.")).toBe(false); }); it("waits for remote matches without showing unrelated files or a premature empty result", async () => { - await render(); + setup(); + await using screen = await renderPanel(); + const { search, visiblePaths, hasMessage, expectVisibleLoadingStatus } = screen; files.searchPending = true; await search("settings.ts"); expect(visiblePaths()).toEqual([]); @@ -215,13 +225,15 @@ describe("Files panel", () => { files.searchEntries = [{ path: "config/settings.ts", kind: "file" }]; files.searchPending = false; - await render(); + await screen.rerender(); expect(visiblePaths()).toEqual(["config/", "config/settings.ts"]); expect(hasMessage("No matching files.")).toBe(false); }); it("restores the tree on Escape after an unsuccessful search", async () => { - await render(); + setup(); + await using screen = await renderPanel(); + const { searchInput, search, visiblePaths, hasMessage } = screen; await search("missing-file"); expect(visiblePaths()).toEqual([]); @@ -236,7 +248,9 @@ describe("Files panel", () => { }); it("does not report an empty result when the search failed", async () => { - await render(); + setup(); + await using screen = await renderPanel(); + const { container, search, visiblePaths, hasMessage } = screen; files.searchError = "Workspace search failed."; await search("missing-file"); expect(visiblePaths()).toEqual([]); @@ -260,71 +274,82 @@ describe("Files panel", () => { const listed = () => files.load.mock.calls.map(([path]) => path); // Answer each listing the panel asks for, as the server would, until it asks for no more. - async function answerListings() { + async function answerListings(screen: Awaited>) { const answered = new Set(); for (;;) { const path = listed().find((requested) => !answered.has(requested)); if (path === undefined) return; answered.add(path); files.directoryEntries = [...files.directoryEntries, ...(workspace[path] ?? [])]; - await render(); + await screen.rerender(); } } - function button(label: "Expand all folders" | "Collapse all folders") { - const match = container!.querySelector(`button[aria-label="${label}"]`); + function button( + screen: Awaited>, + label: "Expand all folders" | "Collapse all folders", + ) { + const match = screen.container.querySelector( + `button[aria-label="${label}"]`, + ); expect(match, label).not.toBeNull(); return match!; } - async function clickRow(path: string) { - const row = container! + async function clickRow(screen: Awaited>, path: string) { + const row = screen.container .querySelector("file-tree-container")! .shadowRoot!.querySelector(`[data-item-path="${path}"]`); expect(row, path).not.toBeNull(); await act(async () => row!.click()); - await answerListings(); + await answerListings(screen); } - beforeEach(() => { + function setupExpandAll() { files.load.mockClear(); files.directoryEntries = [ { path: "src", kind: "directory" }, { path: "node_modules", kind: "directory", ignored: true }, { path: "README.md", kind: "file" }, ]; - }); + } it("opens every folder except ignored ones, which it never lists", async () => { - await render(); - await act(async () => button("Expand all folders").click()); - await answerListings(); + setup(); + setupExpandAll(); + await using screen = await renderPanel(); + const { visiblePaths } = screen; + await act(async () => button(screen, "Expand all folders").click()); + await answerListings(screen); expect(listed().toSorted()).toEqual(["src", "src/lib"]); expect(visiblePaths()).toContain("src/lib/util.ts"); expect(visiblePaths()).not.toContain("node_modules/.modules.yaml"); - button("Collapse all folders"); + button(screen, "Collapse all folders"); // Collapsing a folder by hand ends expand all; reopening it counts as all expanded again, // with the ignored folder still closed. - await clickRow("src/lib/"); - button("Expand all folders"); - await clickRow("src/lib/"); - button("Collapse all folders"); + await clickRow(screen, "src/lib/"); + button(screen, "Expand all folders"); + await clickRow(screen, "src/lib/"); + button(screen, "Collapse all folders"); }); it("opens an ignored folder by hand without walking into it, and collapses it with the rest", async () => { - await render(); - await act(async () => button("Expand all folders").click()); - await answerListings(); - - await clickRow("node_modules/"); + setup(); + setupExpandAll(); + await using screen = await renderPanel(); + const { visiblePaths } = screen; + await act(async () => button(screen, "Expand all folders").click()); + await answerListings(screen); + + await clickRow(screen, "node_modules/"); expect(listed()).toContain("node_modules"); expect(listed()).not.toContain("node_modules/react"); expect(visiblePaths()).toContain("node_modules/react/"); expect(visiblePaths()).not.toContain("node_modules/react/index.js"); - await act(async () => button("Collapse all folders").click()); + await act(async () => button(screen, "Collapse all folders").click()); expect(visiblePaths()).toEqual(["node_modules/", "src/", "README.md"]); }); }); diff --git a/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx b/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx index 7d8e230b56df..a0c403463a57 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx +++ b/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx @@ -1,8 +1,8 @@ +import { render, fakeTimers } from "../../../test/render"; import { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { act, StrictMode, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, useEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; const { writeFile, confirmFile, readScope, getUnsavedFile } = vi.hoisted(() => ({ writeFile: vi.fn(), @@ -36,59 +36,43 @@ const defaultProps = { relativePath: "file.txt", onPendingChange, }; -let root: Root | null; -// The editor's change callback from the latest commit, as an editor would hold it. -let latestChange: (contents: string) => void; - -function FileSurface(props: Parameters[0]) { - const coordinator = useFileSaveCoordinator(props); - const onChange = (contents: string) => coordinator.change(contents); - useEffect(() => { - latestChange = onChange; - }); - return null; -} - -async function render(props = defaultProps) { - root ??= createRoot(document.createElement("div")); - await act(async () => - root!.render( - - - , - ), - ); -} -const mount = render; - -async function unmountSurface() { - await act(async () => root!.unmount()); - root = null; -} - -const changeHandler = () => latestChange; - -beforeEach(() => { - root = null; - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); confirmFile.mockReset(); readScope.mockReset().mockReturnValue(true); getUnsavedFile.mockReset().mockReturnValue(null); onPendingChange.mockReset(); -}); +} -afterEach(async () => { - if (root) await unmountSurface(); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); +async function mount(props = defaultProps) { + let latestChange: (contents: string) => void = () => {}; + function FileSurface(props: Parameters[0]) { + const coordinator = useFileSaveCoordinator(props); + useEffect(() => { + latestChange = (contents) => coordinator.change(contents); + }); + return null; + } + const ui = (props: Parameters[0]) => ( + + + + ); + const screen = await render(ui(props), { router: false }); + return { + changeHandler: () => latestChange, + rerender: (props: Parameters[0]) => screen.rerender(ui(props)), + unmount: screen.unmount, + [Symbol.asyncDispose]: () => screen[Symbol.asyncDispose](), + }; +} describe("file-save React lifecycle", () => { it("persists editor model changes after StrictMode setup replay", async () => { - await mount(); - changeHandler()("AUDIT7907NATIVE\n"); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + screen.changeHandler()("AUDIT7907NATIVE\n"); expect(onPendingChange).toHaveBeenCalledWith("file.txt", true); await vi.advanceTimersByTimeAsync(500); expect(writeFile).toHaveBeenCalledExactlyOnceWith({ @@ -105,9 +89,11 @@ describe("file-save React lifecycle", () => { }); it("persists rendered Markdown task changes after StrictMode setup replay", async () => { - await mount({ ...defaultProps, relativePath: "README.md" }); + setup(); + using _timers = fakeTimers(); + await using screen = await mount({ ...defaultProps, relativePath: "README.md" }); const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true); - changeHandler()(nextContents); + screen.changeHandler()(nextContents); await vi.advanceTimersByTimeAsync(500); expect(writeFile).toHaveBeenCalledExactlyOnceWith({ environmentId, @@ -116,11 +102,13 @@ describe("file-save React lifecycle", () => { }); it("keeps the debounce across rerenders of the same file", async () => { - await mount(); - changeHandler()("first"); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + screen.changeHandler()("first"); await vi.advanceTimersByTimeAsync(300); - await render(defaultProps); - changeHandler()("latest"); + await screen.rerender(defaultProps); + screen.changeHandler()("latest"); await vi.advanceTimersByTimeAsync(499); expect(writeFile).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); @@ -129,10 +117,12 @@ describe("file-save React lifecycle", () => { }); it("flushes on unmount and ignores a retired editor callback", async () => { - await mount(); - const retiredChange = changeHandler(); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + const retiredChange = screen.changeHandler(); retiredChange("pending edit"); - await unmountSurface(); + await screen.unmount(); retiredChange("stale editor contents"); await vi.runAllTimersAsync(); expect(writeFile).toHaveBeenCalledTimes(1); @@ -142,11 +132,13 @@ describe("file-save React lifecycle", () => { it.each([false, true])( "keeps edits pending after permission is revoked before a React update (unmount: %s)", async (unmount) => { - await mount(); - changeHandler()("pending edit"); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + screen.changeHandler()("pending edit"); readScope.mockReturnValue(false); if (unmount) { - await unmountSurface(); + await screen.unmount(); } await vi.runAllTimersAsync(); expect(writeFile).not.toHaveBeenCalled(); @@ -156,14 +148,16 @@ describe("file-save React lifecycle", () => { ); it("resumes an unsaved draft when write permission returns after effect replay", async () => { + setup(); + using _timers = fakeTimers(); readScope.mockReturnValue(false); getUnsavedFile.mockReturnValue({ contents: "pending draft" }); - await mount(); + await using screen = await mount(); await vi.runAllTimersAsync(); expect(writeFile).not.toHaveBeenCalled(); readScope.mockReturnValue(true); - await render(defaultProps); + await screen.rerender(defaultProps); await vi.advanceTimersByTimeAsync(500); expect(writeFile).toHaveBeenCalledExactlyOnceWith({ environmentId, @@ -173,8 +167,10 @@ describe("file-save React lifecycle", () => { }); it("recovers an existing draft once after StrictMode setup replay", async () => { + setup(); + using _timers = fakeTimers(); getUnsavedFile.mockReturnValue({ contents: "reopened draft" }); - await mount(); + await using _screen = await mount(); await vi.runAllTimersAsync(); expect(writeFile).toHaveBeenCalledExactlyOnceWith({ environmentId, @@ -188,13 +184,15 @@ describe("file-save React lifecycle", () => { { cwd: "/other-workspace" }, { environmentId: EnvironmentId.make("other-environment") }, ])("retires callbacks when the file identity changes: %j", async (change) => { - await mount(); - const retiredChange = changeHandler(); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + const retiredChange = screen.changeHandler(); retiredChange("old file edit"); const nextProps = { ...defaultProps, ...change }; - await render(nextProps); + await screen.rerender(nextProps); retiredChange("stale editor contents"); - changeHandler()("new file edit"); + screen.changeHandler()("new file edit"); await vi.runAllTimersAsync(); expect(writeFile.mock.calls.map(([request]) => request)).toEqual([ { @@ -213,12 +211,14 @@ describe("file-save React lifecycle", () => { }); it("does not reactivate a retired callback when the same file mounts again", async () => { - await mount(); - const retiredChange = changeHandler(); - await unmountSurface(); - await mount(); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + const retiredChange = screen.changeHandler(); + await screen.unmount(); + await using reopened = await mount(); retiredChange("stale contents"); - changeHandler()("current contents"); + reopened.changeHandler()("current contents"); await vi.runAllTimersAsync(); expect(writeFile).toHaveBeenCalledTimes(1); expect(writeFile.mock.calls[0]![0].input.contents).toBe("current contents"); diff --git a/apps/web/src/components/media/AttachmentImage.browser.test.tsx b/apps/web/src/components/media/AttachmentImage.browser.test.tsx index a99c64dff9ab..7bcc22a5bf39 100644 --- a/apps/web/src/components/media/AttachmentImage.browser.test.tsx +++ b/apps/web/src/components/media/AttachmentImage.browser.test.tsx @@ -1,6 +1,5 @@ -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { act, render, stubGlobal, type Screen } from "../../../test/render"; +import { expect, it, vi } from "vite-plus/test"; import { AttachmentImage } from "./AttachmentImage"; import { prepareImageForAttachment } from "../../lib/imageCompression"; @@ -9,52 +8,27 @@ vi.mock("../../lib/imageCompression", async (importOriginal) => ({ prepareImageForAttachment: vi.fn(), })); -let root: Root | undefined; -let container: HTMLDivElement | undefined; const fetchImage = vi.fn(); const revokeUrl = vi.fn(); const jpeg = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" }); -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("fetch", fetchImage); - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); - fetchImage.mockResolvedValue(new Response(new Blob(["heic"]))); - vi.mocked(prepareImageForAttachment).mockResolvedValue({ - ok: true, - file: jpeg, - recompressed: true, - }); -}); - -afterEach(async () => { - await act(() => root?.unmount()); - container?.remove(); - root = undefined; - container = undefined; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); -}); - -async function render(element: ReactNode) { - if (!container) { - container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - } - await act(() => root!.render(element)); +function setup() { + fetchImage.mockReset().mockResolvedValue(new Response(new Blob(["heic"]))); + revokeUrl.mockReset(); + vi.mocked(prepareImageForAttachment) + .mockReset() + .mockResolvedValue({ ok: true, file: jpeg, recompressed: true }); } -function image() { - const img = container!.querySelector("img"); +function image(screen: Screen) { + const img = screen.container.querySelector("img"); expect(img).not.toBeNull(); return img!; } // Stands in for the browser failing to decode the current source. -async function failDecode() { - const img = image(); +async function failDecode(screen: Screen) { + const img = image(screen); await act(() => { img.dispatchEvent(new Event("error")); }); @@ -71,49 +45,68 @@ it.each([ ])( "converts $name after native decoding fails and releases its preview", async ({ name, mimeType }) => { - await render( + setup(); + using _fetch = stubGlobal("fetch", fetchImage); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); + await using screen = await render( , ); expect(fetchImage).not.toHaveBeenCalled(); - await failDecode(); - expect(image().getAttribute("src")).toBe("blob:jpeg"); + await failDecode(screen); + await vi.waitFor(() => expect(image(screen).getAttribute("src")).toBe("blob:jpeg")); expect(prepareImageForAttachment).toHaveBeenCalledWith( expect.objectContaining({ name, type: "image/heic" }), 50 * 1024 * 1024, ); - await act(() => root!.unmount()); - root = undefined; + await screen.unmount(); expect(revokeUrl).toHaveBeenCalledWith("blob:jpeg"); }, ); it("leaves PNG errors to the caller without fetching or converting", async () => { + setup(); + using _fetch = stubGlobal("fetch", fetchImage); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); const onError = vi.fn(); - await render(); - expectDecodeErrorFor(onError, await failDecode()); + await using screen = await render( + , + ); + expectDecodeErrorFor(onError, await failDecode(screen)); expect(fetchImage).not.toHaveBeenCalled(); }); it("reports failed HEIC conversion through the existing error handler", async () => { + setup(); + using _fetch = stubGlobal("fetch", fetchImage); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); const onError = vi.fn(); vi.mocked(prepareImageForAttachment).mockResolvedValue({ ok: false, reason: "unreadable" }); - await render(); - expectDecodeErrorFor(onError, await failDecode()); + await using screen = await render( + , + ); + expectDecodeErrorFor(onError, await failDecode(screen)); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); it("discards an old decode when navigating to a different source", async () => { + setup(); + using _fetch = stubGlobal("fetch", fetchImage); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); let finishDecode!: (result: Awaited>) => void; vi.mocked(prepareImageForAttachment).mockReturnValue( new Promise((resolve) => { finishDecode = resolve; }), ); - await render(); - await failDecode(); - await render(); + await using screen = await render(); + await failDecode(screen); + await screen.rerender(); await act(() => finishDecode({ ok: true, file: jpeg, recompressed: true })); - expect(image().getAttribute("src")).toBe("blob:second"); + expect(image(screen).getAttribute("src")).toBe("blob:second"); expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(fetchImage.mock.calls[0]?.[1].signal.aborted).toBe(true); }); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx index 95a37ebeaefe..c30afbdf152e 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.browser.test.tsx @@ -1,7 +1,6 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { act, render, stubGlobal } from "../../../test/render"; import { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ importThreads: vi.fn(), @@ -97,39 +96,28 @@ vi.mock("../ui/toast", () => ({ import { WelcomeWizard } from "./WelcomeWizard"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { +function setup() { + using stack = new DisposableStack(); vi.clearAllMocks(); - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, + stack.use( + stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ), ); - Object.defineProperty(Element.prototype, "getAnimations", { - configurable: true, - value: () => [], - }); + stack.use(vi.spyOn(Element.prototype, "getAnimations").mockReturnValue([])); mocks.projects = [{ id: "test-project", environmentId: "test-env", workspaceRoot: "/project" }]; - mocks.complete.mockResolvedValue(undefined); - mocks.refresh.mockResolvedValue(undefined); - mocks.importThreads.mockResolvedValue({ - _tag: "Success", - value: { importedCount: 28, skippedCount: 1 }, - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); + mocks.complete.mockReset().mockResolvedValue(undefined); + mocks.refresh.mockReset().mockResolvedValue(undefined); + mocks.importThreads + .mockReset() + .mockResolvedValue({ _tag: "Success", value: { importedCount: 28, skippedCount: 1 } }); + return stack.move(); +} async function click(label: string) { const button = [...document.querySelectorAll("button")].find( @@ -140,12 +128,13 @@ async function click(label: string) { } it("enters the workspace after a partial import and warns after navigation finishes", async () => { + using _setup = setup(); let finishNavigation = () => {}; const navigation = new Promise((resolve) => { finishNavigation = resolve; }); const onDone = vi.fn(() => navigation); - await act(async () => root.render()); + await using _screen = await render(); await click("Continue"); await click("Continue"); await click("Import 1 project"); @@ -175,12 +164,13 @@ it.each([ ] as const)( "finishes setup with %i imported and %i skipped threads", async (importedCount, skippedCount, warning) => { + using _setup = setup(); mocks.importThreads.mockResolvedValue({ _tag: "Success", value: { importedCount, skippedCount }, }); const onDone = vi.fn(); - await act(async () => root.render()); + await using _screen = await render(); await click("Continue"); await click("Continue"); await click("Import 1 project"); @@ -201,9 +191,10 @@ it.each([ ); it("keeps setup open when saving completion fails and preserves the import warning on retry", async () => { + using _setup = setup(); mocks.complete.mockRejectedValueOnce(new Error("settings unavailable")); const onDone = vi.fn(); - await act(async () => root.render()); + await using _screen = await render(); await click("Continue"); await click("Continue"); await click("Import 1 project"); @@ -223,8 +214,9 @@ it("keeps setup open when saving completion fails and preserves the import warni }); it("requires explicit Close to complete local setup", async () => { + using _setup = setup(); const onDone = vi.fn(); - await act(async () => root.render()); + await using _screen = await render(); await act(async () => { document.activeElement?.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index 76f5f65ef646..498dc8444fd9 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -9,11 +9,10 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import "~/index.css"; +import { act, render } from "../../../test/render"; type SessionResult = AsyncResult.AsyncResult; type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string }; @@ -136,7 +135,6 @@ import { WelcomeWizard } from "./WelcomeWizard"; const primaryId = EnvironmentId.make("primary"); const remoteId = EnvironmentId.make("remote"); const permissionMessage = "This connection cannot import projects or thread history."; -const roots: Root[] = []; function session(scopes: ReadonlyArray): AuthSessionState { return { @@ -217,12 +215,9 @@ function projectCheckboxes() { async function mountImport(remote = false) { state.selectedEnvironment = remote ? "remote" : "primary"; - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.push(root); - await act(async () => - root.render( + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( , @@ -231,9 +226,13 @@ async function mountImport(remote = false) { await click("Continue"); await click("Continue"); expect(pageText()).toContain("Import your projects"); + stack.move(); + return screen; } -beforeEach(() => { +// The mocked modules read `state`, so every test starts by replacing all of it. +function setup() { + state.sessions.clear(); for (const id of [primaryId, remoteId]) { state.sessions.set( id, @@ -258,17 +257,12 @@ beforeEach(() => { state.onDone.mockReset(); state.scan.mockReset(); state.refreshScan.mockReset(); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - state.sessions.clear(); -}); +} it("keeps scanning, choosing and skipping available to a paired read-only environment", async () => { + setup(); setGrant(remoteId, false); - await mountImport(true); + await using _screen = await mountImport(true); expect(state.scan).toHaveBeenCalledWith(expect.objectContaining({ environmentId: remoteId })); expect(pageText()).toContain("/projects/first"); expect(button("Import 2 projects").disabled).toBe(true); @@ -281,8 +275,9 @@ it("keeps scanning, choosing and skipping available to a paired read-only enviro }); it("waits for the selected grant and enables import when it arrives", async () => { + setup(); appAtomRegistry.set(sessionAtom(primaryId), AsyncResult.initial()); - await mountImport(); + await using _screen = await mountImport(); expect(button("Import 2 projects").disabled).toBe(true); await act(async () => setGrant(primaryId, true)); await click("Import 2 projects"); @@ -292,7 +287,8 @@ it("waits for the selected grant and enables import when it arrives", async () = }); it("preserves selections and denies a click that lands as the paired environment's grant is revoked", async () => { - await mountImport(true); + setup(); + await using _screen = await mountImport(true); await act(async () => projectCheckboxes()[1]!.click()); expect(projectCheckboxes()[1]!.getAttribute("aria-checked")).toBe("false"); const importButton = button("Import 1 project"); @@ -320,12 +316,13 @@ it("preserves selections and denies a click that lands as the paired environment }); it("rechecks after project creation and can retry its history without recreating the project", async () => { + setup(); const creation = deferred(); state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => { await creation.promise; return recordProject(input); }); - await mountImport(); + await using _screen = await mountImport(); await click("Import 2 projects"); expect(state.createProject).toHaveBeenCalledOnce(); await act(async () => { @@ -348,6 +345,7 @@ it("rechecks after project creation and can retry its history without recreating it.each([false, true])( "rechecks after history import before the next project write (existing: %s)", async (existing) => { + setup(); if (existing) { const projectId = ProjectId.make("second-existing"); state.candidates[1] = { ...state.candidates[1]!, projectId }; @@ -359,7 +357,7 @@ it.each([false, true])( state.importThreads.mockImplementationOnce(async () => AsyncResult.success(await imported.promise), ); - await mountImport(); + await using _screen = await mountImport(); await click("Import 2 projects"); expect(state.createProject).toHaveBeenCalledOnce(); expect(state.importThreads).toHaveBeenCalledOnce(); @@ -382,12 +380,13 @@ it.each([false, true])( ); it("completes accepted imports when permission is revoked after the final server write", async () => { + setup(); state.candidates = state.candidates.slice(0, 1); const imported = deferred<{ importedCount: number; skippedCount: number }>(); state.importThreads.mockImplementationOnce(async () => AsyncResult.success(await imported.promise), ); - await mountImport(); + await using _screen = await mountImport(); await click("Import 1 project"); await act(async () => { setGrant(primaryId, false); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.terminal.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.terminal.browser.test.tsx index ebc2f5044800..a351b8114888 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.terminal.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.terminal.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render, type Screen } from "../../../test/render"; import { RegistryContext } from "@effect/atom-react"; import { BearerConnectionTarget, @@ -21,9 +22,7 @@ import { import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import "~/index.css"; @@ -206,7 +205,7 @@ function deferred() { }); return { promise, resolve }; } -const roots: Root[] = []; + function pageText() { return document.body.textContent ?? ""; } @@ -225,23 +224,20 @@ async function click(label: string) { function hasViewport() { return document.querySelector("[data-terminal-viewport]") !== null; } -async function enterRemoteAgents() { - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.push(root); - await act(async () => - root.render( - - - , - ), +async function enterRemoteAgents(screen: Screen) { + await screen.rerender( + + + , ); await click("Continue"); expect(pageText()).toContain("Paired computer"); } -beforeEach(() => { +function setup() { + state.sessions.clear(); + state.providers.clear(); + state.configs.clear(); for (const id of [primaryId, remoteId]) { state.sessions.set( id, @@ -280,20 +276,14 @@ beforeEach(() => { state.pair.mockReset().mockResolvedValue(AsyncResult.success(remoteId)); state.complete.mockReset().mockResolvedValue(undefined); state.done.mockReset(); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - state.sessions.clear(); - state.providers.clear(); - state.configs.clear(); -}); +} describe("welcome agent terminal setup", () => { it("disables both setup actions for the paired read-only connection and preserves local completion", async () => { + setup(); + await using screen = await render(null); setAccess(remoteId, false); - await enterRemoteAgents(); + await enterRemoteAgents(screen); expect(button("Install").disabled).toBe(true); expect(button("Sign in").disabled).toBe(true); expect(pageText()).toContain("This connection cannot control terminals."); @@ -311,8 +301,10 @@ describe("welcome agent terminal setup", () => { }); it("rejects a retained install action after revocation and allows a terminal-only regrant", async () => { + setup(); + await using screen = await render(null); setAccess(primaryId, false); - await enterRemoteAgents(); + await enterRemoteAgents(screen); const install = button("Install"); // The click reaches the still-enabled button before React re-renders the revocation. await act(async () => { @@ -354,8 +346,10 @@ describe("welcome agent terminal setup", () => { }); it("pretypes the selected provider's sign-in command without executing it", async () => { + setup(); + await using screen = await render(null); setAccess(primaryId, false); - await enterRemoteAgents(); + await enterRemoteAgents(screen); await click("Sign in"); expect(state.open).toHaveBeenCalledExactlyOnceWith({ environmentId: remoteId, @@ -378,8 +372,10 @@ describe("welcome agent terminal setup", () => { }); it("checks access when a queued retry starts and recovers after regrant", async () => { + setup(); + await using screen = await render(null); state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Could not open")))); - await enterRemoteAgents(); + await enterRemoteAgents(screen); await click("Install"); const closing = deferred(); state.close.mockReturnValueOnce(closing.promise); @@ -401,9 +397,11 @@ describe("welcome agent terminal setup", () => { }); it("keeps an accepted terminal visible without pretyping when access is revoked during open", async () => { + setup(); + await using screen = await render(null); const opening = deferred(); state.open.mockReturnValueOnce(opening.promise); - await enterRemoteAgents(); + await enterRemoteAgents(screen); await click("Install"); await act(async () => { setAccess(remoteId, false); @@ -426,9 +424,11 @@ describe("welcome agent terminal setup", () => { }); it("settles accepted pretyping locally after revocation without closing the PTY", async () => { + setup(); + await using screen = await render(null); const writing = deferred(); state.write.mockReturnValueOnce(writing.promise); - await enterRemoteAgents(); + await enterRemoteAgents(screen); await click("Sign in"); await act(async () => { setAccess(remoteId, false); @@ -447,9 +447,11 @@ describe("welcome agent terminal setup", () => { }); it("rechecks the original environment when cleanup runs after pending pretyping", async () => { + setup(); + await using screen = await render(null); const writing = deferred(); state.write.mockReturnValueOnce(writing.promise); - await enterRemoteAgents(); + await enterRemoteAgents(screen); await click("Install"); await click("Connect, step 1"); expect(state.close).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/permissions/usePermissionStatus.browser.test.tsx b/apps/web/src/components/permissions/usePermissionStatus.browser.test.tsx index a15e63337c59..badb1f245660 100644 --- a/apps/web/src/components/permissions/usePermissionStatus.browser.test.tsx +++ b/apps/web/src/components/permissions/usePermissionStatus.browser.test.tsx @@ -1,6 +1,6 @@ -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { act, render, fakeTimers } from "../../../test/render"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { usePermissionStatus } from "./usePermissionStatus"; @@ -15,49 +15,32 @@ function deferred() { return { promise, resolve }; } -let root: Root | null; -let current: ReturnType>; -let visibility: DocumentVisibilityState; - -function Probe() { - const status = usePermissionStatus(check, { screen: false, accessibility: false }); - useEffect(() => { - current = status; - }); - return null; +async function start( + onStatus: (status: ReturnType>) => void, +) { + function Probe() { + const status = usePermissionStatus(check, { screen: false, accessibility: false }); + useEffect(() => onStatus(status)); + return null; + } + return render(, { router: false }); } - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.useFakeTimers(); - visibility = "visible"; - Object.defineProperty(document, "visibilityState", { - configurable: true, - get: () => visibility, - }); +function setup() { check.mockReset().mockResolvedValue({ screen: false, accessibility: false }); - root = null; -}); -afterEach(async () => { - await unmount(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - Reflect.deleteProperty(document, "visibilityState"); -}); - -async function start() { - root = createRoot(document.createElement("div")); - await act(async () => root!.render()); -} -async function unmount() { - const mounted = root; - root = null; - if (mounted) await act(async () => mounted.unmount()); } const tick = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms)); it("unlocks Continue only for required grants and relocks on revocation", async () => { - await start(); + setup(); + using _timers = fakeTimers(); + let current!: ReturnType>; + let visibility: DocumentVisibilityState = "visible"; + using _visibility = vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility); + await using _screen = await start((status) => { + current = status; + }); expect(current.isReady(["screen"])).toBe(false); check.mockResolvedValue({ screen: true, accessibility: false }); await tick(1500); @@ -72,22 +55,40 @@ it("unlocks Continue only for required grants and relocks on revocation", async }); it("does not overlap checks and discards completion after closing", async () => { + setup(); + using _timers = fakeTimers(); + let current!: ReturnType>; + let visibility: DocumentVisibilityState = "visible"; + using _visibility = vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility); const pending = deferred(); check.mockReturnValue(pending.promise); - await start(); + await using screen = await start((status) => { + current = status; + }); await tick(4500); window.dispatchEvent(new Event("focus")); expect(check).toHaveBeenCalledTimes(1); const last = current; - await unmount(); + await screen.unmount(); await act(async () => pending.resolve({ screen: true, accessibility: true })); expect(last.isReady(["screen"])).toBe(false); expect(vi.getTimerCount()).toBe(0); }); it("pauses in the background and blocks stale grants after a check failure", async () => { + setup(); + using _timers = fakeTimers(); + let current!: ReturnType>; + let visibility: DocumentVisibilityState = "visible"; + using _visibility = vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility); check.mockResolvedValue({ screen: true, accessibility: true }); - await start(); + await using _screen = await start((status) => { + current = status; + }); expect(current.isReady(["screen"])).toBe(true); visibility = "hidden"; await tick(3000); diff --git a/apps/web/src/components/preview/PreviewEmptyState.browser.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.browser.test.tsx index a00a79bbf0df..c78aab0e2944 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.browser.test.tsx @@ -1,7 +1,6 @@ +import { act, render, type Screen } from "../../../test/render"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; @@ -19,9 +18,6 @@ const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; const onOpenUrl = vi.fn(); const onRemoveRecent = vi.fn(); -let root: Root; -let container: HTMLDivElement; - function server(port: number): PreviewableServer { return { host: "localhost", @@ -35,39 +31,27 @@ function server(port: number): PreviewableServer { } as PreviewableServer; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { vi.clearAllMocks(); mocks.servers = []; - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} -async function render( +async function renderPreview( recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>, ) { - await act(async () => - root.render( - , - ), + return render( + , ); } -function button(name: string) { - const match = [...container.querySelectorAll("button")].find( +function button(screen: Screen, name: string) { + const match = [...screen.container.querySelectorAll("button")].find( (element) => element.getAttribute("aria-label") === name || element.textContent?.includes(name), ); expect(match, `button ${name}`).toBeDefined(); @@ -76,38 +60,42 @@ function button(name: string) { describe("PreviewEmptyState", () => { it("lists a recent entry and a live server matching its host:port, and opens or forgets entries", async () => { + setup(); mocks.servers = [server(5173)]; - await render([ + await using screen = await renderPreview([ { url: "https://myapp.test/admin#users", lastVisitedAt: Date.now(), title: "Admin" }, { url: "http://localhost:5173/", lastVisitedAt: Date.now(), title: "Recent Local" }, ]); - const text = container.textContent; + const text = screen.container.textContent; expect(text).toContain("Recently used"); expect(text).toContain("Local servers"); expect(text).toContain("Recent Local"); expect(text).toContain("node"); - await act(async () => button("Admin").click()); + await act(async () => button(screen, "Admin").click()); expect(onOpenUrl).toHaveBeenCalledExactlyOnceWith("https://myapp.test/admin#users"); - await act(async () => button("Remove myapp.test/admin#users from history").click()); + await act(async () => button(screen, "Remove myapp.test/admin#users from history").click()); expect(onRemoveRecent).toHaveBeenCalledExactlyOnceWith("https://myapp.test/admin#users"); }); it("shows only the recents group when no servers are found", async () => { - await render([{ url: "https://myapp.test/", lastVisitedAt: 0 }]); - expect(container.textContent).toContain("Recently used"); - expect(container.textContent).not.toContain("Local servers"); + setup(); + await using screen = await renderPreview([{ url: "https://myapp.test/", lastVisitedAt: 0 }]); + expect(screen.container.textContent).toContain("Recently used"); + expect(screen.container.textContent).not.toContain("Local servers"); }); it("keeps the original empty state when both groups are empty", async () => { - await render([]); - expect(container.textContent).toContain("No preview yet"); + setup(); + await using screen = await renderPreview([]); + expect(screen.container.textContent).toContain("No preview yet"); }); it("shows an entry with an out-of-range lastVisitedAt instead of crashing", async () => { - await render([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]); - expect(container.textContent).toContain("myapp.test"); - button("Remove myapp.test from history"); + setup(); + await using screen = await renderPreview([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]); + expect(screen.container.textContent).toContain("myapp.test"); + button(screen, "Remove myapp.test from history"); }); }); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.browser.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.browser.test.tsx index 010f4d2438ab..021c91eeb2ca 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.browser.test.tsx @@ -1,36 +1,18 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; +import { expect, it } from "vite-plus/test"; import { FaviconImage } from "./PreviewFaviconIcon"; -const GIF = "data:image/gif;base64,R0lGODlhAQABAAAAACw="; +const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; const captured = `${GIF}#captured`; const remote = `${GIF}#remote`; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -const image = () => container.querySelector("img"); -const failImage = () => act(async () => image()!.dispatchEvent(new Event("error"))); - it("falls through failed favicon sources and retries when the source list changes", async () => { - await act(async () => - root.render(fallback} />), + await using screen = await render( + fallback} />, ); + const image = () => screen.container.querySelector("img"); + const failImage = () => act(async () => image()!.dispatchEvent(new Event("error"))); expect(image()?.getAttribute("src")).toBe(captured); await failImage(); @@ -38,12 +20,10 @@ it("falls through failed favicon sources and retries when the source list change await failImage(); expect(image()).toBeNull(); - expect(container.textContent).toBe("fallback"); + expect(screen.container.textContent).toBe("fallback"); - await act(async () => - root.render( - fallback} />, - ), + await screen.rerender( + fallback} />, ); expect(image()?.getAttribute("src")).toBe(captured); }); diff --git a/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx b/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx index bf6874368f1c..09613f4d578a 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx @@ -1,70 +1,52 @@ +import { act, render, type Screen } from "../../../test/render"; import { Dialog } from "@base-ui/react/dialog"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import { PanelLayoutControls } from "../chat/PanelLayoutControls"; import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - async function renderPanel( + screen: Screen, open: boolean | undefined, mode: PreviewPanelMode = "inline", present = true, ) { - await act(async () => { - root.render( - - - - {present ? ( - - - - - ) : null} - -
- {}} - onToggleThreadPanel={() => {}} - onToggleRightPanel={() => {}} - /> -
-
-
-
, - ); - }); + await screen.rerender( + + + + {present ? ( + + + + + ) : null} + +
+ {}} + onToggleThreadPanel={() => {}} + onToggleRightPanel={() => {}} + /> +
+
+
+
, + ); await act(nextFrame); } @@ -83,89 +65,97 @@ async function enterFocusScope() { describe("right panel keyboard access", () => { it("skips collapsed controls and restores access after reopening", async () => { - await renderPanel(false); + await using screen = await render(null); + await renderPanel(screen, false); const outside = document.querySelector('[data-testid="outside"]')!; expect(document.activeElement).toBe(outside); await enterFocusScope(); expect(document.activeElement).toBe(outside); - await renderPanel(true); + await renderPanel(screen, true); await enterFocusScope(); expect(document.activeElement).toBe(document.querySelector('[aria-label="Preview URL"]')); - await renderPanel(false); + await renderPanel(screen, false); await enterFocusScope(); expect(document.activeElement).toBe(outside); }); it("returns focus to the right-panel toggle when a focused panel closes", async () => { - await renderPanel(true); + await using screen = await render(null); + await renderPanel(screen, true); const input = document.querySelector('[aria-label="Preview URL"]')!; await act(async () => input.focus()); - await renderPanel(false); + await renderPanel(screen, false); expect(document.activeElement).toBe( document.querySelector('[aria-label="Toggle right panel"]'), ); - await renderPanel(true); + await renderPanel(screen, true); await enterFocusScope(); expect(document.activeElement).toBe(input); }); it("retains focus after the closing animation relocates the toggle", async () => { - await renderPanel(true); + await using screen = await render(null); + await renderPanel(screen, true); await act(async () => { document.querySelector('[aria-label="Preview URL"]')!.focus(); }); - await renderPanel(false); + await renderPanel(screen, false); const closingToggle = document.querySelector('[aria-label="Toggle right panel"]'); expect(document.activeElement).toBe(closingToggle); - await renderPanel(false, "inline", false); + await renderPanel(screen, false, "inline", false); const settledToggle = document.querySelector('[aria-label="Toggle right panel"]'); expect(settledToggle).not.toBe(closingToggle); expect(document.activeElement).toBe(settledToggle); }); it("returns focus when the panel closes without an animation", async () => { - await renderPanel(true); + await using screen = await render(null); + await renderPanel(screen, true); await act(async () => { document.querySelector('[aria-label="Preview URL"]')!.focus(); }); - await renderPanel(false, "inline", false); + await renderPanel(screen, false, "inline", false); expect(document.activeElement).toBe( document.querySelector('[aria-label="Toggle right panel"]'), ); }); it("preserves focus moved outside while the closing animation settles", async () => { - await renderPanel(true); + await using screen = await render(null); + await renderPanel(screen, true); await act(async () => { document.querySelector('[aria-label="Preview URL"]')!.focus(); }); - await renderPanel(false); + await renderPanel(screen, false); const outside = document.querySelector('[data-testid="outside"]')!; await act(async () => outside.focus()); - await renderPanel(false, "inline", false); + await renderPanel(screen, false, "inline", false); expect(document.activeElement).toBe(outside); }); it("preserves focus outside the panel when it closes", async () => { - await renderPanel(true); + await using screen = await render(null); + await renderPanel(screen, true); const outside = document.querySelector('[data-testid="outside"]')!; await act(async () => outside.focus()); - await renderPanel(false); + await renderPanel(screen, false); expect(document.activeElement).toBe(outside); }); it.each(["sheet", "sidebar", "embedded"] as const)( "keeps %s controls reachable when the inline open flag is false", async (mode) => { - await renderPanel(false, mode); + await using screen = await render(null); + await renderPanel(screen, false, mode); expect(document.activeElement).toBe(document.querySelector('[aria-label="Preview URL"]')); }, ); it("keeps non-collapsible inline controls reachable", async () => { - await renderPanel(undefined); + await using screen = await render(null); + await renderPanel(screen, undefined); expect(document.activeElement).toBe(document.querySelector('[aria-label="Preview URL"]')); }); }); diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 3855354dc81e..02508cefc683 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -7,12 +7,12 @@ import { FILL_PREVIEW_VIEWPORT, ThreadId, } from "@t3tools/contracts"; -import { act, Profiler, type ComponentProps, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { Profiler, type ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { act, fakeTimers, render, stubGlobal } from "../../../test/render"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; @@ -170,13 +170,6 @@ const TEST_THREAD_REF = { } as const; const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1"); -const roots: Root[] = []; -let root: Root; - -async function render(node: ReactNode) { - await act(async () => root.render(node)); -} - function view(onSendAnnotation?: ComponentProps["onSendAnnotation"]) { return ( { - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.clearAllMocks(); - mocks.readEnvironmentScope.mockReset().mockReturnValue(true); - mocks.setAnnotationSendEnabled.mockReset().mockResolvedValue(undefined); - mocks.pickElement.mockReset(); - mocks.pictureInPicture = false; - mocks.showEmptyState = false; - mocks.loading = false; - mocks.nativeServerTab = false; - mocks.serverEpoch = null; - mocks.servers = []; - mocks.recordingTabIds = new Set(); - mocks.recordingRuntimeTabId = null; - useComposerDraftStore.setState({ - addPreviewAnnotation: mocks.addPreviewAnnotation, - addImage: mocks.addImage, - }); - usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); - useBrowserPointerStore.setState({ byTabId: {} }); - const container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - roots.push(root); - }); - - afterEach(async () => { - for (const each of roots.splice(0)) await act(async () => each.unmount()); - document.body.replaceChildren(); - vi.useRealTimers(); - vi.unstubAllGlobals(); +// The mocked modules and stores are shared by the whole file, so every test starts by +// resetting them. +function setup() { + vi.clearAllMocks(); + mocks.readEnvironmentScope.mockReset().mockReturnValue(true); + mocks.setAnnotationSendEnabled.mockReset().mockResolvedValue(undefined); + mocks.pickElement.mockReset(); + mocks.pictureInPicture = false; + mocks.showEmptyState = false; + mocks.loading = false; + mocks.nativeServerTab = false; + mocks.serverEpoch = null; + mocks.servers = []; + mocks.recordingTabIds = new Set(); + mocks.recordingRuntimeTabId = null; + useComposerDraftStore.setState({ + addPreviewAnnotation: mocks.addPreviewAnnotation, + addImage: mocks.addImage, }); + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); + useBrowserPointerStore.setState({ byTabId: {} }); +} +describe("PreviewView", () => { it("shows the cursor in a replacement browser while the old instance still records", async () => { + setup(); mocks.recordingTabIds.add(TEST_RUNTIME_TAB_ID); mocks.recordingRuntimeTabId = TEST_RUNTIME_TAB_ID; const replacementTabId = previewRuntimeTabId(TEST_THREAD_REF, "replacement-server", "tab-1"); @@ -260,10 +244,10 @@ describe("PreviewView", () => { ]), ), }); - await render(view()); + await using screen = await render(view()); expect(hasCursor()).toBe(false); mocks.serverEpoch = "replacement-server"; - await render(view()); + await screen.rerender(view()); expect(hasCursor()).toBe(true); expect(mocks.recordingTabIds.has(TEST_RUNTIME_TAB_ID)).toBe(true); }); @@ -271,15 +255,16 @@ describe("PreviewView", () => { it.each([false, true])( "aligns the cursor and responsive viewport after host zoom changes (native server tab: %s)", async (nativeServerTab) => { + setup(); mocks.nativeServerTab = nativeServerTab; let windowZoom = 1; - vi.stubGlobal("desktopBridge", { + using _bridge = stubGlobal("desktopBridge", { getWindowZoomFactor: () => windowZoom, getLocalEnvironmentBootstraps: () => [], }); const commit = vi.mocked(commitBrowserViewportChange).mockResolvedValueOnce(undefined); try { - await render(view()); + await using _screen = await render(view()); await act(() => { const surfaces = useBrowserSurfaceStore.getState(); const slot = document.querySelector("[data-browser-surface-slot]")!; @@ -330,10 +315,11 @@ describe("PreviewView", () => { ); it("does not rerender while loading time passes", async () => { - vi.useFakeTimers(); + setup(); + using _timers = fakeTimers(); mocks.loading = true; const onRender = vi.fn(); - await render( + await using _screen = await render( {view()} , @@ -352,7 +338,8 @@ describe("PreviewView", () => { ])( "preserves a direct localhost URL %s in a WSL environment and records it", async (submitted, expected) => { - await render(view()); + setup(); + await using _screen = await render(view()); await submitAddress(submitted); await vi.waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, expected), @@ -363,6 +350,7 @@ describe("PreviewView", () => { ); it("maps an empty-state localhost server onto the WSL host", async () => { + setup(); mocks.showEmptyState = true; mocks.servers = [ { @@ -376,7 +364,7 @@ describe("PreviewView", () => { source: "scanner", } as unknown as PreviewableServer, ]; - await render(view()); + await using _screen = await render(view()); await userEvent.click(page.getByRole("button", { name: /vite/ })); await vi.waitFor(() => @@ -399,7 +387,8 @@ describe("PreviewView", () => { }); it("opens and closes a thread-scoped floating preview for the active tab", async () => { - await render(view()); + setup(); + await using _screen = await render(view()); await userEvent.click(page.getByRole("button", { name: "Float preview over chat" })); expect(usePreviewMiniPlayerStore.getState().byThreadKey).toMatchObject({ "environment-1:thread-1": { source: { kind: "browser", tabId: "tab-1" } }, @@ -411,7 +400,8 @@ describe("PreviewView", () => { }); it("keeps the native preview window as a secondary menu action", async () => { - await render(view()); + setup(); + await using screen = await render(view()); await userEvent.click(page.getByRole("button", { name: "Preview menu" })); await userEvent.click(page.getByRole("menuitem", { name: "Open separate preview window" })); await vi.waitFor(() => @@ -420,7 +410,7 @@ describe("PreviewView", () => { await vi.waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull()); mocks.pictureInPicture = true; - await render(view()); + await screen.rerender(view()); await userEvent.click(page.getByRole("button", { name: "Preview menu" })); await userEvent.click(page.getByRole("menuitem", { name: "Close separate preview window" })); await vi.waitFor(() => @@ -429,10 +419,11 @@ describe("PreviewView", () => { }); it("forwards Cmd/Ctrl+Enter annotations to the composer send path", async () => { + setup(); const picked = annotation("annotation-1"); const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation: picked, submission: "send" }); - await render(view(onSendAnnotation)); + await using _screen = await render(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(picked, null)); @@ -440,6 +431,7 @@ describe("PreviewView", () => { }); it("retains the annotation locally when its own environment loses access during capture", async () => { + setup(); const picked = annotation("annotation-revoked"); const onSendAnnotation = vi.fn(); let submitPick: (() => void) | undefined; @@ -449,7 +441,7 @@ describe("PreviewView", () => { submitPick = () => resolve({ annotation: picked, submission: "send" }); }), ); - await render(view(onSendAnnotation)); + await using screen = await render(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); await vi.waitFor(() => expect(submitPick).toBeDefined()); expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true); @@ -457,7 +449,7 @@ describe("PreviewView", () => { mocks.readEnvironmentScope.mockImplementation( (environmentId?: unknown) => environmentId !== TEST_THREAD_REF.environmentId, ); - await render(view(onSendAnnotation)); + await screen.rerender(view(onSendAnnotation)); expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, false); await act(async () => submitPick!()); await vi.waitFor(() => @@ -467,13 +459,14 @@ describe("PreviewView", () => { mocks.readEnvironmentScope.mockReturnValue(true); mocks.pickElement.mockResolvedValue({ annotation: picked, submission: "send" }); - await render(view(onSendAnnotation)); + await screen.rerender(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(picked, null)); expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true); }); it("does not reopen a cancelled picker after the permission update finishes", async () => { + setup(); let finishUpdate: (() => void) | undefined; mocks.setAnnotationSendEnabled.mockImplementationOnce( () => @@ -481,7 +474,7 @@ describe("PreviewView", () => { finishUpdate = resolve; }), ); - await render(view()); + await using _screen = await render(view()); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); await vi.waitFor(() => expect(finishUpdate).toBeDefined()); expect(mocks.pickElement).not.toHaveBeenCalled(); @@ -493,6 +486,7 @@ describe("PreviewView", () => { }); it("warns when main dropped the crop before handing over the pick", async () => { + setup(); const picked = annotation("annotation-3"); const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ @@ -500,7 +494,7 @@ describe("PreviewView", () => { submission: "send", screenshotFailed: true, }); - await render(view(onSendAnnotation)); + await using _screen = await render(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(picked, null)); @@ -508,6 +502,7 @@ describe("PreviewView", () => { }); it("still sends annotation text when the picked element's crop is malformed", async () => { + setup(); const picked = annotation("annotation-2", { dataUrl: "data:image/png;base64,%%%", width: 10, @@ -516,7 +511,7 @@ describe("PreviewView", () => { }); const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation: picked, submission: "send" }); - await render(view(onSendAnnotation)); + await using _screen = await render(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); const sent = { ...picked, screenshot: null }; diff --git a/apps/web/src/components/projectScriptEditor.browser.test.tsx b/apps/web/src/components/projectScriptEditor.browser.test.tsx index 6e589b38e2e3..2a2fa73b87d6 100644 --- a/apps/web/src/components/projectScriptEditor.browser.test.tsx +++ b/apps/web/src/components/projectScriptEditor.browser.test.tsx @@ -1,9 +1,9 @@ +import { act, render, type Screen } from "../../test/render"; import { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act, StrictMode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; vi.mock(import("~/state/session"), async (original) => ({ ...(await original()), @@ -21,8 +21,6 @@ import { const onSubmit = vi.fn[0]["onSubmit"]>(); const onClose = vi.fn(); const onDelete = vi.fn(); -let root: Root; -let mounted: boolean; function request(name: string, error?: string): ProjectScriptEditorRequest { return { @@ -32,20 +30,18 @@ function request(name: string, error?: string): ProjectScriptEditorRequest { }; } -const open = (nextRequest: ProjectScriptEditorRequest) => - act(async () => - root.render( - - - , - ), +const open = (screen: Screen, nextRequest: ProjectScriptEditorRequest) => + screen.rerender( + + + , ); const submit = () => document.querySelector("form")!.requestSubmit(); @@ -65,25 +61,19 @@ function deferredSave() { return { promise, resolve, reject }; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { onSubmit.mockReset(); onClose.mockReset(); onDelete.mockReset(); - root = createRoot(document.createElement("div")); - mounted = true; -}); - -afterEach(async () => { - if (mounted) await act(async () => root.unmount()); - vi.unstubAllGlobals(); -}); +} describe("project action editor save lifecycle", () => { it("blocks repeated submits and edits until the current save completes", async () => { + setup(); + await using screen = await render(null); const save = deferredSave(); onSubmit.mockReturnValue(save.promise); - await open(request("build")); + await open(screen, request("build")); await act(async () => { submit(); @@ -101,13 +91,15 @@ describe("project action editor save lifecycle", () => { }); it("does not close a replacement request or release its in-flight save", async () => { + setup(); + await using screen = await render(null); const first = deferredSave(); const second = deferredSave(); onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); - await open(request("build")); + await open(screen, request("build")); await act(async () => submit()); - await open(request("test")); + await open(screen, request("test")); expect(saveButton().disabled).toBe(false); expect(document.querySelector("#script-name")!.value).toBe("test"); await act(async () => submit()); @@ -124,12 +116,14 @@ describe("project action editor save lifecycle", () => { it.each(["failure", "rejection"] as const)( "ignores a stale %s after the request changes", async (outcome) => { + setup(); + await using screen = await render(null); const save = deferredSave(); onSubmit.mockReturnValue(save.promise); - await open(request("build")); + await open(screen, request("build")); await act(async () => submit()); - await open(request("test", "New request error")); + await open(screen, request("test", "New request error")); await act(async () => { if (outcome === "failure") save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error")))); @@ -144,9 +138,11 @@ describe("project action editor save lifecycle", () => { ); it("shows a current save error and allows retry", async () => { + setup(); + await using screen = await render(null); onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined)); - await open(request("build")); + await open(screen, request("build")); await act(async () => saveButton().click()); expect(messages()).toContain("Save failed"); @@ -160,16 +156,17 @@ describe("project action editor save lifecycle", () => { }); it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => { + setup(); + await using screen = await render(null); const save = deferredSave(); onSubmit.mockReturnValue(save.promise); - await open(request("build")); + await open(screen, request("build")); await act(async () => submit()); await act(async () => { if (exit === "cancel") button("Cancel").click(); else { - root.unmount(); - mounted = false; + await screen.unmount(); } }); onClose.mockClear(); diff --git a/apps/web/src/components/projectScriptEditor.permissions.browser.test.tsx b/apps/web/src/components/projectScriptEditor.permissions.browser.test.tsx index eb49960876ee..cfe2f475f593 100644 --- a/apps/web/src/components/projectScriptEditor.permissions.browser.test.tsx +++ b/apps/web/src/components/projectScriptEditor.permissions.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render, type Screen } from "../../test/render"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -6,9 +7,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; const permissions = vi.hoisted(() => ({ @@ -61,22 +60,14 @@ const request: ProjectScriptEditorRequest = { }; describe("ProjectScriptEditorDialog", () => { - let root: Root; - - beforeEach(() => { - root = createRoot(document.createElement("div")); + function setup() { permissions.canWriteSettings = false; permissions.canEditProject = true; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); + permissions.listeners.clear(); + } async function openEditor( + screen: Screen, onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)), editorRequest = request, actions = { onDelete: vi.fn(), onClose: vi.fn() }, @@ -84,18 +75,16 @@ describe("ProjectScriptEditorDialog", () => { | typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope = AuthOrchestrationOperateScope, ) { - await act(async () => - root.render( - , - ), + await screen.rerender( + , ); } @@ -119,10 +108,18 @@ describe("ProjectScriptEditorDialog", () => { it.each([null, "test"])( "saves a settings action without task access (id: %s)", async (scriptId) => { + setup(); + await using screen = await render(null); permissions.canEditProject = false; permissions.canWriteSettings = true; const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); - await openEditor(onSubmit, { ...request, scriptId }, undefined, AuthSettingsWriteScope); + await openEditor( + screen, + onSubmit, + { ...request, scriptId }, + undefined, + AuthSettingsWriteScope, + ); permissions.canWriteSettings = false; await submit(); expect(onSubmit).not.toHaveBeenCalled(); @@ -136,10 +133,12 @@ describe("ProjectScriptEditorDialog", () => { ); it("rechecks settings access before deleting a settings action", async () => { + setup(); + await using screen = await render(null); permissions.canEditProject = false; permissions.canWriteSettings = true; const actions = { onDelete: vi.fn(), onClose: vi.fn() }; - await openEditor(undefined, request, actions, AuthSettingsWriteScope); + await openEditor(screen, undefined, request, actions, AuthSettingsWriteScope); await click("Delete"); permissions.canWriteSettings = false; await click("Delete action"); @@ -153,13 +152,15 @@ describe("ProjectScriptEditorDialog", () => { }); it("clears an old shortcut when adding an action with no shortcut", async () => { + setup(); + await using screen = await render(null); permissions.canWriteSettings = true; let persistedKeybinding: string | null = "mod+k"; const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { if (input.keybinding !== undefined) persistedKeybinding = input.keybinding; return AsyncResult.success(undefined); }); - await openEditor(onSubmit, { + await openEditor(screen, onSubmit, { scriptId: null, initial: { ...request.initial, keybinding: null }, }); @@ -173,6 +174,8 @@ describe("ProjectScriptEditorDialog", () => { it.each([false, true])( "re-adds a deleted action while preserving its shortcut without settings access (revoked: %s)", async (revokeBeforeSubmit) => { + setup(); + await using screen = await render(null); permissions.canWriteSettings = revokeBeforeSubmit; const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { @@ -185,7 +188,7 @@ describe("ProjectScriptEditorDialog", () => { if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; return AsyncResult.success(undefined); }); - await openEditor(onSubmit, { + await openEditor(screen, onSubmit, { scriptId: null, initial: { ...request.initial, keybinding: null }, }); @@ -201,15 +204,17 @@ describe("ProjectScriptEditorDialog", () => { it.each([false, true])( "requires settings access to override a retained shortcut when re-adding an action (revoked: %s)", async (revokeBeforeSubmit) => { + setup(); + await using screen = await render(null); permissions.canWriteSettings = true; - vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { persisted.name = input.name; if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; return AsyncResult.success(undefined); }); - await openEditor(onSubmit, { + await openEditor(screen, onSubmit, { scriptId: null, initial: { ...request.initial, keybinding: null }, }); @@ -228,6 +233,8 @@ describe("ProjectScriptEditorDialog", () => { it.each([false, true])( "preserves a concurrent shortcut change when saving only the script (settings access: %s)", async (canWriteSettings) => { + setup(); + await using screen = await render(null); permissions.canWriteSettings = canWriteSettings; const persisted = { name: request.initial.name, keybinding: request.initial.keybinding }; const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { @@ -235,7 +242,7 @@ describe("ProjectScriptEditorDialog", () => { if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; return AsyncResult.success(undefined); }); - await openEditor(onSubmit); + await openEditor(screen, onSubmit); persisted.keybinding = "mod+j"; const name = document.querySelector("#script-name")!; @@ -251,9 +258,11 @@ describe("ProjectScriptEditorDialog", () => { it.each([false, true])( "enforces settings access when submitting an explicit shortcut clear (revoked: %s)", async (revokeBeforeSubmit) => { + setup(); + await using screen = await render(null); permissions.canWriteSettings = true; const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); - await openEditor(onSubmit); + await openEditor(screen, onSubmit); await pressInShortcutField("{Backspace}"); if (revokeBeforeSubmit) permissions.canWriteSettings = false; await submit(); @@ -272,10 +281,12 @@ describe("ProjectScriptEditorDialog", () => { it.each([false, true])( "requires project access to save even with settings access (revoked: %s)", async (revokeBeforeSubmit) => { + setup(); + await using screen = await render(null); permissions.canEditProject = revokeBeforeSubmit; permissions.canWriteSettings = true; const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); - await openEditor(onSubmit); + await openEditor(screen, onSubmit); permissions.canEditProject = false; await submit(); @@ -288,8 +299,10 @@ describe("ProjectScriptEditorDialog", () => { ); it("rechecks project access before deleting from an already open confirmation", async () => { + setup(); + await using screen = await render(null); const actions = { onDelete: vi.fn(), onClose: vi.fn() }; - await openEditor(undefined, request, actions); + await openEditor(screen, undefined, request, actions); await click("Delete"); permissions.canEditProject = false; diff --git a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx index a397e5d04f45..b6b6cb6248ba 100644 --- a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx @@ -1,61 +1,42 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - describe("disabled candidate picker", () => { it.each(["keyboard", "pointer"])( "shows the refusal reason for %s without opening the picker", async (input) => { const onOpenChange = vi.fn(); const onSelect = vi.fn(); - await act(async () => { - root.render( - {}} - searchLabel="Search reviewers" - isPending={false} - error={null} - candidates={["alex"]} - emptyLabel="No reviewers" - noMatchLabel="No matching reviewers" - errorLabel="Unable to load reviewers" - truncated={false} - truncatedLabel="More reviewers available" - candidateKey={(candidate) => candidate} - disabled={false} - onSelect={onSelect} - > - {(candidate) => candidate} - , - ); - }); - const trigger = container.querySelector( + await using screen = await render( + {}} + searchLabel="Search reviewers" + isPending={false} + error={null} + candidates={["alex"]} + emptyLabel="No reviewers" + noMatchLabel="No matching reviewers" + errorLabel="Unable to load reviewers" + truncated={false} + truncatedLabel="More reviewers available" + candidateKey={(candidate) => candidate} + disabled={false} + onSelect={onSelect} + > + {(candidate) => candidate} + , + ); + const trigger = screen.container.querySelector( 'button[aria-label="Request reviewers"]', )!; if (input === "keyboard") { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx index 5d96fed4e215..323297b7309c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx @@ -10,15 +10,15 @@ import { type ThreadPullRequestLink, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { selectActiveRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; import { RightPanelSurfaceContent } from "../chat/RightPanelSurfaceContent"; import "~/index.css"; +import { act, render as renderUi } from "../../../test/render"; const { newThread, @@ -251,31 +251,30 @@ const shortcutContext = () => ({ isDesktop: false, }); -const roots: Root[] = []; - -function render(props: Partial> = {}) { +function panelContainer() { const container = document.createElement("div"); container.className = "flex h-[700px] w-[900px]"; - document.body.append(container); - const root = createRoot(container); - roots.push(root); - return act(async () => - root.render( - , - ), + return container; +} + +function render(props: Partial> = {}) { + return renderUi( + , + { container: panelContainer() }, ); } -beforeEach(() => { +// The mocked modules and stores are shared by the whole file, so every test starts by +// resetting them. +function setup() { activityDetail = null; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); currentDetail = detail; useComposerDraftStore.setState({ draftsByThreadKey: {} }); useRightPanelStore.setState({ byThreadKey: {} }); @@ -292,12 +291,7 @@ beforeEach(() => { value: { branch: "feature", worktreePath: "/workspace/pr" }, }); update.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); -}); -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - vi.unstubAllGlobals(); -}); +} const surface = { projectId: detail.projectId, repository: detail.repository, number: 1 }; const link = (number: number, source: ThreadPullRequestLink["source"]): ThreadPullRequestLink => ({ @@ -337,7 +331,8 @@ it.each([ { isComposing: true, keyCode: 13 }, { isComposing: false, keyCode: 229 }, ])("does not save the PR title during IME composition (%j)", async ({ isComposing, keyCode }) => { - await render(); + setup(); + await using _screen = await render(); await page.getByRole("button", { name: "Edit title" }).click(); const titleInput = page.getByRole("textbox", { name: "Pull request title" }); const title = "日本語のタイトル"; @@ -395,7 +390,8 @@ describe.each([ render({ context, ...(target ? { composerDraftTarget: target, threadRef } : {}) }); it(`${thread === stackThread ? "hides" : "offers"} the checkout`, async () => { - await renderPanel(); + setup(); + await using _screen = await renderPanel(); await expect.element(page.getByText("Test pull request").first()).toBeVisible(); expect(page.getByRole("button", { name: "Check out" }).elements()).toHaveLength( thread === stackThread ? 0 : 1, @@ -410,6 +406,7 @@ describe.each([ "Fix check", "Add to agent", ])("%s writes to the correct composer", async (action) => { + setup(); // Cover both a host without inline comments and an account that cannot write them. currentDetail = thread === stackThread @@ -422,7 +419,7 @@ describe.each([ } : { ...detail, viewerPermissions: { ...detail.viewerPermissions, comment: true } }; if (target) useComposerDraftStore.getState().setPrompt(target, "Keep my draft"); - await renderPanel(); + await using _screen = await renderPanel(); await runAction(target ? action : action.replace("in this thread", "in a thread")); const draft = () => useComposerDraftStore.getState().getComposerDraft(target ?? newDraftId); await vi.waitFor(() => { @@ -462,6 +459,7 @@ describe.each([ }); it("offers both selection actions and queues a permitted host review locally", async () => { + setup(); currentDetail = { ...detail, capabilities: { @@ -470,7 +468,7 @@ it("offers both selection actions and queues a permitted host review locally", a }, viewerPermissions: { ...detail.viewerPermissions, comment: true }, }; - await render({ composerDraftTarget: draftId, threadRef }); + await using _screen = await render({ composerDraftTarget: draftId, threadRef }); await tab("Code").click(); await page.getByRole("button", { name: "Expand all files", exact: true }).click(); await selectFirstLine(); @@ -492,7 +490,8 @@ it("offers both selection actions and queues a permitted host review locally", a }); it("cancels an agent-only selection and submits a fresh one with the keyboard without a host review", async () => { - await render({ composerDraftTarget: draftId, threadRef }); + setup(); + await using _screen = await render({ composerDraftTarget: draftId, threadRef }); await tab("Code").click(); await page.getByRole("button", { name: "Expand all files", exact: true }).click(); await selectFirstLine(); @@ -518,7 +517,8 @@ it("cancels an agent-only selection and submits a fresh one with the keyboard wi }); it("folds the title only for the active tab's own scroller", async () => { - await render(); + setup(); + await using _screen = await render(); const title = page.getByRole("heading", { level: 1, name: "Test pull request" }); await expect.element(title).toBeVisible(); const titleFold = () => title.element().closest("[inert]") !== null; @@ -563,66 +563,61 @@ it("folds the title only for the active tab's own scroller", async () => { }); it("restores the selected PR tab when switching threads and remounting the right panel", async () => { + setup(); const otherThread = { ...threadRef, threadId: ThreadId.make("thread-2") }; useRightPanelStore.getState().openPullRequest(threadRef, surface); useRightPanelStore.getState().openPullRequest(otherThread, surface); const codeTab = () => page.getByRole("button", { name: "Expand all files", exact: true }); - const container = document.createElement("div"); - container.className = "flex h-[700px] w-[900px]"; - document.body.append(container); - const root = createRoot(container); - roots.push(root); - const show = (ref: ScopedThreadRef) => { + const content = (ref: ScopedThreadRef) => { const selected = selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref); - return act(async () => { - root.render( - {}} - onCloseSurface={() => {}} - terminal={{ - launchContext: null, - focusRequestId: 0, - keybindings: [], - onAddTerminalContext: () => {}, - onSplitTerminal: () => {}, - onSplitTerminalVertical: () => {}, - onNewTerminal: () => {}, - onActiveTerminalChange: () => {}, - onCloseTerminal: () => {}, - }} - pullRequest={{ - capabilityKnown: true, - supported: true, - projectId: detail.projectId, - pullRequests: [], - linkedPullRequest: null, - branchPullRequest: null, - onBack: undefined, - }} - files={{ - projectName: null, - workspaceRoot: undefined, - availableEditors: [], - pendingSurfaceIds: new Set(), - onOpenFile: () => {}, - onPendingChange: () => {}, - }} - />, - ); - }); + return ( + {}} + onCloseSurface={() => {}} + terminal={{ + launchContext: null, + focusRequestId: 0, + keybindings: [], + onAddTerminalContext: () => {}, + onSplitTerminal: () => {}, + onSplitTerminalVertical: () => {}, + onNewTerminal: () => {}, + onActiveTerminalChange: () => {}, + onCloseTerminal: () => {}, + }} + pullRequest={{ + capabilityKnown: true, + supported: true, + projectId: detail.projectId, + pullRequests: [], + linkedPullRequest: null, + branchPullRequest: null, + onBack: undefined, + }} + files={{ + projectName: null, + workspaceRoot: undefined, + availableEditors: [], + pendingSurfaceIds: new Set(), + onOpenFile: () => {}, + onPendingChange: () => {}, + }} + /> + ); }; + await using screen = await renderUi(content(threadRef), { container: panelContainer() }); + const show = (ref: ScopedThreadRef) => screen.rerender(content(ref)); - await show(threadRef); await expect.element(page.getByTestId("summary-scroller")).toBeVisible(); await tab("Code").click(); await expect.element(codeTab()).toBeVisible(); @@ -640,7 +635,7 @@ it("restores the selected PR tab when switching threads and remounting the right await show(threadRef); await expect.element(codeTab()).toBeVisible(); - await act(async () => root.render(null)); + await screen.rerender(null); useRightPanelStore.getState().openPullRequest(threadRef, { ...surface, url: detail.url }); await show(threadRef); await expect.element(codeTab()).toBeVisible(); @@ -651,7 +646,8 @@ it("restores the selected PR tab when switching threads and remounting the right }); it("stays collapsed at the bottom after native scroll clamping and reopens at the top", async () => { - await render(); + setup(); + await using _screen = await render(); const title = page.getByRole("heading", { level: 1, name: "Test pull request" }); await expect.element(title).toBeVisible(); const scroller = page.getByTestId("summary-scroller").element() as HTMLElement; @@ -688,6 +684,7 @@ it("stays collapsed at the bottom after native scroll clamping and reopens at th }); it("shows the current checkout recovery detail on the first failure and on retry", async () => { + setup(); const first = new GitManagerError({ cwd: "/workspace", operation: "preparePullRequestThread", @@ -699,7 +696,7 @@ it("shows the current checkout recovery detail on the first failure and on retry command: "git worktree add", detail: "The worktree directory already exists. Choose another directory.", }); - await render(); + await using _screen = await render(); for (const [index, error] of [first, second].entries()) { prepareThread.mockImplementationOnce(async () => { @@ -717,6 +714,7 @@ it("shows the current checkout recovery detail on the first failure and on retry }); it("keeps discussion counts and Timeline expansion in sync with activity refreshes", async () => { + setup(); activityDetail = { ...detail, provider: SourceControlProviderKind.make("gitlab"), @@ -751,7 +749,7 @@ it("keeps discussion counts and Timeline expansion in sync with activity refresh }, ], }; - await render({ reference: activityDetail }); + await using _screen = await render({ reference: activityDetail }); await expect.element(page.getByText("Discussion finding", { exact: true })).toBeVisible(); await expect.element(page.getByText("Discussion reply", { exact: true })).toBeVisible(); expect diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx index 6f6569a0b534..e857300a7bcd 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx @@ -1,7 +1,6 @@ +import { act, render, type Screen } from "../../../test/render"; import { AuthSourceControlWriteScope, EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; const permissions = vi.hoisted(() => ({ @@ -33,52 +32,38 @@ const primary = EnvironmentId.make("primary"); const secondary = EnvironmentId.make("secondary"); const onSave = vi.fn<(next: string) => void>(); const onCancel = vi.fn<() => void>(); -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { permissions.allowed.clear(); permissions.listeners.clear(); onSave.mockReset(); onCancel.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} async function openEditor(options?: { value?: string; allowEmpty?: boolean }) { - await act(async () => - root.render( - , - ), + return render( + , ); } -function button(label: string) { - const match = [...container.querySelectorAll("button")].find( +function button(screen: Screen, label: string) { + const match = [...screen.container.querySelectorAll("button")].find( (element) => element.textContent === label, ); expect(match, `button ${label}`).toBeDefined(); return match!; } -const clickSave = () => act(async () => button("Save").click()); +const clickSave = (screen: Screen) => act(async () => button(screen, "Save").click()); async function grant(allowed: boolean) { await act(async () => { @@ -89,69 +74,75 @@ async function grant(allowed: boolean) { } it("does not save a secondary environment's description using the primary grant", async () => { + setup(); permissions.allowed.add(primary); - await openEditor(); + await using screen = await openEditor(); - expect(button("Save").disabled).toBe(true); - await clickSave(); + expect(button(screen, "Save").disabled).toBe(true); + await clickSave(screen); expect(onSave).not.toHaveBeenCalled(); }); it("rejects a Save click when the grant changes before React updates", async () => { + setup(); permissions.allowed.add(secondary); - await openEditor(); + await using screen = await openEditor(); permissions.allowed.delete(secondary); - expect(button("Save").disabled).toBe(false); - await clickSave(); + expect(button(screen, "Save").disabled).toBe(false); + await clickSave(screen); expect(onSave).not.toHaveBeenCalled(); }); it("keeps an edited draft through revocation and saves it after access returns without remounting", async () => { + setup(); permissions.allowed.add(secondary); - await openEditor(); - const textarea = container.querySelector("textarea")!; + await using screen = await openEditor(); + const textarea = screen.container.querySelector("textarea")!; await userEvent.clear(textarea); await userEvent.type(textarea, "Keep these unsaved edits"); await grant(false); - expect(button("Save").disabled).toBe(true); - expect(container.querySelector("textarea")).toBe(textarea); + expect(button(screen, "Save").disabled).toBe(true); + expect(screen.container.querySelector("textarea")).toBe(textarea); await grant(true); - await clickSave(); + await clickSave(screen); expect(onSave).toHaveBeenCalledExactlyOnceWith("Keep these unsaved edits"); }); it("saves with source-control permission alone, from the button or Cmd/Ctrl+Enter", async () => { + setup(); permissions.allowed.add(secondary); - await openEditor(); + await using screen = await openEditor(); - await clickSave(); + await clickSave(screen); expect(onSave).toHaveBeenCalledExactlyOnceWith("Original description"); - container.querySelector("textarea")!.focus(); + screen.container.querySelector("textarea")!.focus(); await userEvent.keyboard("{Control>}{Enter}{/Control}"); expect(onSave).toHaveBeenCalledTimes(2); }); it("allows clearing a description only after a pending grant becomes available", async () => { - await openEditor({ value: "", allowEmpty: true }); - await clickSave(); + setup(); + await using screen = await openEditor({ value: "", allowEmpty: true }); + await clickSave(screen); expect(onSave).not.toHaveBeenCalled(); await grant(true); - await clickSave(); + await clickSave(screen); expect(onSave).toHaveBeenCalledExactlyOnceWith(""); }); it("keeps cancelling a local edit available without a write grant", async () => { - await openEditor(); + setup(); + await using screen = await openEditor(); - await act(async () => button("Cancel").click()); - container.querySelector("textarea")!.focus(); + await act(async () => button(screen, "Cancel").click()); + screen.container.querySelector("textarea")!.focus(); await userEvent.keyboard("{Escape}"); expect(onCancel).toHaveBeenCalledTimes(2); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 1d518deec477..4e482c81db5c 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -5,9 +5,7 @@ import { type PullRequestDetailView, type PullRequestComment, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; vi.mock(import("~/state/use-atom-command"), async (original) => ({ @@ -15,6 +13,7 @@ vi.mock(import("~/state/use-atom-command"), async (original) => ({ useAtomCommand: (() => vi.fn()) as never, })); +import { act, render as renderUi, type Screen } from "../../../test/render"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; const detail: PullRequestDetailView = { @@ -69,78 +68,71 @@ const detail: PullRequestDetailView = { }, }; -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); +// The comment filter persists to localStorage, so tests that touch it start and end empty. +function emptyLocalStorage(): Disposable { localStorage.clear(); - vi.unstubAllGlobals(); -}); + return { [Symbol.dispose]: () => localStorage.clear() }; +} -const render = (value = detail) => - act(async () => - root.render( - {}} - environmentId={EnvironmentId.make("environment")} - threadRef={null} - reference={value} - detail={value} - activityPending={false} - activityError={null} - onRefresh={() => {}} - />, - ), - ); +const tab = (value = detail) => ( + {}} + environmentId={EnvironmentId.make("environment")} + threadRef={null} + reference={value} + detail={value} + activityPending={false} + activityError={null} + onRefresh={() => {}} + /> +); -function heading(title: string) { - const section = container.querySelector(`section[aria-label="${title}"]`); - expect(section, `section ${title}`).not.toBeNull(); - return [...section!.querySelectorAll("button")].find( - (button) => button.querySelector("span")?.textContent === title, - )!; +async function render(value = detail) { + const screen = await renderUi(tab(value)); + const { container } = screen; + const heading = (title: string) => { + const section = container.querySelector(`section[aria-label="${title}"]`); + expect(section, `section ${title}`).not.toBeNull(); + return [...section!.querySelectorAll("button")].find( + (button) => button.querySelector("span")?.textContent === title, + )!; + }; + return Object.assign(screen, { + click: (title: string) => act(async () => heading(title).click()), + expanded: (title: string) => heading(title).getAttribute("aria-expanded"), + visibleText: () => container.innerText, + }); } -const click = (title: string) => act(async () => heading(title).click()); -const expanded = (title: string) => heading(title).getAttribute("aria-expanded"); -const visibleText = () => container.innerText; - it("toggles checks from their heading and resets sections for another pull request", async () => { - await render(); - expect(visibleText()).not.toContain("Unit tests"); - await click("Checks"); - expect(visibleText()).toContain("Unit tests"); - await click("Checks"); - expect(expanded("Checks")).toBe("false"); - await click("Checks"); - await click("Description"); - await render({ ...detail, url: "https://github.com/owner/repo/pull/2", number: 2 }); - expect(expanded("Checks")).toBe("false"); - expect(expanded("Description")).toBe("true"); + await using screen = await render(); + expect(screen.visibleText()).not.toContain("Unit tests"); + await screen.click("Checks"); + expect(screen.visibleText()).toContain("Unit tests"); + await screen.click("Checks"); + expect(screen.expanded("Checks")).toBe("false"); + await screen.click("Checks"); + await screen.click("Description"); + await screen.rerender(tab({ ...detail, url: "https://github.com/owner/repo/pull/2", number: 2 })); + expect(screen.expanded("Checks")).toBe("false"); + expect(screen.expanded("Description")).toBe("true"); }); it("keeps an unsaved description when collapsed and reopened", async () => { - await render(); + await using screen = await render(); await act(async () => - container.querySelector('[aria-label="Edit description"]')!.click(), + screen.container.querySelector('[aria-label="Edit description"]')!.click(), ); - const textarea = container.querySelector("textarea")!; + const textarea = screen.container.querySelector("textarea")!; await userEvent.clear(textarea); await userEvent.type(textarea, "Unsaved description"); - await click("Description"); - expect(expanded("Description")).toBe("false"); - await click("Description"); - expect(container.querySelector("textarea")!.value).toBe("Unsaved description"); + await screen.click("Description"); + expect(screen.expanded("Description")).toBe("false"); + await screen.click("Description"); + expect(screen.container.querySelector("textarea")!.value).toBe("Unsaved description"); }); -function commentTexts() { +function commentTexts({ container }: Screen) { const section = [...container.querySelectorAll("section")].find((element) => element.getAttribute("aria-label")?.startsWith("Comments"), )!; @@ -149,7 +141,7 @@ function commentTexts() { .map((p) => p.textContent ?? ""); } -const filterButton = () => +const filterButton = ({ container }: Screen) => container.querySelector('[aria-label="Filter comments"]')!; function filterItem(label: string) { @@ -160,12 +152,15 @@ function filterItem(label: string) { return item!; } -async function toggleFilter(label: string) { - if (!document.querySelector('[role="menuitemcheckbox"]')) await userEvent.click(filterButton()); +async function toggleFilter(screen: Screen, label: string) { + if (!document.querySelector('[role="menuitemcheckbox"]')) { + await userEvent.click(filterButton(screen)); + } await userEvent.click(filterItem(label)); } it("hides bot and resolved comments until the filter shows them, and remembers the choice", async () => { + using _storage = emptyLocalStorage(); const comment = (index: number, login: string, body: string, isBot = false) => ({ id: `comment-${index}`, kind: "issue-comment" as const, @@ -202,30 +197,32 @@ it("hides bot and resolved comments until the filter shows them, and remembers t }, ], }; - await render(value); - expect(commentTexts()).toEqual(["Human comment"]); - expect(filterButton().textContent).toContain("13 hidden"); + await using screen = await render(value); + expect(commentTexts(screen)).toEqual(["Human comment"]); + expect(filterButton(screen).textContent).toContain("13 hidden"); - await toggleFilter("Bot comments"); - expect(commentTexts().filter((text) => text.startsWith("Bot report"))).toHaveLength(9); - expect(commentTexts()).toContain("Human comment"); - expect(commentTexts()).not.toContain("Bot report 0"); + await toggleFilter(screen, "Bot comments"); + expect(commentTexts(screen).filter((text) => text.startsWith("Bot report"))).toHaveLength(9); + expect(commentTexts(screen)).toContain("Human comment"); + expect(commentTexts(screen)).not.toContain("Bot report 0"); - await toggleFilter("Resolved or dismissed"); + await toggleFilter(screen, "Resolved or dismissed"); await userEvent.keyboard("{Escape}"); expect( - [...container.querySelectorAll("button")].some((button) => button.textContent === "Resolved"), + [...screen.container.querySelectorAll("button")].some( + (button) => button.textContent === "Resolved", + ), ).toBe(true); - await act(async () => root.unmount()); - root = createRoot(container); - await render({ ...value, url: `${value.url}0`, number: 10 }); - expect(commentTexts()).toContain("Bot report 11"); - await userEvent.click(filterButton()); + await screen[Symbol.asyncDispose](); + await using remounted = await render({ ...value, url: `${value.url}0`, number: 10 }); + expect(commentTexts(remounted)).toContain("Bot report 11"); + await userEvent.click(filterButton(remounted)); expect(filterItem("Bot comments").getAttribute("aria-checked")).toBe("true"); }); it("reveals whole older conversations with honest partial reply counts and a host link", async () => { + using _storage = emptyLocalStorage(); const comment = (index: number): PullRequestComment => ({ id: `comment-${index}`, kind: "issue-comment", @@ -254,32 +251,32 @@ it("reveals whole older conversations with honest partial reply counts and a hos commentCount: 15, reviewThreads: [{ ...thread, commentCount: 5 }], }; - await render(value); - await toggleFilter("Resolved or dismissed"); + await using screen = await render(value); + await toggleFilter(screen, "Resolved or dismissed"); await userEvent.keyboard("{Escape}"); - const older = [...container.querySelectorAll("button")].find((button) => + const older = [...screen.container.querySelectorAll("button")].find((button) => button.textContent?.startsWith("Show 1 older"), )!; expect(older).toBeDefined(); expect.soft(older.textContent).toMatch(/1 older conversation.*1 hidden/); - expect(commentTexts()).not.toContain(reply.body); + expect(commentTexts(screen)).not.toContain(reply.body); await userEvent.click(older); - const resolved = [...container.querySelectorAll("button")].find( + const resolved = [...screen.container.querySelectorAll("button")].find( (button) => button.textContent === "Resolved", )!; expect(resolved.getAttribute("aria-expanded")).toBe("false"); - expect(commentTexts()).not.toContain(reply.body); - expect.soft(visibleText()).toContain("4 replies"); + expect(commentTexts(screen)).not.toContain(reply.body); + expect.soft(screen.visibleText()).toContain("4 replies"); await userEvent.click(resolved); - expect(commentTexts().slice(-2)).toEqual([finding.body, reply.body]); - expect.soft(visibleText()).toContain("3 more replies"); - const hostLink = [...container.querySelectorAll("a")].find( + expect(commentTexts(screen).slice(-2)).toEqual([finding.body, reply.body]); + expect.soft(screen.visibleText()).toContain("3 more replies"); + const hostLink = [...screen.container.querySelectorAll("a")].find( (link) => link.href === finding.url && /host/i.test(link.textContent ?? "") && link.checkVisibility(), ); expect.soft(hostLink, "visible link to the unloaded replies on the host").toBeDefined(); - await render({ ...value, reviewThreads: [thread] }); - expect.soft(visibleText()).toMatch(/more replies/i); - expect(visibleText()).not.toContain("3 more replies"); + await screen.rerender(tab({ ...value, reviewThreads: [thread] })); + expect.soft(screen.visibleText()).toMatch(/more replies/i); + expect(screen.visibleText()).not.toContain("3 more replies"); }); diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.browser.test.tsx b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.browser.test.tsx index b967849e9521..c68e6ba0d6b8 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.browser.test.tsx +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render, fakeTimers } from "../../../test/render"; import { EnvironmentId, ProjectId, @@ -6,9 +7,8 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act, StrictMode, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, useEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ host: { data: null as unknown, refresh: vi.fn() }, @@ -57,51 +57,44 @@ function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesVie }; } -let root: Root; -let current: PullRequestFilesViewedView; - -function Surface() { - const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true }); - useEffect(() => { - current = view; - }); - return null; -} - -const view = () => current; -const render = () => - act(async () => - root.render( - - - , - ), - ); - -async function reads(state: "unviewed" | "viewed" | "dismissed") { - host.data = answer(state); - await render(); -} - -beforeEach(async () => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { host.data = answer("unviewed"); host.refresh.mockReset(); toastAdd.mockReset(); setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - root = createRoot(document.createElement("div")); - await render(); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); +} +async function renderSurface() { + let current!: PullRequestFilesViewedView; + function Surface() { + const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true }); + useEffect(() => { + current = view; + }); + return null; + } + const ui = () => ( + + + + ); + const screen = await render(ui(), { router: false }); + return { + view: () => current, + reads: async (state: "unviewed" | "viewed" | "dismissed") => { + host.data = answer(state); + await screen.rerender(ui()); + }, + rerender: () => screen.rerender(ui()), + [Symbol.asyncDispose]: () => screen[Symbol.asyncDispose](), + }; +} describe("a mark whose file was pushed to before the read that followed it", () => { it("gives way to the host and shows the file as changed", async () => { + setup(); + using _timers = fakeTimers(); + await using screen = await renderSurface(); + const { view, reads } = screen; view().setViewed("a.ts", true); await act(async () => vi.advanceTimersByTimeAsync(500)); expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ @@ -117,6 +110,10 @@ describe("a mark whose file was pushed to before the read that followed it", () }); it("stays given way to on every later read, having nothing left to recover", async () => { + setup(); + using _timers = fakeTimers(); + await using screen = await renderSurface(); + const { view, reads } = screen; view().setViewed("a.ts", true); await act(async () => vi.advanceTimersByTimeAsync(500)); await reads("dismissed"); @@ -131,6 +128,10 @@ describe("a mark whose file was pushed to before the read that followed it", () describe("a mark the host has not answered for yet", () => { it("holds the press while the write is still out", async () => { + setup(); + using _timers = fakeTimers(); + await using screen = await renderSurface(); + const { view, reads } = screen; let land = (_result: unknown) => {}; setFilesViewed.mockReturnValueOnce(new Promise((resolve) => (land = resolve))); @@ -145,6 +146,10 @@ describe("a mark the host has not answered for yet", () => { }); it("holds a press made since the read that would otherwise answer for it", async () => { + setup(); + using _timers = fakeTimers(); + await using screen = await renderSurface(); + const { view, reads } = screen; view().setViewed("a.ts", true); await act(async () => vi.advanceTimersByTimeAsync(500)); @@ -158,22 +163,22 @@ describe("a mark the host has not answered for yet", () => { describe("a failed batch that the host partly applied", () => { it("refreshes the host and accepts the applied marks", async () => { + setup(); + using _timers = fakeTimers(); + await using screen = await renderSurface(); + const { view } = screen; setFilesViewed.mockResolvedValueOnce( AsyncResult.failure(Cause.fail(new Error("partial batch"))), ); host.refresh.mockImplementation(() => { host.data = answer("viewed"); - root.render( - - - , - ); }); - act(() => { + await act(() => { view().setViewed("a.ts", true); view().setViewed("b.ts", true); }); await act(async () => vi.advanceTimersByTimeAsync(500)); + await screen.rerender(); expect(view().isViewed("a.ts")).toBe(true); expect(view().isViewed("b.ts")).toBe(false); }); diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.browser.test.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.browser.test.tsx index a9eefdaa5823..16193c83cbb4 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.browser.test.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.browser.test.tsx @@ -1,8 +1,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { AuthFilesystemReadScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, type Screen } from "../../../test/render"; const state = vi.hoisted(() => ({ readScope: vi.fn(), @@ -75,79 +75,69 @@ vi.mock(import("~/state/queries"), async (original) => { import { useRightPanelStore } from "~/rightPanelStore"; import { ProjectContentSearchDialog } from "./ProjectContentSearchDialog"; -let root: Root; -let container: HTMLDivElement; -const onOpenChange = vi.fn(); -const openFile = vi.fn(); - -beforeEach(() => { +function setup() { state.access = { canReadFiles: true, isCheckingAccess: false }; state.listeners.clear(); state.readScope.mockReset().mockReturnValue(true); - openFile.mockClear(); - onOpenChange.mockClear(); - vi.spyOn(useRightPanelStore.getState(), "openFile").mockImplementation(openFile); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + const openFile = vi.fn(); + const onOpenChange = vi.fn(); + const openFileSpy = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(openFile); + return { openFile, onOpenChange, [Symbol.dispose]: () => openFileSpy.mockRestore() }; +} -const input = () => container.querySelector("input")!; -const result = () => container.querySelector("[data-content-search-result]"); +const input = (screen: Screen) => screen.container.querySelector("input")!; +const result = (screen: Screen) => + screen.container.querySelector("[data-content-search-result]"); -async function openSearch() { - await act(() => root.render()); +function openSearch(onOpenChange: (open: boolean) => void) { + return render(); } -async function type(text: string) { +async function type(screen: Screen, text: string) { await act(async () => { const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; - setValue.call(input(), text); - input().dispatchEvent(new Event("input", { bubbles: true })); + setValue.call(input(screen), text); + input(screen).dispatchEvent(new Event("input", { bubbles: true })); }); } it("enables search after access resolves and opens a result in its own environment", async () => { + using env = setup(); state.access = { canReadFiles: false, isCheckingAccess: true }; - await openSearch(); - expect(input().disabled).toBe(true); - expect(container.textContent).toContain("Checking file access…"); + await using screen = await openSearch(env.onOpenChange); + expect(input(screen).disabled).toBe(true); + expect(screen.container.textContent).toContain("Checking file access…"); await act(() => setAccess({ canReadFiles: true, isCheckingAccess: false })); - expect(input().disabled).toBe(false); - await type("match"); - await act(async () => result()!.click()); + expect(input(screen).disabled).toBe(false); + await type(screen, "match"); + await act(async () => result(screen)!.click()); expect(state.readScope).toHaveBeenCalledWith(target.environmentId, AuthFilesystemReadScope); - expect(openFile).toHaveBeenCalledWith(target.threadRef, "src/index.ts", 3); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(env.openFile).toHaveBeenCalledWith(target.threadRef, "src/index.ts", 3); + expect(env.onOpenChange).toHaveBeenCalledWith(false); }); it.each(["pointer", "keyboard"])( "rechecks access before a retained %s action opens a result", async (action) => { - await openSearch(); - await type("match"); + using env = setup(); + await using screen = await openSearch(env.onOpenChange); + await type(screen, "match"); state.readScope.mockReturnValue(false); await act(async () => { - if (action === "pointer") result()!.click(); + if (action === "pointer") result(screen)!.click(); else - input().dispatchEvent( + input(screen).dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), ); }); expect(state.readScope).toHaveBeenCalled(); - expect(openFile).not.toHaveBeenCalled(); - expect(onOpenChange).not.toHaveBeenCalled(); + expect(env.openFile).not.toHaveBeenCalled(); + expect(env.onOpenChange).not.toHaveBeenCalled(); }, ); diff --git a/apps/web/src/components/settings/AcpRegistryIcon.browser.test.tsx b/apps/web/src/components/settings/AcpRegistryIcon.browser.test.tsx index 51844a282363..49f3f6a27263 100644 --- a/apps/web/src/components/settings/AcpRegistryIcon.browser.test.tsx +++ b/apps/web/src/components/settings/AcpRegistryIcon.browser.test.tsx @@ -1,71 +1,66 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; +import { render, stubGlobal, type Screen } from "../../../test/render"; import { AcpRegistryAgentIcon } from "./AcpRegistryIcon"; const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg"; -const fetchIcon = vi.fn(); -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("caches", undefined); - vi.stubGlobal("fetch", fetchIcon); - fetchIcon.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +function setup() { + const fetchIcon = vi.fn(); + const stack = new DisposableStack(); + stack.use(stubGlobal("caches", undefined)); + stack.use(stubGlobal("fetch", fetchIcon)); + return { fetchIcon, [Symbol.dispose]: () => stack.dispose() }; +} -const image = () => container.querySelector("img"); -const fallback = () => container.querySelector('[data-slot="acp-icon-fallback"]'); +const image = (screen: Screen) => screen.container.querySelector("img"); +const fallback = (screen: Screen) => + screen.container.querySelector('[data-slot="acp-icon-fallback"]'); it("falls back to the raw allowlisted CDN URL when the validating fetch is blocked", async () => { + using env = setup(); // The official CDN serves no CORS headers, so the fetch can reject even // though native loading works. - fetchIcon.mockRejectedValue(new TypeError("Failed to fetch")); - await act(async () => root.render()); + env.fetchIcon.mockRejectedValue(new TypeError("Failed to fetch")); + await using screen = await render(); - await vi.waitFor(() => expect(image()?.getAttribute("src")).toBe(url)); - expect(image()?.referrerPolicy).toBe("no-referrer"); - expect(fallback()).not.toBeNull(); + await vi.waitFor(() => expect(image(screen)?.getAttribute("src")).toBe(url)); + expect(image(screen)?.referrerPolicy).toBe("no-referrer"); + expect(fallback(screen)).not.toBeNull(); }); -function serve(body: string) { +function serve(fetchIcon: ReturnType["fetchIcon"], body: string) { fetchIcon.mockResolvedValue( new Response(body, { status: 200, headers: { "content-type": "image/svg+xml" } }), ); } it("swaps the fallback for the validated blob once the image loads", async () => { - serve(''); - await act(async () => root.render()); + using env = setup(); + serve(env.fetchIcon, ''); + await using screen = await render(); - await vi.waitFor(() => expect(fallback()).toBeNull()); - expect(image()?.src).toMatch(/^blob:/); - expect(image()?.checkVisibility()).toBe(true); + await vi.waitFor(() => expect(fallback(screen)).toBeNull()); + expect(image(screen)?.src).toMatch(/^blob:/); + expect(image(screen)?.checkVisibility()).toBe(true); }); it("keeps the fallback and drops the image when the validated blob fails to decode", async () => { - serve("not an svg"); - await act(async () => root.render()); + using env = setup(); + serve(env.fetchIcon, "not an svg"); + await using screen = await render(); - await vi.waitFor(() => expect(fetchIcon).toHaveBeenCalledOnce()); - await vi.waitFor(() => expect(image()).toBeNull()); - expect(fallback()).not.toBeNull(); + await vi.waitFor(() => expect(env.fetchIcon).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(image(screen)).toBeNull()); + expect(fallback(screen)).not.toBeNull(); }); it("never renders an icon from outside the official CDN", async () => { - await act(async () => - root.render(), + using env = setup(); + await using screen = await render( + , ); - expect(fetchIcon).not.toHaveBeenCalled(); - expect(image()).toBeNull(); - expect(fallback()).not.toBeNull(); + expect(env.fetchIcon).not.toHaveBeenCalled(); + expect(image(screen)).toBeNull(); + expect(fallback(screen)).not.toBeNull(); }); diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx index 7814e3ce0d81..ecc870f300b3 100644 --- a/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx @@ -5,11 +5,11 @@ import { type AcpRegistrySearchInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +import { act, fakeTimers, render } from "../../../test/render"; + const state = vi.hoisted(() => ({ result: null as { readonly agents: ReadonlyArray } | null, error: null as string | null, @@ -69,31 +69,26 @@ const gemini: AcpRegistrySearchAgent = { integrity: "registry", }; -const roots: Root[] = []; -let root: Root; - -function render(options?: { +function step(options?: { readonly configured?: boolean; readonly onPrepared?: (agent: AcpRegistrySearchAgent) => void; }) { - return act(async () => - root.render( - , - ), + return ( + ); } @@ -109,7 +104,7 @@ function deferred() { return { promise, resolve }; } -beforeEach(() => { +function setup() { state.canManage = true; state.result = null; state.error = null; @@ -120,19 +115,10 @@ beforeEach(() => { _tag: "Success", value: { agentId: "gemini", version: "1.2.3", distribution: "npx", prepared: true }, }); - const container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - roots.push(root); -}); - -afterEach(async () => { - vi.useRealTimers(); - for (const mounted of roots.splice(0)) await act(async () => mounted.unmount()); - document.body.replaceChildren(); -}); +} it("shows the full catalog initially and after clearing a debounced search", async () => { + setup(); const agents = Array.from({ length: 25 }, (_, index) => ({ ...gemini, id: `gemini-${index}`, @@ -146,11 +132,11 @@ it("shows the full catalog initially and after clearing a debounced search", asy }; }); const addButtons = () => page.getByRole("button", { name: /^Add Gemini CLI / }).elements(); - await render(); + await using _screen = await render(step()); expect(addButtons()).toHaveLength(25); await expect.element(page.getByRole("button", { name: "Add Gemini CLI 25" })).toBeEnabled(); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); await userEvent.fill(searchBox(), " Gemini "); await act(async () => vi.advanceTimersByTime(200)); await userEvent.fill(searchBox(), " Gemini CLI "); @@ -166,38 +152,40 @@ it("shows the full catalog initially and after clearing a debounced search", asy }); it("shows the loading, error, and empty states", async () => { + setup(); state.isPending = true; - await render(); + await using screen = await render(step()); await expect.element(page.getByText("Searching the registry...")).toBeVisible(); state.isPending = false; state.error = "Registry unavailable."; - await render(); + await screen.rerender(step()); await expect.element(page.getByText("Registry unavailable.")).toBeVisible(); state.error = null; state.result = { agents: [] }; - await render(); + await screen.rerender(step()); await expect.element(page.getByText("No compatible agents found")).toBeVisible(); }); it("submits a typed query at once and refreshes when the same query is submitted again", async () => { - await render(); + setup(); + await using screen = await render(step()); // A slow runner must not let the typing debounce submit the query before Enter does. - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); await userEvent.fill(searchBox(), "Codex"); await userEvent.keyboard("{Enter}"); expect(lastQuery()).toBe("Codex"); expect(state.refresh).not.toHaveBeenCalled(); state.result = { agents: [gemini] }; - await render(); + await screen.rerender(step()); await userEvent.click(searchBox()); await userEvent.keyboard("{Enter}"); expect(state.refresh).toHaveBeenCalledOnce(); state.isPending = true; - await render(); + await screen.rerender(step()); await expect .element(page.getByRole("status").filter({ hasText: "Refreshing ACP Registry results." })) .toBeInTheDocument(); @@ -205,13 +193,14 @@ it("submits a typed query at once and refreshes when the same query is submitted }); it("prepares a result before handing it back to the wizard", async () => { + setup(); state.prepare.mockResolvedValueOnce({ _tag: "Success", value: { agentId: "gemini", version: "2.0.0", distribution: "binary", prepared: true }, }); state.result = { agents: [gemini] }; const onPrepared = vi.fn(); - await render({ onPrepared }); + await using _screen = await render(step({ onPrepared })); await page.getByRole("button", { name: "Add Gemini CLI" }).click(); @@ -226,10 +215,11 @@ it("prepares a result before handing it back to the wizard", async () => { }); it("shows progress, locks the step while an agent prepares, and reports a failure", async () => { + setup(); const pending = deferred(); state.prepare.mockReturnValueOnce(pending.promise); state.result = { agents: [{ ...gemini, distribution: "binary" }] }; - await render(); + await using _screen = await render(step()); await page.getByRole("button", { name: "Add Gemini CLI" }).click(); await expect.element(page.getByRole("button", { name: "Downloading Gemini CLI" })).toBeDisabled(); @@ -243,9 +233,10 @@ it("shows progress, locks the step while an agent prepares, and reports a failur }); it("rechecks the grant before preparing an agent", async () => { + setup(); state.result = { agents: [gemini] }; const onPrepared = vi.fn(); - await render({ onPrepared }); + await using _screen = await render(step({ onPrepared })); state.canManage = false; await page.getByRole("button", { name: "Add Gemini CLI" }).click(); @@ -255,14 +246,15 @@ it("rechecks the grant before preparing an agent", async () => { }); it("ignores prepare completion after unmount", async () => { + setup(); const pending = deferred(); state.prepare.mockReturnValueOnce(pending.promise); state.result = { agents: [gemini] }; const onPrepared = vi.fn(); - await render({ onPrepared }); + await using screen = await render(step({ onPrepared })); await page.getByRole("button", { name: "Add Gemini CLI" }).click(); - await act(async () => root.unmount()); + await screen.unmount(); await act(async () => pending.resolve({ _tag: "Success", @@ -274,8 +266,9 @@ it("ignores prepare completion after unmount", async () => { }); it("shows an existing registry configuration as already added", async () => { + setup(); state.result = { agents: [gemini] }; - await render({ configured: true }); + await using _screen = await render(step({ configured: true })); await expect .element(page.getByRole("button", { name: "Already added Gemini CLI" })) .toBeDisabled(); diff --git a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx index 9d4995be732d..5ff45e2557cd 100644 --- a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx +++ b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx @@ -6,9 +6,9 @@ import { ThreadId, type ServerProvider, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const commands = vi.hoisted(() => ({ list: vi.fn(), @@ -85,33 +85,30 @@ function deferred() { return { promise, resolve }; } -let root: Root; -let container: HTMLDivElement; - -function render(readOnly = false) { - return act(async () => - root.render( - , - ), +async function renderSection(readOnly = false) { + const screen = await render( + , ); + const button = (label: string) => { + const found = [...screen.container.querySelectorAll("button")].find( + (element) => element.textContent === label, + ); + expect(found, `button ${label}`).toBeDefined(); + return found!; + }; + return Object.assign(screen, { + button, + click: (label: string) => act(async () => button(label).click()), + }); } -function button(label: string) { - const found = [...container.querySelectorAll("button")].find( - (element) => element.textContent === label, - ); - expect(found, `button ${label}`).toBeDefined(); - return found!; -} -const click = (label: string) => act(async () => button(label).click()); -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { grants.scopes = new Set(["orchestration:read", "orchestration:operate", "providers:manage"]); commands.list.mockReset().mockResolvedValue({ _tag: "Success", @@ -157,29 +154,21 @@ beforeEach(() => { value: { loggedOut: true }, }); dialogs.confirm.mockReset().mockResolvedValue(true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} describe("AcpSessionManagementSection", () => { it("lists and imports native sessions through the owning environment", async () => { - await render(); - await click("List sessions"); + setup(); + await using screen = await renderSection(); + await screen.click("List sessions"); expect(commands.list).toHaveBeenCalledWith({ environmentId, input: { instanceId, projectId }, }); - expect(container.textContent).toContain(session.title); + expect(screen.container.textContent).toContain(session.title); - await click("Import"); + await screen.click("Import"); expect(commands.import).toHaveBeenCalledWith({ environmentId, @@ -191,12 +180,13 @@ describe("AcpSessionManagementSection", () => { updatedAt: session.updatedAt, }, }); - expect(button("Imported").disabled).toBe(true); + expect(screen.button("Imported").disabled).toBe(true); }); it("logs out the provider instance through the owning environment", async () => { - await render(); - await click("Log out"); + setup(); + await using screen = await renderSection(); + await screen.click("Log out"); expect(commands.logout).toHaveBeenCalledWith({ environmentId, @@ -205,9 +195,10 @@ describe("AcpSessionManagementSection", () => { }); it("deletes unimported native sessions after destructive confirmation", async () => { - await render(); - await click("List sessions"); - await click("Delete"); + setup(); + await using screen = await renderSection(); + await screen.click("List sessions"); + await screen.click("Delete"); expect(dialogs.confirm).toHaveBeenCalledOnce(); expect(commands.delete).toHaveBeenCalledWith({ @@ -217,17 +208,18 @@ describe("AcpSessionManagementSection", () => { }); it("locks the project picker while a project-scoped request is pending", async () => { + setup(); const pending = deferred<{ readonly _tag: "Success"; readonly value: { readonly providers: [] }; }>(); commands.listProviders.mockReturnValueOnce(pending.promise); - await render(); + await using screen = await renderSection(); const picker = () => - container.querySelector('[aria-label="Project for ACP sessions"]')!; + screen.container.querySelector('[aria-label="Project for ACP sessions"]')!; expect(picker().hasAttribute("data-disabled")).toBe(false); - await click("List providers"); + await screen.click("List providers"); expect(picker().hasAttribute("data-disabled")).toBe(true); await act(async () => pending.resolve({ _tag: "Success", value: { providers: [] } })); @@ -235,17 +227,19 @@ describe("AcpSessionManagementSection", () => { }); it("lists, saves, and disables configurable ACP providers", async () => { - await render(); - await click("List providers"); + setup(); + await using screen = await renderSection(); + await screen.click("List providers"); expect(commands.listProviders).toHaveBeenCalledWith({ environmentId, input: { instanceId, projectId }, }); expect( - container.querySelector('input[aria-label="google base URL"]')?.value, + screen.container.querySelector('input[aria-label="google base URL"]') + ?.value, ).toBe("https://api.example.test/v1"); - await click("Save"); + await screen.click("Save"); expect(commands.setProvider).toHaveBeenCalledWith({ environmentId, input: { @@ -257,7 +251,7 @@ describe("AcpSessionManagementSection", () => { }, }); - await click("Disable"); + await screen.click("Disable"); expect(commands.disableProvider).toHaveBeenCalledWith({ environmentId, input: { instanceId, projectId, providerId: "google" }, @@ -266,29 +260,32 @@ describe("AcpSessionManagementSection", () => { }); it("imports with task access even when provider configuration is read-only", async () => { + setup(); grants.scopes.delete("providers:manage"); - await render(true); - await click("List sessions"); - await click("Import"); + await using screen = await renderSection(true); + await screen.click("List sessions"); + await screen.click("Import"); expect(commands.import).toHaveBeenCalledOnce(); }); it("does not import with provider management alone", async () => { + setup(); grants.scopes.delete("orchestration:operate"); - await render(); - await click("List sessions"); - expect(button("Import").disabled).toBe(true); - await click("Import"); + await using screen = await renderSection(); + await screen.click("List sessions"); + expect(screen.button("Import").disabled).toBe(true); + await screen.click("Import"); expect(commands.import).not.toHaveBeenCalled(); }); it("rechecks task access after confirming deletion", async () => { - await render(); - await click("List sessions"); + setup(); + await using screen = await renderSection(); + await screen.click("List sessions"); dialogs.confirm.mockImplementationOnce(async () => { grants.scopes.delete("orchestration:operate"); return true; }); - await click("Delete"); + await screen.click("Delete"); expect(commands.delete).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx index f4d5179da2d5..1a11e8e0ebc2 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx @@ -1,8 +1,8 @@ import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const actions = vi.hoisted(() => ({ toast: vi.fn(), @@ -100,20 +100,14 @@ function deferred() { return { promise, resolve }; } -let root: Root; - -function render(onOpenChange = actions.onOpenChange) { - return act(async () => - root.render( - , - ), - ); -} +const dialog = () => ( + +); function button(label: string) { const found = [...document.querySelectorAll("button")].find( (element) => element.textContent?.trim() === label, @@ -134,30 +128,28 @@ async function type(selector: string, value: string) { const signInStep = () => document.querySelector('[aria-label="Sign-in step"]'); async function prepareCodexInstance() { - await render(); + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render(dialog())); await click("Configure manually"); await type("#add-provider-label", "Work"); await click("Next"); + stack.move(); + return screen; } -describe("AddProviderInstanceDialog environment routing", () => { - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - actions.canManageProviders = true; - actions.toast.mockReset(); - actions.onOpenChange.mockReset(); - settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} }); - settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); - settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate); - root = createRoot(document.createElement("div")); - }); - afterEach(async () => { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); - }); +function setup() { + actions.canManageProviders = true; + actions.toast.mockReset(); + actions.onOpenChange.mockReset(); + settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} }); + settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); + settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate); +} +describe("AddProviderInstanceDialog environment routing", () => { it("creates a provider with its default identity without typing", async () => { - await render(); + setup(); + await using _screen = await render(dialog()); const grok = [...document.querySelectorAll('[role="radio"]')].find((radio) => radio.textContent?.includes("Grok"), ) as HTMLElement; @@ -173,10 +165,11 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("chooses an unused identity for another account without replacing configured instances", async () => { + setup(); settingsHooks.read.mockReturnValue({ providerInstances: { codex_2: { driver: "codex", enabled: false } }, }); - await render(); + await using _screen = await render(dialog()); await click("Configure manually"); await click("Next"); await click("Add instance"); @@ -193,16 +186,18 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("reads and writes settings through the supplied environment", async () => { - await render(); + setup(); + await using _screen = await render(dialog()); expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId); }); it("awaits an atomic create before closing and retains prepared ACP metadata", async () => { + setup(); const mutation = deferred<{ readonly _tag: "Success"; readonly value: unknown }>(); settingsHooks.mutate.mockReturnValueOnce(mutation.promise); - await render(); + await using _screen = await render(dialog()); await click("Use Kilo Code"); expect(document.body.textContent).toContain("v4.2.0 · binary"); @@ -232,7 +227,8 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("configures a manually entered registry agent from the first provider screen", async () => { - await render(); + setup(); + await using _screen = await render(dialog()); await click("Enter registry agent"); await type("#add-provider-acpRegistry-manual-agentId", "devin"); await click("Next"); @@ -250,7 +246,8 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("creates a local command in the selected environment without a sign-in step", async () => { - await render(); + setup(); + await using _screen = await render(dialog()); await click("Use local command"); await click("Next"); expect(document.body.textContent).toContain("Executable is required."); @@ -295,8 +292,9 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("keeps the dialog open when the atomic upsert fails", async () => { + setup(); settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); - await render(); + await using _screen = await render(dialog()); await click("Use Kilo Code"); await click("Continue to sign-in"); @@ -308,7 +306,8 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("adds an instance with the selected environment's provider grant alone", async () => { - await prepareCodexInstance(); + setup(); + await using _screen = await prepareCodexInstance(); await click("Add instance"); expect(settingsHooks.mutate).toHaveBeenCalledWith({ @@ -328,7 +327,8 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("rejects a queued save after the provider grant is revoked", async () => { - await prepareCodexInstance(); + setup(); + await using screen = await prepareCodexInstance(); const save = button("Add instance"); actions.canManageProviders = false; // The rendered button is still enabled; the click must recheck the live grant. @@ -337,18 +337,19 @@ describe("AddProviderInstanceDialog environment routing", () => { expect(settingsHooks.mutate).not.toHaveBeenCalled(); expect(actions.toast).not.toHaveBeenCalled(); expect(actions.onOpenChange).not.toHaveBeenCalled(); - await render(); + await screen.rerender(dialog()); expect(button("Add instance").disabled).toBe(true); }); it("keeps a denied draft available when the provider grant arrives", async () => { + setup(); actions.canManageProviders = false; - await prepareCodexInstance(); + await using screen = await prepareCodexInstance(); expect(button("Add instance").disabled).toBe(true); expect(settingsHooks.mutate).not.toHaveBeenCalled(); actions.canManageProviders = true; - await render(); + await screen.rerender(dialog()); expect(button("Add instance").disabled).toBe(false); await click("Add instance"); expect(settingsHooks.mutate).toHaveBeenCalledOnce(); diff --git a/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx b/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx index 3af26ae02a4c..3fc121d19e2e 100644 --- a/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx +++ b/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx @@ -3,10 +3,10 @@ import { type DesktopCaptureConfigPreview, type DesktopSnapShotState, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const bridge = vi.hoisted(() => ({ previewSnapShotConfig: vi.fn(), @@ -44,10 +44,19 @@ function deferred() { return { promise, resolve }; } -let root: Root; -let container: HTMLDivElement; +function setup() { + const platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); + bridge.setSnapShotShortcutSuppressed.mockReset().mockResolvedValue(undefined); + bridge.previewSnapShotConfig.mockReset().mockResolvedValue(preview); + bridge.applySnapShotConfig + .mockReset() + .mockResolvedValue({ backupPath: "/config/backup", warning: null }); + complete.mockReset().mockResolvedValue(undefined); + toast.mockClear(); + return { [Symbol.dispose]: () => platform.mockRestore() }; +} -async function render(desktop: "niri" | "hyprland" = "niri") { +async function renderConfig(desktop: "niri" | "hyprland" = "niri") { const state: DesktopSnapShotState = { mode: "portal", linuxBackend: desktop, @@ -59,62 +68,46 @@ async function render(desktop: "niri" | "hyprland" = "niri") { shortcutConfigPath: preview.path, shortcutBinding: "manual binding", }; - await act(async () => root.render()); -} -function button(label: string) { - return [...container.querySelectorAll("button")].find((element) => element.textContent === label); -} -async function click(label: string) { - const target = button(label); - expect(target, `button ${label}`).toBeDefined(); - await act(async () => target!.click()); -} -const recorder = () => container.querySelector("[data-keybinding-capture]")!; -const diff = () => container.querySelector('[aria-label="Shortcut changes"]'); -const alert = () => container.querySelector('[role="alert"]'); -const advanced = () => container.querySelector("details")!; -async function recordKeys(keys: string) { - await act(async () => userEvent.click(recorder())); - await act(async () => userEvent.keyboard(keys)); + const screen = await render(); + const { container } = screen; + const button = (label: string) => + [...container.querySelectorAll("button")].find((element) => element.textContent === label); + const recorder = () => container.querySelector("[data-keybinding-capture]")!; + return Object.assign(screen, { + button, + recorder, + diff: () => container.querySelector('[aria-label="Shortcut changes"]'), + alert: () => container.querySelector('[role="alert"]'), + advanced: () => container.querySelector("details")!, + async click(label: string) { + const target = button(label); + expect(target, `button ${label}`).toBeDefined(); + await act(async () => target!.click()); + }, + async recordKeys(keys: string) { + await act(async () => userEvent.click(recorder())); + await act(async () => userEvent.keyboard(keys)); + }, + }); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - Object.defineProperty(navigator, "platform", { configurable: true, get: () => "Linux" }); - bridge.setSnapShotShortcutSuppressed.mockReset().mockResolvedValue(undefined); - bridge.previewSnapShotConfig.mockReset().mockResolvedValue(preview); - bridge.applySnapShotConfig - .mockReset() - .mockResolvedValue({ backupPath: "/config/backup", warning: null }); - complete.mockReset().mockResolvedValue(undefined); - toast.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); - it.each(["niri", "hyprland"] as const)( "shows the fallback before review and requires separate read and write approval on %s", async (desktop) => { - await render(desktop); - expect(recorder().getAttribute("aria-label")).toBe( + using _env = setup(); + await using screen = await renderConfig(desktop); + expect(screen.recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Shift+2", ); - expect(advanced().textContent).toContain("manual binding"); + expect(screen.advanced().textContent).toContain("manual binding"); expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled(); - await click("Review changes"); - expect(recorder().getAttribute("aria-label")).toBe( + await screen.click("Review changes"); + expect(screen.recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Shift+2", ); expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); - expect(diff()).not.toBeNull(); - await click("Save shortcut"); + expect(screen.diff()).not.toBeNull(); + await screen.click("Save shortcut"); expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(preview.id); expect(complete).toHaveBeenCalledOnce(); expect(toast).toHaveBeenCalledWith(expect.objectContaining({ type: "success" })); @@ -124,9 +117,10 @@ it.each(["niri", "hyprland"] as const)( it.each(["niri", "hyprland"] as const)( "lets users choose %s keys without opening Advanced, then approve the matching diff", async (desktop) => { - await render(desktop); - expect(advanced().contains(recorder())).toBe(false); - await recordKeys("{Control>}{Alt>}y{/Alt}{/Control}"); + using _env = setup(); + await using screen = await renderConfig(desktop); + expect(screen.advanced().contains(screen.recorder())).toBe(false); + await screen.recordKeys("{Control>}{Alt>}y{/Alt}{/Control}"); expect(bridge.setSnapShotShortcutSuppressed).toHaveBeenNthCalledWith(1, true); expect(bridge.setSnapShotShortcutSuppressed).toHaveBeenLastCalledWith(false); expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled(); @@ -137,14 +131,14 @@ it.each(["niri", "hyprland"] as const)( after: preview.after.replace("Ctrl+Shift+2", "Ctrl+Alt+Y"), }; bridge.previewSnapShotConfig.mockResolvedValue(custom); - await click("Review changes"); + await screen.click("Review changes"); expect(bridge.previewSnapShotConfig).toHaveBeenCalledExactlyOnceWith({ operation: "install", chooseFile: false, shortcut: "ctrl+alt+y", }); expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); - await click("Save shortcut"); + await screen.click("Save shortcut"); expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(custom.id); expect(toast).toHaveBeenCalledWith( expect.objectContaining({ description: "Use Ctrl+Alt+Y from another app." }), @@ -153,111 +147,121 @@ it.each(["niri", "hyprland"] as const)( ); it("uses the existing config keys when no replacement was chosen", async () => { + using _env = setup(); bridge.previewSnapShotConfig.mockResolvedValue({ ...preview, shortcut: "Super+F8" }); - await render(); - await click("Review changes"); + await using screen = await renderConfig(); + await screen.click("Review changes"); expect(bridge.previewSnapShotConfig).toHaveBeenCalledExactlyOnceWith({ operation: "install", chooseFile: false, }); - expect(recorder().getAttribute("aria-label")).toContain("F8"); + expect(screen.recorder().getAttribute("aria-label")).toContain("F8"); }); it("requires a new diff after changing keys during review", async () => { - await render(); - await click("Review changes"); - expect(diff()).not.toBeNull(); - await recordKeys("{Meta>}{F8}{/Meta}"); - expect(diff()).toBeNull(); + using _env = setup(); + await using screen = await renderConfig(); + await screen.click("Review changes"); + expect(screen.diff()).not.toBeNull(); + await screen.recordKeys("{Meta>}{F8}{/Meta}"); + expect(screen.diff()).toBeNull(); expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); const replacement = { ...preview, id: "replacement", shortcut: "Super+F8" }; bridge.previewSnapShotConfig.mockResolvedValue(replacement); - await click("Review changes"); + await screen.click("Review changes"); expect(bridge.previewSnapShotConfig).toHaveBeenLastCalledWith({ operation: "install", chooseFile: false, shortcut: "meta+f8", }); - await click("Save shortcut"); + await screen.click("Save shortcut"); expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(replacement.id); }); it.each(["Escape", "blur"])( "keeps the reviewed diff when recording is cancelled with %s", async (cancel) => { - await render(); - await click("Review changes"); - await act(async () => userEvent.click(recorder())); - expect(button("Save shortcut")?.disabled).toBe(true); + using _env = setup(); + await using screen = await renderConfig(); + await screen.click("Review changes"); + await act(async () => userEvent.click(screen.recorder())); + expect(screen.button("Save shortcut")?.disabled).toBe(true); if (cancel === "Escape") await act(async () => userEvent.keyboard("{Escape}")); - else await act(async () => recorder().blur()); - expect(button("Save shortcut")?.disabled).toBe(false); + else await act(async () => screen.recorder().blur()); + expect(screen.button("Save shortcut")?.disabled).toBe(false); expect(bridge.previewSnapShotConfig).toHaveBeenCalledOnce(); - await click("Save shortcut"); + await screen.click("Save shortcut"); expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(preview.id); }, ); it("cancelling a reviewed diff does not write or finish setup", async () => { - await render(); - await click("Review changes"); - await click("Cancel"); - expect(recorder().getAttribute("aria-label")).toBe( + using _env = setup(); + await using screen = await renderConfig(); + await screen.click("Review changes"); + await screen.click("Cancel"); + expect(screen.recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Shift+2", ); - expect(button("Review changes")).toBeDefined(); + expect(screen.button("Review changes")).toBeDefined(); expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); expect(complete).not.toHaveBeenCalled(); }); it("shows reading feedback until a proposal arrives", async () => { + using _env = setup(); const pending = deferred(); bridge.previewSnapShotConfig.mockReturnValue(pending.promise); - await render(); - await click("Review changes"); - expect(button("Preparing changes…")?.disabled).toBe(true); + await using screen = await renderConfig(); + await screen.click("Review changes"); + expect(screen.button("Preparing changes…")?.disabled).toBe(true); await act(async () => pending.resolve(preview)); - expect(button("Save shortcut")?.disabled).toBe(false); + expect(screen.button("Save shortcut")?.disabled).toBe(false); }); it("keeps read failures actionable with technical details in Advanced, then permits retry", async () => { + using _env = setup(); bridge.previewSnapShotConfig.mockRejectedValueOnce(new Error("EACCES: /config/niri/config.kdl")); - await render(); - await click("Review changes"); - expect(alert()?.textContent).toBe("Couldn't prepare the changes. Check Advanced for help."); - expect(advanced().textContent).toContain("EACCES: /config/niri/config.kdl"); - await click("Review changes"); - expect(alert()).toBeNull(); - expect(button("Save shortcut")?.disabled).toBe(false); + await using screen = await renderConfig(); + await screen.click("Review changes"); + expect(screen.alert()?.textContent).toBe( + "Couldn't prepare the changes. Check Advanced for help.", + ); + expect(screen.advanced().textContent).toContain("EACCES: /config/niri/config.kdl"); + await screen.click("Review changes"); + expect(screen.alert()).toBeNull(); + expect(screen.button("Save shortcut")?.disabled).toBe(false); expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); }); it("withdraws a stale diff and requires another read instead of retrying its write", async () => { - await render(); - await click("Review changes"); + using _env = setup(); + await using screen = await renderConfig(); + await screen.click("Review changes"); bridge.applySnapShotConfig.mockRejectedValue( new Error("Your config changed since this preview."), ); - await click("Save shortcut"); - expect(alert()?.textContent).toContain("Review the changes and try again"); - expect(advanced().textContent).toContain("Your config changed since this preview."); - expect(button("Review changes")).toBeDefined(); - expect(diff()).toBeNull(); + await screen.click("Save shortcut"); + expect(screen.alert()?.textContent).toContain("Review the changes and try again"); + expect(screen.advanced().textContent).toContain("Your config changed since this preview."); + expect(screen.button("Review changes")).toBeDefined(); + expect(screen.diff()).toBeNull(); expect(complete).not.toHaveBeenCalled(); }); it("does not finish or claim success when the desktop could not reload", async () => { - await render(); - await click("Review changes"); + using _env = setup(); + await using screen = await renderConfig(); + await screen.click("Review changes"); bridge.applySnapShotConfig.mockResolvedValue({ backupPath: "/config/backup", warning: "Config saved, but reload failed.", }); - await click("Save shortcut"); - expect(container.querySelector('[role="status"]')?.textContent).toBe( + await screen.click("Save shortcut"); + expect(screen.container.querySelector('[role="status"]')?.textContent).toBe( "Saved, but the shortcut needs attention. Check Advanced for help.", ); - expect(advanced().textContent).toContain("Config saved, but reload failed."); + expect(screen.advanced().textContent).toContain("Config saved, but reload failed."); expect(complete).not.toHaveBeenCalled(); expect(toast).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/settings/DeviceToolUpdate.browser.test.tsx b/apps/web/src/components/settings/DeviceToolUpdate.browser.test.tsx index 0563ba0eb3ae..7bdd693f2c3f 100644 --- a/apps/web/src/components/settings/DeviceToolUpdate.browser.test.tsx +++ b/apps/web/src/components/settings/DeviceToolUpdate.browser.test.tsx @@ -6,9 +6,10 @@ import { type DeviceServiceState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const { list } = vi.hoisted(() => ({ list: vi.fn() })); @@ -86,20 +87,6 @@ const deviceState = { revision: 1, } satisfies DeviceServiceState; -let root: Root | undefined; -let container: HTMLDivElement | undefined; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - list.mockReset(); -}); - -afterEach(async () => { - await act(() => root?.unmount()); - container?.remove(); - vi.unstubAllGlobals(); -}); - it.each([ { name: "a failed install", @@ -122,26 +109,22 @@ it.each([ ])( "reports $name as the server describes it, not as a network problem", async ({ error, message }) => { - list.mockResolvedValue({ _tag: "Failure", cause: Cause.fail(error) }); - container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - await act(() => { - root!.render( - , - ); - }); + list.mockReset().mockResolvedValue({ _tag: "Failure", cause: Cause.fail(error) }); + await using screen = await render( + , + ); - const update = [...container.querySelectorAll("button")].find( + const update = [...screen.container.querySelectorAll("button")].find( (button) => button.textContent === "Update to v0.12.0", ); expect(update).toBeDefined(); await act(async () => update!.click()); expect(list).toHaveBeenCalledWith({ environmentId, input: { updateTool: "hub" } }); - expect(container.querySelector('[role="alert"]')?.textContent).toBe(message); + expect(screen.container.querySelector('[role="alert"]')?.textContent).toBe(message); }, ); diff --git a/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx b/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx index 997fcae85b4c..1a2495f0c371 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx @@ -1,8 +1,8 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { act, render } from "../../../test/render"; + const families = vi.hoisted(() => { const families = [ "Arial", @@ -26,24 +26,6 @@ vi.mock("../../appearanceFonts", () => ({ import { discoverInstalledFonts, FontFamilyPicker } from "./FontFamilyPicker"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await act(async () => discoverInstalledFonts()); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - function highlightedOptions(): string[] { return [...document.querySelectorAll('[role="option"][data-highlighted]')].map( (option) => option.textContent ?? "", @@ -53,16 +35,15 @@ function highlightedOptions(): string[] { describe("FontFamilyPicker", () => { it("highlights, moves through, and selects matches after filtering", async () => { const onSelect = vi.fn(); - await act(async () => - root.render( - , - ), + await act(() => discoverInstalledFonts()); + await using _screen = await render( + , ); const input = document.querySelector('input[placeholder="Search fonts…"]'); diff --git a/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx index abb14f61b47b..3f4c5a632911 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx @@ -1,16 +1,10 @@ import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; -import { - createMemoryHistory, - createRootRoute, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { act, StrictMode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import "../../index.css"; +import { render } from "../../../test/render"; const { listBrowserImportSources } = vi.hoisted(() => ({ listBrowserImportSources: vi.fn().mockResolvedValue([]), @@ -55,57 +49,45 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({ import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { listBrowserImportSources.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} async function openSettings() { - const router = createRouter({ - routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), - history: createMemoryHistory(), - }); - await router.load(); - await act(async () => - root.render( + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( - + , ), ); - await vi.waitFor(() => expect(container.querySelector("section#browser")).not.toBeNull()); + await vi.waitFor(() => expect(screen.container.querySelector("section#browser")).not.toBeNull()); + stack.move(); + return screen; } describe("Integrations browser discovery", () => { it("keeps agent browser access in the single Browser section", async () => { - await openSettings(); + setup(); + await using screen = await openSettings(); expect(page.getByRole("heading", { name: "Browser", exact: true }).elements()).toHaveLength(1); const access = page.getByRole("switch", { name: "Agent browser access", exact: true }); await expect.element(access).toBeVisible(); - expect(access.element().closest("section")).toBe(container.querySelector("section#browser")); + expect(access.element().closest("section")).toBe( + screen.container.querySelector("section#browser"), + ); expect(access.element().closest("#agent-browser-access")).not.toBeNull(); }); it("does not scan browser files when entering or revisiting settings", async () => { - await openSettings(); + setup(); + await using first = await openSettings(); expect(listBrowserImportSources).not.toHaveBeenCalled(); - await act(async () => root.unmount()); - root = createRoot(container); - await openSettings(); + await first.unmount(); + await using _second = await openSettings(); expect(listBrowserImportSources).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/components/settings/KeybindingsSettings.environment.browser.test.tsx b/apps/web/src/components/settings/KeybindingsSettings.environment.browser.test.tsx index 2f438dfc4397..5ef9b6358195 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.environment.browser.test.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.environment.browser.test.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render, type Screen } from "../../../test/render"; const state = vi.hoisted(() => ({ canOperate: false, @@ -54,49 +54,39 @@ vi.mock(import("../../editorPreferences"), async (original) => ({ import { KeybindingsSettingsPanel } from "./KeybindingsSettings"; -let root: Root; -let container: HTMLDivElement; +function setup() { + state.canOperate = false; + state.canWriteSettings = true; + state.connected = true; + state.openEditor.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); +} -const render = () => act(async () => root.render()); -const openButton = () => { - const button = container.querySelector( +const renderPanel = () => render(); +const openButton = (screen: Screen) => { + const button = screen.container.querySelector( 'button[aria-label="Open keybindings.json"]', ); expect(button).not.toBeNull(); return button!; }; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - state.canOperate = false; - state.canWriteSettings = true; - state.connected = true; - state.openEditor.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - describe("KeybindingsSettings editor permission", () => { it("rejects editor launches when settings writes are the only write grant", async () => { - await render(); - expect(openButton().disabled).toBe(true); - await act(async () => openButton().click()); + setup(); + await using screen = await renderPanel(); + expect(openButton(screen).disabled).toBe(true); + await act(async () => openButton(screen).click()); expect(state.openEditor).not.toHaveBeenCalled(); }); it("opens the primary environment's file after operate is granted without settings writes", async () => { + setup(); state.canWriteSettings = false; - await render(); + await using screen = await renderPanel(); state.canOperate = true; - await render(); - expect(openButton().disabled).toBe(false); - await act(async () => openButton().click()); + await screen.rerender(); + expect(openButton(screen).disabled).toBe(false); + await act(async () => openButton(screen).click()); expect(state.openEditor).toHaveBeenCalledWith({ environmentId: "primary-settings", @@ -105,30 +95,33 @@ describe("KeybindingsSettings editor permission", () => { }); it("rejects a queued editor launch after operate is revoked", async () => { + setup(); state.canOperate = true; - await render(); - const button = openButton(); + await using screen = await renderPanel(); + const button = openButton(screen); state.canOperate = false; // The rendered button is still enabled; the click must recheck the live grant. await act(async () => button.click()); expect(state.openEditor).not.toHaveBeenCalled(); - await render(); - expect(openButton().disabled).toBe(true); + await screen.rerender(); + expect(openButton(screen).disabled).toBe(true); }); }); describe("KeybindingsSettings write notice", () => { it("asks for a connected environment when none is connected", async () => { + setup(); state.connected = false; - await render(); - expect(container.textContent).toContain("Connect an environment to change keybindings."); + await using screen = await renderPanel(); + expect(screen.container.textContent).toContain("Connect an environment to change keybindings."); }); it("names the permission when the connection cannot write settings", async () => { + setup(); state.canWriteSettings = false; - await render(); - expect(container.textContent).toContain( + await using screen = await renderPanel(); + expect(screen.container.textContent).toContain( "This connection can view keybindings but cannot change them.", ); }); diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx index 62288a12b007..6d0de80caa48 100644 --- a/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx @@ -1,11 +1,10 @@ import { RegistryContext } from "@effect/atom-react"; import { EnvironmentId, type AssetCreateUrlResult, type ProjectEntry } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import "~/index.css"; +import { act, render, stubGlobal } from "../../../test/render"; type AssetResult = AsyncResult.AsyncResult; const state = vi.hoisted(() => ({ @@ -56,12 +55,8 @@ import { ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - Object.defineProperty(navigator, "platform", { configurable: true, get: () => "MacIntel" }); +function setup() { + const platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); toast.mockClear(); state.entries = []; state.assets.clear(); @@ -69,36 +64,26 @@ beforeEach(() => { state.createUrl .mockReset() .mockImplementation(({ input }) => state.assets.get(input.resource.path)!); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); + return { [Symbol.dispose]: () => platform.mockRestore() }; +} async function renderPicker(onPickExternal?: () => Promise) { const onOpenChange = vi.fn(); const onSelect = vi.fn(); - await act(async () => - root.render( - - - , - ), + const screen = await render( + + + , ); - return { onOpenChange, onSelect }; + return Object.assign(screen, { onOpenChange, onSelect }); } async function clickOpenInFinder() { const button = [...document.querySelectorAll("button")].find((element) => @@ -109,14 +94,15 @@ async function clickOpenInFinder() { } it("selects an image from the native file picker", async () => { + using _env = setup(); const onPickExternal = vi.fn().mockResolvedValue("/Users/me/Pictures/icon.png"); - const { onOpenChange, onSelect } = await renderPicker(onPickExternal); + await using screen = await renderPicker(onPickExternal); await clickOpenInFinder(); expect(onPickExternal).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(onSelect).toHaveBeenCalledWith("/Users/me/Pictures/icon.png"); + expect(screen.onOpenChange).toHaveBeenCalledWith(false); + expect(screen.onSelect).toHaveBeenCalledWith("/Users/me/Pictures/icon.png"); }); it("hides the native picker for WSL project paths", () => { @@ -125,14 +111,13 @@ it("hides the native picker for WSL project paths", () => { }); it("keeps the dialog open when the native picker fails", async () => { - const { onOpenChange, onSelect } = await renderPicker( - vi.fn().mockRejectedValue(new Error("picker failed")), - ); + using _env = setup(); + await using screen = await renderPicker(vi.fn().mockRejectedValue(new Error("picker failed"))); await clickOpenInFinder(); - expect(onOpenChange).not.toHaveBeenCalled(); - expect(onSelect).not.toHaveBeenCalled(); + expect(screen.onOpenChange).not.toHaveBeenCalled(); + expect(screen.onSelect).not.toHaveBeenCalled(); expect(toast).toHaveBeenCalledWith({ type: "error", title: "Could not open image picker", @@ -177,9 +162,10 @@ async function expectPreview(row: HTMLElement) { } it("loads previews asynchronously and only acquires offscreen images after scrolling", async () => { + using _env = setup(); const paths = Array.from({ length: 40 }, (_, index) => `icons/icon-${index}.svg`); addFiles(paths); - const { onSelect } = await renderPicker(); + await using screen = await renderPicker(); const first = fileRow("icons/icon-0.svg"); const last = fileRow("icons/icon-39.svg"); @@ -207,13 +193,14 @@ it("loads previews asynchronously and only acquires offscreen images after scrol await publishUrl("icons/icon-39.svg", imageUrl); await expectPreview(last); await act(async () => last.click()); - expect(onSelect).toHaveBeenCalledWith("icons/icon-39.svg"); + expect(screen.onSelect).toHaveBeenCalledWith("icons/icon-39.svg"); }); it("loads without IntersectionObserver and recovers a failed image when its URL changes", async () => { - vi.stubGlobal("IntersectionObserver", undefined); + using _env = setup(); + using _observer = stubGlobal("IntersectionObserver", undefined); addFiles(["icons/logo.svg"]); - await renderPicker(); + await using _screen = await renderPicker(); const row = fileRow("icons/logo.svg"); expect(state.createUrl).toHaveBeenCalledWith({ @@ -236,23 +223,25 @@ it("loads without IntersectionObserver and recovers a failed image when its URL }); it("keeps denied previews selectable with their file icon", async () => { - vi.stubGlobal("IntersectionObserver", undefined); + using _env = setup(); + using _observer = stubGlobal("IntersectionObserver", undefined); state.canReadFiles = false; addFiles(["icons/private.svg"]); - const { onSelect } = await renderPicker(); + await using screen = await renderPicker(); const row = fileRow("icons/private.svg"); await act(async () => row.click()); expect(row.querySelector("img")).toBeNull(); expect(row.querySelector("svg")).not.toBeNull(); expect(state.createUrl).not.toHaveBeenCalled(); - expect(onSelect).toHaveBeenCalledWith("icons/private.svg"); + expect(screen.onSelect).toHaveBeenCalledWith("icons/private.svg"); }); it("keeps the file icon until the image load is acknowledged", async () => { - vi.stubGlobal("IntersectionObserver", undefined); + using _env = setup(); + using _observer = stubGlobal("IntersectionObserver", undefined); addFiles(["icons/loading.svg"]); - await renderPicker(); + await using _screen = await renderPicker(); const row = fileRow("icons/loading.svg"); // Hold the load notification so a fast data URL has the same UI state as a slow request. const holdLoad = (event: Event) => { diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx index eab8eb3bd885..b4ff861b9cb8 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx @@ -1,27 +1,10 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { act, render } from "../../../test/render"; import { deriveProjectIdentity } from "../../projectIdentity"; import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - function button(name: string) { return vi.waitFor(() => { const match = [...document.querySelectorAll("button, [role='radio']")].find( @@ -41,23 +24,22 @@ async function click(name: string) { async function openPicker() { const onSelect = vi.fn(); const onOpenChange = vi.fn(); - await act(async () => - root.render( - , - ), + const screen = await render( + , ); - return { onSelect, onOpenChange }; + return Object.assign(screen, { onSelect, onOpenChange }); } describe("ProjectIconPickerDialog", () => { it("starts an automatic project on icons in its derived color and saves the picked icon", async () => { - const { onSelect, onOpenChange } = await openPicker(); + await using screen = await openPicker(); + const { onSelect, onOpenChange } = screen; const { color } = deriveProjectIdentity("Test"); expect((await button("Icons")).getAttribute("aria-pressed")).toBe("true"); expect( @@ -73,7 +55,8 @@ describe("ProjectIconPickerDialog", () => { }); it("saves a monogram only once the letters are letters or numbers", async () => { - const { onSelect } = await openPicker(); + await using screen = await openPicker(); + const { onSelect } = screen; await click("Monogram"); const letters = await vi.waitFor(() => { const input = document.querySelector("#project-monogram"); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index acae49cafc6b..4a47a353dbeb 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -24,11 +24,10 @@ import { } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +import { act, render, type Screen } from "../../../test/render"; import type { Project } from "../../types"; type SessionResult = AsyncResult.AsyncResult; @@ -164,15 +163,8 @@ function deferred() { return { promise, resolve }; } -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { vi.clearAllMocks(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); state.sessions.clear(); for (const id of [primaryId, remoteId]) state.sessions.set(id, Atom.make(writable()).pipe(Atom.keepAlive)); @@ -209,13 +201,7 @@ beforeEach(() => { state.delete.mockResolvedValue(AsyncResult.success(undefined)); state.confirm.mockResolvedValue(true); useComposerDraftStore.setState({ clearProjectDraftThreadId: state.clearDraft }); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} function projectKey() { return buildSidebarProjectSnapshots({ @@ -255,15 +241,19 @@ async function mountPanel(search: Omit = {}) { history: createMemoryHistory({ initialEntries: [`/settings/projects?${query}`] }), }); await router.load(); - await act(async () => - root.render( + // The panel's own route tree validates settings search params, which the default router lacks. + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( , + { router: false }, ), ); - await vi.waitFor(() => expect(nameInput()).not.toBeNull()); - return router; + await vi.waitFor(() => expect(nameInput(screen)).not.toBeNull()); + stack.move(); + return { ...screen, router }; } async function mountSettings(search: SettingsScopeSearch) { @@ -288,21 +278,21 @@ async function mountSettings(search: SettingsScopeSearch) { }), }); await router.load(); - await act(async () => - root.render( - - - , - ), + const screen = await render( + + + , + { router: false }, ); - return router; + return { ...screen, router }; } -const nameInput = () => container.querySelector('[aria-label="Project name"]')!; -const removeCheckout = (root: string) => - container.querySelector(`[aria-label="Remove checkout ${root}"]`)!; -const removeAll = () => - [...container.querySelectorAll("button")].find((button) => +const nameInput = (screen: Screen) => + screen.container.querySelector('[aria-label="Project name"]')!; +const removeCheckout = (screen: Screen, root: string) => + screen.container.querySelector(`[aria-label="Remove checkout ${root}"]`)!; +const removeAll = (screen: Screen) => + [...screen.container.querySelectorAll("button")].find((button) => button.textContent?.includes("Remove all entries"), )!; @@ -310,27 +300,29 @@ async function grant(id: EnvironmentId, result: SessionResult) { await act(async () => appAtomRegistry.set(state.sessions.get(id)!, result)); } -async function rename(title: string) { - await userEvent.fill(nameInput(), title); +async function rename(screen: Screen, title: string) { + await userEvent.fill(nameInput(screen), title); await userEvent.keyboard("{Enter}"); } describe("project settings permissions", () => { it("disables shared edits when any member lacks project access", async () => { + setup(); await grant(remoteId, AsyncResult.success(session([AuthSettingsWriteScope]))); - await mountPanel(); + await using screen = await mountPanel(); - expect(nameInput().disabled).toBe(true); - expect(removeAll().disabled).toBe(true); - expect(container.textContent).toContain( + expect(nameInput(screen).disabled).toBe(true); + expect(removeAll(screen).disabled).toBe(true); + expect(screen.container.textContent).toContain( "Shared settings require permission to change every checkout in this group.", ); expect(state.update).not.toHaveBeenCalled(); }); it("renames every member when all have project access without settings access", async () => { - await mountPanel(); - await rename("Renamed"); + setup(); + await using screen = await mountPanel(); + await rename(screen, "Renamed"); expect(state.update.mock.calls.map(([request]) => request.environmentId)).toEqual([ primaryId, remoteId, @@ -339,10 +331,11 @@ describe("project settings permissions", () => { }); it("stops before the next member if its grant is revoked during an earlier update", async () => { + setup(); const firstUpdate = deferred>(); state.update.mockReturnValueOnce(firstUpdate.promise); - await mountPanel(); - await rename("Renamed"); + await using screen = await mountPanel(); + await rename(screen, "Renamed"); expect(state.update).toHaveBeenCalledOnce(); await grant(remoteId, AsyncResult.success(session([]))); @@ -355,19 +348,21 @@ describe("project settings permissions", () => { }); it("disables removal for a checkout whose project grant is missing", async () => { + setup(); await grant(remoteId, AsyncResult.success(session([]))); - await mountPanel(); - expect(removeCheckout("/work/remote").disabled).toBe(true); - expect(removeCheckout("/work/primary").disabled).toBe(false); + await using screen = await mountPanel(); + expect(removeCheckout(screen, "/work/remote").disabled).toBe(true); + expect(removeCheckout(screen, "/work/primary").disabled).toBe(false); }); it("removes a writable checkout while removal of the mixed group stays denied", async () => { + setup(); await grant(remoteId, AsyncResult.success(session([]))); - const router = await mountPanel(); - const initialHref = router.state.location.href; - expect(removeAll().disabled).toBe(true); + await using screen = await mountPanel(); + const initialHref = screen.router.state.location.href; + expect(removeAll(screen).disabled).toBe(true); - await userEvent.click(removeCheckout("/work/primary")); + await userEvent.click(removeCheckout(screen, "/work/primary")); await vi.waitFor(() => expect(state.delete).toHaveBeenCalledOnce()); expect(state.delete).toHaveBeenCalledWith({ environmentId: primaryId, @@ -375,14 +370,15 @@ describe("project settings permissions", () => { }); await vi.waitFor(() => expect(state.releaseUploads).toHaveBeenCalledOnce()); expect(state.clearDraft).toHaveBeenCalledOnce(); - expect(router.state.location.href).toBe(initialHref); + expect(screen.router.state.location.href).toBe(initialHref); }); it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { + setup(); const confirmed = deferred(); state.confirm.mockReturnValueOnce(confirmed.promise); - await mountPanel(); - await userEvent.click(removeAll()); + await using screen = await mountPanel(); + await userEvent.click(removeAll(screen)); expect(state.confirm).toHaveBeenCalledOnce(); await grant(remoteId, AsyncResult.success(session([]))); @@ -396,16 +392,17 @@ describe("project settings permissions", () => { }); it("does not save an edit whose grant was revoked mid-edit, and recovers when restored", async () => { - await mountPanel(); - await userEvent.fill(nameInput(), "Renamed"); + setup(); + await using screen = await mountPanel(); + await userEvent.fill(nameInput(screen), "Renamed"); await grant(remoteId, AsyncResult.success(session([]))); - await act(async () => nameInput().blur()); + await act(async () => nameInput(screen).blur()); expect(state.update).not.toHaveBeenCalled(); - expect(nameInput().disabled).toBe(true); + expect(nameInput(screen).disabled).toBe(true); await grant(remoteId, writable()); - expect(nameInput().disabled).toBe(false); - await rename("Renamed"); + expect(nameInput(screen).disabled).toBe(false); + await rename(screen, "Renamed"); expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]); }); @@ -420,11 +417,12 @@ describe("project settings permissions", () => { ] as const)( "uses the current grant while a member's session is %s", async (_state, result, allowed) => { + setup(); await grant(remoteId, result()); - await mountPanel(); - expect(nameInput().disabled).toBe(!allowed); + await using screen = await mountPanel(); + expect(nameInput(screen).disabled).toBe(!allowed); if (!allowed) return; - await rename("Renamed"); + await rename(screen, "Renamed"); expect(state.update).toHaveBeenCalledTimes(2); }, ); @@ -432,9 +430,10 @@ describe("project settings permissions", () => { describe("project settings scope", () => { it("offers project checkout environments while keeping unselected and unknown projects recoverable", async () => { + setup(); state.projects = state.projects.filter((project) => project.environmentId === primaryId); const key = projectKey(); - const router = await mountSettings({}); + await using screen = await mountSettings({}); const environmentMenu = () => page.getByRole("button", { name: /^Environment scope:/ }); const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ }); const remoteOption = () => page.getByRole("menuitemradio", { name: /^remote/ }); @@ -445,7 +444,7 @@ describe("project settings scope", () => { await act(async () => userEvent.keyboard("{Escape}")); await act(async () => - router.navigate({ to: "/settings/projects", search: () => ({ project: key }) }), + screen.router.navigate({ to: "/settings/projects", search: () => ({ project: key }) }), ); await expect.element(page.getByRole("textbox", { name: "Project name" })).toBeVisible(); await act(async () => userEvent.click(environmentMenu())); @@ -454,14 +453,17 @@ describe("project settings scope", () => { await act(async () => userEvent.keyboard("{Escape}")); await act(async () => - router.navigate({ to: "/settings/projects", search: () => ({ project: "removed-project" }) }), + screen.router.navigate({ + to: "/settings/projects", + search: () => ({ project: "removed-project" }), + }), ); await act(async () => userEvent.click(environmentMenu())); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).toBeVisible(); await act(async () => userEvent.click(remoteOption())); await vi.waitFor(() => - expect(router.state.location.search).toEqual({ + expect(screen.router.state.location.search).toEqual({ project: "removed-project", machine: remoteId, }), @@ -475,9 +477,10 @@ describe("project settings scope", () => { "This checkout is no longer available in the selected project and environment.", ], ])("recovers a stale environment link with checkout %s", async (checkout, message) => { + setup(); state.projects = state.projects.filter((project) => project.environmentId === primaryId); const key = projectKey(); - const router = await mountSettings({ + await using screen = await mountSettings({ project: key, machine: remoteId, ...(checkout ? { checkout } : {}), @@ -495,7 +498,7 @@ describe("project settings scope", () => { await expect .element(page.getByRole("textbox", { name: "Project name" })) .toHaveValue("Shared repo"); - expect(router.state.location.search).toEqual({ project: key }); + expect(screen.router.state.location.search).toEqual({ project: key }); await expect .element(page.getByRole("button", { name: "Environment scope: All environments" })) .toBeVisible(); @@ -507,16 +510,17 @@ describe("project settings scope", () => { }); it("returns to the project overview after removal, clearing the selection but keeping the environment", async () => { + setup(); state.projects = [state.projects[1]!]; - const router = await mountPanel({ machine: remoteId, checkout: "remote:/work/remote" }); - const removeProject = [...container.querySelectorAll("button")].find( + await using screen = await mountPanel({ machine: remoteId, checkout: "remote:/work/remote" }); + const removeProject = [...screen.container.querySelectorAll("button")].find( (button) => button.textContent === "Remove project", )!; await act(async () => userEvent.click(removeProject)); await vi.waitFor(() => { - expect(router.state.location.pathname).toBe("/settings/projects"); - expect(router.state.location.search).toEqual({ machine: remoteId }); + expect(screen.router.state.location.pathname).toBe("/settings/projects"); + expect(screen.router.state.location.search).toEqual({ machine: remoteId }); }); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx b/apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx index d0ca84fb88cf..5c056d1cb932 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx @@ -1,27 +1,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ComponentProps } from "react"; +import { describe, expect, it } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { render } from "../../../test/render"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - const driver = ProviderDriverKind.make("codex"); function provider(overrides: Partial): ServerProvider { @@ -45,50 +29,48 @@ async function renderCard( liveProvider: ServerProvider, mode: ComponentProps["mode"], ) { - await act(async () => - root.render( - undefined} - hiddenModels={[]} - favoriteModels={[]} - modelOrder={[]} - onHiddenModelsChange={() => undefined} - onFavoriteModelsChange={() => undefined} - onToggleFavorite={() => undefined} - onModelOrderChange={() => undefined} - />, - ), + return render( + undefined} + hiddenModels={[]} + favoriteModels={[]} + modelOrder={[]} + onHiddenModelsChange={() => undefined} + onFavoriteModelsChange={() => undefined} + onToggleFavorite={() => undefined} + onModelOrderChange={() => undefined} + />, ); } describe("ProviderInstanceCard", () => { it("hides the account email until the user reveals it, and hides it again", async () => { - await renderCard( + await using screen = await renderCard( provider({ auth: { status: "authenticated", email: "developer@example.com" } }), "editor", ); - expect(container.textContent).toContain("Authenticated as"); - expect(container.textContent).not.toContain("developer@example.com"); + expect(screen.container.textContent).toContain("Authenticated as"); + expect(screen.container.textContent).not.toContain("developer@example.com"); - const toggle = container.querySelector( + const toggle = screen.container.querySelector( 'button[aria-label="Toggle account email visibility"]', )!; await userEvent.click(toggle); expect(toggle.textContent).toBe("developer@example.com"); await userEvent.click(toggle); - expect(container.textContent).not.toContain("developer@example.com"); + expect(screen.container.textContent).not.toContain("developer@example.com"); }); it.each(["list", "editor"] as const)( "surfaces a failed probe message in the %s", async (mode) => { - await renderCard( + await using screen = await renderCard( provider({ version: null, status: "error", @@ -98,8 +80,8 @@ describe("ProviderInstanceCard", () => { }), mode, ); - expect(container.textContent).toContain("Unavailable"); - expect(container.textContent).toContain("is not a symlink"); + expect(screen.container.textContent).toContain("Unavailable"); + expect(screen.container.textContent).toContain("is not a symlink"); }, ); }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index f3c320c9214d..8e5e161b3e9c 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -8,12 +8,12 @@ import { type UnifiedSettings, } from "@t3tools/contracts"; import { AsyncResult, type Atom } from "effect/reactivity"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { act, render } from "../../../test/render"; const state = vi.hoisted(() => ({ providers: null as unknown as Atom.Writable>, @@ -187,31 +187,26 @@ function provider(): ServerProvider { }; } -let root: Root; -let container: HTMLDivElement; - const setProviders = (providers: ReadonlyArray) => act(async () => appAtomRegistry.set(state.providers, providers)); -async function render(options?: { +function panel(options?: { readonly readOnly?: boolean; readonly targetInstanceId?: ProviderInstanceId; }) { - await act(async () => - root.render( - -
- -
-
, - ), + return ( + +
+ +
+
); } @@ -222,8 +217,7 @@ const addProvider = () => page.getByRole("button", { name: "Add provider" }).fir const select = (name: string) => button(`Select ${name}`); const deleteButton = (id: string) => button(`Delete instance ${id}`); -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function setup() { await page.viewport(1280, 900); state.settings = DEFAULT_UNIFIED_SETTINGS; state.canManageProviders = true; @@ -241,20 +235,12 @@ beforeEach(async () => { state.uninstall.mockReset().mockResolvedValue(AsyncResult.success({})); state.acceptUrlAuth.mockReset().mockResolvedValue(AsyncResult.success({ accepted: true })); await setProviders([]); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} describe("EnvironmentProviderSettings routing", () => { it("shows Codex and Claude while hiding untouched disabled provider slots", async () => { - await render(); + await setup(); + await using _screen = await render(panel()); await expect.element(select("Codex")).toBeVisible(); await expect.element(select("Claude")).toBeVisible(); for (const label of ["Cursor", "Grok", "Pi", "OpenCode", "Antigravity"]) { @@ -263,6 +249,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("keeps explicitly configured providers visible when disabled", async () => { + await setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -272,11 +259,12 @@ describe("EnvironmentProviderSettings routing", () => { }, }, }; - await render(); + await using _screen = await render(panel()); await expect.element(select("Grok")).toBeVisible(); }); it("keeps legacy provider configuration visible when disabled", async () => { + await setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -287,12 +275,13 @@ describe("EnvironmentProviderSettings routing", () => { }, }, }; - await render(); + await using _screen = await render(panel()); await expect.element(select("Grok")).toBeVisible(); }); it("reads and writes settings on the selected environment", async () => { - await render(); + await setup(); + await using _screen = await render(panel()); await expect.element(select("Codex")).toBeVisible(); expect(new Set(state.readEnvironmentIds)).toEqual(new Set([environmentId])); expect(new Set(state.updateEnvironmentIds)).toEqual(new Set([environmentId])); @@ -300,8 +289,9 @@ describe("EnvironmentProviderSettings routing", () => { }); it("routes refresh and provider update commands to the selected environment", async () => { + await setup(); await setProviders([provider()]); - await render(); + await using _screen = await render(panel()); await userEvent.click(refreshButton()); expect(state.refresh).toHaveBeenCalledWith({ @@ -318,6 +308,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("opens the requested provider instance instead of the first provider", async () => { + await setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -329,13 +320,14 @@ describe("EnvironmentProviderSettings routing", () => { }, }; await setProviders([provider()]); - await render({ targetInstanceId: customId }); + await using _screen = await render(panel({ targetInstanceId: customId })); await expect.element(deleteButton(customId)).toBeVisible(); }); it("saves model favorites and visibility on the selected environment", async () => { + await setup(); await setProviders([provider()]); - await render(); + await using _screen = await render(panel()); await userEvent.click(button("Add Chosen to favorites")); expect(state.toggleFavorite).toHaveBeenCalledExactlyOnceWith(codexId, "chosen"); @@ -348,8 +340,9 @@ describe("EnvironmentProviderSettings routing", () => { }); it("does not substitute another account when the requested instance was removed", async () => { + await setup(); await setProviders([provider()]); - await render({ targetInstanceId: customId }); + await using _screen = await render(panel({ targetInstanceId: customId })); await expect .element(page.getByText("This provider instance is no longer available on this device.")) .toBeVisible(); @@ -357,6 +350,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("keeps provider selection available while write controls are read only", async () => { + await setup(); state.canWriteSettings = false; state.settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -369,19 +363,20 @@ describe("EnvironmentProviderSettings routing", () => { }, }; await setProviders([provider()]); - await render({ readOnly: true }); + await using screen = await render(panel({ readOnly: true })); await expect.element(page.getByRole("heading", { name: "Limited permissions" })).toBeVisible(); await userEvent.click(select("Work")); await expect.element(deleteButton(customId)).toBeInTheDocument(); - expect(container.querySelector("[inert]")).not.toBeNull(); + expect(screen.container.querySelector("[inert]")).not.toBeNull(); await expect.element(refreshButton()).toBeVisible(); await expect.element(addProvider()).not.toBeInTheDocument(); }); it("keeps the editable layout interactive when not read only", async () => { + await setup(); await setProviders([provider()]); - await render(); + await using _screen = await render(panel()); await expect.element(page.getByText("Limited permissions")).not.toBeInTheDocument(); await expect.element(refreshButton()).toBeVisible(); await expect.element(addProvider()).toBeVisible(); @@ -389,28 +384,31 @@ describe("EnvironmentProviderSettings routing", () => { }); it("removes an open add-instance dialog when the provider grant is revoked", async () => { - await render(); + await setup(); + await using screen = await render(panel()); await userEvent.click(addProvider()); await expect.element(page.getByRole("dialog")).toBeVisible(); state.canManageProviders = false; - await render({ readOnly: true }); + await screen.rerender(panel({ readOnly: true })); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); expect(state.updateSettings).not.toHaveBeenCalled(); }); it("keeps Advanced visible when search targets the provider health interval", async () => { - await render(); + await setup(); + await using screen = await render(panel()); await expect.element(page.getByText("Advanced", { exact: true })).toBeVisible(); - expect(container.querySelector("#provider-health-check-interval")).not.toBeNull(); + expect(screen.container.querySelector("#provider-health-check-interval")).not.toBeNull(); state.searchTargetId = "provider-health-check-interval"; - await render(); + await screen.rerender(panel()); await expect.element(page.getByText("Advanced", { exact: true })).toBeVisible(); - expect(container.querySelector("#provider-health-check-interval")).not.toBeNull(); + expect(screen.container.querySelector("#provider-health-check-interval")).not.toBeNull(); }); it("deletes and resets provider configuration without erasing shared preferences", async () => { + await setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -426,7 +424,7 @@ describe("EnvironmentProviderSettings routing", () => { }, favorites: [{ provider: customId, model: "favorite" }], }; - await render(); + await using _screen = await render(panel()); await userEvent.click(select("Work")); await userEvent.click(deleteButton(customId)); expect(state.mutateProviderInstance).toHaveBeenLastCalledWith({ @@ -443,6 +441,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("updates one provider instance without sending a stale whole map", async () => { + await setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -453,7 +452,7 @@ describe("EnvironmentProviderSettings routing", () => { }, }, }; - await render(); + await using _screen = await render(panel()); await userEvent.click(page.getByRole("switch", { name: "Enable Work" }).first()); expect(state.mutateProviderInstance).toHaveBeenCalledWith( @@ -467,6 +466,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("lets the server decide managed ACP cleanup after an atomic delete", async () => { + await setup(); const firstId = ProviderInstanceId.make("acpRegistry_kilo_one"); const secondId = ProviderInstanceId.make("acpRegistry_kilo_two"); const registryInstance = (displayName: string) => ({ @@ -482,7 +482,7 @@ describe("EnvironmentProviderSettings routing", () => { [secondId]: registryInstance("Kilo two"), }, }; - await render(); + await using _screen = await render(panel()); await userEvent.click(select("Kilo one")); await userEvent.click(deleteButton(firstId)); @@ -496,6 +496,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("keeps the signed-in ACP account visible when login methods are no longer advertised", async () => { + await setup(); const instanceId = ProviderInstanceId.make("acpRegistry_devin"); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -516,11 +517,12 @@ describe("EnvironmentProviderSettings routing", () => { setup: { canAuthenticate: false, canInstall: false }, }, ]); - await render({ targetInstanceId: instanceId }); + await using _screen = await render(panel({ targetInstanceId: instanceId })); await expect.element(page.getByRole("heading", { name: "Account" })).toBeVisible(); }); it("routes explicit ACP browser authentication consent to the selected environment", async () => { + await setup(); const instanceId = ProviderInstanceId.make("acpRegistry_antigravity"); const action = { elicitationId: "google-login-1", @@ -545,7 +547,7 @@ describe("EnvironmentProviderSettings routing", () => { auth: { status: "unauthenticated", action }, }, ]); - await render({ targetInstanceId: instanceId }); + await using _screen = await render(panel({ targetInstanceId: instanceId })); const link = page.getByRole("link", { name: "Continue authentication" }); link.element().addEventListener("click", (event) => event.preventDefault()); await userEvent.click(link); diff --git a/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx b/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx index e6d5ae1c01f4..a02de7ed5b78 100644 --- a/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx @@ -6,10 +6,10 @@ import { type ProviderInstallState, type ServerProvider, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { page, userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const setup = vi.hoisted(() => ({ auth: null as ProviderAuthState | null, @@ -119,130 +119,122 @@ function deferred() { return { promise, resolve }; } -let root: Root; -let container: HTMLDivElement; +interface SectionOptions { + readOnly?: boolean; + provider?: ServerProvider; + enabled?: boolean; + binaryPath?: string; +} -function renderSetup( - options: { - readOnly?: boolean; - provider?: ServerProvider; - enabled?: boolean; - binaryPath?: string; - } = {}, -) { - return act(async () => - root.render( - , - ), +function section(options: SectionOptions = {}) { + return ( + ); } -function button(label: string) { - return ( +async function renderSetup(options: SectionOptions = {}) { + const screen = await render(section(options)); + const { container } = screen; + const button = (label: string) => [...container.querySelectorAll("button")].find( (element) => element.textContent === label || element.getAttribute("aria-label") === label, - ) ?? null - ); -} -async function click(label: string) { - const target = button(label); - expect(target, `button ${label}`).not.toBeNull(); - expect(target!.disabled, `${label} disabled`).toBe(false); - await act(async () => target!.click()); + ) ?? null; + return Object.assign(screen, { + button, + async click(label: string) { + const target = button(label); + expect(target, `button ${label}`).not.toBeNull(); + expect(target!.disabled, `${label} disabled`).toBe(false); + await act(async () => target!.click()); + }, + occurrences: (text: string) => (container.textContent ?? "").split(text).length - 1, + callbackInput: () => + container.querySelector(`[id="provider-callback-${instanceId}"]`)!, + }); } -function occurrences(text: string) { - return (container.textContent ?? "").split(text).length - 1; + +function resetSetup() { + vi.clearAllMocks(); + setup.auth = authState(); + setup.installation = { + driver: ProviderDriverKind.make("antigravity"), + operationId: null, + phase: "idle", + downloadedBytes: 0, + totalBytes: null, + version: null, + installedVersion: null, + canRemove: false, + message: null, + }; + for (const command of [ + setup.startAuth, + setup.completeAuth, + setup.cancelAuth, + setup.logoutAuth, + setup.startInstall, + setup.cancelInstall, + setup.removeInstall, + ]) { + command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + } + setup.confirm.mockReset().mockResolvedValue(false); } -const callbackInput = () => - container.querySelector(`[id="provider-callback-${instanceId}"]`)!; describe("Antigravity setup", () => { - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.clearAllMocks(); - setup.auth = authState(); - setup.installation = { - driver: ProviderDriverKind.make("antigravity"), - operationId: null, - phase: "idle", - downloadedBytes: 0, - totalBytes: null, - version: null, - installedVersion: null, - canRemove: false, - message: null, - }; - for (const command of [ - setup.startAuth, - setup.completeAuth, - setup.cancelAuth, - setup.logoutAuth, - setup.startInstall, - setup.cancelInstall, - setup.removeInstall, - ]) { - command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); - } - setup.confirm.mockReset().mockResolvedValue(false); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - }); - it("waits for verified auth after submitting a callback to the selected environment", async () => { + resetSetup(); const callbackUrl = "http://127.0.0.1:5555/?state=test-only&code=test-only"; - await renderSetup(); - await act(async () => userEvent.fill(callbackInput(), callbackUrl)); - await click("Continue"); + await using screen = await renderSetup(); + await act(async () => userEvent.fill(screen.callbackInput(), callbackUrl)); + await screen.click("Continue"); expect(setup.completeAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId, flowId: "flow-1", callbackUrl }, }); - expect(occurrences("Signed in.")).toBe(0); - expect(callbackInput().value).toBe(""); + expect(screen.occurrences("Signed in.")).toBe(0); + expect(screen.callbackInput().value).toBe(""); setup.auth = authState({ phase: "verifying", authorizationUrl: null }); await publish(); - expect(occurrences("Signed in.")).toBe(0); + expect(screen.occurrences("Signed in.")).toBe(0); setup.auth = authState({ phase: "succeeded", authorizationUrl: null }); - await renderSetup({ - provider: { ...provider, status: "ready", auth: { status: "authenticated" } }, - }); - expect(occurrences("Signed in.")).toBe(1); + await screen.rerender( + section({ + provider: { ...provider, status: "ready", auth: { status: "authenticated" } }, + }), + ); + expect(screen.occurrences("Signed in.")).toBe(1); }); it("offers sign-in again when credentials expire after a completed auth flow", async () => { + resetSetup(); setup.auth = authState({ phase: "succeeded", authorizationUrl: null, message: "Google sign-in complete.", }); - await renderSetup({ + await using screen = await renderSetup({ provider: { ...provider, status: "ready", auth: { status: "authenticated" } }, }); - await renderSetup(); - expect(button("Sign in")).not.toBeNull(); - expect(occurrences("Signed in.")).toBe(0); - expect(occurrences("Google sign-in complete.")).toBe(0); + await screen.rerender(section()); + expect(screen.button("Sign in")).not.toBeNull(); + expect(screen.occurrences("Signed in.")).toBe(0); + expect(screen.occurrences("Google sign-in complete.")).toBe(0); }); it("does not send a callback left over from a replaced sign-in flow", async () => { + resetSetup(); const interaction = { type: "browser" as const, id: "same-interaction", @@ -251,25 +243,29 @@ describe("Antigravity setup", () => { acceptsCallback: true, }; setup.auth = authState({ interaction }); - await renderSetup(); + await using screen = await renderSetup(); await act(async () => - userEvent.fill(callbackInput(), "http://127.0.0.1:5555/?state=old-flow&code=test-only"), + userEvent.fill( + screen.callbackInput(), + "http://127.0.0.1:5555/?state=old-flow&code=test-only", + ), ); setup.auth = authState({ flowId: "flow-2", interaction }); await publish(); - expect(callbackInput().value).toBe(""); - await act(async () => callbackInput().form!.requestSubmit()); + expect(screen.callbackInput().value).toBe(""); + await act(async () => screen.callbackInput().form!.requestSubmit()); expect(setup.completeAuth).not.toHaveBeenCalled(); expect(setup.authState).toHaveBeenLastCalledWith({ environmentId, input: { instanceId } }); }); it("coalesces repeated sign-in clicks while start is pending", async () => { + resetSetup(); setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); const pending = deferred<{ _tag: "Success"; value: undefined }>(); setup.startAuth.mockReturnValueOnce(pending.promise); - await renderSetup(); - const signIn = button("Sign in")!; + await using screen = await renderSetup(); + const signIn = screen.button("Sign in")!; // Both clicks land before React re-renders the button as disabled. await act(async () => { signIn.click(); @@ -282,6 +278,7 @@ describe("Antigravity setup", () => { }); it("shows a repeated runtime status message only once", async () => { + resetSetup(); setup.installation = { ...setup.installation!, operationId: "install-1", @@ -289,11 +286,12 @@ describe("Antigravity setup", () => { message: "Checking the downloaded runtime.", }; - await renderSetup(); - expect(occurrences("Checking the downloaded runtime.")).toBe(1); + await using screen = await renderSetup(); + expect(screen.occurrences("Checking the downloaded runtime.")).toBe(1); }); it("exposes download progress as a bounded progressbar only while the total is known", async () => { + resetSetup(); const downloading = (downloadedBytes: number, totalBytes: number | null) => { setup.installation = { ...setup.installation!, @@ -305,7 +303,7 @@ describe("Antigravity setup", () => { return publish(); }; const progressbar = page.getByRole("progressbar", { name: "Antigravity download" }); - await renderSetup(); + await using _screen = await renderSetup(); await downloading(40_000_000, 100_000_000); await expect.element(progressbar).toHaveValue(40_000_000); @@ -317,6 +315,7 @@ describe("Antigravity setup", () => { }); it("removes an owned damaged runtime only after confirmation", async () => { + resetSetup(); setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); setup.installation = { ...setup.installation!, @@ -324,22 +323,23 @@ describe("Antigravity setup", () => { canRemove: true, installedVersion: null, }; - await renderSetup(); - await click("Remove downloaded runtime"); + await using screen = await renderSetup(); + await screen.click("Remove downloaded runtime"); expect(setup.confirm).toHaveBeenCalledOnce(); expect(setup.removeInstall).not.toHaveBeenCalled(); setup.confirm.mockResolvedValue(true); - await click("Remove downloaded runtime"); + await screen.click("Remove downloaded runtime"); expect(setup.removeInstall).toHaveBeenCalledWith({ environmentId, input: { instanceId } }); }); it.each([true, false])( "can sign out a verified account when its instance is enabled=%s", async (enabled) => { + resetSetup(); setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); setup.confirm.mockResolvedValue(true); - await renderSetup({ + await using screen = await renderSetup({ enabled, provider: { ...provider, @@ -349,7 +349,7 @@ describe("Antigravity setup", () => { auth: { status: "authenticated" }, }, }); - await click("Sign out"); + await screen.click("Sign out"); expect(setup.logoutAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId } }); }, ); @@ -357,44 +357,52 @@ describe("Antigravity setup", () => { it.each(["unauthenticated", "unknown"] as const)( "does not offer sign-out for an unverified %s account", async (status) => { + resetSetup(); setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); - await renderSetup({ + await using screen = await renderSetup({ provider: { ...provider, auth: { status, canLogout: true } }, }); - expect(button("Sign in")).not.toBeNull(); - expect(button("Sign out")).toBeNull(); - expect(button("Change account")).toBeNull(); + expect(screen.button("Sign in")).not.toBeNull(); + expect(screen.button("Sign out")).toBeNull(); + expect(screen.button("Change account")).toBeNull(); }, ); it("offers account actions after verified login when discovery cannot identify auth", async () => { + resetSetup(); setup.auth = authState({ phase: "succeeded", flowId: null, authorizationUrl: null }); - await renderSetup({ provider: { ...provider, auth: { status: "unknown" } } }); - expect(button("Change account")).not.toBeNull(); - expect(button("Sign out")).not.toBeNull(); - expect(button("Sign in")).toBeNull(); + await using screen = await renderSetup({ + provider: { ...provider, auth: { status: "unknown" } }, + }); + expect(screen.button("Change account")).not.toBeNull(); + expect(screen.button("Sign out")).not.toBeNull(); + expect(screen.button("Sign in")).toBeNull(); }); it("does not let a shared managed install hide an invalid custom binary path", async () => { + resetSetup(); setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); setup.installation = { ...setup.installation!, installedVersion: "test-version", canRemove: true, }; - await renderSetup({ + await using screen = await renderSetup({ provider: { ...provider, installed: false }, binaryPath: "/missing/antigravity", }); - expect(button("Sign in")?.disabled).toBe(true); + expect(screen.button("Sign in")?.disabled).toBe(true); expect(setup.startAuth).not.toHaveBeenCalled(); }); it.each(["read-only", "older-server"] as const)( "does not open private setup subscriptions for a %s view", async (mode) => { + resetSetup(); const { setup: _setup, ...olderProvider } = provider; - await renderSetup(mode === "read-only" ? { readOnly: true } : { provider: olderProvider }); + await using _screen = await renderSetup( + mode === "read-only" ? { readOnly: true } : { provider: olderProvider }, + ); expect(setup.authState).not.toHaveBeenCalled(); expect(setup.installState).not.toHaveBeenCalled(); expect(setup.startAuth).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/settings/SecretInput.browser.test.tsx b/apps/web/src/components/settings/SecretInput.browser.test.tsx index 960214b5ca58..041799562ff6 100644 --- a/apps/web/src/components/settings/SecretInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretInput.browser.test.tsx @@ -1,64 +1,47 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { render, type Screen } from "../../../test/render"; import { SecretInput } from "./SecretInput"; -let root: Root; -let container: HTMLDivElement; +const input = (screen: Screen) => screen.container.querySelector("input")!; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -const input = () => container.querySelector("input")!; - -async function edit(value: string) { - await userEvent.clear(input()); - if (value) await userEvent.type(input(), value); +async function edit(screen: Screen, value: string) { + await userEvent.clear(input(screen)); + if (value) await userEvent.type(input(screen), value); await userEvent.click(document.body); } describe("SecretInput", () => { it("preserves a configured secret on empty edits and commits a replacement", async () => { const onCommit = vi.fn(); - await act(async () => root.render()); - expect(input().type).toBe("password"); + await using screen = await render(); + expect(input(screen).type).toBe("password"); - await userEvent.click(input()); + await userEvent.click(input(screen)); await userEvent.click(document.body); expect(onCommit).not.toHaveBeenCalled(); - await edit("replacement"); + await edit(screen, "replacement"); expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement"); - expect(input().value).toBe(""); + expect(input(screen).value).toBe(""); }); it("allows clearing an unsaved draft", async () => { const onCommit = vi.fn(); - await act(async () => root.render()); - await edit(""); + await using screen = await render(); + await edit(screen, ""); expect(onCommit).toHaveBeenCalledExactlyOnceWith(""); }); it("keeps an in-progress replacement when settings refresh", async () => { const onCommit = vi.fn(); - const render = () => root.render(); - await act(async () => render()); + const ui = () => ; + await using screen = await render(ui()); - await userEvent.type(input(), "replacement"); - await act(async () => render()); - expect(input().value).toBe("replacement"); + await userEvent.type(input(screen), "replacement"); + await screen.rerender(ui()); + expect(input(screen).value).toBe("replacement"); await userEvent.keyboard("{Enter}"); expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement"); diff --git a/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx index 594a623c438c..44a067138512 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx @@ -1,7 +1,7 @@ +import { useEffect } from "react"; +import { act, render } from "../../../test/render"; import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts"; -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ settings: null as UnifiedSettings | null, @@ -37,30 +37,28 @@ vi.mock(import("../../localApi"), async (original) => ({ import { useSettingsRestore } from "./SettingsPanels"; -let root: Root; -let restore: ReturnType; - -function Probe() { - const result = useSettingsRestore(); - useEffect(() => { - restore = result; - }); - return null; +async function renderRestore() { + let restore!: ReturnType; + function Probe() { + const result = useSettingsRestore(); + useEffect(() => { + restore = result; + }); + return null; + } + const screen = await render(); + return { + ...screen, + get restore() { + return restore; + }, + }; } - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { vi.clearAllMocks(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS }; state.confirm.mockResolvedValue(true); - root = createRoot(document.createElement("div")); -}); -afterEach(async () => { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); -}); - -const render = () => act(async () => root.render()); +} describe("restoring V2 settings", () => { it.each([ @@ -68,8 +66,10 @@ describe("restoring V2 settings", () => { ["autoResumeLimitedThreads", "Auto-resume limited threads"], ["snoozeLimitedThreads", "Snooze limited threads"], ] as const)("restores %s when it is the only changed setting", async (key, label) => { + setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, [key]: true }; - await render(); + await using screen = await renderRestore(); + const { restore } = screen; expect(restore.changedSettingLabels).toEqual([label]); await act(() => restore.restoreDefaults()); @@ -80,9 +80,11 @@ describe("restoring V2 settings", () => { }); it("does not reset settings after cancellation", async () => { + setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, autoResumeLimitedThreads: true }; state.confirm.mockResolvedValue(false); - await render(); + await using screen = await renderRestore(); + const { restore } = screen; await act(() => restore.restoreDefaults()); diff --git a/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx b/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx index 2c813f07dac6..67b144d0112a 100644 --- a/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx @@ -1,12 +1,11 @@ +import { act, render } from "../../../test/render"; import { DEFAULT_CLIENT_SETTINGS, type ClientSettingsPatch, type DesktopSnapShotState, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; const bridge = vi.hoisted(() => ({ getSnapShotState: vi.fn<() => Promise>(), @@ -62,39 +61,49 @@ import { readSnapShotSetupResume } from "../../lib/snapShotSetupResume"; import { SnapShotSettings } from "./SnapShotSettings"; let state: DesktopSnapShotState; -let root: Root; -let container: HTMLDivElement; - async function mount() { - root = createRoot(container); - await act(async () => root.render()); + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render()); + const { container } = screen; await vi.waitFor(() => expect(bridge.getSnapShotState).toHaveBeenCalled()); await act(async () => {}); + const dialog = () => document.querySelector('[role="dialog"]'); + const scope = () => dialog() ?? container; + function button(label: string, within: ParentNode = scope()) { + return ( + [...within.querySelectorAll("button")].find( + (element) => element.textContent?.trim() === label, + ) ?? null + ); + } + async function click(label: string) { + const target = button(label); + expect(target, `button ${label}`).not.toBeNull(); + await act(async () => target!.click()); + } + const recorder = () => scope().querySelector("[data-keybinding-capture]")!; + const toggle = (label: string) => + container.querySelector(`[role="switch"][aria-label="${label}"]`)!; + const stepTitle = () => dialog()?.querySelector("h3")?.textContent; + + const environment = stack.move(); + return { + ...screen, + dialog, + button, + click, + recorder, + toggle, + stepTitle, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } -const dialog = () => document.querySelector('[role="dialog"]'); -const scope = () => dialog() ?? container; -function button(label: string, within: ParentNode = scope()) { - return ( - [...within.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === label, - ) ?? null - ); -} -async function click(label: string) { - const target = button(label); - expect(target, `button ${label}`).not.toBeNull(); - await act(async () => target!.click()); -} -const recorder = () => scope().querySelector("[data-keybinding-capture]")!; -const toggle = (label: string) => - container.querySelector(`[role="switch"][aria-label="${label}"]`)!; -const stepTitle = () => dialog()?.querySelector("h3")?.textContent; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { vi.clearAllMocks(); + const storage = new Map(Object.entries(localStorage)); localStorage.clear(); - Object.defineProperty(navigator, "platform", { configurable: true, get: () => "Linux" }); + settingsStore.current = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; state = { mode: "portal", @@ -115,22 +124,22 @@ beforeEach(() => { state = { ...state, shortcut: patch.snapShotShortcut ?? state.shortcut }; settingsStore.set({ ...settingsStore.current, ...patch }); }); - container = document.createElement("div"); - document.body.append(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); + return { + [Symbol.dispose]() { + localStorage.clear(); + for (const [key, value] of storage) localStorage.setItem(key, value); + }, + }; +} it.each(["niri", "hyprland"] as const)( "reopens %s setup at Shortcut without reading config or expanding Settings", async (desktop) => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); state = { ...state, linuxBackend: desktop }; - await mount(); + await using screen = await mount(); + const { container, dialog, button, click, stepTitle } = screen; expect(dialog()).toBeNull(); expect(button("Review changes", container)).toBeNull(); await click("Change shortcut"); @@ -146,7 +155,10 @@ it.each(["niri", "hyprland"] as const)( ); it("returns to Access if the Hyprland helper needs attention before changing keys", async () => { - await mount(); + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); + await using screen = await mount(); + const { button, click, stepTitle } = screen; state = { ...state, hyprlandHelper: { status: "not-installed", message: "Install helper" } }; await click("Change shortcut"); expect(stepTitle()).toBe("Allow snapshots"); @@ -157,6 +169,8 @@ it("returns to Access if the Hyprland helper needs attention before changing key it.each(["direct", "gnome-extension", "kde"] as const)( "keeps %s shortcut recording and saving inline", async (backend) => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); state = { ...state, mode: backend === "direct" ? "direct" : "portal", @@ -165,7 +179,8 @@ it.each(["direct", "gnome-extension", "kde"] as const)( shortcutLabel: backend === "direct" ? undefined : "Press 2", shortcutMessage: backend === "direct" ? null : "Desktop shortcut: Press 2", }; - await mount(); + await using screen = await mount(); + const { container, dialog, click, recorder } = screen; if (backend !== "direct") { expect(recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Shift+2", @@ -188,9 +203,12 @@ it.each(["direct", "gnome-extension", "kde"] as const)( ); it("turns capture on directly on Windows without opening setup", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); settingsStore.current = { ...settingsStore.current, snapShotEnabled: false }; state = { ...state, mode: "direct", linuxBackend: undefined, windows: true }; - await mount(); + await using screen = await mount(); + const { dialog, button, toggle } = screen; await act(async () => toggle("Enable snapshots").click()); expect(settingsStore.update).toHaveBeenCalledWith({ snapShotEnabled: true }); expect(toggle("Enable snapshots").getAttribute("aria-checked")).toBe("true"); @@ -199,6 +217,8 @@ it("turns capture on directly on Windows without opening setup", async () => { }); it("keeps the approved desktop shortcut when recording is cancelled in setup", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); state = { ...state, linuxBackend: "gnome-extension", @@ -206,7 +226,8 @@ it("keeps the approved desktop shortcut when recording is cancelled in setup", a shortcutRegistered: true, shortcutLabel: "Press 8", }; - await mount(); + await using screen = await mount(); + const { dialog, click, recorder } = screen; await click("Manage capture"); expect(dialog()).not.toBeNull(); expect(recorder().getAttribute("aria-label")).toBe( @@ -245,8 +266,11 @@ function usePortalShortcut(shortcutCanRetry: boolean) { } it("keeps older portal shortcuts editable without offering unsupported permissions", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); usePortalShortcut(false); - await mount(); + await using screen = await mount(); + const { button, recorder } = screen; expect(button("Shortcut permissions")).toBeNull(); expect(bridge.setupSnapShot).not.toHaveBeenCalled(); await act(async () => userEvent.click(recorder())); @@ -254,9 +278,12 @@ it("keeps older portal shortcuts editable without offering unsupported permissio }); it("shows permission errors once in a toast and allows retrying", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); usePortalShortcut(true); bridge.setupSnapShot.mockRejectedValueOnce(new Error("The desktop service disconnected.")); - await mount(); + await using screen = await mount(); + const { container, click } = screen; await click("Shortcut permissions"); expect(toast).toHaveBeenCalledExactlyOnceWith({ type: "error", @@ -270,9 +297,12 @@ it("shows permission errors once in a toast and allows retrying", async () => { }); it("keeps a failed preference unchanged and reports the save error in a toast", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); usePortalShortcut(true); settingsStore.update.mockRejectedValueOnce(new Error("The settings file is read-only.")); - await mount(); + await using screen = await mount(); + const { toggle } = screen; const flash = () => toggle("Flash captured window"); expect(flash().getAttribute("aria-checked")).toBe("true"); await act(async () => flash().click()); @@ -288,9 +318,12 @@ it("keeps a failed preference unchanged and reports the save error in a toast", }); it("keeps setup errors in the wizard and does not toast them after closing it", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); usePortalShortcut(true); state = { ...state, shortcutRegistered: false }; - await mount(); + await using screen = await mount(); + const { dialog, click, stepTitle } = screen; await click("Manage capture"); expect(stepTitle()).toBe("Choose your shortcut"); bridge.setupSnapShot.mockRejectedValueOnce(new Error("The desktop service disconnected.")); @@ -322,8 +355,11 @@ function useMacPermissions(granted: boolean) { it.each([false, true])( "resumes macOS permission setup after restart and clears it on close, completed=%s", async (completed) => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); useMacPermissions(false); - await mount(); + await using screen = await mount(); + let { dialog, button, click, toggle, stepTitle } = screen; await act(async () => toggle("Enable snapshots").click()); expect(stepTitle()).toBe("Allow snapshots"); bridge.setupSnapShot.mockImplementationOnce(async () => { @@ -333,10 +369,11 @@ it.each([false, true])( expect(bridge.setupSnapShot).toHaveBeenCalledWith("allow-screen-recording"); // macOS quits the app from its permission prompt; the next launch resumes setup. - await act(async () => root.unmount()); + await screen.unmount(); state = { ...state, shortcutRegistered: true }; state = { ...state, macPermissions: { screenRecording: true, accessibility: true } }; - await mount(); + await using resumed = await mount(); + ({ dialog, button, click, toggle, stepTitle } = resumed); expect(stepTitle()).toBe("Allow snapshots"); expect(button("Finish later")).not.toBeNull(); if (completed) { @@ -352,8 +389,11 @@ it.each([false, true])( ); it("requires a successful macOS test capture before enabling and allows retry", async () => { + using _state = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); useMacPermissions(true); - await mount(); + await using screen = await mount(); + const { dialog, click, toggle, stepTitle } = screen; await act(async () => toggle("Enable snapshots").click()); bridge.setupSnapShot.mockRejectedValueOnce(new Error("Capture was denied")); await click("Test capture and continue"); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx index 326a1d4c2f8e..8a677af06311 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx @@ -1,7 +1,7 @@ +import { render } from "../../../test/render"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { act, StrictMode, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "../../index.css"; @@ -61,9 +61,6 @@ vi.mock(import("./settingsLayout"), async (original) => ({ import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; -let root: Root; -let container: HTMLDivElement; - const resetButton = (label: string) => page.getByRole("button", { name: `Reset ${label} to default` }); const writeForAll = () => page.getByRole("button", { name: "Write custom instructions for all" }); @@ -73,17 +70,13 @@ const bulkEditor = () => name: "Custom source control instructions for all selected environments", }); -async function render() { - await act(async () => - root.render( - - - , - ), - ); -} +const content = () => ( + + + +); -beforeEach(async () => { +async function setup() { state.styles = [ { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, @@ -98,23 +91,17 @@ beforeEach(async () => { followChangeRequestTemplates: false, }, ]; + const screen = await render(content()); state.updateSettings.mockReset().mockImplementation((patch) => { state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle })); - void render(); + void screen.rerender(content()); }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await render(); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); + return screen; +} describe("mixed source control instructions", () => { it("resets mixed template preferences without replacing each environment's instructions", async () => { + await using _screen = await setup(); const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions, @@ -133,8 +120,9 @@ describe("mixed source control instructions", () => { }); it("resets every environment even when the representative already has default instructions", async () => { + await using screen = await setup(); state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle }; - await render(); + await screen.rerender(content()); await userEvent.click(resetButton("source control writing style")); @@ -151,6 +139,7 @@ describe("mixed source control instructions", () => { }); it("does not write an untouched bulk draft", async () => { + await using _screen = await setup(); const initialStyles = state.styles; await userEvent.click(writeForAll()); await expect.element(bulkEditor()).toHaveValue(""); @@ -161,6 +150,7 @@ describe("mixed source control instructions", () => { }); it("applies edited instructions to every selected environment", async () => { + await using _screen = await setup(); await userEvent.click(writeForAll()); await userEvent.fill(bulkEditor(), " Keep titles concise. "); await expect.element(applyToAll()).toBeEnabled(); @@ -175,6 +165,7 @@ describe("mixed source control instructions", () => { }); it("allows an intentional clear after editing", async () => { + await using _screen = await setup(); await userEvent.click(writeForAll()); await userEvent.fill(bulkEditor(), "Temporary instructions"); await userEvent.clear(bulkEditor()); @@ -186,6 +177,7 @@ describe("mixed source control instructions", () => { }); it("shows the plain editor once instructions agree, even while templates differ", async () => { + await using _screen = await setup(); await userEvent.click(writeForAll()); await userEvent.fill(bulkEditor(), "Shared instructions"); await userEvent.click(applyToAll()); diff --git a/apps/web/src/components/settings/StorageSettings.browser.test.tsx b/apps/web/src/components/settings/StorageSettings.browser.test.tsx index 9573f3b3fb49..dc545a12eca4 100644 --- a/apps/web/src/components/settings/StorageSettings.browser.test.tsx +++ b/apps/web/src/components/settings/StorageSettings.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render } from "../../../test/render"; import { RegistryProvider } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, @@ -5,9 +6,8 @@ import { ThreadId, type StorageCleanupReport, } from "@t3tools/contracts"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { type ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; const state = vi.hoisted(() => ({ @@ -92,10 +92,7 @@ vi.mock(import("../ui/toast"), async (original) => { import { StorageSettingsPanel } from "./StorageSettings"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { +function setup() { state.settings = { ...DEFAULT_SERVER_SETTINGS, storageCleanup: { @@ -112,32 +109,22 @@ beforeEach(() => { }; state.report = null; state.run.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); +} -async function render() { - await act(async () => - root.render( - - - , - ), - ); - return Array.from(container.querySelectorAll("button")).find( +const panel = () => ( + + + +); +const deleteButton = (container: HTMLElement) => + Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Delete now", )!; -} it.each(["machine-wide immediate", "project custom"])( "runs cleanup when only %s settled retention is enabled", async (scope) => { + setup(); if (scope === "machine-wide immediate") { state.settings = { ...state.settings, @@ -167,7 +154,8 @@ it.each(["machine-wide immediate", "project custom"])( }; let complete!: (result: { _tag: "Success"; value: StorageCleanupReport }) => void; state.run.mockImplementation(() => new Promise((resolve) => (complete = resolve))); - const button = await render(); + await using screen = await render(panel()); + const button = deleteButton(screen.container); expect(button.disabled).toBe(false); await act(async () => button.click()); @@ -182,7 +170,9 @@ it.each(["machine-wide immediate", "project custom"])( ); it("keeps cleanup disabled when settled retention and all other rules are off", async () => { - const button = await render(); + setup(); + await using screen = await render(panel()); + const button = deleteButton(screen.container); expect(button.disabled).toBe(true); await act(async () => button.click()); @@ -190,6 +180,7 @@ it("keeps cleanup disabled when settled retention and all other rules are off", }); it("shows orphan sizes and identifies failed worktrees with one connected machine", async () => { + setup(); state.settings = { ...state.settings, storageCleanup: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 0 }, @@ -226,7 +217,8 @@ it("shows orphan sizes and identifies failed worktrees with one connected machin omittedCount: 0, bytesFreed: 0, }; - await render(); + await using screen = await render(panel()); + const { container } = screen; const lastRun = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Last run"), )!; diff --git a/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx b/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx index 278442ed468f..212787aa1e0f 100644 --- a/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx +++ b/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx @@ -1,7 +1,6 @@ +import { render } from "../../../test/render"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; const { state, updateSettings } = vi.hoisted(() => ({ @@ -46,40 +45,29 @@ vi.mock(import("./useScopedSettings"), async (original) => ({ import { TelemetryExportSettings } from "./TelemetryExportSettings"; -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); +function setup() { updateSettings.mockClear(); - vi.unstubAllGlobals(); -}); - -const field = (id: string) => container.querySelector(`#${id}`)!; -async function replace(id: string, value: string) { - await userEvent.clear(field(id)); - await userEvent.type(field(id), value); } describe("TelemetryExportSettings", () => { it("does not resend a field the user restored after another client changed it", async () => { + setup(); state.observability = { ...DEFAULT_SERVER_SETTINGS.observability, otlpTracesUrl: "http://old/v1/traces", }; - await act(async () => root.render()); + await using screen = await render(); + const { container } = screen; + const field = (id: string) => container.querySelector(`#${id}`)!; + async function replace(id: string, value: string) { + await userEvent.clear(field(id)); + await userEvent.type(field(id), value); + } await replace("otlpTracesUrl", "http://draft/v1/traces"); await replace("otlpTracesUrl", "http://old/v1/traces"); state.observability = { ...state.observability, otlpTracesUrl: "http://other/v1/traces" }; - await act(async () => root.render()); + await screen.rerender(); await replace("otlpMetricsUrl", "http://new/v1/metrics"); const save = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Save", diff --git a/apps/web/src/components/settings/ThemeEditorHost.browser.test.tsx b/apps/web/src/components/settings/ThemeEditorHost.browser.test.tsx index f625a4471e93..2616e1f226e5 100644 --- a/apps/web/src/components/settings/ThemeEditorHost.browser.test.tsx +++ b/apps/web/src/components/settings/ThemeEditorHost.browser.test.tsx @@ -1,6 +1,5 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; +import { describe, expect, it, vi } from "vite-plus/test"; import { installCustomTheme, @@ -41,8 +40,6 @@ import { ThemeEditorHost } from "./ThemeEditorHost"; import { useThemeEditorStore } from "./themeEditorStore"; type Field = "editingTheme" | "seedTheme"; -let root: Root; -let container: HTMLDivElement; function install(id: string) { return installCustomTheme( @@ -65,7 +62,7 @@ function open(field: Field, themeId: string) { }), ); } -async function shown(field: Field) { +async function shown(container: HTMLElement, field: Field) { await vi.waitFor(() => expect(container.querySelector("dl")).not.toBeNull(), { timeout: 5_000, }); @@ -73,66 +70,71 @@ async function shown(field: Field) { return terms.find((term) => term.textContent === field)?.nextElementSibling?.textContent; } -// The host lazy-loads the panel; load it once so the first test does not race the import. -beforeAll(() => import("./ThemeEditorPanel")); - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function setup() { + await import("./ThemeEditorPanel"); + const storage = new Map(Object.entries(localStorage)); + const session = useThemeEditorStore.getState().session; localStorage.clear(); invalidateCustomThemes(); useThemeEditorStore.setState({ session: null }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await act(async () => root.render()); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - invalidateCustomThemes(); - vi.unstubAllGlobals(); -}); + return { + [Symbol.dispose]() { + localStorage.clear(); + for (const [key, value] of storage) localStorage.setItem(key, value); + invalidateCustomThemes(); + useThemeEditorStore.setState({ session }); + }, + }; +} describe("ThemeEditorHost", () => { it.each(["editingTheme", "seedTheme"] as const)( "reopens the same %s with its saved colors", async (field) => { + using _state = await setup(); + await using screen = await render(); + const { container } = screen; const theme = install("saved-colors"); await open(field, theme.id); - expect(await shown(field)).toBe("#1f6e4a"); + expect(await shown(container, field)).toBe("#1f6e4a"); await act(async () => useThemeEditorStore.getState().closeThemeEditor()); expect(container.querySelector("dl")).toBeNull(); updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } }); await open(field, theme.id); - expect(await shown(field)).toBe("#7241b8"); + expect(await shown(container, field)).toBe("#7241b8"); }, ); it.each(["editingTheme", "seedTheme"] as const)( "refreshes an open %s when the library changes", async (field) => { + using _state = await setup(); + await using screen = await render(); + const { container } = screen; const theme = install("updated-theme"); await open(field, theme.id); - expect(await shown(field)).toBe("#1f6e4a"); + expect(await shown(container, field)).toBe("#1f6e4a"); await act(async () => updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } }), ); - expect(await shown(field)).toBe("#7241b8"); + expect(await shown(container, field)).toBe("#7241b8"); }, ); it("does not keep editing a theme removed from the library", async () => { + using _state = await setup(); + await using screen = await render(); + const { container } = screen; const theme = install("removed-theme"); await open("editingTheme", theme.id); - expect(await shown("editingTheme")).toBe("#1f6e4a"); + expect(await shown(container, "editingTheme")).toBe("#1f6e4a"); await act(async () => removeCustomTheme(theme.id)); - expect(await shown("editingTheme")).toBe("none"); + expect(await shown(container, "editingTheme")).toBe("none"); }); }); diff --git a/apps/web/src/components/settings/colorPickers.browser.test.tsx b/apps/web/src/components/settings/colorPickers.browser.test.tsx index 69998c90ab0e..2fb3346f5f88 100644 --- a/apps/web/src/components/settings/colorPickers.browser.test.tsx +++ b/apps/web/src/components/settings/colorPickers.browser.test.tsx @@ -1,8 +1,9 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import type { ReactNode } from "react"; + +import { act, fakeTimers, render } from "../../../test/render"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { ThemeColorField } from "./ThemeColorPicker"; @@ -11,25 +12,16 @@ const SATURATION = "Accent color saturation"; const BRIGHTNESS = "Accent color brightness"; const PLANE = "Accent color saturation and brightness"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function renderPicker(ui: ReactNode) { + await using stack = new AsyncDisposableStack(); // Synthetic pointer ids are not active pointers, so the browser would reject capture. - vi.spyOn(HTMLElement.prototype, "setPointerCapture").mockImplementation(() => {}); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + stack.use(vi.spyOn(HTMLElement.prototype, "setPointerCapture").mockImplementation(() => {})); + const screen = stack.use(await render(ui)); + const environment = stack.move(); + return Object.assign(screen, { + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }); +} function byLabel(label: string) { const element = document.querySelector(`[aria-label="${label}"]`); @@ -77,32 +69,35 @@ const nextFrame = () => async function renderAccent(value: string | undefined, commitDelayMs?: number) { const onCommit = vi.fn(); - await act(async () => - root.render( - , - ), + const screen = await renderPicker( + , ); await open(`${value ? "Change" : "Add"} accent color for Codex`); - return onCommit; + return Object.assign(screen, { onCommit }); } async function renderThemeField(value: string) { const onChange = vi.fn(); - const render = (current: string) => - root.render(); - await act(async () => render(value)); + const field = (current: string) => ( + + ); + const screen = await renderPicker(field(value)); await open("Choose Accent color color"); - return { onChange, echo: (next: string) => act(async () => render(next)) }; + return Object.assign(screen, { + onChange, + echo: (current: string) => screen.rerender(field(current)), + }); } describe("provider accent color picker", () => { it("adjusts each color axis independently from the keyboard", async () => { - const onCommit = await renderAccent("#ff0000"); + await using screen = await renderAccent("#ff0000"); + const { onCommit } = screen; await press(HUE, "ArrowLeft"); expect(slider(HUE).getAttribute("aria-valuenow")).toBe("359"); @@ -134,7 +129,8 @@ describe("provider accent color picker", () => { }); it("supports Home/End and clamps each axis without changing the other", async () => { - const onCommit = await renderAccent("#ff0000"); + await using screen = await renderAccent("#ff0000"); + const { onCommit } = screen; await press(SATURATION, "Home"); await press(SATURATION, "ArrowLeft"); @@ -156,7 +152,8 @@ describe("provider accent color picker", () => { }); it("adds an accent only after choosing one and clears it back to no accent", async () => { - const onCommit = await renderAccent(undefined); + await using screen = await renderAccent(undefined); + const { onCommit } = screen; const clearButton = () => [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Clear color"), @@ -174,8 +171,9 @@ describe("provider accent color picker", () => { }); it("debounces commits and commits the pending color on unmount", async () => { - const onCommit = await renderAccent("#ff0000", 250); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await using screen = await renderAccent("#ff0000", 250); + const { onCommit } = screen; + using _timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); await press(HUE, "ArrowRight", true); await press(HUE, "ArrowRight", true); @@ -184,17 +182,17 @@ describe("provider accent color picker", () => { expect(onCommit).toHaveBeenCalledExactlyOnceWith("#ff5500"); await press(HUE, "ArrowRight", true); - await act(async () => root.unmount()); + await screen.unmount(); expect(onCommit).toHaveBeenLastCalledWith("#ff8000"); await act(async () => vi.advanceTimersByTime(250)); expect(onCommit).toHaveBeenCalledTimes(2); - root = createRoot(container); }); }); describe("theme color field", () => { it("batches native range changes into one commit per frame and keeps alpha", async () => { - const { onChange } = await renderThemeField("#ff000080"); + await using screen = await renderThemeField("#ff000080"); + const { onChange } = screen; // Both changes land in one task, so no animation frame can run between them. await act(async () => { @@ -212,7 +210,8 @@ describe("theme color field", () => { }); it("flushes the final drag color on release of the dragging pointer only", async () => { - const { onChange } = await renderThemeField("#ff000080"); + await using screen = await renderThemeField("#ff000080"); + const { onChange } = screen; // The drag runs in one task, so no animation frame can commit mid-drag. await act(async () => { @@ -230,7 +229,8 @@ describe("theme color field", () => { }); it("clamps out-of-bounds drags and flushes on cancellation and unmount", async () => { - const { onChange } = await renderThemeField("#ff000080"); + await using screen = await renderThemeField("#ff000080"); + const { onChange } = screen; await act(async () => { pointer(PLANE, "pointerdown", -0.5, -0.5); @@ -240,14 +240,14 @@ describe("theme color field", () => { await act(async () => { pointer(PLANE, "pointerdown", 1.5, 1.5); - root.unmount(); + await screen.unmount(); }); expect(onChange).toHaveBeenLastCalledWith("accent", "#00000080"); - root = createRoot(container); }); it("keeps the selected hue when a grey color is echoed back from theme settings", async () => { - const { onChange, echo } = await renderThemeField("#ff0000"); + await using screen = await renderThemeField("#ff0000"); + const { onChange, echo } = screen; await press(HUE, "ArrowRight", true); await act(async () => { diff --git a/apps/web/src/components/settings/settingsLayout.browser.test.tsx b/apps/web/src/components/settings/settingsLayout.browser.test.tsx index b44b0cb56bf8..d759fffc3b44 100644 --- a/apps/web/src/components/settings/settingsLayout.browser.test.tsx +++ b/apps/web/src/components/settings/settingsLayout.browser.test.tsx @@ -1,29 +1,29 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { scrollToSettingsTarget } from "./settingsLayout"; const PULSE = "settings-search-target-pulse"; -afterEach(() => { - document.body.replaceChildren(); - vi.restoreAllMocks(); -}); - function mount(html: string) { - document.body.innerHTML = html; - return vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {}); + const container = document.createElement("div"); + container.innerHTML = html; + document.body.append(container); + return { [Symbol.dispose]: () => container.remove() }; } function reduceMotion(matches: boolean) { - vi.spyOn(window, "matchMedia").mockReturnValue({ matches } as MediaQueryList); + return vi.spyOn(window, "matchMedia").mockReturnValue({ matches } as MediaQueryList); } describe("settings search targets", () => { it("scrolls a section's header into view, focuses the section, and pulses until blur", () => { - reduceMotion(false); - const scrollIntoView = mount( + using _motion = reduceMotion(false); + using _fixture = mount( `

Providers

Body

`, ); + using scrollIntoView = vi + .spyOn(HTMLElement.prototype, "scrollIntoView") + .mockImplementation(() => {}); const section = document.getElementById("providers")!; expect(scrollToSettingsTarget("providers")).toBe(true); @@ -38,10 +38,11 @@ describe("settings search targets", () => { }); it("restarts the pulse when the same destination is chosen again", () => { - reduceMotion(false); - mount(`
`); + using _motion = reduceMotion(false); + using _fixture = mount(`
`); const target = document.getElementById("word-wrap")!; const observer = new MutationObserver(() => {}); + using _observer = { [Symbol.dispose]: () => observer.disconnect() }; observer.observe(target, { attributeFilter: ["class"], attributeOldValue: true }); scrollToSettingsTarget("word-wrap"); @@ -51,8 +52,11 @@ describe("settings search targets", () => { }); it("does not animate the destination when reduced motion is requested", () => { - reduceMotion(true); - const scrollIntoView = mount(`
`); + using _motion = reduceMotion(true); + using _fixture = mount(`
`); + using scrollIntoView = vi + .spyOn(HTMLElement.prototype, "scrollIntoView") + .mockImplementation(() => {}); const target = document.getElementById("word-wrap")!; expect(scrollToSettingsTarget("word-wrap")).toBe(true); @@ -66,8 +70,8 @@ describe("settings search targets", () => { "fully reveals the row when the opening style clears: %s", async (phase) => { // Keep scrolling synchronous while the panel height transitions independently. - reduceMotion(true); - document.body.innerHTML = ` + using _motion = reduceMotion(true); + using _fixture = mount(`
@@ -75,7 +79,7 @@ describe("settings search targets", () => {
Legacy sidebar
-
`; +
`); const viewport = document.getElementById("viewport")!; const panel = document.getElementById("panel")!; const target = document.getElementById("legacy-sidebar")!; @@ -116,8 +120,8 @@ describe("settings search targets", () => { ); it("keeps the newer search destination visible when an earlier panel finishes opening", async () => { - reduceMotion(true); - document.body.innerHTML = ` + using _motion = reduceMotion(true); + using _fixture = mount(`
New target
@@ -127,7 +131,7 @@ describe("settings search targets", () => {
Legacy sidebar
-
`; +
`); const panel = document.getElementById("panel")!; void panel.offsetHeight; panel.style.height = "600px"; diff --git a/apps/web/src/components/settings/settingsScopeRetention.browser.test.tsx b/apps/web/src/components/settings/settingsScopeRetention.browser.test.tsx index 8c029938b17d..fad9e050517b 100644 --- a/apps/web/src/components/settings/settingsScopeRetention.browser.test.tsx +++ b/apps/web/src/components/settings/settingsScopeRetention.browser.test.tsx @@ -1,6 +1,6 @@ -import { act, useState, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; +import { useState, type ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; import type { SettingsScopeSearch } from "./settingsScope"; interface ScopeState { @@ -85,29 +85,14 @@ function StatefulContent() { ); } -let root: Root; -let container: HTMLDivElement; const Layout = Route.options.component!; await Layout.preload?.(); -async function render(scope: ScopeState) { +function setup(pathname: string, scope: ScopeState) { + state.pathname = pathname; state.scope = scope; - await act(async () => root.render()); -} -function retainedValue() { - return container.querySelector('button[aria-label="Retained value"]')?.textContent ?? null; } -beforeEach(() => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); - const first = { search: { machine: "first", project: "one" }, environmentId: "first" }; const otherProject = { search: { machine: "first", project: "two" }, environmentId: "first" }; const otherMachine = { search: { machine: "second", project: "one" }, environmentId: "second" }; @@ -126,14 +111,18 @@ it.each([ ["/settings/diagnostics", "a disconnect", "0", first, disconnected], ["/settings/keybindings", "another project", "0", first, otherProject], ])("%s after %s shows %s", async (pathname, _change, expected, before, after) => { - state.pathname = pathname; - await render(before); + setup(pathname, before); + await using screen = await render(); + const { container } = screen; + const retainedValue = () => + container.querySelector('button[aria-label="Retained value"]')?.textContent ?? null; await act(async () => container.querySelector('button[aria-label="Retained value"]')?.click(), ); expect(retainedValue()).toBe("1"); - await render(after); + state.scope = after; + await screen.rerender(); expect(retainedValue()).toBe(expected); }); diff --git a/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx b/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx index cc354b1d2119..64a99622ae85 100644 --- a/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx +++ b/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render } from "../../../test/render"; import { DEFAULT_SERVER_SETTINGS, EnvironmentId, @@ -6,9 +7,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { NewProjectScriptInput } from "../projectScriptEditor"; @@ -87,35 +86,35 @@ function Probe(props: { return null; } -let root: Root; -let container: HTMLDivElement; - async function renderSettings( keybindings: ResolvedKeybindingsConfig, scope: "project" | "environment" = "project", ) { let settings: ProjectScriptSettings | undefined; - await act(async () => - root.render( - (settings = next)} - />, - ), + keybindings, + ...(scope === "project" + ? { project: { id: ProjectId.make("project"), scripts: [lint] } } + : {}), + }, + ]} + onResult={(next) => (settings = next)} + />, ); - return settings!; + return { + ...screen, + get settings() { + return settings!; + }, + }; } async function submitInAct( @@ -128,29 +127,19 @@ async function submitInAct( }); return result!; } - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { vi.clearAllMocks(); - for (const command of Object.values(commands)) { + for (const command of Object.values(commands)) command.mockResolvedValue(AsyncResult.success(undefined)); - } - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} describe("useProjectScriptSettings", () => { it.each(["project", "environment"] as const)( "preserves the shortcut for %s action name and command edits", async (scope) => { - const { submit } = await renderSettings(lintBinding, scope); + setup(); + await using screen = await renderSettings(lintBinding, scope); + const { submit } = screen.settings; const changes = { name: "Lint and fix", command: "vp lint --fix" }; const result = await submitInAct(submit, changes); @@ -172,7 +161,9 @@ describe("useProjectScriptSettings", () => { ); it("saves a recorded action shortcut", async () => { - const { submit } = await renderSettings([]); + setup(); + await using screen = await renderSettings([]); + const { submit } = screen.settings; const result = await submitInAct(submit, { keybinding: "mod+shift+k" }); @@ -184,7 +175,9 @@ describe("useProjectScriptSettings", () => { }); it("removes a cleared action shortcut", async () => { - const { submit } = await renderSettings(lintBinding); + setup(); + await using screen = await renderSettings(lintBinding); + const { submit } = screen.settings; const result = await submitInAct(submit, { keybinding: null }); @@ -197,7 +190,9 @@ describe("useProjectScriptSettings", () => { }); it("replaces an explicitly changed action shortcut", async () => { - const { submit } = await renderSettings(lintBinding); + setup(); + await using screen = await renderSettings(lintBinding); + const { submit } = screen.settings; const result = await submitInAct(submit, { keybinding: "mod+shift+l" }); @@ -214,7 +209,9 @@ describe("useProjectScriptSettings", () => { }); it("removes the shortcut when its action is deleted", async () => { - const { persist } = await renderSettings(lintBinding); + setup(); + await using screen = await renderSettings(lintBinding); + const { persist } = screen.settings; await act(async () => { expect((await persist(() => []))._tag).toBe("Success"); diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx index c81720038dd4..b463ed296d6b 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx @@ -1,8 +1,8 @@ +import { act, render } from "../../../test/render"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { useEffect } from "react"; import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; const suppress = vi.hoisted(() => vi.fn<(enabled: boolean) => Promise>()); vi.mock(import("../../lib/desktopSnapShot"), async (original) => ({ @@ -22,8 +22,6 @@ function deferred() { const recorded = vi.fn(); const error = vi.fn(); -let root: Root; -let container: HTMLDivElement; let recording = false; function Recorder(props: { allowModifierPairs?: boolean; shortcutLabel?: string }) { @@ -40,39 +38,33 @@ function Recorder(props: { allowModifierPairs?: boolean; shortcutLabel?: string return recorder.input; } -async function render(props: Parameters[0] = {}) { - await act(async () => root.render()); -} -const button = () => container.querySelector("[data-keybinding-capture]")!; -async function start(props: Parameters[0] = {}) { - await render(props); - await act(async () => userEvent.click(button())); - expect(recording).toBe(true); -} -const press = (keys: string) => act(async () => userEvent.keyboard(keys)); -function setPlatform(platform: string) { - Object.defineProperty(navigator, "platform", { configurable: true, get: () => platform }); -} - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { suppress.mockReset().mockResolvedValue(undefined); recorded.mockReset(); error.mockReset(); - setPlatform("Linux x86_64"); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); + recording = false; +} +async function renderRecorder(props: Parameters[0] = {}) { + const screen = await render(); + const button = () => + screen.container.querySelector("[data-keybinding-capture]")!; + return { + ...screen, + button, + async start() { + await act(() => userEvent.click(button())); + expect(recording).toBe(true); + }, + }; +} +const press = (keys: string) => act(async () => userEvent.keyboard(keys)); it("suppresses capture while recording and uses the physical key for shifted digits", async () => { - await start(); + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await renderRecorder(); + const { button } = screen; + await screen.start(); expect(suppress).toHaveBeenCalledExactlyOnceWith(true); expect(button().textContent).toBe("Press shortcut…"); await press("{Control>}{Shift>}2{/Shift}{/Control}"); @@ -89,8 +81,11 @@ it("suppresses capture while recording and uses the physical key for shifted dig }); it("lets users replace an unrecognized desktop label without displaying guessed keys", async () => { + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); const shortcutLabel = "Use the shortcut assigned in desktop settings"; - await render({ shortcutLabel }); + await using screen = await renderRecorder({ shortcutLabel }); + const { button } = screen; expect(button().textContent).toBe("Change shortcut"); expect(button().getAttribute("aria-label")).toBe("Change snapshot shortcut"); await act(async () => userEvent.click(button())); @@ -103,31 +98,42 @@ it("lets users replace an unrecognized desktop label without displaying guessed }); it("records macOS Command without treating it as Control", async () => { - setPlatform("MacIntel"); - await start({ allowModifierPairs: true }); + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + _platform.mockReturnValue("MacIntel"); + await using screen = await renderRecorder({ allowModifierPairs: true }); + + await screen.start(); await press("{Meta>}k{/Meta}"); expect(recorded).toHaveBeenCalledWith(expect.objectContaining({ key: "k", modKey: true })); }); it.each(["Escape", "blur", "unmount"])("cancels on %s without selecting keys", async (cancel) => { - await start(); + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await renderRecorder(); + const { button } = screen; + await screen.start(); if (cancel === "Escape") { const outside = vi.fn(); document.addEventListener("keydown", outside); + using _listener = { [Symbol.dispose]: () => document.removeEventListener("keydown", outside) }; await press("{Escape}"); - document.removeEventListener("keydown", outside); expect(outside).not.toHaveBeenCalled(); } else if (cancel === "blur") await act(async () => button().blur()); - else await act(async () => root.render(null)); + else await screen.unmount(); expect(suppress).toHaveBeenLastCalledWith(false); expect(recorded).not.toHaveBeenCalled(); if (cancel !== "unmount") expect(recording).toBe(false); }); it("ignores late suppression acknowledgements after cancellation", async () => { + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); const pending = deferred(); suppress.mockImplementation((enabled) => (enabled ? pending.promise : Promise.resolve())); - await render(); + await using screen = await renderRecorder(); + const { button } = screen; await act(async () => userEvent.click(button())); await act(async () => button().blur()); await act(async () => pending.resolve()); @@ -137,15 +143,22 @@ it("ignores late suppression acknowledgements after cancellation", async () => { }); it("reports suppression failures instead of arming the recorder", async () => { + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); suppress.mockRejectedValue(new Error("Couldn't pause capture")); - await render(); + await using screen = await renderRecorder(); + const { button } = screen; await act(async () => userEvent.click(button())); expect(recording).toBe(false); expect(error).toHaveBeenCalledWith("Couldn't pause capture"); }); it("does not record Tab, unmodified keys, or modifier keys on their own", async () => { - await start(); + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await renderRecorder(); + const { button } = screen; + await screen.start(); await press("y"); await press("{Control>}{/Control}"); expect(recorded).not.toHaveBeenCalled(); @@ -155,7 +168,11 @@ it("does not record Tab, unmodified keys, or modifier keys on their own", async }); it("rejects modifier pairs on Wayland but accepts the next key chord", async () => { - await start(); + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await renderRecorder(); + + await screen.start(); await press("{ShiftLeft>}{ShiftRight>}{/ShiftRight}{/ShiftLeft}"); expect(recorded).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith(expect.stringContaining("Add a letter, number")); @@ -165,7 +182,11 @@ it("rejects modifier pairs on Wayland but accepts the next key chord", async () }); it("preserves native modifier-pair recording and tracks released keys", async () => { - await start({ allowModifierPairs: true }); + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await renderRecorder({ allowModifierPairs: true }); + + await screen.start(); await press("{ShiftLeft}"); await press("{ShiftRight>}"); expect(recorded).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/ui/sidebar.browser.test.tsx b/apps/web/src/components/ui/sidebar.browser.test.tsx index fe6bcbefb237..c96e27cc6130 100644 --- a/apps/web/src/components/ui/sidebar.browser.test.tsx +++ b/apps/web/src/components/ui/sidebar.browser.test.tsx @@ -1,49 +1,23 @@ +import { render } from "../../../test/render"; import "../../index.css"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { Sidebar, SidebarProvider, SidebarTrigger } from "./sidebar"; import { resolveSidebarState } from "./sidebarState"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(async () => { - await page.viewport(1280, 900); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); - -async function renderSidebar(open: boolean) { - await act(async () => { - root.render( - - - - - - - - , - ); - }); -} - -function element(selector: string) { - const node = container.querySelector(selector); - if (!node) throw new Error(`Missing element: ${selector}`); - return node; -} +const sidebarUi = (open: boolean) => ( + + + + + + + + +); describe("responsive sidebar state", () => { it("uses mobile sheet visibility for the shared responsive state", () => { @@ -59,13 +33,15 @@ describe("responsive sidebar state", () => { describe("desktop sidebar focus", () => { it("moves focus to the external toggle on collapse and restores sidebar access on expansion", async () => { - await renderSidebar(true); + await page.viewport(1280, 900); + await using screen = await render(sidebarUi(true)); + const element = (selector: string) => screen.container.querySelector(selector)!; const control = element('[data-testid="sidebar-control"]'); const sidebar = element('[data-slot="sidebar"]'); control.focus(); expect(document.activeElement).toBe(control); - await renderSidebar(false); + await screen.rerender(sidebarUi(false)); expect(document.activeElement).toBe(element('[data-testid="external-trigger"]')); expect(sidebar.inert).toBe(true); @@ -79,7 +55,7 @@ describe("desktop sidebar focus", () => { await userEvent.keyboard("{Tab}"); expect(document.activeElement).toBe(element('[data-testid="content-control"]')); - await renderSidebar(true); + await screen.rerender(sidebarUi(true)); expect(sidebar.inert).toBe(false); control.focus(); @@ -87,12 +63,14 @@ describe("desktop sidebar focus", () => { }); it("keeps focus in the content when the sidebar collapses", async () => { - await renderSidebar(true); + await page.viewport(1280, 900); + await using screen = await render(sidebarUi(true)); + const element = (selector: string) => screen.container.querySelector(selector)!; const control = element('[data-testid="content-control"]'); control.focus(); expect(document.activeElement).toBe(control); - await renderSidebar(false); + await screen.rerender(sidebarUi(false)); expect(document.activeElement).toBe(control); }); diff --git a/apps/web/src/components/ui/switch.browser.test.tsx b/apps/web/src/components/ui/switch.browser.test.tsx index 05bf3ed3ede2..57736792e70e 100644 --- a/apps/web/src/components/ui/switch.browser.test.tsx +++ b/apps/web/src/components/ui/switch.browser.test.tsx @@ -1,25 +1,9 @@ -import { act, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../../test/render"; +import { useState } from "react"; +import { describe, expect, it } from "vite-plus/test"; import { Switch } from "./switch"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - function ControlledSwitch({ initialChecked, initialMixed = false, @@ -43,32 +27,20 @@ function ControlledSwitch({ ); } -function switchRoot() { - const element = container.querySelector('[role="switch"]'); - if (!element) throw new Error("Switch was not rendered"); - return element; -} - -async function clickSwitch() { - await act(async () => switchRoot().click()); -} - -async function renderSwitch(initialChecked: boolean, initialMixed = false) { - await act(async () => { - root.render(); - }); -} - describe("Switch accessibility", () => { it.each([ { initialChecked: false, initialMixed: false, before: false, after: true }, { initialChecked: true, initialMixed: false, before: true, after: false }, { initialChecked: false, initialMixed: true, before: "mixed", after: true }, ])("exposes $before before activation and $after after activation", async (state) => { - await renderSwitch(state.initialChecked, state.initialMixed); + await using screen = await render( + , + ); + const { container } = screen; + const switchRoot = () => container.querySelector('[role="switch"]')!; expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.before)); - await clickSwitch(); + await act(() => switchRoot().click()); expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.after)); expect(container.querySelector('input[type="checkbox"]')?.checked).toBe( diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index cb208289a46c..a70900c189d4 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render } from "../../../test/render"; import { EnvironmentId, ProviderDriverKind, @@ -6,9 +7,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import type { LimitPresentations } from "@t3tools/shared/usageLimits"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; // Real pointer clicks need the dialog's stacking styles to land above Base UI's modal overlay. @@ -70,27 +69,12 @@ const presentations = (resetsAt: string | undefined, availableCount: number): Li ], ]); -let root: Root; -let container: HTMLDivElement; - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { consume.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await act(() => - root.render( - , - ), - ); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} +const initial = () => ( + +); async function click(label: string) { const button = Array.from(document.querySelectorAll("button")).find( @@ -134,6 +118,8 @@ it.each([ text: "Codex rejected the credit.", }, ])("keeps pending feedback outside the closed popup until $label", async ({ result, text }) => { + setup(); + await using _screen = await render(initial()); let answer!: (result: unknown) => void; consume.mockReturnValue(new Promise((resolve) => (answer = resolve))); await click("Alpha:"); @@ -160,6 +146,9 @@ it.each([ }); it("omits an empty reset plate but keeps countdowns and banked credits visible", async () => { + setup(); + await using screen = await render(initial()); + await page.viewport(1440, 900); const now = Date.parse("2026-09-03T11:00:00Z"); const segment = page.getByRole("button", { name: /^Alpha:/ }); @@ -168,13 +157,13 @@ it("omits an empty reset plate but keeps countdowns and banked credits visible", segment.element().querySelector('[class~="bg-background/85"]'); await act(() => - root.render(), + screen.rerender(), ); await expect.element(segment.getByText("100%", { exact: true })).toBeVisible(); expect(plate()).toBeNull(); await act(() => - root.render( + screen.rerender( , ), ); @@ -182,13 +171,16 @@ it("omits an empty reset plate but keeps countdowns and banked credits visible", expect(plate()?.textContent).toBe("↻ 3h 0m"); await act(() => - root.render(), + screen.rerender(), ); await expect.element(plate()).toBeVisible(); expect(plate()?.textContent).toBe("1"); }); it("moves the one limit popover between windows on hover", async () => { + setup(); + await using screen = await render(initial()); + const { container } = screen; const [environmentId, presentation] = [...presentations("2026-09-03T14:00:00Z", 1)][0]!; const serverConfig = presentation.serverConfig!; const provider = serverConfig.providers[0]!; @@ -217,7 +209,7 @@ it("moves the one limit popover between windows on hover", async () => { ]); // Room above the bars so the popover opens there instead of flipping over the next card. container.style.paddingTop = "20rem"; - await act(() => root.render()); + await act(() => screen.rerender()); const segment = (label: string) => Array.from(document.querySelectorAll("button")).find((node) => node.getAttribute("aria-label")?.startsWith(label), @@ -244,17 +236,20 @@ it("moves the one limit popover between windows on hover", async () => { await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); await expectDismissedInPlace(() => - act(() => root.render()), + act(() => screen.rerender()), ); await act(() => userEvent.hover(container, { position: { x: 4, y: 4 } })); - await act(() => root.render()); + await act(() => screen.rerender()); await expect.poll(popups).toEqual([]); await act(() => userEvent.click(segment("Alpha: 60%"))); await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); }); it("closes an open source-account popover when reset availability replaces its trigger", async () => { + setup(); + await using screen = await render(initial()); + const [environmentId, presentation] = [...presentations("2026-09-03T14:00:00Z", 1)][0]!; const provider = presentation.serverConfig!.providers[0]!; const source = (nextCreditId?: string): LimitPresentations => @@ -291,18 +286,20 @@ it("closes an open source-account popover when reset availability replaces its t ], ]); const popup = () => document.querySelector('[data-slot="popover-popup"]'); - await act(() => root.render()); + await act(() => + screen.rerender(), + ); await click("Alpha: 60%"); await expect.poll(() => popup()?.textContent).toContain("Use reset"); await expectDismissedInPlace(() => - act(() => root.render()), + act(() => screen.rerender()), ); await click("Alpha: 60%"); await expect.poll(() => popup()?.textContent).toContain("Left60%"); await expectDismissedInPlace(() => - act(() => root.render()), + act(() => screen.rerender()), ); await click("Alpha: 60%"); await expect.poll(() => popup()?.textContent).toContain("Use reset"); diff --git a/apps/web/src/components/usage/UsagePage.browser.test.tsx b/apps/web/src/components/usage/UsagePage.browser.test.tsx index 16ce09b401a5..1b862045e1eb 100644 --- a/apps/web/src/components/usage/UsagePage.browser.test.tsx +++ b/apps/web/src/components/usage/UsagePage.browser.test.tsx @@ -1,8 +1,7 @@ +import { render } from "../../../test/render"; import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -45,7 +44,7 @@ const environments = [ }, ]; -beforeEach(() => { +function setup() { testState.useUsage.mockReturnValue({ merged: mergeUsage([], USAGE_CONTRACT_VERSION), environments, @@ -55,31 +54,11 @@ beforeEach(() => { isPartial: false, refresh: vi.fn(), }); -}); + testState.navigate.mockClear(); + testState.canGoBack = true; +} describe("UsagePage Escape navigation", () => { - let renderer: Root; - let container: HTMLDivElement; - let back: ReturnType; - - beforeEach(async () => { - back = vi.spyOn(window.history, "back").mockImplementation(() => {}); - testState.navigate.mockClear(); - testState.canGoBack = true; - container = document.createElement("div"); - document.body.append(container); - renderer = createRoot(container); - await act(() => { - renderer.render(); - }); - }); - - afterEach(async () => { - await act(() => renderer.unmount()); - container.remove(); - back.mockRestore(); - }); - // Playwright cannot press a repeating or composing key, so those cases dispatch the event. function escape(properties: { repeat?: boolean; isComposing?: boolean } = {}) { return new KeyboardEvent("keydown", { @@ -91,14 +70,22 @@ describe("UsagePage Escape navigation", () => { } it("returns to the previous page on Escape", async () => { + setup(); + using back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + await using _screen = await render(); + await userEvent.keyboard("{Escape}"); expect(back).toHaveBeenCalledOnce(); expect(testState.navigate).not.toHaveBeenCalled(); }); it("returns home when there is no previous app page", async () => { + setup(); + using back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + await using screen = await render(); + testState.canGoBack = false; - await act(() => renderer.render()); + await screen.rerender(); await userEvent.keyboard("{Escape}"); expect(testState.navigate).toHaveBeenCalledWith({ to: "/" }); @@ -106,6 +93,10 @@ describe("UsagePage Escape navigation", () => { }); it("closes the environment menu before Escape navigates back", async () => { + setup(); + using back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + await using screen = await render(); + const { container } = screen; const trigger = container.querySelector('[data-slot="menu-trigger"]')!; await userEvent.click(trigger); await expect.element(page.getByRole("menu")).toBeVisible(); @@ -119,9 +110,15 @@ describe("UsagePage Escape navigation", () => { expect(back).toHaveBeenCalledOnce(); }); - it.each([{ repeat: true }, { isComposing: true }])("ignores Escape with %j", (properties) => { - document.body.dispatchEvent(escape(properties)); - expect(back).not.toHaveBeenCalled(); - expect(testState.navigate).not.toHaveBeenCalled(); - }); + it.each([{ repeat: true }, { isComposing: true }])( + "ignores Escape with %j", + async (properties) => { + setup(); + using back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + await using _screen = await render(); + document.body.dispatchEvent(escape(properties)); + expect(back).not.toHaveBeenCalled(); + expect(testState.navigate).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx index 928679b122a5..fad5217e749f 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.browser.test.tsx @@ -1,10 +1,10 @@ +import { act, render } from "../../../test/render"; import { RegistryContext } from "@effect/atom-react"; import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import type { Atom } from "effect/reactivity"; -import { act, StrictMode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { StrictMode } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -76,8 +76,6 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; import { UsagePage } from "./UsagePage"; const PREFERENCES_KEY = "t3code:usage-page-preferences:v1"; -let root: Root; -let container: HTMLDivElement; // Limits refreshes are throttled per environment for the whole session, so each test // gets fresh environment ids. let environmentNumber = 0; @@ -128,37 +126,34 @@ const resetsIn = (countdown: string) => const refreshButton = () => page.getByRole("button", { name: "Refresh limits" }); const selectMetric = (label: string) => userEvent.click(page.getByRole("button", { name: label })); -async function render({ strict = false } = {}) { +async function renderPage({ strict = false } = {}) { const content = ( ); - await act(async () => root.render(strict ? {content} : content)); + return render(strict ? {content} : content); } -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function setup() { // The metric toggle and its refresh button only show on wide screens. await page.viewport(1440, 900); - vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); + const now = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); environmentNumber += 1; environmentId = EnvironmentId.make(`test-${environmentNumber}`); + const previous = localStorage.getItem(PREFERENCES_KEY); localStorage.setItem(PREFERENCES_KEY, JSON.stringify({ metric: "limits", windowDays: 30 })); state.refreshProviders.mockReset().mockResolvedValue(undefined); await setPresentations([[environmentId, presentation()]]); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + return { + [Symbol.dispose]() { + now.mockRestore(); + if (previous === null) localStorage.removeItem(PREFERENCES_KEY); + else localStorage.setItem(PREFERENCES_KEY, previous); + appAtomRegistry.set(state.presentations, new Map()); + }, + }; +} it.each([ ["wide", 1440], @@ -166,8 +161,9 @@ it.each([ ])( "refreshes the visible limits countdown from the %s toolbar without switching tabs, even when quota is unchanged", async (_layout, width) => { + using _state = await setup(); await page.viewport(width, 900); - await render(); + await using _screen = await renderPage(); await expect.element(resetsIn("2h 0m")).toBeVisible(); setNow("2026-09-11T12:30:00Z"); @@ -180,7 +176,8 @@ it.each([ ); it("uses the current time when returning to limits from tokens", async () => { - await render(); + using _state = await setup(); + await using _screen = await renderPage(); await selectMetric("Tokens"); setNow("2026-09-11T13:00:00Z"); await selectMetric("Limits"); @@ -190,17 +187,17 @@ it("uses the current time when returning to limits from tokens", async () => { }); it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => { + using _state = await setup(); localStorage.setItem(PREFERENCES_KEY, JSON.stringify({ metric: "tokens", windowDays: 30 })); - await render({ strict: true }); + await using screen = await renderPage({ strict: true }); expect(state.refreshProviders).not.toHaveBeenCalled(); await selectMetric("Limits"); await vi.waitFor(() => expect(state.refreshProviders).toHaveBeenCalledTimes(1)); await selectMetric("Tokens"); await selectMetric("Limits"); - await act(async () => root.unmount()); - root = createRoot(container); - await render({ strict: true }); + await screen.unmount(); + await using _remounted = await renderPage({ strict: true }); expect(state.refreshProviders).toHaveBeenCalledTimes(1); await selectMetric("Tokens"); @@ -210,8 +207,9 @@ it("refreshes once on opening Limits and suppresses rapid returns and remounts", }); it("waits for connection and refreshes new environments during a slow refresh", async () => { + using _state = await setup(); await setPresentations([[environmentId, presentation("disconnected")]]); - await render(); + await using _screen = await renderPage(); expect(state.refreshProviders).not.toHaveBeenCalled(); let finishRefresh!: () => void; @@ -232,11 +230,12 @@ it("waits for connection and refreshes new environments during a slow refresh", }); it("keeps manual refresh busy until the already-running automatic check settles", async () => { + using _state = await setup(); let finishRefresh!: () => void; state.refreshProviders.mockImplementationOnce( () => new Promise((resolve) => (finishRefresh = () => resolve(undefined))), ); - await render(); + await using _screen = await renderPage(); await vi.waitFor(() => expect(state.refreshProviders).toHaveBeenCalledTimes(1)); await userEvent.click(refreshButton()); diff --git a/apps/web/src/hooks/useActiveThreadRef.browser.test.tsx b/apps/web/src/hooks/useActiveThreadRef.browser.test.tsx index d31ccda34cf9..04ae1264433a 100644 --- a/apps/web/src/hooks/useActiveThreadRef.browser.test.tsx +++ b/apps/web/src/hooks/useActiveThreadRef.browser.test.tsx @@ -1,18 +1,32 @@ +import { useEffect } from "react"; +import { render } from "../../test/render"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { useActiveThreadRef } from "./useActiveThreadRef"; type ThreadLike = { environmentId: EnvironmentId; id: ThreadId; status: string } | null; -let root: Root; -let observed: Array = []; - -function Probe({ thread }: { thread: ThreadLike }) { - observed.push(useActiveThreadRef(thread)); - return null; +async function renderThread(value: ThreadLike) { + let observed: ScopedThreadRef | null = null; + function Probe({ thread }: { thread: ThreadLike }) { + const result = useActiveThreadRef(thread); + useEffect(() => { + observed = result; + }); + return null; + } + const screen = await render(); + return { + ...screen, + get current() { + return observed; + }, + async update(value: ThreadLike) { + await screen.rerender(); + return observed; + }, + }; } const thread = (id: string, status = "idle"): ThreadLike => ({ @@ -21,35 +35,21 @@ const thread = (id: string, status = "idle"): ThreadLike => ({ status, }); -async function render(value: ThreadLike) { - await act(() => root.render()); - return observed.at(-1); -} - -beforeEach(() => { - observed = []; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); - describe("useActiveThreadRef", () => { it("keeps the same ref while the thread object is replaced during a run", async () => { - const first = await render(thread("t1", "running")); - const afterUpdate = await render(thread("t1", "waiting")); + await using screen = await renderThread(thread("t1", "running")); + const first = screen.current; + const afterUpdate = await screen.update(thread("t1", "waiting")); expect(first).toEqual({ environmentId: "env-1", threadId: "t1" }); expect(afterUpdate).toBe(first); }); it("returns a new ref when the thread changes or goes away", async () => { - const first = await render(thread("t1")); - const switched = await render(thread("t2")); - const gone = await render(null); + await using screen = await renderThread(thread("t1")); + const first = screen.current; + const switched = await screen.update(thread("t2")); + const gone = await screen.update(null); expect(switched).not.toBe(first); expect(switched).toEqual({ environmentId: "env-1", threadId: "t2" }); diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.browser.test.tsx b/apps/web/src/hooks/useEnvironmentDisconnectDelay.browser.test.tsx index 669a9e1362e9..907748c3bb8f 100644 --- a/apps/web/src/hooks/useEnvironmentDisconnectDelay.browser.test.tsx +++ b/apps/web/src/hooks/useEnvironmentDisconnectDelay.browser.test.tsx @@ -1,65 +1,52 @@ +import { act, fakeTimers, render } from "../../test/render"; import { EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { useEnvironmentDisconnectDelay } from "./useEnvironmentDisconnectDelay"; const environmentId = EnvironmentId.make("remote"); -let root: Root; -let container: HTMLDivElement; - function Probe({ unavailableId }: { unavailableId: EnvironmentId | null }) { return <>{useEnvironmentDisconnectDelay(unavailableId) ? "Disconnected" : "Waiting"}; } -const shown = () => container.textContent; -const render = (unavailableId: EnvironmentId | null) => - act(() => root.render()); - -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - root = createRoot(container); - render(environmentId); -}); - -afterEach(() => { - act(() => root.unmount()); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -it("waits 20 seconds without restarting on renders for the same environment", () => { - act(() => vi.advanceTimersByTime(10_000)); +it("waits 20 seconds without restarting on renders for the same environment", async () => { + using _timers = fakeTimers(); + await using screen = await render(, { router: false }); + const shown = () => screen.container.textContent; + await act(() => vi.advanceTimersByTime(10_000)); expect(shown()).toBe("Waiting"); - render(environmentId); - act(() => vi.advanceTimersByTime(9_999)); + await screen.rerender(); + await act(() => vi.advanceTimersByTime(9_999)); expect(shown()).toBe("Waiting"); - act(() => vi.advanceTimersByTime(1)); + await act(() => vi.advanceTimersByTime(1)); expect(shown()).toBe("Disconnected"); }); -it("cancels a brief outage and starts a fresh delay on the next outage", () => { - act(() => vi.advanceTimersByTime(10_000)); - render(null); - act(() => vi.advanceTimersByTime(20_000)); +it("cancels a brief outage and starts a fresh delay on the next outage", async () => { + using _timers = fakeTimers(); + await using screen = await render(, { router: false }); + const shown = () => screen.container.textContent; + await act(() => vi.advanceTimersByTime(10_000)); + await screen.rerender(); + await act(() => vi.advanceTimersByTime(20_000)); expect(shown()).toBe("Waiting"); - render(environmentId); - act(() => vi.advanceTimersByTime(19_999)); + await screen.rerender(); + await act(() => vi.advanceTimersByTime(19_999)); expect(shown()).toBe("Waiting"); - act(() => vi.advanceTimersByTime(1)); + await act(() => vi.advanceTimersByTime(1)); expect(shown()).toBe("Disconnected"); - render(null); + await screen.rerender(); expect(shown()).toBe("Waiting"); }); -it("does not carry elapsed time to another environment", () => { - act(() => vi.advanceTimersByTime(20_000)); +it("does not carry elapsed time to another environment", async () => { + using _timers = fakeTimers(); + await using screen = await render(, { router: false }); + const shown = () => screen.container.textContent; + await act(() => vi.advanceTimersByTime(20_000)); expect(shown()).toBe("Disconnected"); - render(EnvironmentId.make("another-remote")); + await screen.rerender(); expect(shown()).toBe("Waiting"); - act(() => vi.advanceTimersByTime(20_000)); + await act(() => vi.advanceTimersByTime(20_000)); expect(shown()).toBe("Disconnected"); }); diff --git a/apps/web/src/hooks/useHtmlRenderTheme.browser.test.tsx b/apps/web/src/hooks/useHtmlRenderTheme.browser.test.tsx index 368b3d9beb05..1115c5accb0d 100644 --- a/apps/web/src/hooks/useHtmlRenderTheme.browser.test.tsx +++ b/apps/web/src/hooks/useHtmlRenderTheme.browser.test.tsx @@ -1,9 +1,9 @@ import { HTML_RENDER_DEFAULT_FONTS, type HtmlRenderTheme } from "@t3tools/shared/htmlRender"; import { BUILT_IN_THEME_IDS } from "@t3tools/shared/themePalettes"; -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "../../test/render"; import { getStandardThemeColors, setEnvironmentThemes, @@ -24,21 +24,12 @@ vi.mock(import("./useTheme"), async (original) => ({ useTheme: (() => selection) as never, })); -let root: Root | undefined; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - selection.themeHalves = null; -}); - -afterEach(() => { - act(() => root?.unmount()); - root = undefined; - setEnvironmentThemes([]); - vi.unstubAllGlobals(); -}); +function setup(theme: ThemePreference, resolvedTheme: ThemeAppearance): Disposable { + Object.assign(selection, { theme, resolvedTheme, themeHalves: null }); + return { [Symbol.dispose]: () => setEnvironmentThemes([]) }; +} -function readRenderTheme(): HtmlRenderTheme { +async function readRenderTheme(): Promise { let theme: HtmlRenderTheme | undefined; function Probe() { const resolved = useHtmlRenderTheme(); @@ -47,9 +38,7 @@ function readRenderTheme(): HtmlRenderTheme { }, [resolved]); return null; } - act(() => root?.unmount()); - root = createRoot(document.createElement("div")); - act(() => root!.render()); + await using _screen = await render(); if (!theme) throw new Error("The HTML render theme was not resolved."); return theme; } @@ -58,11 +47,10 @@ describe("useHtmlRenderTheme", () => { for (const appearance of ["light", "dark"] as const) { it.each([appearance, ...BUILT_IN_THEME_IDS])( `injects hex colors for %s in ${appearance} mode`, - (preference) => { - selection.theme = preference; - selection.resolvedTheme = appearance; + async (preference) => { + using _selection = setup(preference, appearance); - const theme = readRenderTheme(); + const theme = await readRenderTheme(); expect(theme.appearance).toBe(appearance); for (const [variable, value] of Object.entries(theme.variables)) { @@ -76,7 +64,8 @@ describe("useHtmlRenderTheme", () => { ); } - it("preserves alpha when converting a published palette", () => { + it("preserves alpha when converting a published palette", async () => { + using _selection = setup("transparent", "dark"); setEnvironmentThemes([ { id: "transparent", @@ -85,10 +74,8 @@ describe("useHtmlRenderTheme", () => { colors: { ...getStandardThemeColors("dark"), accent: "oklch(0.5 0 0 / 0.5)" }, }, ]); - selection.theme = "transparent"; - selection.resolvedTheme = "dark"; - const theme = readRenderTheme(); + const theme = await readRenderTheme(); expect(theme.variables["--accent"]).toBe("#63636380"); expect(theme.variables["--chart-1"]).toBe("#63636380"); diff --git a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx index 803eb0f0534f..98937bbfe075 100644 --- a/apps/web/src/hooks/useLiveRefresh.browser.test.tsx +++ b/apps/web/src/hooks/useLiveRefresh.browser.test.tsx @@ -1,87 +1,81 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, fakeTimers, render } from "../../test/render"; import { LIVE_REFRESH_IDLE_AFTER_MS, useLiveRefresh } from "./useLiveRefresh"; -let root: Root; -let visibility: DocumentVisibilityState; -const refresh = vi.fn(); - -function setVisibility(next: DocumentVisibilityState) { - visibility = next; - document.dispatchEvent(new Event("visibilitychange")); +function setup() { + using stack = new DisposableStack(); + stack.use(fakeTimers()); + let visibility: DocumentVisibilityState = "visible"; + stack.use(vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility)); + const environment = stack.move(); + return { + refresh: vi.fn(), + setVisibility(next: DocumentVisibilityState) { + visibility = next; + document.dispatchEvent(new Event("visibilitychange")); + }, + [Symbol.dispose]: () => environment.dispose(), + }; } -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - visibility = "visible"; - vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); - refresh.mockReset(); - root = createRoot(document.createElement("div")); -}); - -afterEach(() => { - act(() => root.unmount()); - vi.restoreAllMocks(); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - describe("live refresh cadence", () => { - it("polls every 45 seconds, pauses while hidden or busy, respects disabling, and cleans up", () => { + it("polls every 45 seconds, pauses while hidden or busy, respects disabling, and cleans up", async () => { + using env = setup(); + const { refresh } = env; vi.setSystemTime(0); function Probe({ pending = false, enabled = true }) { useLiveRefresh(pending ? null : refresh, { intervalMs: 45_000, enabled }); return null; } - act(() => root.render()); - act(() => vi.advanceTimersByTime(44_999)); + await using screen = await render(); + await act(() => vi.advanceTimersByTime(44_999)); expect(refresh).not.toHaveBeenCalled(); - act(() => vi.advanceTimersByTime(1)); + await act(() => vi.advanceTimersByTime(1)); expect(refresh).toHaveBeenCalledTimes(1); - act(() => vi.advanceTimersByTime(45_000)); + await act(() => vi.advanceTimersByTime(45_000)); expect(refresh).toHaveBeenCalledTimes(2); - setVisibility("hidden"); - act(() => vi.advanceTimersByTime(90_000)); + env.setVisibility("hidden"); + await act(() => vi.advanceTimersByTime(90_000)); expect(refresh).toHaveBeenCalledTimes(2); - setVisibility("visible"); + env.setVisibility("visible"); window.dispatchEvent(new Event("focus")); expect(refresh).toHaveBeenCalledTimes(3); - act(() => root.render()); - act(() => vi.advanceTimersByTime(45_000)); + await screen.rerender(); + await act(() => vi.advanceTimersByTime(45_000)); expect(refresh).toHaveBeenCalledTimes(3); - act(() => root.render()); - act(() => vi.advanceTimersByTime(45_000)); + await screen.rerender(); + await act(() => vi.advanceTimersByTime(45_000)); expect(refresh).toHaveBeenCalledTimes(4); - act(() => root.render()); - act(() => vi.advanceTimersByTime(90_000)); + await screen.rerender(); + await act(() => vi.advanceTimersByTime(90_000)); window.dispatchEvent(new Event("focus")); expect(refresh).toHaveBeenCalledTimes(4); - act(() => root.render(null)); - act(() => vi.advanceTimersByTime(45_000)); + await screen.rerender(null); + await act(() => vi.advanceTimersByTime(45_000)); expect(refresh).toHaveBeenCalledTimes(4); }); it.each(["focus", "visibilitychange"])( "keeps an idle view paused after %s until input", - (event) => { + async (event) => { + using env = setup(); + const { refresh } = env; vi.setSystemTime(1_000_000); function Probe() { useLiveRefresh(refresh, { intervalMs: 45_000 }); return null; } - act(() => root.render()); - act(() => vi.advanceTimersByTime(LIVE_REFRESH_IDLE_AFTER_MS)); + await using _screen = await render(); + await act(() => vi.advanceTimersByTime(LIVE_REFRESH_IDLE_AFTER_MS)); refresh.mockClear(); - act(() => (event === "focus" ? window : document).dispatchEvent(new Event(event))); + await act(() => (event === "focus" ? window : document).dispatchEvent(new Event(event))); expect(refresh).not.toHaveBeenCalled(); - act(() => vi.advanceTimersByTime(90_000)); + await act(() => vi.advanceTimersByTime(90_000)); expect(refresh).not.toHaveBeenCalled(); - act(() => document.dispatchEvent(new Event("pointerdown"))); + await act(() => document.dispatchEvent(new Event("pointerdown"))); expect(refresh).toHaveBeenCalledTimes(1); - act(() => vi.advanceTimersByTime(45_000)); + await act(() => vi.advanceTimersByTime(45_000)); expect(refresh).toHaveBeenCalledTimes(2); }, ); diff --git a/apps/web/src/hooks/useLocalStorage.hook.browser.test.tsx b/apps/web/src/hooks/useLocalStorage.hook.browser.test.tsx index 26f06c75e8d9..7501763d43e2 100644 --- a/apps/web/src/hooks/useLocalStorage.hook.browser.test.tsx +++ b/apps/web/src/hooks/useLocalStorage.hook.browser.test.tsx @@ -1,51 +1,52 @@ import * as Schema from "effect/Schema"; -import { act, createElement, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; import { useLocalStorage } from "./useLocalStorage"; const KEY = "corrupt-key"; -let root: Root; -let current: ReturnType>; +type StoredNumber = ReturnType>; -function Probe() { - const result = useLocalStorage(KEY, 0, Schema.Number); - useEffect(() => { - current = result; - }); - return null; -} - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(console, "error").mockImplementation(() => {}); +async function renderWithCorruptValue() { + await using stack = new AsyncDisposableStack(); + stack.use(vi.spyOn(console, "error").mockImplementation(() => {})); localStorage.setItem(KEY, JSON.stringify("not a number")); - root = createRoot(document.createElement("div")); - await act(() => root.render(createElement(Probe))); -}); - -afterEach(async () => { - await act(() => root.unmount()); - localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + stack.defer(() => localStorage.clear()); + let current: StoredNumber | undefined; + function Probe() { + const result = useLocalStorage(KEY, 0, Schema.Number); + useEffect(() => { + current = result; + }); + return null; + } + stack.use(await render()); + const environment = stack.move(); + return { + get current() { + return current!; + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} describe("useLocalStorage with a stored value that fails decode", () => { it("replaces it with a plain write", async () => { - expect(current[0]).toBe(0); + await using stored = await renderWithCorruptValue(); + expect(stored.current[0]).toBe(0); - await act(() => current[1](42)); + await act(() => stored.current[1](42)); expect(localStorage.getItem(KEY)).toBe("42"); - expect(current[0]).toBe(42); + expect(stored.current[0]).toBe(42); }); it("runs an updater from the initial value", async () => { - await act(() => current[1]((value) => value + 1)); + await using stored = await renderWithCorruptValue(); + await act(() => stored.current[1]((value) => value + 1)); expect(localStorage.getItem(KEY)).toBe("1"); - expect(current[0]).toBe(1); + expect(stored.current[0]).toBe(1); }); }); diff --git a/apps/web/src/hooks/usePullRequestChecksRefresh.browser.test.tsx b/apps/web/src/hooks/usePullRequestChecksRefresh.browser.test.tsx index 12bee62ca8e6..4c98e1807956 100644 --- a/apps/web/src/hooks/usePullRequestChecksRefresh.browser.test.tsx +++ b/apps/web/src/hooks/usePullRequestChecksRefresh.browser.test.tsx @@ -1,32 +1,18 @@ import type { PullRequestCheck } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; +import { act, fakeTimers, render } from "../../test/render"; import { usePullRequestChecksRefresh } from "./usePullRequestChecksRefresh"; -let root: Root; -let visibility: DocumentVisibilityState; - -beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(1_000_000); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - visibility = "visible"; - vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); - root = createRoot(document.createElement("div")); -}); - -afterEach(() => { - act(() => root.unmount()); - vi.restoreAllMocks(); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - it.each(["pending", "action-required"] as const)( "polls quiet PRs each minute, speeds up for %s or missing checks, resumes from idle, and stops for closed PRs", - (pendingStatus) => { + async (pendingStatus) => { + using _timers = fakeTimers(); + vi.setSystemTime(1_000_000); + let visibility: DocumentVisibilityState = "visible"; + using _visibility = vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility); const refresh = vi.fn(); function Probe({ status = "success", @@ -43,46 +29,45 @@ it.each(["pending", "action-required"] as const)( }); return null; } - const update = (props: Parameters[0]) => - act(() => root.render()); + await using screen = await render(); + const update = (props: Parameters[0]) => screen.rerender(); const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms)); - update({}); - advance(59_999); + await advance(59_999); expect(refresh).toHaveBeenCalledTimes(0); - advance(1); + await advance(1); expect(refresh).toHaveBeenCalledTimes(1); - update({ status: pendingStatus }); - advance(44_999); + await update({ status: pendingStatus }); + await advance(44_999); expect(refresh).toHaveBeenCalledTimes(1); - advance(1); + await advance(1); expect(refresh).toHaveBeenCalledTimes(2); - update({ status: "failure" }); - advance(60_000); + await update({ status: "failure" }); + await advance(60_000); expect(refresh).toHaveBeenCalledTimes(3); - update({ status: null }); + await update({ status: null }); for (let tick = 0; tick < 3; tick++) { - advance(45_000); - update({ status: null }); + await advance(45_000); + await update({ status: null }); } expect(refresh).toHaveBeenCalledTimes(6); - advance(44_999); + await advance(44_999); expect(refresh).toHaveBeenCalledTimes(6); - advance(1); + await advance(1); expect(refresh).toHaveBeenCalledTimes(7); - advance(6 * 60_000); + await advance(6 * 60_000); const beforeResume = refresh.mock.calls.length; - act(() => document.dispatchEvent(new Event("pointerdown"))); + await act(() => document.dispatchEvent(new Event("pointerdown"))); expect(refresh).toHaveBeenCalledTimes(beforeResume + 1); visibility = "hidden"; - act(() => document.dispatchEvent(new Event("visibilitychange"))); - advance(60_000); + await act(() => document.dispatchEvent(new Event("visibilitychange"))); + await advance(60_000); expect(refresh).toHaveBeenCalledTimes(beforeResume + 1); visibility = "visible"; - act(() => document.dispatchEvent(new Event("visibilitychange"))); + await act(() => document.dispatchEvent(new Event("visibilitychange"))); expect(refresh).toHaveBeenCalledTimes(beforeResume + 2); - update({ enabled: false }); - advance(120_000); + await update({ enabled: false }); + await advance(120_000); expect(refresh).toHaveBeenCalledTimes(beforeResume + 2); }, ); diff --git a/apps/web/src/hooks/useResizableWidth.browser.test.tsx b/apps/web/src/hooks/useResizableWidth.browser.test.tsx index 190b7b7c78f2..7624ca605399 100644 --- a/apps/web/src/hooks/useResizableWidth.browser.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.browser.test.tsx @@ -1,16 +1,9 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render, type Screen } from "../../test/render"; import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; -let root: Root; -let container: HTMLDivElement; -let captured = false; -let frame: FrameRequestCallback | undefined; -const cancelAnimationFrame = vi.fn(); -let setItem: ReturnType>; - function Panel({ edge = "left", maxWidth = 800, @@ -35,97 +28,106 @@ function Panel({ ); } -const host = () => container.querySelector("[data-testid=host]")!; -const shownWidth = () => Number(container.querySelector("output")!.textContent); -const liveWidth = () => host().style.getPropertyValue(RESIZABLE_WIDTH_PROPERTY); -const bodyStyle = () => document.body.style; - -function handle() { - const element = container.querySelector("[data-testid=handle]")!; - // Synthetic pointers have no active pointer for the browser to capture. - element.setPointerCapture = () => { - captured = true; - }; - element.hasPointerCapture = () => captured; - element.releasePointerCapture = () => { - captured = false; - }; - return element; -} - -function pointer( - type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel" | "lostpointercapture", - clientX = 100, -) { - handle().dispatchEvent( - new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX }), +async function renderPanel() { + await using stack = new AsyncDisposableStack(); + let captured = false; + let frame: FrameRequestCallback | undefined; + const cancelAnimationFrame = vi.fn(); + stack.use( + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + frame = callback; + return 42; + }), ); -} + stack.use(vi.spyOn(window, "cancelAnimationFrame").mockImplementation(cancelAnimationFrame)); + const setItem = stack.use(vi.spyOn(Storage.prototype, "setItem")); + localStorage.clear(); + stack.defer(() => localStorage.clear()); + let screen: Screen = stack.use(await render()); + const container = () => screen.container; + const host = () => container().querySelector("[data-testid=host]")!; -async function render(element = ) { - await act(() => root.render(element)); -} + function handle() { + const element = container().querySelector("[data-testid=handle]")!; + // Synthetic pointers have no active pointer for the browser to capture. + element.setPointerCapture = () => { + captured = true; + }; + element.hasPointerCapture = () => captured; + element.releasePointerCapture = () => { + captured = false; + }; + return element; + } -async function mountFresh(element = ) { - root = createRoot(container); - await render(element); + const environment = stack.move(); + return { + setItem, + cancelAnimationFrame, + get captured() { + return captured; + }, + frame: () => frame?.(0), + host, + shownWidth: () => Number(container().querySelector("output")!.textContent), + liveWidth: () => host().style.getPropertyValue(RESIZABLE_WIDTH_PROPERTY), + pointer( + type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel" | "lostpointercapture", + clientX = 100, + ) { + handle().dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + clientX, + }), + ); + }, + rerender: (element: ReactNode = ) => screen.rerender(element), + unmount: () => screen.unmount(), + async mountFresh(element: ReactNode = ) { + screen = environment.use(await render(element)); + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } -beforeEach(async () => { - localStorage.clear(); - captured = false; - frame = undefined; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - frame = callback; - return 42; - }); - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(cancelAnimationFrame); - setItem = vi.spyOn(Storage.prototype, "setItem"); - container = document.createElement("div"); - document.body.append(container); - await mountFresh(); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - cancelAnimationFrame.mockClear(); -}); +const bodyStyle = () => document.body.style; describe("panel resize cleanup", () => { it.each(["unmount", "lost capture", "blur", "cancel"])( "clears the cursor and pending resize after %s", async (reason) => { + await using panel = await renderPanel(); + const { pointer, host, shownWidth, liveWidth, setItem, cancelAnimationFrame } = panel; await act(() => { pointer("pointerdown"); pointer("pointermove", 50); }); const resizedHost = host(); - await act(() => frame?.(0)); + await act(() => panel.frame()); expect(liveWidth()).toBe("450px"); expect(resizedHost.style.getPropertyValue("transition-property")).toBe("none"); expect(shownWidth()).toBe(400); await act(() => pointer("pointermove", 25)); expect(bodyStyle().cursor).toBe("col-resize"); expect(bodyStyle().userSelect).toBe("none"); + if (reason === "unmount") await panel.unmount(); await act(() => { - if (reason === "unmount") root.unmount(); if (reason === "lost capture") pointer("lostpointercapture"); if (reason === "blur") window.dispatchEvent(new Event("blur")); if (reason === "cancel") pointer("pointercancel"); }); expect(bodyStyle().cursor).toBe(""); expect(bodyStyle().userSelect).toBe(""); - expect(captured).toBe(false); + expect(panel.captured).toBe(false); expect(resizedHost.style.getPropertyValue("transition-property")).toBe(""); expect(cancelAnimationFrame).toHaveBeenCalledWith(42); if (reason === "unmount") { expect(setItem).not.toHaveBeenCalled(); - await mountFresh(); + await panel.mountFresh(); } else { expect(shownWidth()).toBe(475); expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475"); @@ -136,7 +138,9 @@ describe("panel resize cleanup", () => { it.each(["left", "right"] as const)( "uses the release position for a fast %s-edge drag", async (edge) => { - await render(); + await using panel = await renderPanel(); + const { pointer, shownWidth, setItem, cancelAnimationFrame } = panel; + await panel.rerender(); await act(() => { pointer("pointerdown"); pointer("pointermove", edge === "left" ? 50 : 150); @@ -152,16 +156,18 @@ describe("panel resize cleanup", () => { [800, 450], [450, 800], ])("uses updated bounds during a drag from max %s to %s", async (initialMax, nextMax) => { - await render(); + await using panel = await renderPanel(); + const { pointer, shownWidth, liveWidth, setItem } = panel; + await panel.rerender(); await act(() => { pointer("pointerdown"); pointer("pointermove", -200); }); - await act(() => frame?.(0)); + await act(() => panel.frame()); expect(liveWidth()).toBe(`${Math.min(700, initialMax)}px`); - await render(); + await panel.rerender(); await act(() => pointer("pointermove", -250)); - await act(() => frame?.(0)); + await act(() => panel.frame()); expect(liveWidth()).toBe(`${Math.min(750, nextMax)}px`); await act(() => pointer("pointerup", -300)); expect(shownWidth()).toBe(nextMax); @@ -169,6 +175,8 @@ describe("panel resize cleanup", () => { }); it("handles release before any move event", async () => { + await using panel = await renderPanel(); + const { pointer, shownWidth, setItem } = panel; await act(() => { pointer("pointerdown"); pointer("pointerup", 25); @@ -178,6 +186,8 @@ describe("panel resize cleanup", () => { }); it("clamps the release position to the panel bounds", async () => { + await using panel = await renderPanel(); + const { pointer, shownWidth, setItem } = panel; await act(() => { pointer("pointerdown"); pointer("pointerup", -1000); @@ -187,6 +197,8 @@ describe("panel resize cleanup", () => { }); it("saves the final width when release is followed by lost capture", async () => { + await using panel = await renderPanel(); + const { pointer, shownWidth, setItem } = panel; await act(() => { pointer("pointerdown"); pointer("pointermove", 50); @@ -196,19 +208,21 @@ describe("panel resize cleanup", () => { expect(shownWidth()).toBe(450); expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "450"); expect(bodyStyle().cursor).toBe(""); - expect(captured).toBe(false); + expect(panel.captured).toBe(false); }); }); describe("panel width storage changes", () => { it("restores separate thread widths without remounting and retains them after reload", async () => { + await using panel = await renderPanel(); + const { pointer, shownWidth } = panel; await act(() => { pointer("pointerdown"); pointer("pointermove", 50); pointer("pointerup", 50); }); expect(shownWidth()).toBe(450); - await render(); + await panel.rerender(); expect(shownWidth()).toBe(400); await act(() => { pointer("pointerdown"); @@ -216,35 +230,37 @@ describe("panel width storage changes", () => { pointer("pointerup", -100); }); expect(shownWidth()).toBe(600); - await render(); + await panel.rerender(); expect(shownWidth()).toBe(450); - await act(() => root.unmount()); - await mountFresh(); + await panel.unmount(); + await panel.mountFresh(); expect(shownWidth()).toBe(600); }); it("cancels an unfinished drag on a thread switch without saving it to either thread", async () => { + await using panel = await renderPanel(); + const { pointer, shownWidth, liveWidth, setItem } = panel; localStorage.setItem("thread-b", "650"); setItem.mockClear(); await act(() => { pointer("pointerdown"); pointer("pointermove", 50); }); - await act(() => frame?.(0)); + await act(() => panel.frame()); expect(liveWidth()).toBe("450px"); await act(() => pointer("pointermove", 25)); - await render(); + await panel.rerender(); expect(shownWidth()).toBe(650); expect(liveWidth()).toBe("650px"); - expect(captured).toBe(false); + expect(panel.captured).toBe(false); expect(bodyStyle().cursor).toBe(""); await act(() => { - frame?.(0); + panel.frame(); pointer("pointerup", 25); }); expect(shownWidth()).toBe(650); expect(setItem).not.toHaveBeenCalled(); - await render(); + await panel.rerender(); expect(shownWidth()).toBe(400); }); }); diff --git a/apps/web/src/hooks/useSettings.favorites.browser.test.tsx b/apps/web/src/hooks/useSettings.favorites.browser.test.tsx index fa9298d2ac19..e60168f9c6fd 100644 --- a/apps/web/src/hooks/useSettings.favorites.browser.test.tsx +++ b/apps/web/src/hooks/useSettings.favorites.browser.test.tsx @@ -13,9 +13,10 @@ import { import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render, type Screen } from "../../test/render"; const state = vi.hoisted(() => ({ settings: new Map(), @@ -70,51 +71,39 @@ const initialSettings = { favorites: [], providerModelPreferences: {}, } satisfies ServerSettings; -let registry: AtomRegistry.AtomRegistry; -let root: Root; -let container: HTMLDivElement; -let durable: Map; -let toggles: Array<() => Promise>; -let pendingClick: Promise | undefined; -let writes: Array<{ +interface Write { environmentId: EnvironmentId; patch: ServerSettingsPatch; finish: (success?: boolean) => void; -}>; +} const settingsAtom = (id: EnvironmentId) => state.settings.get(id) as Atom.Writable; -function FavoriteEditor({ environmentId }: { environmentId: EnvironmentId }) { +function FavoriteEditor({ + environmentId, + toggles, + onClicked, +}: { + environmentId: EnvironmentId; + toggles: Array<() => Promise>; + onClicked: (pending: Promise) => void; +}) { const toggle = useToggleEnvironmentModelFavorite(environmentId); const onClick = () => toggle(provider, model); toggles.push(onClick); - return ; -} - -function mount(environmentId = primaryId) { - return act(() => - root.render( - - - , - ), - ); -} - -function click(): Promise { - container.querySelector("button")!.click(); - return pendingClick!; + return ; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - registry = AtomRegistry.make(); +async function setup() { + await using stack = new AsyncDisposableStack(); + const registry = stack.adopt(AtomRegistry.make(), (registry) => registry.dispose()); state.settings.clear(); state.connections.clear(); - durable = new Map(); - toggles = []; - writes = []; + const durable = new Map(); + const toggles: Array<() => Promise> = []; + const writes: Write[] = []; + let pendingClick: Promise | undefined; for (const id of [primaryId, remoteId]) { state.settings.set(id, Atom.make(initialSettings)); durable.set(id, initialSettings); @@ -143,22 +132,45 @@ beforeEach(() => { }); }), ); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - registry.dispose(); - vi.unstubAllGlobals(); -}); + const environment = stack.move(); + let screen: Screen | undefined; + const show = async (ui: ReactNode) => { + const tree = {ui}; + if (screen) await screen.rerender(tree); + else screen = environment.use(await render(tree)); + }; + + return { + registry, + durable, + toggles, + writes, + show, + mount: (environmentId = primaryId) => + show( + { + pendingClick = pending; + }} + />, + ), + click(): Promise { + screen!.container.querySelector("button")!.click(); + return pendingClick!; + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} describe("environment favorite toggles", () => { it.each([false, true])( "toggles twice before any server echo when initially favorited=%s", async (initiallyFavorited) => { + await using fixture = await setup(); + const { registry, durable, writes, mount, click } = fixture; const favorites = initiallyFavorited ? [{ provider, model }] : []; const settings = { ...initialSettings, favorites }; registry.set(settingsAtom(primaryId), settings); @@ -180,6 +192,8 @@ describe("environment favorite toggles", () => { ); it("keeps the newest pending intent when an earlier write completes", async () => { + await using fixture = await setup(); + const { durable, writes, mount, click } = fixture; await mount(); const first = click(); const second = click(); @@ -201,6 +215,8 @@ describe("environment favorite toggles", () => { }); it("clears failed intent so a retry still adds the favorite", async () => { + await using fixture = await setup(); + const { durable, writes, mount, click } = fixture; await mount(); const failed = click(); await act(async () => {}); @@ -214,6 +230,8 @@ describe("environment favorite toggles", () => { }); it("toggles the acknowledged choice when the RPC reply precedes its server echo", async () => { + await using fixture = await setup(); + const { registry, durable, writes, mount, click } = fixture; await mount(); const first = click(); await act(async () => {}); @@ -228,6 +246,8 @@ describe("environment favorite toggles", () => { }); it("uses a later remote change instead of a cached acknowledged choice", async () => { + await using fixture = await setup(); + const { registry, durable, writes, mount, click } = fixture; await mount(); const first = click(); await act(async () => {}); @@ -244,6 +264,8 @@ describe("environment favorite toggles", () => { }); it("preserves an acknowledged add when a later remove fails before its echo", async () => { + await using fixture = await setup(); + const { durable, writes, mount, click } = fixture; await mount(); const first = click(); await act(async () => {}); @@ -261,6 +283,8 @@ describe("environment favorite toggles", () => { }); it("does not reuse another environment's pending intent for the same model", async () => { + await using fixture = await setup(); + const { durable, writes, mount, click } = fixture; await mount(); const primary = click(); await mount(remoteId); @@ -275,6 +299,8 @@ describe("environment favorite toggles", () => { }); it("reads the latest server state even when the click callback predates its echo", async () => { + await using fixture = await setup(); + const { registry, durable, toggles, writes, mount } = fixture; await mount(); const previousClick = toggles.at(-1)!; const updated = { ...initialSettings, favorites: [{ provider, model }] }; @@ -297,6 +323,8 @@ describe("legacy model preferences migration", () => { it.each(["succeeds", "fails"] as const)( "waits for connection and retries only after reconnect when the first save %s", async (outcome) => { + await using fixture = await setup(); + const { registry, show } = fixture; const environmentId = EnvironmentId.make(`migration-${outcome}`); const otherId = EnvironmentId.make(`other-${outcome}`); for (const id of [environmentId, otherId]) { @@ -326,13 +354,7 @@ describe("legacy model preferences migration", () => { .mockReturnValueOnce(firstSave) .mockResolvedValue(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); const renderReader = (id = environmentId) => - act(() => - root.render( - - - , - ), - ); + show(); const connect = (id: EnvironmentId, generation: number) => act(() => registry.set( diff --git a/apps/web/src/hooks/useSettings.selector.browser.test.tsx b/apps/web/src/hooks/useSettings.selector.browser.test.tsx index 1fed4e5a2c5a..6f00ac274b0a 100644 --- a/apps/web/src/hooks/useSettings.selector.browser.test.tsx +++ b/apps/web/src/hooks/useSettings.selector.browser.test.tsx @@ -1,6 +1,7 @@ -import { act, createElement, Profiler, useEffect, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createElement, Profiler, useEffect, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; vi.mock("~/localApi", () => ({ ensureLocalApi: () => ({ @@ -15,11 +16,12 @@ import { useClientSettings, } from "./useSettings"; -const renders = { meter: 0, grouping: 0 }; -const seen = { grouping: [] as unknown[] }; -let root: Root; +interface Counts { + readonly renders: { meter: number; grouping: number }; + readonly grouping: unknown[]; +} -function counted(key: keyof typeof renders, child: ReactNode) { +function counted(renders: Counts["renders"], key: keyof typeof renders, child: ReactNode) { return createElement( Profiler, { @@ -37,43 +39,35 @@ function MeterReader() { return createElement("span", null, String(enabled)); } -function GroupingReader() { +function GroupingReader({ seen }: { seen: unknown[] }) { const grouping = useClientSettings((settings) => ({ mode: settings.sidebarProjectGroupingMode, overrides: settings.sidebarProjectGroupingOverrides, })); useEffect(() => { - seen.grouping.push(grouping); + seen.push(grouping); }); return null; } -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function renderReaders() { await ensureClientSettingsHydrated(); - renders.meter = 0; - renders.grouping = 0; - seen.grouping = []; - root = createRoot(document.createElement("div")); - await act(() => - root.render( - createElement( - "div", - null, - counted("meter", createElement(MeterReader)), - counted("grouping", createElement(GroupingReader)), - ), + const counts: Counts = { renders: { meter: 0, grouping: 0 }, grouping: [] }; + const screen = await render( + createElement( + "div", + null, + counted(counts.renders, "meter", createElement(MeterReader)), + counted(counts.renders, "grouping", createElement(GroupingReader, { seen: counts.grouping })), ), ); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); + return { ...counts, [Symbol.asyncDispose]: () => screen[Symbol.asyncDispose]() }; +} describe("useClientSettings(selector)", () => { it("re-renders only readers whose selected value changed", async () => { + await using readers = await renderReaders(); + const { renders } = readers; const before = { ...renders }; await act(() => @@ -87,11 +81,13 @@ describe("useClientSettings(selector)", () => { }); it("keeps a structurally equal object selection", async () => { + await using readers = await renderReaders(); + const { renders } = readers; const before = renders.grouping; await act(() => persistClientSettingsPatch({ sidebarProjectGroupingOverrides: {} })); expect(renders.grouping).toBe(before); - expect(new Set(seen.grouping).size).toBe(1); + expect(new Set(readers.grouping).size).toBe(1); }); }); diff --git a/apps/web/src/hooks/useSettings.sync.browser.test.tsx b/apps/web/src/hooks/useSettings.sync.browser.test.tsx index c6c9b2f2ab6e..617fedfc1c10 100644 --- a/apps/web/src/hooks/useSettings.sync.browser.test.tsx +++ b/apps/web/src/hooks/useSettings.sync.browser.test.tsx @@ -9,9 +9,9 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; type SessionResult = AsyncResult.AsyncResult; const state = vi.hoisted(() => ({ @@ -67,34 +67,28 @@ const session = (scopes: ReadonlyArray): AuthSessionState sessionCookieName: "t3_session", }, }); -let root: Root; -let container: HTMLDivElement; -let handlers: Array<() => void> = []; - -function SettingsEditor() { +function SettingsEditor({ handlers }: { handlers: Array<() => void> }) { const updateSettings = useUpdatePrimarySettings(); const save = () => void updateSettings(patch); handlers.push(save); return ; } -function saveSharedSettings() { - container.querySelector("button")!.click(); -} - async function mountEditor() { - await act(() => - root.render( - - - , - ), + const handlers: Array<() => void> = []; + const screen = await render( + + + , ); + return { + handlers, + saveSharedSettings: () => screen.container.querySelector("button")!.click(), + [Symbol.asyncDispose]: () => screen[Symbol.asyncDispose](), + }; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - handlers = []; +function setup() { state.sessions.clear(); state.sessions.set( primaryId, @@ -113,19 +107,11 @@ beforeEach(() => { state.persist.mockReset(); state.persist.mockResolvedValue(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); state.toast.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} describe("shared settings writes", () => { it("waits for the remaining target before reporting a partial save", async () => { + setup(); let finishRemote = () => {}; const remote = new Promise((resolve) => { finishRemote = () => resolve(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); @@ -134,8 +120,8 @@ describe("shared settings writes", () => { AsyncResult.failure(Cause.fail(new Error("Permission denied"))), ); state.persist.mockReturnValueOnce(remote); - await mountEditor(); - await act(async () => saveSharedSettings()); + await using editor = await mountEditor(); + await act(async () => editor.saveSharedSettings()); expect(state.toast).not.toHaveBeenCalled(); await act(async () => finishRemote()); expect(state.toast).toHaveBeenCalledExactlyOnceWith({ @@ -146,12 +132,13 @@ describe("shared settings writes", () => { }); it("keeps a single-target failure specific to that environment", async () => { + setup(); appAtomRegistry.set(state.sessions.get(remoteId)!, AsyncResult.success(session([]))); state.persist.mockResolvedValueOnce( AsyncResult.failure(Cause.fail(new Error("Permission denied"))), ); - await mountEditor(); - await act(async () => saveSharedSettings()); + await using editor = await mountEditor(); + await act(async () => editor.saveSharedSettings()); expect(state.toast).toHaveBeenCalledExactlyOnceWith({ type: "error", title: "Setting not saved", @@ -160,12 +147,13 @@ describe("shared settings writes", () => { }); it("names the failed environment when a server rejects a save after the grant check", async () => { + setup(); state.persist.mockResolvedValueOnce(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); state.persist.mockResolvedValueOnce( AsyncResult.failure(Cause.fail(new Error("Permission denied"))), ); - await mountEditor(); - await act(async () => saveSharedSettings()); + await using editor = await mountEditor(); + await act(async () => editor.saveSharedSettings()); expect(state.toast).toHaveBeenCalledExactlyOnceWith({ type: "error", title: "Setting saved on some environments", @@ -174,8 +162,9 @@ describe("shared settings writes", () => { }); it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => { - await mountEditor(); - saveSharedSettings(); + setup(); + await using editor = await mountEditor(); + editor.saveSharedSettings(); expect(state.persist.mock.calls).toEqual([ [{ environmentId: primaryId, input: { patch } }], @@ -192,9 +181,10 @@ describe("shared settings writes", () => { () => AsyncResult.failure(Cause.fail(new Error("session rejected"))), ], ] as const)("skips a remote whose grant is %s", async (_label, result) => { + setup(); appAtomRegistry.set(state.sessions.get(remoteId)!, result()); - await mountEditor(); - saveSharedSettings(); + await using editor = await mountEditor(); + editor.saveSharedSettings(); expect(state.persist).toHaveBeenCalledExactlyOnceWith({ environmentId: primaryId, @@ -205,11 +195,12 @@ describe("shared settings writes", () => { it.each(["disconnected", "unsupported"] as const)( "skips a %s remote even with a cold grant", async (condition) => { + setup(); const remote = state.environments[1]!; if (condition === "disconnected") remote.connection.phase = "disconnected"; else remote.serverConfig.environment.capabilities.threadAutoSettlement = false; - await mountEditor(); - saveSharedSettings(); + await using editor = await mountEditor(); + editor.saveSharedSettings(); expect(state.persist).toHaveBeenCalledExactlyOnceWith({ environmentId: primaryId, @@ -221,13 +212,14 @@ describe("shared settings writes", () => { it.each(["primary", "hosted"] as const)( "rechecks a cold remote grant that resolves to denied after the %s handler renders", async (mode) => { + setup(); if (mode === "hosted") { state.environments = state.environments.filter( (environment) => environment.environmentId !== primaryId, ); } - await mountEditor(); - const previousUpdate = handlers.at(-1)!; + await using editor = await mountEditor(); + const previousUpdate = editor.handlers.at(-1)!; await act(() => { appAtomRegistry.set(state.sessions.get(remoteId)!, AsyncResult.success(session([]))); }); diff --git a/apps/web/src/hooks/useStableCallback.browser.test.tsx b/apps/web/src/hooks/useStableCallback.browser.test.tsx index 24b48a12e241..8e08ede9d6b7 100644 --- a/apps/web/src/hooks/useStableCallback.browser.test.tsx +++ b/apps/web/src/hooks/useStableCallback.browser.test.tsx @@ -1,32 +1,18 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; +import { render } from "../../test/render"; import { useStableCallback } from "./useStableCallback"; -let root: Root; -let handlers: Array<() => string> = []; - -function Probe({ label }: { label: string }) { +function Probe({ label, handlers }: { label: string; handlers: Array<() => string> }) { handlers.push(useStableCallback(() => label)); return null; } -beforeEach(() => { - handlers = []; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); - describe("useStableCallback", () => { it("keeps one identity and calls the latest committed function", async () => { - await act(() => root.render()); - await act(() => root.render()); + const handlers: Array<() => string> = []; + await using screen = await render(); + await screen.rerender(); expect(new Set(handlers).size).toBe(1); expect(handlers[0]!()).toBe("second"); diff --git a/apps/web/src/panelAnimations.browser.test.tsx b/apps/web/src/panelAnimations.browser.test.tsx index a59f78b2a57b..4f64ea19c18d 100644 --- a/apps/web/src/panelAnimations.browser.test.tsx +++ b/apps/web/src/panelAnimations.browser.test.tsx @@ -1,48 +1,42 @@ -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, render } from "../test/render"; import { usePanelNavigationSuppression } from "./panelAnimations"; -let root: Root; -let pendingFrames: FrameRequestCallback[] = []; -let observed: boolean[] = []; - -function SuppressionProbe({ navigationKey }: { navigationKey: string }) { +function SuppressionProbe({ + navigationKey, + observed, +}: { + navigationKey: string; + observed: boolean[]; +}) { const suppressed = usePanelNavigationSuppression(navigationKey); useLayoutEffect(() => { observed.push(suppressed); - }, [suppressed]); + }, [observed, suppressed]); return null; } -beforeEach(() => { - pendingFrames = []; - observed = []; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - // Frames are stepped by hand so each assertion lands between two paints. - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - pendingFrames.push(callback); - return pendingFrames.length; - }); - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function paintPendingFrame() { - const callback = pendingFrames.shift(); - await act(() => callback?.(0)); -} - describe("usePanelNavigationSuppression", () => { it("suppresses initial and navigated panel state until each route has painted", async () => { - await act(() => root.render()); + const pendingFrames: FrameRequestCallback[] = []; + const observed: boolean[] = []; + // Frames are stepped by hand so each assertion lands between two paints. + using _requestFrame = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback) => pendingFrames.push(callback)); + using _cancelFrame = vi + .spyOn(window, "cancelAnimationFrame") + .mockImplementation(() => undefined); + const paintPendingFrame = async () => { + const callback = pendingFrames.shift(); + await act(() => callback?.(0)); + }; + + await using screen = await render( + , + ); expect(observed.at(-1)).toBe(true); await paintPendingFrame(); @@ -50,7 +44,7 @@ describe("usePanelNavigationSuppression", () => { await paintPendingFrame(); expect(observed.at(-1)).toBe(false); - await act(() => root.render()); + await screen.rerender(); expect(observed.at(-1)).toBe(true); await paintPendingFrame(); diff --git a/apps/web/src/state/queries.threadSearch.browser.test.tsx b/apps/web/src/state/queries.threadSearch.browser.test.tsx index 8d3f299e074a..c7a0c4d2053c 100644 --- a/apps/web/src/state/queries.threadSearch.browser.test.tsx +++ b/apps/web/src/state/queries.threadSearch.browser.test.tsx @@ -1,8 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, fakeTimers, render } from "../../test/render"; import { useThreadSearch } from "./queries"; const searchResults = vi.hoisted(() => ({ @@ -24,33 +23,23 @@ function Probe(props: { query: string; onResult: (result: ThreadSearch) => void } describe("useThreadSearch", () => { - let root: Root; - beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - root = createRoot(document.createElement("div")); - }); - afterEach(() => { - act(() => root.unmount()); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("keeps the settled query and matches stable while typing, then reports the settled query", () => { + it("keeps the settled query and matches stable while typing, then reports the settled query", async () => { + using _timers = fakeTimers(); const results: ThreadSearch[] = []; - const render = (query: string) => { - const element = results.push(result)} />; - act(() => root.render(element)); - return results.at(-1)!; - }; - - render(""); - const typing = ["li", "lin", "link "].map(render); + const probe = (query: string) => ( + results.push(result)} /> + ); + await using screen = await render(probe("")); + const typing: ThreadSearch[] = []; + for (const query of ["li", "lin", "link "]) { + await screen.rerender(probe(query)); + typing.push(results.at(-1)!); + } expect(typing.map((result) => result.query)).toEqual(["", "", ""]); expect(new Set(typing.map((result) => result.matches)).size).toBe(1); expect(typing.every((result) => result.isPending)).toBe(true); - act(() => { + await act(() => { vi.advanceTimersByTime(200); }); const settled = results.at(-1)!; diff --git a/apps/web/src/state/terminalSessionAvailability.browser.test.tsx b/apps/web/src/state/terminalSessionAvailability.browser.test.tsx index cbb60e849fca..0a64ad86b052 100644 --- a/apps/web/src/state/terminalSessionAvailability.browser.test.tsx +++ b/apps/web/src/state/terminalSessionAvailability.browser.test.tsx @@ -1,8 +1,8 @@ import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts"; -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { render } from "../../test/render"; import { useKnownTerminalSessions } from "./terminalSessions"; const state = vi.hoisted(() => ({ @@ -23,8 +23,7 @@ vi.mock(import("./query"), async (original) => ({ : { data: state.data, error: state.error }) as never, })); -let root: Root; -let current: ReturnType; +type KnownSessions = ReturnType; const target = { environmentId: EnvironmentId.make("secondary"), threadId: ThreadId.make("thread"), @@ -43,56 +42,55 @@ const terminal: TerminalSummary = { updatedAt: "2026-09-05T00:00:00.000Z", }; -function Probe() { +function Probe({ onSessions }: { onSessions: (sessions: KnownSessions) => void }) { const sessions = useKnownTerminalSessions(target); useEffect(() => { - current = sessions; - }, [sessions]); + onSessions(sessions); + }, [onSessions, sessions]); return null; } -function render() { - return act(() => root.render()); +function setup() { + Object.assign(state, { canRead: true, data: null, error: null }); + let current: KnownSessions | undefined; + const onSessions = (sessions: KnownSessions) => { + current = sessions; + }; + return { + get current() { + return current; + }, + element: () => , + }; } -beforeEach(() => { - state.canRead = true; - state.data = null; - state.error = null; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); - it("distinguishes missing metadata from an authoritative empty list across scope changes", async () => { - await render(); - expect(current).toBeNull(); + const probe = setup(); + await using screen = await render(probe.element()); + expect(probe.current).toBeNull(); state.data = [terminal]; - await render(); - expect(current?.[0]).toMatchObject({ + await screen.rerender(probe.element()); + expect(probe.current?.[0]).toMatchObject({ target: { environmentId: "secondary", terminalId: "term-1" }, state: { hasRunningSubprocess: true }, }); state.canRead = false; - await render(); - expect(current).toBeNull(); + await screen.rerender(probe.element()); + expect(probe.current).toBeNull(); state.canRead = true; state.data = []; - await render(); - expect(current).toEqual([]); + await screen.rerender(probe.element()); + expect(probe.current).toEqual([]); }); it("does not reuse cached terminal metadata after a subscription failure", async () => { + const probe = setup(); state.data = [terminal]; - await render(); + await using screen = await render(probe.element()); state.error = "Access denied"; - await render(); - expect(current).toBeNull(); + await screen.rerender(probe.element()); + expect(probe.current).toBeNull(); }); diff --git a/apps/web/src/state/usage.browser.test.tsx b/apps/web/src/state/usage.browser.test.tsx index 78e0ddd075ad..ea55e99a122f 100644 --- a/apps/web/src/state/usage.browser.test.tsx +++ b/apps/web/src/state/usage.browser.test.tsx @@ -4,10 +4,10 @@ import { USAGE_CONTRACT_VERSION, type UsageProviderKind, } from "@t3tools/contracts"; -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "../../test/render"; import { useUsage, type EnvironmentUsageStatus, type UsageView } from "./usage"; const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); @@ -85,150 +85,156 @@ function environment( }; } -let root: Root; -let latest: UsageView; +interface ProbeProps { + selected: ReadonlySet | null; + hidden?: ReadonlySet; + window?: typeof input; +} function Probe({ selected, hidden, window = input, -}: { - selected: ReadonlySet | null; - hidden?: ReadonlySet; - window?: typeof input; -}) { + onUsage, +}: ProbeProps & { onUsage: (usage: UsageView) => void }) { const usage = useUsage(window, selected, hidden); useLayoutEffect(() => { - latest = usage; - }, [usage]); + onUsage(usage); + }, [onUsage, usage]); return null; } -async function select(...ids: string[]) { - await act(() => - root.render( EnvironmentId.make(id)))} />), - ); -} - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function setup() { testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; - root = createRoot(document.createElement("div")); - await act(() => root.render()); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); + let latest: UsageView | undefined; + const onUsage = (usage: UsageView) => { + latest = usage; + }; + const screen = await render(); + const show = (props: ProbeProps) => screen.rerender(); + return { + get latest() { + return latest!; + }, + show, + select: (...ids: string[]) => + show({ selected: new Set(ids.map((id) => EnvironmentId.make(id))) }), + [Symbol.asyncDispose]: () => screen[Symbol.asyncDispose](), + }; +} describe("usage environment selection", () => { it("starts with all environments and adds results as they arrive", async () => { - expect(latest.merged.costUsd).toBe(30); - expect(latest.isPending).toBe(false); - expect(latest.isPartial).toBe(true); + await using screen = await setup(); + expect(screen.latest.merged.costUsd).toBe(30); + expect(screen.latest.isPending).toBe(false); + expect(screen.latest.isPartial).toBe(true); testState.environments = [...testState.environments.slice(0, 2), environment("slow", 40)]; - await act(() => root.render()); - expect(latest.merged.costUsd).toBe(70); - expect(latest.isPartial).toBe(false); + await screen.show({ selected: null }); + expect(screen.latest.merged.costUsd).toBe(70); + expect(screen.latest.isPartial).toBe(false); }); it("excludes unselected usage and pending environments, then restores all", async () => { - await select("b"); - expect(latest.merged.costUsd).toBe(20); - expect(latest.merged.models.map((model) => model.model)).toEqual(["b"]); - expect(latest.isPending).toBe(false); - expect(latest.isPartial).toBe(false); - expect(latest.environments).toHaveLength(3); - - await act(() => root.render()); - expect(latest.merged.costUsd).toBe(30); - expect(latest.isPartial).toBe(true); + await using screen = await setup(); + await screen.select("b"); + expect(screen.latest.merged.costUsd).toBe(20); + expect(screen.latest.merged.models.map((model) => model.model)).toEqual(["b"]); + expect(screen.latest.isPending).toBe(false); + expect(screen.latest.isPartial).toBe(false); + expect(screen.latest.environments).toHaveLength(3); + + await screen.show({ selected: null }); + expect(screen.latest.merged.costUsd).toBe(30); + expect(screen.latest.isPartial).toBe(true); }); it("distinguishes a pending selection from an empty or failed selection", async () => { - await select("slow"); - expect(latest.isPending).toBe(true); - expect(latest.merged.costUsd).toBe(0); + await using screen = await setup(); + await screen.select("slow"); + expect(screen.latest.isPending).toBe(true); + expect(screen.latest.merged.costUsd).toBe(0); - await select(); - expect(latest.selectedEnvironments).toHaveLength(0); - expect(latest.isPending).toBe(false); - expect(latest.isPartial).toBe(false); + await screen.select(); + expect(screen.latest.selectedEnvironments).toHaveLength(0); + expect(screen.latest.isPending).toBe(false); + expect(screen.latest.isPartial).toBe(false); testState.environments = [{ ...environment("slow", null), isPending: false, error: "Offline" }]; - await select("slow"); - expect(latest.isPending).toBe(false); - expect(latest.isPartial).toBe(false); + await screen.select("slow"); + expect(screen.latest.isPending).toBe(false); + expect(screen.latest.isPartial).toBe(false); }); it("deduplicates within the selection so an excluded owner cannot hide usage", async () => { + await using screen = await setup(); testState.environments = [environment("a", 10, "shared"), environment("b", 20, "shared")]; - await act(() => root.render()); - expect(latest.merged.costUsd).toBe(10); + await screen.show({ selected: null }); + expect(screen.latest.merged.costUsd).toBe(10); - await select("b"); - expect(latest.merged.costUsd).toBe(20); - expect(latest.merged.duplicateSources).toEqual([]); + await screen.select("b"); + expect(screen.latest.merged.costUsd).toBe(20); + expect(screen.latest.merged.duplicateSources).toEqual([]); }); it("keeps selected cached results visible during a refresh", async () => { + await using screen = await setup(); testState.environments = [ { ...environment("a", 10), isPending: true }, environment("slow", null), ]; - await select("a"); - expect(latest.merged.costUsd).toBe(10); - expect(latest.isPending).toBe(false); - expect(latest.isPartial).toBe(false); + await screen.select("a"); + expect(screen.latest.merged.costUsd).toBe(10); + expect(screen.latest.isPending).toBe(false); + expect(screen.latest.isPartial).toBe(false); }); it("shows the last answered usage until the next window answers, for the same selection", async () => { + await using screen = await setup(); const selected = new Set([EnvironmentId.make("a")]); - await act(() => root.render()); - expect(latest.shown?.merged.costUsd).toBe(10); + await screen.show({ selected }); + expect(screen.latest.shown?.merged.costUsd).toBe(10); const nextWindow = { ...input, sinceDay: UsageDay.make("2026-08-28") }; testState.environments = [environment("a", null)]; - await act(() => root.render()); - expect(latest.isPending).toBe(true); - expect(latest.shown?.window).toBe(input); - expect(latest.shown?.merged.costUsd).toBe(10); + await screen.show({ selected, window: nextWindow }); + expect(screen.latest.isPending).toBe(true); + expect(screen.latest.shown?.window).toBe(input); + expect(screen.latest.shown?.merged.costUsd).toBe(10); testState.environments = [{ ...environment("a", null), isPending: false, error: "Offline" }]; - await act(() => root.render()); - expect(latest.isPending).toBe(false); - expect(latest.shown?.window).toBe(input); - expect(latest.shown?.merged.costUsd).toBe(10); + await screen.show({ selected, window: nextWindow }); + expect(screen.latest.isPending).toBe(false); + expect(screen.latest.shown?.window).toBe(input); + expect(screen.latest.shown?.merged.costUsd).toBe(10); testState.environments = [environment("a", 30)]; - await act(() => root.render()); - expect(latest.shown?.window).toBe(nextWindow); - expect(latest.shown?.merged.costUsd).toBe(30); + await screen.show({ selected, window: nextWindow }); + expect(screen.latest.shown?.window).toBe(nextWindow); + expect(screen.latest.shown?.merged.costUsd).toBe(30); testState.environments = [environment("a", null)]; - await act(() => - root.render(
, ); - await act(() => userEvent.hover(screen.container.querySelector("button")!)); + await userEvent.hover(screen.container.querySelector("button")!); const popup = () => document.querySelector('[data-slot="tooltip-popup"]')!; await expect.poll(() => popup()?.querySelectorAll("a").length).toBe(40); await expect.poll(() => popup().getBoundingClientRect().top).toBeGreaterThanOrEqual(0); @@ -212,13 +212,13 @@ it("keeps a long PR hover list in the viewport and lets the last row be selected expect(lastRow.getBoundingClientRect().top).toBeGreaterThan( popup().getBoundingClientRect().bottom, ); - await act(() => userEvent.wheel(popup(), { delta: { y: 10_000 } })); + await userEvent.wheel(popup(), { delta: { y: 10_000 } }); await expect .poll(() => lastRow.getBoundingClientRect().bottom) .toBeLessThanOrEqual(popup().getBoundingClientRect().bottom); expect(lastRow.getBoundingClientRect().top).toBeGreaterThanOrEqual( popup().getBoundingClientRect().top, ); - await act(() => userEvent.click(lastRow)); + await userEvent.click(lastRow); expect(onOpenPullRequest).toHaveBeenCalledExactlyOnceWith(expect.anything(), lastRow.href); }); diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index 51bb2d4e6422..01a1ed13fdcb 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -84,12 +84,12 @@ it("keeps only the usage limit attached while an active goal waits in the collap "Usage limit reached", ); - await act(async () => page.getByRole("button", { name: "Show other notices" }).click()); + await page.getByRole("button", { name: "Show other notices" }).click(); await expect.element(goal).toBeVisible(); await expect.element(limit).toBeVisible(); await expect.poll(visibleBanners).toHaveLength(2); - await act(async () => userEvent.keyboard("{Escape}")); + await userEvent.keyboard("{Escape}"); await expect.element(goal).not.toBeVisible(); await expect.element(limit).toBeVisible(); expect(visibleBanners()).toHaveLength(1); diff --git a/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx b/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx index 752ec8af46f0..01dae940a8e7 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.browser.test.tsx @@ -66,7 +66,7 @@ const editor = () => page.getByRole("textbox", { name: "Message" }); const stash = () => page.getByRole("list", { name: "Stashed prompts" }); const restore = (prompt: string) => page.getByRole("button", { name: `Restore stashed prompt: ${prompt}` }); -const press = (keys: string) => act(async () => userEvent.keyboard(keys)); +const press = (keys: string) => userEvent.keyboard(keys); const focus = (element: Element) => act(async () => (element as HTMLElement).focus()); it("leaves dialog, unrelated input and question button shortcuts alone while the stash is open", async () => { @@ -128,7 +128,7 @@ it("navigates, deletes and restores stashed prompts from the composer and drawer await press("{Escape}"); await expect.element(stash()).not.toBeInTheDocument(); - await act(async () => userEvent.click(page.getByRole("button", { name: "Open stash" }))); + await userEvent.click(page.getByRole("button", { name: "Open stash" })); await expect.element(restore("first prompt")).not.toBeInTheDocument(); expect(stash().element().textContent).toContain("second prompt"); await focus(editor().element()); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 7b99f41dad7c..665eb5e16f69 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -215,10 +215,8 @@ describe("MessagesTimeline work rows", () => { ); const row = screen.container.querySelector('[data-v2-item-type="file_change"]')!; const press = async (target: HTMLElement) => { - await act(async () => { - target.focus(); - await userEvent.keyboard(key); - }); + target.focus(); + await userEvent.keyboard(key); }; expect(row.getAttribute("aria-expanded")).toBe("false"); await press(row); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx index 21a39de1f767..95b2d99e4ce5 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -12,7 +12,7 @@ import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { act, render as renderUi } from "../../../test/render"; +import { render as renderUi } from "../../../test/render"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { ProviderModelPicker } from "./ProviderModelPicker"; import type { ModelEsque } from "./providerIconUtils"; @@ -85,7 +85,7 @@ function renderPicker(input: { } async function click(target: Parameters[0]) { - await act(async () => userEvent.click(target)); + await userEvent.click(target); } async function openLegacyPicker(activeModel: string) { diff --git a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx index 14a68afcb434..58da8b82f2a8 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx @@ -30,7 +30,7 @@ vi.mock(import("../../assets/assetUrls"), async (importOriginal) => ({ resources.map((resource) => `https://assets.test/${resource.attachmentId}`)) as never, })); -import { act, render as renderUi } from "../../../test/render"; +import { render as renderUi } from "../../../test/render"; import { QueuedRunsControl } from "./QueuedRunsControl"; function setup() { @@ -204,9 +204,7 @@ it("previews assistant quotes as readable text and preserves the citation when e .element(page.getByText(`${quotedText} Why per worktree?`, { exact: true })) .toBeVisible(); - await act(async () => { - await page.getByRole("button", { name: "Edit queued message", exact: true }).nth(1).click(); - }); + await page.getByRole("button", { name: "Edit queued message", exact: true }).nth(1).click(); expect(onEditQueuedRun).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ text: quoteAndText }), ); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx index a25bb6eb6e62..0097c191d827 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx @@ -1,4 +1,4 @@ -import { act, render as renderUi } from "../../../test/render"; +import { render as renderUi } from "../../../test/render"; import "../../index.css"; import { expect, it } from "vite-plus/test"; @@ -15,13 +15,13 @@ it("expands even a short JSON error to its exact diagnostics and collapses back await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); expect(container.textContent).not.toContain("unsupported_value"); - await act(async () => page.getByRole("button", { name: "Show full error" }).click()); + await page.getByRole("button", { name: "Show full error" }).click(); const diagnostics = page.getByText(error, { exact: true }); await expect.element(diagnostics).toBeVisible(); expect(diagnostics.element().textContent).toBe(error); - await act(async () => page.getByRole("button", { name: "Show less" }).click()); + await page.getByRole("button", { name: "Show less" }).click(); await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); expect(container.textContent).not.toContain("unsupported_value"); diff --git a/apps/web/src/components/chat/composerFocusRedirect.browser.test.ts b/apps/web/src/components/chat/composerFocusRedirect.browser.test.ts index d0e0f78dce52..8f5f195ae847 100644 --- a/apps/web/src/components/chat/composerFocusRedirect.browser.test.ts +++ b/apps/web/src/components/chat/composerFocusRedirect.browser.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { shouldTypeToFocusComposer } from "./composerFocusRedirect"; @@ -15,14 +15,15 @@ function typeOn(target: Element, key: string): boolean { return redirected; } -afterEach(() => { - document.body.replaceChildren(); -}); +function attach(element: Element): Disposable { + document.body.append(element); + return { [Symbol.dispose]: () => element.remove() }; +} describe("shouldTypeToFocusComposer", () => { it("sends letters typed on the chat to the composer", () => { const chat = document.createElement("div"); - document.body.append(chat); + using _chat = attach(chat); expect(typeOn(chat, "a")).toBe(true); }); @@ -31,7 +32,7 @@ describe("shouldTypeToFocusComposer", () => { const deviceScreen = document.createElement("div"); deviceScreen.setAttribute("role", "application"); deviceScreen.tabIndex = 0; - document.body.append(deviceScreen); + using _deviceScreen = attach(deviceScreen); expect(typeOn(deviceScreen, "a")).toBe(false); }); diff --git a/apps/web/src/components/chat/threadFindPosition.browser.test.ts b/apps/web/src/components/chat/threadFindPosition.browser.test.ts index 792cdba1fcb6..60ef3068557d 100644 --- a/apps/web/src/components/chat/threadFindPosition.browser.test.ts +++ b/apps/web/src/components/chat/threadFindPosition.browser.test.ts @@ -1,15 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { readThreadFindPosition } from "./threadFindPosition"; -afterEach(() => vi.restoreAllMocks()); -beforeEach(() => { - Object.defineProperty(Range.prototype, "getBoundingClientRect", { - configurable: true, - writable: true, - value: () => new DOMRect(), - }); -}); - +// The container stays detached, so unmocked ranges measure as an empty rect. function viewport(text = "COD4 COD4") { const container = document.createElement("div"); container.innerHTML = `
COD4
@@ -27,7 +19,9 @@ function viewport(text = "COD4 COD4") { describe("reading position for find", () => { it("skips earlier rows and occurrences above the viewport within the current message", () => { const container = viewport(); - vi.spyOn(Range.prototype, "getBoundingClientRect").mockImplementation(function (this: Range) { + using _ranges = vi.spyOn(Range.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Range, + ) { return new DOMRect(0, this.startOffset === 0 ? 60 : 160, 40, 20); }); expect(readThreadFindPosition(container, "COD4", (id) => id, 100)).toEqual({ @@ -38,7 +32,9 @@ describe("reading position for find", () => { it("starts after the current message when all its occurrences are above the viewport", () => { const container = viewport(); - vi.spyOn(Range.prototype, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 60, 40, 20)); + using _ranges = vi + .spyOn(Range.prototype, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, 60, 40, 20)); expect(readThreadFindPosition(container, "COD4", (id) => id, 100)).toEqual({ entryId: "reading", occurrence: 2, diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts index b38e2f7018df..2c5901116091 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts @@ -9,9 +9,10 @@ import { ProjectId, ThreadId, } from "@t3tools/contracts"; -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render, type Screen } from "../../../test/render"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import type { DesktopSnapShotBridge } from "../../lib/desktopSnapShot"; @@ -66,7 +67,8 @@ const storage = vi.hoisted(() => { const environmentId = EnvironmentId.make("snap-shot-environment"); const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-project")); -beforeEach(() => { +// Tests stub globals and spy on module exports freely; disposing restores all of them. +function setup(): Disposable { storage.clear(); vi.stubGlobal("localStorage", storage); useComposerDraftStore.setState({ @@ -76,17 +78,18 @@ beforeEach(() => { stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); -}); - -afterEach(() => { - dismissAllSnapShotAnimations(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); + return { + [Symbol.dispose]() { + dismissAllSnapShotAnimations(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }, + }; +} describe("window capture failures", () => { it("dismisses an older failed capture without disturbing a newer capture", async () => { + using _env = setup(); const target = DraftId.make("snap-shot-draft"); const pendingStarts = new Set(); const soundedIds = new Set(["older", "newer"]); @@ -113,6 +116,7 @@ describe("window capture failures", () => { }); it("does not resurrect a failed capture when its draft becomes ready later", async () => { + using _env = setup(); const target = DraftId.make("snap-shot-draft"); let resolveTarget: ((target: DraftId) => void) | undefined; const targetReady = new Promise((resolve) => { @@ -133,6 +137,7 @@ describe("window capture failures", () => { }); it("keeps global failures dismissing all active and pending captures", async () => { + using _env = setup(); const target = DraftId.make("snap-shot-draft"); let resolveTarget: ((target: DraftId) => void) | undefined; const targetReady = new Promise((resolve) => { @@ -168,10 +173,7 @@ describe("pending snapshot processing", () => { }, }; const target = scopeThreadRef(environmentId, ThreadId.make("enabled-thread")); - let root: Root; - let container: HTMLDivElement; - - function setup(routeThreadRef: typeof target | null = target) { + function coordinatorFixture(routeThreadRef: typeof target | null = target) { let currentSettings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true, @@ -212,43 +214,44 @@ describe("pending snapshot processing", () => { const sound = vi.spyOn(snapShotSound, "playSnapShotSound").mockImplementation(() => undefined); const toast = vi.spyOn(toastManager, "add").mockReturnValue("toast-id"); vi.stubGlobal("desktopBridge", bridge); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - const render = () => act(async () => root.render(createElement(SnapShotCoordinator))); + let screen: Screen | undefined; + // Settings and route changes rerender the same coordinator instead of remounting it. + const renderCoordinator = async () => { + if (screen) await screen.rerender(createElement(SnapShotCoordinator)); + // useHandleNewThread is mocked above, so nothing in the coordinator touches the router. + else screen = await render(createElement(SnapShotCoordinator), { router: false }); + }; return { bridge, route, sound, toast, - render, + render: renderCoordinator, setPending: (items: typeof pending) => { pending = items; }, setEnabled: async (snapShotEnabled: boolean) => { currentSettings = { ...currentSettings, snapShotEnabled }; - await render(); + await renderCoordinator(); }, emit: (event: DesktopSnapShotEvent) => act(async () => { for (const listener of listeners) listener(event); }), + [Symbol.asyncDispose]: async () => { + await screen?.[Symbol.asyncDispose](); + }, }; } - afterEach(async () => { - await act(async () => root?.unmount()); - container?.remove(); - }); - const focus = () => act(async () => { window.dispatchEvent(new Event("focus")); }); it("leaves captures untouched while disabled and resumes them when enabled again", async () => { - const fixture = setup(); + using _env = setup(); + await using fixture = coordinatorFixture(); const { bridge, sound, toast } = fixture; await fixture.setEnabled(false); await focus(); @@ -287,7 +290,8 @@ describe("pending snapshot processing", () => { }); it("reports each pending capture failure once, allows a new failure kind, and resets after success", async () => { - const fixture = setup(null); + using _env = setup(); + await using fixture = coordinatorFixture(null); const { bridge, sound, toast, route } = fixture; await fixture.render(); await focus(); @@ -337,7 +341,8 @@ describe("pending snapshot processing", () => { }); it("suppresses deferred failures while disabled and reports fresh failures after re-enable", async () => { - const fixture = setup(); + using _env = setup(); + await using fixture = coordinatorFixture(); const { bridge, sound, toast } = fixture; let rejectRead: ((error: Error) => void) | undefined; bridge.readSnapShot.mockReturnValueOnce( @@ -404,7 +409,8 @@ describe("pending snapshot processing", () => { }); it("keeps a capture pending when disabled during target resolution, then retries on re-enable", async () => { - const fixture = setup(null); + using _env = setup(); + await using fixture = coordinatorFixture(null); const { bridge, route } = fixture; route.defaultProjectRef = projectRef; const draftId = DraftId.make("resolving-snapshot"); @@ -432,7 +438,8 @@ describe("pending snapshot processing", () => { }); it("finishes and acknowledges delivery already reading when disabled", async () => { - const fixture = setup(); + using _env = setup(); + await using fixture = coordinatorFixture(); let finishReading: ((value: typeof capture) => void) | undefined; const reading = new Promise((resolve) => { finishReading = resolve; @@ -467,7 +474,8 @@ describe("window capture delivery", () => { ])( "preserves capture contents for $target before a stalled animation finishes ($accessibleText)", async ({ target, accessibleText }) => { - vi.useFakeTimers(); + using _env = setup(); + using _timers = fakeTimers(); const never = new Promise(() => undefined); const animationFrames: Array = []; const acknowledgeSnapShot = vi.fn(async () => undefined); @@ -574,6 +582,7 @@ describe("window capture delivery", () => { describe("window capture target resolution", () => { it("shares bare-route draft creation between animation start and capture drain", async () => { + using _env = setup(); const draftId = DraftId.make("snap-shot-draft"); let finishResolution: ((target: DraftId) => void) | undefined; const resolveTarget = vi.fn( @@ -595,6 +604,7 @@ describe("window capture target resolution", () => { }); it("follows a draft to its promoted server thread", () => { + using _env = setup(); const draftId = DraftId.make("snap-shot-draft"); const promotedRef = scopeThreadRef(environmentId, ThreadId.make("snap-shot-promoted-thread")); const store = useComposerDraftStore.getState(); @@ -607,6 +617,7 @@ describe("window capture target resolution", () => { }); it("keeps the routed server thread before its shell loads", () => { + using _env = setup(); const routeThreadRef = scopeThreadRef(environmentId, ThreadId.make("snap-shot-routed-thread")); expect(resolveExistingSnapShotTarget(routeThreadRef, routeThreadRef)).toEqual(routeThreadRef); @@ -638,6 +649,7 @@ describe("open question delivery", () => { }; it("attaches a capture to the open question draft and leaves the thread draft alone", async () => { + using _env = setup(); const pins = new Map(); const untrack = trackOpenQuestionAttachmentDraft(threadRef, request); try { @@ -655,6 +667,7 @@ describe("open question delivery", () => { }); it("falls back to the thread when a newer question replaces the pinned one during compression", async () => { + using _env = setup(); const pins = new Map(); const untrackFirst = trackOpenQuestionAttachmentDraft(threadRef, request); let untrackSecond = () => {}; @@ -686,6 +699,7 @@ describe("open question delivery", () => { }); it("keeps a capture pinned before any question off a question that opens during the read", async () => { + using _env = setup(); const pins = new Map(); const resolveTarget = () => resolveSnapShotAttachmentTarget(pins, capture.id, threadRef); // The drain pins a capture it never saw requested before reading it. @@ -707,6 +721,7 @@ describe("open question delivery", () => { }); it("refuses a capture once the request's other questions hold the shared limit", async () => { + using _env = setup(); useComposerDraftStore.getState().addImages( first, Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }, (_, index) => ({ @@ -739,6 +754,7 @@ describe("durable snapshot delivery", () => { it.each([false, true])( "retains a capture on quota failure and retries without duplicates (staged: %s)", async (staged) => { + using _env = setup(); const target = scopeThreadRef(environmentId, ThreadId.make("quota-thread")); const capture = { id: "12345678-1234-1234-1234-123456789abc", @@ -798,6 +814,7 @@ describe("snapshot destination ownership", () => { it.each(["unmount", "blur", "disabled animations"])( "keeps the original environment and thread after %s", async (reason) => { + using _env = setup(); const original = scopeThreadRef(environmentId, ThreadId.make("original")); const next = scopeThreadRef(EnvironmentId.make("another-environment"), ThreadId.make("next")); const targets = new Map>(); diff --git a/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx b/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx index 09613f4d578a..23ff1bb4d5b2 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx @@ -59,7 +59,7 @@ function nextFrame() { async function enterFocusScope() { const toggle = document.querySelector('[aria-label="Toggle right panel"]')!; await act(async () => toggle.focus()); - await act(() => userEvent.tab()); + await userEvent.tab(); await act(nextFrame); } diff --git a/apps/web/src/components/preview/previewSessionSync.browser.test.ts b/apps/web/src/components/preview/previewSessionSync.browser.test.ts index ba6a60cdbb44..ac19c95f4fd7 100644 --- a/apps/web/src/components/preview/previewSessionSync.browser.test.ts +++ b/apps/web/src/components/preview/previewSessionSync.browser.test.ts @@ -7,9 +7,10 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; // Only the transport is substituted. The hook, atom registry and preview // store run unchanged, including lazy evaluation and refresh notifications. @@ -78,71 +79,70 @@ import { AppAtomRegistryProvider, appAtomRegistry } from "~/rpc/atomRegistry"; import { previewEnvironment } from "~/state/preview"; import { useDesktopHostedPreviewSessions, usePreviewSession } from "./usePreviewSession"; -let root: Root | null = null; -let container: HTMLDivElement | null = null; -let sequence = 0; -let ref: ScopedThreadRef; - -function SessionSync() { - usePreviewSession(ref); - return null; -} - -const listAtom = () => { - previewEnvironment.list({ environmentId: ref.environmentId, input: { threadId: ref.threadId } }); - return harness.lists.get(ref.threadId)!.atom; -}; -const result = ( - revision: number, - tabIds: string[], - serverEpoch = "epoch-1", -): PreviewListResult => ({ - serverEpoch, - revision, - sessions: tabIds.map((tabId) => ({ - threadId: ref.threadId, - tabId, - navStatus: { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-10-02T00:00:00.000Z", - })), -}); - -function DesktopSessionSync() { - useDesktopHostedPreviewSessions(ref.environmentId); - return null; -} +function setup() { + harness.lists.clear(); + harness.events.clear(); + resetPreviewStateForTests(); + // The app atom registry outlives each test, so every test gets its own environment and thread. + const testName = expect.getState().currentTestName!; + const ref: ScopedThreadRef = { + environmentId: EnvironmentId.make(`preview-sync ${testName}`), + threadId: ThreadId.make(`thread-sync ${testName}`), + }; -async function mount(Component = SessionSync) { - container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - await act(async () => { - root!.render(createElement(AppAtomRegistryProvider, null, createElement(Component))); + const listAtom = () => { + previewEnvironment.list({ + environmentId: ref.environmentId, + input: { threadId: ref.threadId }, + }); + return harness.lists.get(ref.threadId)!.atom; + }; + const result = ( + revision: number, + tabIds: string[], + serverEpoch = "epoch-1", + ): PreviewListResult => ({ + serverEpoch, + revision, + sessions: tabIds.map((tabId) => ({ + threadId: ref.threadId, + tabId, + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-02T00:00:00.000Z", + })), }); -} -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - sequence += 1; - ref = { - environmentId: EnvironmentId.make(`preview-sync-${sequence}`), - threadId: ThreadId.make(`thread-sync-${sequence}`), - }; -}); + function SessionSync() { + usePreviewSession(ref); + return null; + } + function DesktopSessionSync() { + useDesktopHostedPreviewSessions(ref.environmentId); + return null; + } + // The session hooks render nothing and never read or change the route. + const mount = (Component: () => null = SessionSync) => + render(createElement(AppAtomRegistryProvider, null, createElement(Component)), { + router: false, + }); -afterEach(async () => { - await act(async () => root?.unmount()); - root = null; - container?.remove(); - container = null; - resetPreviewStateForTests(); - vi.unstubAllGlobals(); -}); + return { + ref, + listAtom, + result, + mount, + DesktopSessionSync, + [Symbol.dispose]: () => resetPreviewStateForTests(), + }; +} describe("native preview session synchronization", () => { it("discovers and closes a server tab in a background thread without mounting its chat", async () => { - await mount(DesktopSessionSync); + using sync = setup(); + const { ref, listAtom, result } = sync; + await using _screen = await sync.mount(sync.DesktopSessionSync); expect(readThreadPreviewState(ref).sessions).toEqual({}); previewEnvironment.events({ environmentId: ref.environmentId, input: {} }); const events = harness.events.get(ref.environmentId)!; @@ -183,18 +183,22 @@ describe("native preview session synchronization", () => { }); it("evaluates the lazy list once and restores sessions that predate mounting", async () => { + using sync = setup(); + const { ref, listAtom, result } = sync; const atom = listAtom(); - await mount(); + await using _screen = await sync.mount(); expect(harness.lists.get(ref.threadId)?.box.requests).toBe(1); appAtomRegistry.set(atom, AsyncResult.success(result(1, ["restored"]))); expect(readThreadPreviewState(ref).activeTabId).toBe("restored"); }); it("preserves a local tab while cached absence is waiting for a fresh response", async () => { + using sync = setup(); + const { ref, listAtom, result } = sync; const atom = listAtom(); appAtomRegistry.set(atom, AsyncResult.waiting(AsyncResult.success(result(1, [])))); applyPreviewServerSnapshot(ref, result(1, ["local"]).sessions[0]!); - await mount(); + await using _screen = await sync.mount(); expect(readThreadPreviewState(ref).activeTabId).toBe("local"); expect(harness.lists.get(ref.threadId)?.box.requests).toBe(0); appAtomRegistry.set(atom, AsyncResult.success(result(2, ["local"]))); @@ -204,6 +208,8 @@ describe("native preview session synchronization", () => { }); it("refreshes a completed cache without treating its old absence as authoritative", async () => { + using sync = setup(); + const { ref, listAtom, result } = sync; const atom = listAtom(); appAtomRegistry.set(atom, AsyncResult.success(result(1, []))); reconcilePreviewServerSessions(ref, result(1, [])); @@ -221,7 +227,7 @@ describe("native preview session synchronization", () => { controller: "human", favicon: null, }); - await mount(); + await using _screen = await sync.mount(); expect(harness.lists.get(ref.threadId)?.box.requests).toBe(1); expect(readThreadPreviewState(ref).activeTabId).toBe("local"); expect(readThreadPreviewState(ref).desktopOverlay?.zoomFactor).toBe(1.5); @@ -231,8 +237,10 @@ describe("native preview session synchronization", () => { }); it("retains newer event state when an older list response completes, then retries the list", async () => { + using sync = setup(); + const { ref, listAtom, result } = sync; const atom = listAtom(); - await mount(); + await using _screen = await sync.mount(); const snapshot = result(2, ["opened"]).sessions[0]!; previewEnvironment.events({ environmentId: ref.environmentId, input: {} }); appAtomRegistry.set( @@ -261,9 +269,11 @@ describe("native preview session synchronization", () => { }); it("keeps the local snapshot on a completed failure and reconciles a later success", async () => { + using sync = setup(); + const { ref, listAtom, result } = sync; const atom = listAtom(); applyPreviewServerSnapshot(ref, result(1, ["local"]).sessions[0]!); - await mount(); + await using _screen = await sync.mount(); appAtomRegistry.set(atom, AsyncResult.failure(Cause.die("offline"))); expect(readThreadPreviewState(ref).activeTabId).toBe("local"); appAtomRegistry.set(atom, AsyncResult.success(result(2, []))); diff --git a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx index b6b6cb6248ba..7952157a9485 100644 --- a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx @@ -1,4 +1,4 @@ -import { act, render } from "../../../test/render"; +import { render } from "../../../test/render"; import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; @@ -40,21 +40,19 @@ describe("disabled candidate picker", () => { 'button[aria-label="Request reviewers"]', )!; if (input === "keyboard") { - await act(() => userEvent.tab()); + await userEvent.tab(); expect(document.activeElement).toBe(trigger); } else { - await act(() => userEvent.hover(trigger)); + await userEvent.hover(trigger); } await expect .poll(() => document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent) .toContain("Only collaborators can request reviewers."); - await act(async () => { - trigger.focus(); - await userEvent.keyboard("{Enter}"); - await userEvent.keyboard(" "); - // Playwright skips aria-disabled targets; the click must still land to prove it is inert. - await userEvent.click(trigger, { force: true }); - }); + trigger.focus(); + await userEvent.keyboard("{Enter}"); + await userEvent.keyboard(" "); + // Playwright skips aria-disabled targets; the click must still land to prove it is inert. + await userEvent.click(trigger, { force: true }); expect(onOpenChange).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled(); }, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx index 1a11e8e0ebc2..7fc7520cb521 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx @@ -122,7 +122,7 @@ const input = (selector: string) => { return found!; }; async function type(selector: string, value: string) { - await act(async () => userEvent.fill(input(selector), value)); + await userEvent.fill(input(selector), value); await act(async () => input(selector).blur()); } const signInStep = () => document.querySelector('[aria-label="Sign-in step"]'); diff --git a/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx b/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx index 3fc121d19e2e..0dc5432dee73 100644 --- a/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx +++ b/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx @@ -85,8 +85,8 @@ async function renderConfig(desktop: "niri" | "hyprland" = "niri") { await act(async () => target!.click()); }, async recordKeys(keys: string) { - await act(async () => userEvent.click(recorder())); - await act(async () => userEvent.keyboard(keys)); + await userEvent.click(recorder()); + await userEvent.keyboard(keys); }, }); } @@ -184,9 +184,9 @@ it.each(["Escape", "blur"])( using _env = setup(); await using screen = await renderConfig(); await screen.click("Review changes"); - await act(async () => userEvent.click(screen.recorder())); + await userEvent.click(screen.recorder()); expect(screen.button("Save shortcut")?.disabled).toBe(true); - if (cancel === "Escape") await act(async () => userEvent.keyboard("{Escape}")); + if (cancel === "Escape") await userEvent.keyboard("{Escape}"); else await act(async () => screen.recorder().blur()); expect(screen.button("Save shortcut")?.disabled).toBe(false); expect(bridge.previewSnapShotConfig).toHaveBeenCalledOnce(); diff --git a/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx b/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx index 1a2495f0c371..fa4327da432b 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx @@ -48,10 +48,10 @@ describe("FontFamilyPicker", () => { const input = document.querySelector('input[placeholder="Search fonts…"]'); expect(input).not.toBeNull(); - const press = (key: string) => act(() => userEvent.keyboard(`{${key}}`)); + const press = (key: string) => userEvent.keyboard(`{${key}}`); // Both matches were already rendered further down the unfiltered list. - await act(() => userEvent.type(input!, "co")); + await userEvent.type(input!, "co"); await expect.poll(highlightedOptions).toEqual(["Courier New"]); await press("ArrowDown"); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 4a47a353dbeb..5f7137ca5b33 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -438,19 +438,19 @@ describe("project settings scope", () => { const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ }); const remoteOption = () => page.getByRole("menuitemradio", { name: /^remote/ }); - await act(async () => userEvent.click(environmentMenu())); + await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).toBeVisible(); - await act(async () => userEvent.keyboard("{Escape}")); + await userEvent.keyboard("{Escape}"); await act(async () => screen.router.navigate({ to: "/settings/projects", search: () => ({ project: key }) }), ); await expect.element(page.getByRole("textbox", { name: "Project name" })).toBeVisible(); - await act(async () => userEvent.click(environmentMenu())); + await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).not.toBeInTheDocument(); - await act(async () => userEvent.keyboard("{Escape}")); + await userEvent.keyboard("{Escape}"); await act(async () => screen.router.navigate({ @@ -458,10 +458,10 @@ describe("project settings scope", () => { search: () => ({ project: "removed-project" }), }), ); - await act(async () => userEvent.click(environmentMenu())); + await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).toBeVisible(); - await act(async () => userEvent.click(remoteOption())); + await userEvent.click(remoteOption()); await vi.waitFor(() => expect(screen.router.state.location.search).toEqual({ project: "removed-project", @@ -491,9 +491,7 @@ describe("project settings scope", () => { await expect .element(page.getByRole("button", { name: "Environment scope: remote" })) .toBeVisible(); - await act(async () => - userEvent.click(page.getByRole("button", { name: "Show all environments" })), - ); + await userEvent.click(page.getByRole("button", { name: "Show all environments" })); await expect .element(page.getByRole("textbox", { name: "Project name" })) @@ -517,7 +515,7 @@ it("returns to the project overview after removal, clearing the selection but ke (button) => button.textContent === "Remove project", )!; - await act(async () => userEvent.click(removeProject)); + await userEvent.click(removeProject); await vi.waitFor(() => { expect(screen.router.state.location.pathname).toBe("/settings/projects"); diff --git a/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx b/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx index a02de7ed5b78..d3e25b90159c 100644 --- a/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx @@ -195,7 +195,7 @@ describe("Antigravity setup", () => { resetSetup(); const callbackUrl = "http://127.0.0.1:5555/?state=test-only&code=test-only"; await using screen = await renderSetup(); - await act(async () => userEvent.fill(screen.callbackInput(), callbackUrl)); + await userEvent.fill(screen.callbackInput(), callbackUrl); await screen.click("Continue"); expect(setup.completeAuth).toHaveBeenCalledWith({ @@ -244,11 +244,9 @@ describe("Antigravity setup", () => { }; setup.auth = authState({ interaction }); await using screen = await renderSetup(); - await act(async () => - userEvent.fill( - screen.callbackInput(), - "http://127.0.0.1:5555/?state=old-flow&code=test-only", - ), + await userEvent.fill( + screen.callbackInput(), + "http://127.0.0.1:5555/?state=old-flow&code=test-only", ); setup.auth = authState({ flowId: "flow-2", interaction }); await publish(); diff --git a/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx b/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx index 67b144d0112a..f2ae1fefe8bd 100644 --- a/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx @@ -186,13 +186,14 @@ it.each(["direct", "gnome-extension", "kde"] as const)( "Record snapshot shortcut, currently Ctrl+Shift+2", ); } - await act(async () => userEvent.click(recorder())); - await act(async () => userEvent.keyboard("{Control>}{Alt>}y{/Alt}{/Control}")); + await userEvent.click(recorder()); + await userEvent.keyboard("{Control>}{Alt>}y{/Alt}{/Control}"); expect(bridge.checkSnapShotShortcut).toHaveBeenCalledOnce(); - expect(container.textContent).toContain("Ready to save."); - expect(recorder().getAttribute("aria-label")).toBe( - "Record snapshot shortcut, currently Ctrl+Alt+Y", - ); + // The status and label update only after the awaited shortcut check resolves. + await expect.poll(() => container.textContent).toContain("Ready to save."); + await expect + .poll(() => recorder().getAttribute("aria-label")) + .toBe("Record snapshot shortcut, currently Ctrl+Alt+Y"); await click("Save"); expect(settingsStore.update).toHaveBeenCalledWith({ snapShotShortcut: expect.objectContaining({ key: "y", modKey: true, altKey: true }), @@ -233,9 +234,9 @@ it("keeps the approved desktop shortcut when recording is cancelled in setup", a expect(recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Alt+8", ); - await act(async () => userEvent.click(recorder())); + await userEvent.click(recorder()); expect(recorder().textContent).toBe("Press shortcut…"); - await act(async () => userEvent.keyboard("{Escape}")); + await userEvent.keyboard("{Escape}"); expect(dialog()).not.toBeNull(); expect(recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Alt+8", @@ -273,7 +274,7 @@ it("keeps older portal shortcuts editable without offering unsupported permissio const { button, recorder } = screen; expect(button("Shortcut permissions")).toBeNull(); expect(bridge.setupSnapShot).not.toHaveBeenCalled(); - await act(async () => userEvent.click(recorder())); + await userEvent.click(recorder()); expect(recorder().textContent).toBe("Press shortcut…"); }); diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx index b463ed296d6b..bd2a3a54337c 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx @@ -1,6 +1,5 @@ import { act, render } from "../../../test/render"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; -import { useEffect } from "react"; import { userEvent } from "vite-plus/test/browser"; import { expect, it, vi } from "vite-plus/test"; @@ -22,7 +21,6 @@ function deferred() { const recorded = vi.fn(); const error = vi.fn(); -let recording = false; function Recorder(props: { allowModifierPairs?: boolean; shortcutLabel?: string }) { const recorder = useSnapShotShortcutRecorder({ @@ -32,9 +30,6 @@ function Recorder(props: { allowModifierPairs?: boolean; shortcutLabel?: string onRecord: recorded, onError: error, }); - useEffect(() => { - recording = recorder.recording; - }); return recorder.input; } @@ -42,22 +37,25 @@ function setup() { suppress.mockReset().mockResolvedValue(undefined); recorded.mockReset(); error.mockReset(); - recording = false; } async function renderRecorder(props: Parameters[0] = {}) { const screen = await render(); const button = () => screen.container.querySelector("[data-keybinding-capture]")!; + // aria-pressed mirrors the hook's recording state, which flips once suppression resolves. + const expectRecording = (recording: boolean) => + expect.element(screen.getByRole("button")).toHaveAttribute("aria-pressed", String(recording)); return { ...screen, button, + expectRecording, async start() { - await act(() => userEvent.click(button())); - expect(recording).toBe(true); + await userEvent.click(button()); + await expectRecording(true); }, }; } -const press = (keys: string) => act(async () => userEvent.keyboard(keys)); +const press = (keys: string) => userEvent.keyboard(keys); it("suppresses capture while recording and uses the physical key for shifted digits", async () => { setup(); @@ -77,7 +75,7 @@ it("suppresses capture while recording and uses the physical key for shifted dig shiftKey: true, }); expect(suppress).toHaveBeenLastCalledWith(false); - expect(recording).toBe(false); + await screen.expectRecording(false); }); it("lets users replace an unrecognized desktop label without displaying guessed keys", async () => { @@ -88,7 +86,7 @@ it("lets users replace an unrecognized desktop label without displaying guessed const { button } = screen; expect(button().textContent).toBe("Change shortcut"); expect(button().getAttribute("aria-label")).toBe("Change snapshot shortcut"); - await act(async () => userEvent.click(button())); + await userEvent.click(button()); expect(button().textContent).toBe("Press shortcut…"); await press("{Control>}{Shift>}2{/Shift}{/Control}"); expect(recorded).toHaveBeenCalledWith( @@ -124,7 +122,7 @@ it.each(["Escape", "blur", "unmount"])("cancels on %s without selecting keys", a else await screen.unmount(); expect(suppress).toHaveBeenLastCalledWith(false); expect(recorded).not.toHaveBeenCalled(); - if (cancel !== "unmount") expect(recording).toBe(false); + if (cancel !== "unmount") await screen.expectRecording(false); }); it("ignores late suppression acknowledgements after cancellation", async () => { @@ -134,10 +132,10 @@ it("ignores late suppression acknowledgements after cancellation", async () => { suppress.mockImplementation((enabled) => (enabled ? pending.promise : Promise.resolve())); await using screen = await renderRecorder(); const { button } = screen; - await act(async () => userEvent.click(button())); + await userEvent.click(button()); await act(async () => button().blur()); await act(async () => pending.resolve()); - expect(recording).toBe(false); + await screen.expectRecording(false); expect(suppress).toHaveBeenLastCalledWith(false); expect(recorded).not.toHaveBeenCalled(); }); @@ -148,8 +146,8 @@ it("reports suppression failures instead of arming the recorder", async () => { suppress.mockRejectedValue(new Error("Couldn't pause capture")); await using screen = await renderRecorder(); const { button } = screen; - await act(async () => userEvent.click(button())); - expect(recording).toBe(false); + await userEvent.click(button()); + await screen.expectRecording(false); expect(error).toHaveBeenCalledWith("Couldn't pause capture"); }); @@ -162,7 +160,7 @@ it("does not record Tab, unmodified keys, or modifier keys on their own", async await press("y"); await press("{Control>}{/Control}"); expect(recorded).not.toHaveBeenCalled(); - expect(recording).toBe(true); + await screen.expectRecording(true); await press("{Tab}"); expect(document.activeElement).not.toBe(button()); }); @@ -176,7 +174,7 @@ it("rejects modifier pairs on Wayland but accepts the next key chord", async () await press("{ShiftLeft>}{ShiftRight>}{/ShiftRight}{/ShiftLeft}"); expect(recorded).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith(expect.stringContaining("Add a letter, number")); - expect(recording).toBe(true); + await screen.expectRecording(true); await press("{Control>}{Alt>}y{/Alt}{/Control}"); expect(recorded).toHaveBeenCalledOnce(); }); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index a70900c189d4..b5b6f0341510 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -81,7 +81,7 @@ async function click(label: string) { (node) => node.textContent === label || node.getAttribute("aria-label")?.startsWith(label), ); expect(button).toBeDefined(); - await act(() => userEvent.click(button!)); + await userEvent.click(button!); } /** @@ -225,24 +225,24 @@ it("moves the one limit popover between windows on hover", async () => { const session = segment("Alpha: 60%"); const weekly = segment("Alpha: 68%"); - await act(() => userEvent.click(session)); + await userEvent.click(session); await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); - await act(() => userEvent.hover(weekly)); + await userEvent.hover(weekly); await expect.poll(popups).toEqual([expect.stringContaining("Left68%")]); // Weekly's popover now sits over the middle of the Session bar; come back in at its edge. - await act(() => userEvent.hover(session, { position: { x: 4, y: 4 } })); + await userEvent.hover(session, { position: { x: 4, y: 4 } }); await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); await expectDismissedInPlace(() => act(() => screen.rerender()), ); - await act(() => userEvent.hover(container, { position: { x: 4, y: 4 } })); + await userEvent.hover(container, { position: { x: 4, y: 4 } }); await act(() => screen.rerender()); await expect.poll(popups).toEqual([]); - await act(() => userEvent.click(segment("Alpha: 60%"))); + await userEvent.click(segment("Alpha: 60%")); await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); }); diff --git a/apps/web/src/composer-list-split.browser.test.ts b/apps/web/src/composer-list-split.browser.test.ts index ed672464c7c1..5201c11203cf 100644 --- a/apps/web/src/composer-list-split.browser.test.ts +++ b/apps/web/src/composer-list-split.browser.test.ts @@ -2,7 +2,7 @@ import { Editor } from "@tiptap/core"; import { TaskList } from "@tiptap/extension-task-list"; import { TextSelection } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { backspaceAcrossList, @@ -19,13 +19,8 @@ import { } from "./composer-rich-text-doc"; // ProseMirror's DOM observer can flush on a timer after a test ends, which -// throws once its DOM is gone, so every editor is destroyed after its test. -const editors: Editor[] = []; -afterEach(() => { - for (const editor of editors.splice(0)) editor.destroy(); -}); - -function makeEditor(value: string) { +// throws once its DOM is gone, so every editor is acquired with `using`. +function makeEditor(value: string): Editor & Disposable { const editor = new Editor({ extensions: [ StarterKit.configure({ @@ -47,12 +42,11 @@ function makeEditor(value: string) { ], content: buildDocJson(value, (name) => ({ label: name, description: null })), }); - editors.push(editor); - return editor; + return Object.assign(editor, { [Symbol.dispose]: () => editor.destroy() }); } function splitLastItem(value: string, type: "listItem" | "taskItem", overrides: object) { - const editor = makeEditor(value); + using editor = makeEditor(value); editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); editor.commands.splitListItem(type, overrides); editor.view.dispatch(editor.state.tr.insertText("b")); @@ -88,7 +82,8 @@ function placeCaret(editor: Editor, text: string, offset = 0) { function storedDraft(editor: Editor) { const stored = serializeEditorDoc(editor.state.doc).value; - expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + using reloaded = makeEditor(stored); + expect(reloaded.state.doc.toString()).toBe(editor.state.doc.toString()); return stored; } @@ -102,7 +97,7 @@ describe("Shift+Enter twice on an item", () => { ["- [ ] a\n - [ ] x", "- [ ] a\n - [ ] x\n- [ ] b"], ["- [x] a\n - [ ] x\n- [ ] c", "- [x] a\n - [ ] x\n- [ ] b\n- [ ] c"], ])("after x in %j writes %j", (value, expected) => { - const editor = makeEditor(value); + using editor = makeEditor(value); let caret = -1; editor.state.doc.descendants((node, pos) => { if (node.isText && node.text === "x") caret = pos + 1; @@ -115,7 +110,8 @@ describe("Shift+Enter twice on an item", () => { editor.view.dispatch(editor.state.tr.insertText("b")); const stored = serializeEditorDoc(editor.state.doc).value; expect(stored).toBe(expected); - expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + using reloaded = makeEditor(stored); + expect(reloaded.state.doc.toString()).toBe(editor.state.doc.toString()); }); }); @@ -125,7 +121,7 @@ describe("Shift+Enter in the middle of an ordered list", () => { ["1) a\n2) x\n3) c", "1) a\n2) x\n3) b\n4) c"], ["1. a\n 1. x\n 2. c\n2. d", "1. a\n 1. x\n 2. b\n 3. c\n2. d"], ])("after x in %j writes %j", (value, expected) => { - const editor = makeEditor(value); + using editor = makeEditor(value); placeCaret(editor, "x", 1); expect(splitOrLiftListItem(editor)).toBe(true); editor.view.dispatch(editor.state.tr.insertText("b")); @@ -135,7 +131,7 @@ describe("Shift+Enter in the middle of an ordered list", () => { describe("Shift+Enter on an empty task under a bullet", () => { it.each(["- a\n - [ ] x", "- [ ] a\n - x"])("in %j keeps the draft honest", (value) => { - const editor = makeEditor(value); + using editor = makeEditor(value); placeCaret(editor, "x", 1); splitOrLiftListItem(editor); splitOrLiftListItem(editor); @@ -153,7 +149,7 @@ describe("Backspace at the start of a line", () => { ["- a\n - x\nb", "b", "- a\n - xb"], ["1. a\nb", "b", "1. ab"], ])("in %j at %s writes %j", (value, at, expected) => { - const editor = makeEditor(value); + using editor = makeEditor(value); placeCaret(editor, at); expect(backspaceAcrossList(editor)).toBe(true); expect(storedDraft(editor)).toBe(expected); @@ -163,7 +159,7 @@ describe("Backspace at the start of a line", () => { ["- a\n```\nl1\nl2\n```", "- a\nl1\nl2"], ["p\n```ts\nl1\n```", "p\nl1"], ])("removes the fence of %j rather than joining its code up", (value, expected) => { - const editor = makeEditor(value); + using editor = makeEditor(value); placeCaret(editor, "l1"); editor.commands.keyboardShortcut("Backspace"); expect(storedDraft(editor)).toBe(expected); @@ -176,14 +172,14 @@ describe("Delete at the end of a line", () => { ["- a\n - x\n- c", "- ax\n- c"], ["- a\n\nb", "- a\nb"], ])("in %j writes %j", (value, expected) => { - const editor = makeEditor(value); + using editor = makeEditor(value); placeCaret(editor, "a", 1); expect(deleteAcrossList(editor)).toBe(true); expect(storedDraft(editor)).toBe(expected); }); it.each(["- a\n```\nl1\n```", "a\n```\nl1\n```"])("leaves the fence in %j alone", (value) => { - const editor = makeEditor(value); + using editor = makeEditor(value); placeCaret(editor, "a", 1); editor.commands.keyboardShortcut("Delete"); expect(storedDraft(editor)).toBe(value); @@ -192,7 +188,7 @@ describe("Delete at the end of a line", () => { describe("blocks that a list item or quote cannot hold", () => { it("puts lines pasted at the end of an item after the list", () => { - const editor = makeEditor("- a"); + using editor = makeEditor("- a"); editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); const lines = buildTiptapContent("one\ntwo", (name) => ({ label: name, description: null })); editor.commands.insertContent(lines); @@ -200,7 +196,7 @@ describe("blocks that a list item or quote cannot hold", () => { }); it("will not make a task list inside a quote", () => { - const editor = makeEditor("> q"); + using editor = makeEditor("> q"); placeCaret(editor, "q"); expect(editor.commands.wrapInList("taskList")).toBe(false); expect(storedDraft(editor)).toBe("> q"); @@ -216,7 +212,7 @@ describe("[ ] typed at the start of a bullet item", () => { ["- [ ]x\n - y", "- [ ] x\n - y"], ["- [ ]x", "- [ ] x"], ])("makes the item in %j a task where it stands", (value, expected) => { - const editor = makeEditor(value); + using editor = makeEditor(value); placeCaret(editor, "[ ]x"); const from = editor.state.selection.from; editor.view.dispatch( @@ -233,7 +229,7 @@ describe("[ ] typed at the start of a bullet item", () => { describe("a rule at the end of the draft", () => { it("keeps an empty line after it to type on", () => { - const editor = makeEditor("a\n---"); + using editor = makeEditor("a\n---"); editor.view.dispatch( editor.state.tr.delete(editor.state.doc.content.size - 2, editor.state.doc.content.size), ); diff --git a/apps/web/src/lib/editableFocus.browser.test.ts b/apps/web/src/lib/editableFocus.browser.test.ts index ea09ef792aac..9491ffeeacc4 100644 --- a/apps/web/src/lib/editableFocus.browser.test.ts +++ b/apps/web/src/lib/editableFocus.browser.test.ts @@ -1,11 +1,7 @@ -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { isEditableFocused } from "./editableFocus"; -afterEach(() => { - document.body.replaceChildren(); -}); - describe("isEditableFocused", () => { it("sees a text field focused inside an open shadow root", () => { // A key event from a shadow input is retargeted to its host, so the host is @@ -14,6 +10,7 @@ describe("isEditableFocused", () => { const input = document.createElement("input"); host.attachShadow({ mode: "open" }).append(input); document.body.append(host); + using _host = { [Symbol.dispose]: () => host.remove() }; expect(isEditableFocused(host)).toBe(false); input.focus(); diff --git a/apps/web/src/shortcutModifierState.browser.test.ts b/apps/web/src/shortcutModifierState.browser.test.ts index e017369ff4a9..575f590747cb 100644 --- a/apps/web/src/shortcutModifierState.browser.test.ts +++ b/apps/web/src/shortcutModifierState.browser.test.ts @@ -1,6 +1,7 @@ -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../test/render"; import { areShortcutModifierStatesEqual, @@ -17,33 +18,16 @@ const emptyState = (): ShortcutModifierState => ({ }); describe("useShortcutModifierState", () => { - let root: Root; - let container: HTMLDivElement; - - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - }); - it.each(["keyup", "paste", "blur"] as const)( "does not render for unchanged modifiers after %s resets the state", async (reset) => { - const render = vi.fn(); + const onRender = vi.fn(); function Consumer() { - render(useShortcutModifierState()); + onRender(useShortcutModifierState()); return null; } - await act(async () => { - root.render(createElement(Consumer)); - }); + // The test counts the hook's renders, and the hook never reads a route. + await using _screen = await render(createElement(Consumer), { router: false }); const press = async (type: "keydown" | "keyup", key: string, shiftKey = false) => { await act(async () => { window.dispatchEvent(new KeyboardEvent(type, { key, shiftKey })); @@ -51,7 +35,7 @@ describe("useShortcutModifierState", () => { }; await press("keydown", "Shift", true); - expect(render).toHaveBeenLastCalledWith({ ...emptyState(), shiftKey: true }); + expect(onRender).toHaveBeenLastCalledWith({ ...emptyState(), shiftKey: true }); if (reset === "keyup") { await press("keyup", "Shift"); } else { @@ -59,8 +43,8 @@ describe("useShortcutModifierState", () => { window.dispatchEvent(new Event(reset)); }); } - expect(render).toHaveBeenLastCalledWith(emptyState()); - render.mockClear(); + expect(onRender).toHaveBeenLastCalledWith(emptyState()); + onRender.mockClear(); for (const key of "typing") { await press("keydown", key); @@ -70,9 +54,9 @@ describe("useShortcutModifierState", () => { window.dispatchEvent(new Event("paste")); window.dispatchEvent(new Event("blur")); }); - expect(render).not.toHaveBeenCalled(); + expect(onRender).not.toHaveBeenCalled(); await press("keydown", "Shift", true); - expect(render).toHaveBeenCalledExactlyOnceWith({ ...emptyState(), shiftKey: true }); + expect(onRender).toHaveBeenCalledExactlyOnceWith({ ...emptyState(), shiftKey: true }); }, ); }); diff --git a/oxlint-plugin-t3code/rules/require-disposable-render.test.ts b/oxlint-plugin-t3code/rules/require-disposable-render.test.ts index bc69f362c01b..92dbb5c21bf9 100644 --- a/oxlint-plugin-t3code/rules/require-disposable-render.test.ts +++ b/oxlint-plugin-t3code/rules/require-disposable-render.test.ts @@ -9,6 +9,9 @@ const unitTest = createOxlintRuleHarness("t3code/require-disposable-render", { filename: "widget.test.tsx", }); +const reportsInputInAct = (output: string) => + assert.match(output, /Do not wrap real input \(userEvent or a locator's click\/fill\) in act/); + describe("t3code/require-disposable-render", () => { browserTest.valid( "allows the disposable helper", @@ -19,6 +22,7 @@ describe("t3code/require-disposable-render", () => { " await using screen = await render(null);", " await act(() => screen.rerender(null));", ' await userEvent.click(screen.getByRole("button"));', + " await act(() => store.setState({ open: true }));", "});", ].join("\n"), ); @@ -44,13 +48,99 @@ describe("t3code/require-disposable-render", () => { "rejects userEvent wrapped in act from react", [ 'import { act } from "react";', + 'import { userEvent } from "vite-plus/test/browser";', 'it("clicks", async () => {', " await act(async () => {", " await userEvent.click(document.body);", " });", "});", ].join("\n"), - (output) => assert.match(output, /Do not wrap userEvent in React's act/), + reportsInputInAct, + ); + browserTest.invalid( + "rejects userEvent wrapped in the render helper's act", + [ + 'import { act } from "../../test/render";', + 'import { userEvent } from "vite-plus/test/browser";', + 'it("types", async () => {', + ' await act(() => userEvent.type(document.body, "a"));', + "});", + ].join("\n"), + reportsInputInAct, + ); + browserTest.invalid( + "rejects userEvent wrapped in a renamed render helper act", + [ + 'import { act as flush } from "../test/render.tsx";', + 'import { userEvent } from "vite-plus/test/browser";', + 'it("clicks", async () => {', + " await flush(async () => {", + " await userEvent.click(document.body);", + " });", + "});", + ].join("\n"), + reportsInputInAct, + ); + browserTest.invalid( + "rejects a renamed userEvent wrapped in act", + [ + 'import { act } from "../test/render";', + 'import { userEvent as ue } from "vite-plus/test/browser";', + 'it("clicks", async () => {', + " await act(() => ue.click(document.body));", + "});", + ].join("\n"), + reportsInputInAct, + ); + browserTest.invalid( + "rejects a locator click wrapped in act", + [ + 'import { act } from "../test/render";', + 'import { page } from "vite-plus/test/browser";', + 'it("clicks", async () => {', + ' await act(async () => page.getByRole("button", { name: "Save" }).nth(0).click());', + "});", + ].join("\n"), + reportsInputInAct, + ); + browserTest.invalid( + "rejects React.act from a namespace import", + [ + 'import * as React from "react";', + 'it("renders", async () => {', + " await React.act(async () => {});", + "});", + ].join("\n"), + (output) => assert.match(output, /import `act` from there/), + ); + browserTest.invalid( + "rejects userEvent wrapped in React.act", + [ + 'import * as React from "react";', + 'import { userEvent } from "vite-plus/test/browser";', + 'it("clicks", async () => {', + " await React.act(() => userEvent.click(document.body));", + "});", + ].join("\n"), + reportsInputInAct, + ); + browserTest.valid( + "allows act around a DOM click and userEvent in a function act only defines", + [ + 'import { act } from "../test/render";', + 'import { page, userEvent } from "vite-plus/test/browser";', + 'it("clicks", async () => {', + ' await act(async () => page.getByRole("button").element().click());', + " await act(async () => {", + " const later = () => userEvent.click(document.body);", + " store.setState({ later });", + " });", + "});", + ].join("\n"), + ); + browserTest.invalid( + "rejects lifecycle hooks through a test runner namespace", + ['import * as vt from "vitest";', "vt.beforeEach(() => {});"].join("\n"), ); browserTest.invalid( "rejects importing lifecycle hooks from the test runner", diff --git a/oxlint-plugin-t3code/rules/require-disposable-render.ts b/oxlint-plugin-t3code/rules/require-disposable-render.ts index fe8bd4c80dec..f27420276f8a 100644 --- a/oxlint-plugin-t3code/rules/require-disposable-render.ts +++ b/oxlint-plugin-t3code/rules/require-disposable-render.ts @@ -8,14 +8,31 @@ const LIFECYCLE_HOOKS = new Set(["afterAll", "afterEach", "beforeAll", "beforeEa const TEST_RUNNERS = new Set(["@effect/vitest", "vite-plus/test", "vitest"]); const REACT_ACT_EXPORTS = new Set(["act", "unstable_act"]); const ACT_ENVIRONMENT = "IS_REACT_ACT_ENVIRONMENT"; +const RENDER_HELPER = /(?:^|\/)test\/render(?:\.tsx)?$/u; +const LOCATOR_QUERY = /^getBy/u; +// Locator methods that dispatch real input through the browser provider. +const LOCATOR_INPUT = new Set([ + "clear", + "click", + "dblClick", + "dropTo", + "fill", + "hover", + "selectOptions", + "tripleClick", + "unhover", + "upload", +]); +// These leave the locator for a DOM element, whose click() is synthetic and fine inside act. +const LOCATOR_EXITS = new Set(["element", "elements", "query"]); const HELPER = "Render with `await using screen = await render(...)` from apps/web/test/render.tsx"; const MESSAGES = { reactDomClient: `${HELPER}; it owns the React root and unmounts it on dispose.`, reactAct: `${HELPER} and import \`act\` from there; it enables React's act environment only while act runs.`, actEnvironment: `${HELPER}; its act() sets ${ACT_ENVIRONMENT} for exactly as long as it runs.`, - actAroundUserEvent: - "Do not wrap userEvent in React's act; real input is not an act scope. Await the userEvent call directly.", + actAroundInput: + "Do not wrap real input (userEvent or a locator's click/fill) in act; the browser runner flushes React updates after real input. Await the input directly.", lifecycleHook: "Browser tests acquire what they need with `using`/`await using` instead of lifecycle hooks, so nothing outlives the test that created it.", }; @@ -23,6 +40,32 @@ const MESSAGES = { const stringValue = (node: ESTree.Node | null | undefined) => node?.type === "Literal" && typeof node.value === "string" ? node.value : undefined; +const importedName = (specifier: ESTree.ImportSpecifier) => + specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value; + +const isFunction = (node: ESTree.Node) => + node.type === "ArrowFunctionExpression" || + node.type === "FunctionExpression" || + node.type === "FunctionDeclaration"; + +// Walks a.b().c().d back to its root, reporting whether a getBy* query built a locator on the way +// without leaving it through element()/query(). +const isLocatorChain = (node: ESTree.Node): boolean => { + let sawQuery = false; + for (let current: ESTree.Node | undefined = node; current;) { + if (current.type === "CallExpression") { + current = current.callee; + continue; + } + if (current.type !== "MemberExpression") break; + const name = Option.getOrUndefined(getPropertyName(current.property)); + if (name !== undefined && LOCATOR_EXITS.has(name)) return false; + if (name !== undefined && LOCATOR_QUERY.test(name)) sawQuery = true; + current = current.object; + } + return sawQuery; +}; + const namesActEnvironment = (node: unknown) => { const target = unwrapExpression(node); if (Option.isNone(target)) return false; @@ -44,49 +87,94 @@ export default defineRule({ }, create(context) { const isBrowserTest = BROWSER_TEST_FILE.test(context.filename); - const reactActNames = new Set(); + // Local names bound to React's act or the render helper's act. + const actNames = new Set(); + const reactNamespaces = new Set(); + const runnerNamespaces = new Set(); + const userEventNames = new Set(); + + const namespaceMember = (node: ESTree.Node, namespaces: ReadonlySet) => + node.type === "MemberExpression" && + node.object.type === "Identifier" && + namespaces.has(node.object.name) + ? Option.getOrUndefined(getPropertyName(node.property)) + : undefined; + + const isReactNamespaceAct = (callee: ESTree.Node) => { + const member = namespaceMember(callee, reactNamespaces); + return member !== undefined && REACT_ACT_EXPORTS.has(member); + }; - const isInsideReactAct = (node: ESTree.Node) => { + const isActCall = (node: ESTree.Node) => + node.type === "CallExpression" && + ((node.callee.type === "Identifier" && actNames.has(node.callee.name)) || + isReactNamespaceAct(node.callee)); + + // A function defined inside act but not passed straight to a call may run after act settles. + const isInsideAct = (node: ESTree.Node) => { for (let current = node.parent; current; current = current.parent) { + if (isActCall(current)) return true; if ( - current.type === "CallExpression" && - current.callee.type === "Identifier" && - reactActNames.has(current.callee.name) + isFunction(current) && + !( + current.parent?.type === "CallExpression" && + (current.parent.arguments as ReadonlyArray).includes(current) + ) ) { - return true; + return false; } } return false; }; + const isRealInput = (callee: ESTree.MemberExpression) => { + if (callee.object.type === "Identifier" && userEventNames.has(callee.object.name)) { + return true; + } + const method = Option.getOrUndefined(getPropertyName(callee.property)); + return method !== undefined && LOCATOR_INPUT.has(method) && isLocatorChain(callee.object); + }; + return { ImportDeclaration(node) { + for (const specifier of node.specifiers) { + if (specifier.type === "ImportSpecifier" && importedName(specifier) === "userEvent") { + userEventNames.add(specifier.local.name); + } + } if (isBrowserTest && TEST_RUNNERS.has(node.source.value)) { for (const specifier of node.specifiers) { + if (specifier.type === "ImportNamespaceSpecifier") { + runnerNamespaces.add(specifier.local.name); + continue; + } if (specifier.type !== "ImportSpecifier") continue; - const imported = - specifier.imported.type === "Identifier" - ? specifier.imported.name - : specifier.imported.value; - if (LIFECYCLE_HOOKS.has(imported)) { + if (LIFECYCLE_HOOKS.has(importedName(specifier))) { context.report({ node: specifier, message: MESSAGES.lifecycleHook }); } } return; } + if (RENDER_HELPER.test(node.source.value)) { + for (const specifier of node.specifiers) { + if (specifier.type === "ImportSpecifier" && importedName(specifier) === "act") { + actNames.add(specifier.local.name); + } + } + return; + } if (node.source.value === "react-dom/client") { context.report({ node: node.source, message: MESSAGES.reactDomClient }); return; } if (node.source.value !== "react") return; for (const specifier of node.specifiers) { - if (specifier.type !== "ImportSpecifier") continue; - const imported = - specifier.imported.type === "Identifier" - ? specifier.imported.name - : specifier.imported.value; - if (REACT_ACT_EXPORTS.has(imported)) { - reactActNames.add(specifier.local.name); + if (specifier.type !== "ImportSpecifier") { + reactNamespaces.add(specifier.local.name); + continue; + } + if (REACT_ACT_EXPORTS.has(importedName(specifier))) { + actNames.add(specifier.local.name); context.report({ node: specifier, message: MESSAGES.reactAct }); } } @@ -102,12 +190,19 @@ export default defineRule({ if (callee.value.type === "MemberExpression") { // vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true) leaves the flag on for the whole test. const method = getPropertyName(callee.value.property); - if ( - callee.value.object.type === "Identifier" && - callee.value.object.name === "userEvent" && - isInsideReactAct(node) - ) { - context.report({ node, message: MESSAGES.actAroundUserEvent }); + if (isReactNamespaceAct(callee.value)) { + context.report({ node: node.callee, message: MESSAGES.reactAct }); + return; + } + if (isBrowserTest) { + const hook = namespaceMember(callee.value, runnerNamespaces); + if (hook !== undefined && LIFECYCLE_HOOKS.has(hook)) { + context.report({ node: node.callee, message: MESSAGES.lifecycleHook }); + return; + } + } + if (isRealInput(callee.value) && isInsideAct(node)) { + context.report({ node, message: MESSAGES.actAroundInput }); return; } if ( diff --git a/vite.config.ts b/vite.config.ts index 3c63eae544ed..b6d286e307fd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -188,7 +188,7 @@ export default defineConfig({ rules: { "t3code/prefer-effect-fn": "off" }, }, { - files: ["apps/web/**/*.test.tsx"], + files: ["apps/web/**/*.test.{ts,tsx}"], rules: { "t3code/require-disposable-render": "error" }, }, { From 5d4c202e3f5be0993efea751ca0f0bd4dedb64d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 11:32:16 -0700 Subject: [PATCH 18/96] fix(web,mobile): Limits bars show the even-pace mark as a tick in a gap (#22408) Co-authored-by: Claude Opus 5.5 --- .../src/features/usage/UsageLimitsPooled.tsx | 73 ++- .../src/features/usage/UsageLimitsSection.tsx | 74 ++- .../usage/UsageLimits.browser.test.tsx | 89 +++ apps/web/src/components/usage/UsageLimits.tsx | 65 ++- .../usage/UsageLimitsPooled.browser.test.tsx | 17 + .../components/usage/UsageLimitsPooled.tsx | 38 +- .../test/fixtures/usage-limits-hub/README.md | 8 + .../test/fixtures/usage-limits-hub/hub.mjs | 91 +++ .../fixtures/usage-limits-hub/scenarios.json | 516 ++++++++++++++++++ knip.jsonc | 5 +- 10 files changed, 912 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/components/usage/UsageLimits.browser.test.tsx create mode 100644 apps/web/test/fixtures/usage-limits-hub/README.md create mode 100644 apps/web/test/fixtures/usage-limits-hub/hub.mjs create mode 100644 apps/web/test/fixtures/usage-limits-hub/scenarios.json diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 9013a3dfcc9f..010c902364c4 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -41,6 +41,53 @@ function accountName(account: LimitAccount) { return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; } +// Same gap-and-tick as the Limits rows: two clipped copies of the segment leave a see-through gap. +function PaceGap({ + timeLeft, + children, +}: { + readonly timeLeft: number | null; + readonly children: ReactNode; +}) { + const [width, setWidth] = useState(0); + const tickX = + timeLeft === null || width < 6 + ? null + : Math.min(Math.max((width * timeLeft) / 100, 3), width - 3); + return ( + setWidth(event.nativeEvent.layout.width)} + > + {tickX === null + ? children + : ( + [ + [0, tickX - 3], + [tickX + 3, width], + ] as const + ).map(([start, end]) => ( + + + {children} + + + ))} + {tickX !== null ? ( + + ) : null} + + ); +} + function AccountSegment({ remaining, color, @@ -155,22 +202,18 @@ function PoolWindowCard({ accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}`} accessibilityHint="Show account details" onPress={() => openAccount(account)} - className="h-7 min-w-0 flex-1 rounded-md bg-subtle" + className="h-7 min-w-0 flex-1 rounded-md" > - - - - {timeLeft !== null ? ( - - ) : null} + + + + + + 0 ? { width: barWidth } : undefined} + > + 30 && props.color ? { backgroundColor: props.color } : null, + ]} + /> + + + ); return ( @@ -57,27 +83,35 @@ function WindowRow(props: { {remaining}% left - - - 30 && props.color ? { backgroundColor: props.color } : null, - ]} - /> - - - {timeLeft !== null ? ( + setBarWidth(event.nativeEvent.layout.width)} + > + {tickX === null ? ( + bar + ) : ( + + {/* Clip the same full-width bar so neither fill proportions nor corner radii change. */} + {( + [ + [0, tickX - 3], + [tickX + 3, barWidth], + ] as const + ).map(([start, end]) => ( + + {bar} + + ))} + + )} + {tickX !== null ? ( ) : null} diff --git a/apps/web/src/components/usage/UsageLimits.browser.test.tsx b/apps/web/src/components/usage/UsageLimits.browser.test.tsx new file mode 100644 index 000000000000..c0786c675f24 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimits.browser.test.tsx @@ -0,0 +1,89 @@ +import { render } from "../../../test/render"; +import { ProviderDriverKind, type ServerProviderUsageWindow } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import "~/index.css"; + +vi.mock("../../state/session", () => ({ + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { consumeResetCredit: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" })); + +import { LimitWindows } from "./UsageLimits"; + +const codex = ProviderDriverKind.make("codex"); +const now = Date.parse("2026-09-03T12:00:00Z"); + +const sessionWindow = (minutesUntilReset: number | null): ServerProviderUsageWindow => ({ + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + ...(minutesUntilReset === null + ? {} + : { + windowDurationMins: 300, + resetsAt: new Date(now + minutesUntilReset * 60_000).toISOString(), + }), +}); + +const sized = (width: string) => { + const container = document.createElement("div"); + container.style.width = width; + return container; +}; + +const bar = () => { + const element = page.getByRole("img", { name: /^Session:/ }).element(); + const mark = element.querySelector(":scope > span[aria-hidden]"); + return { + rect: element.getBoundingClientRect(), + mark: mark?.getBoundingClientRect(), + clip: getComputedStyle(element.querySelector(":scope > div")!).clipPath, + }; +}; + +it("places the even-pace tick in a gap at the share of the window left", async () => { + await using _screen = await render( + , + { container: sized("640px") }, + ); + const { rect, mark, clip } = bar(); + expect(mark!.left + mark!.width / 2 - rect.left).toBeCloseTo(rect.width * 0.3, 0); + expect(clip).toMatch(/^polygon/); +}); + +it("keeps the tick inside the bar at both ends of the window", async () => { + for (const minutesUntilReset of [0, 300]) { + await using _screen = await render( + , + { container: sized("640px") }, + ); + const { rect, mark } = bar(); + expect(mark!.left).toBeGreaterThanOrEqual(rect.left); + expect(mark!.right).toBeLessThanOrEqual(rect.right); + } +}); + +it("draws a continuous bar when the window timing is unknown", async () => { + await using _screen = await render( + , + { container: sized("640px") }, + ); + expect(bar()).toMatchObject({ mark: undefined, clip: "none" }); +}); + +it("draws neither gap nor tick on a bar narrower than 6px", async () => { + // 11rem + 1rem + 3.5px + 1rem + 7rem: the bar column gets 3.5px. + await using _screen = await render( + , + { container: sized("323.5px") }, + ); + const { rect, mark, clip } = bar(); + expect(rect.width).toBeCloseTo(3.5, 1); + expect({ mark, clip }).toEqual({ mark: undefined, clip: "none" }); +}); diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 0ac9cf236292..bac2682a0245 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -18,9 +18,10 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; -import { Fragment, type ReactNode, useState } from "react"; +import { Fragment, type ReactNode, useLayoutEffect, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; +import { observeResize } from "../../lib/observeResize"; import { environmentPresentations } from "../../state/presentation"; import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { serverEnvironment } from "../../state/server"; @@ -73,6 +74,43 @@ export function PaceIcon({ pace }: { readonly pace: LimitPace }) { ); } +// The tick sits in a transparent gap cut from the bar, clamped so it stays inside at 0% and 100%. +// The gap is a clip-path slot rather than a mask: a mask composites its layer and re-antialiases +// the bar's rounded ends, and the polygon reaches 1px past the box so only the slot edges clip. +function paceMarkStyles(timeLeft: number | null) { + if (timeLeft === null) return null; + const at = `clamp(3px, ${timeLeft}%, calc(100% - 3px))`; + const [left, right] = [`calc(${at} - 3px)`, `calc(${at} + 3px)`]; + const [near, far] = ["-1px", "calc(100% + 1px)"]; + return { + tick: { left: at }, + gap: { + clipPath: `polygon(${near} ${near}, ${left} ${near}, ${left} ${far}, ${right} ${far}, ${right} ${near}, ${far} ${near}, ${far} ${far}, ${near} ${far})`, + }, + // For a track painted on an element that also draws focus rings, which a clip would cut. + gappedTrack: { + backgroundColor: "transparent", + backgroundImage: `linear-gradient(to right, var(--color-muted) ${left}, transparent ${left}, transparent ${right}, var(--color-muted) ${right})`, + }, + }; +} + +/** + * Pace mark styles for the bar given the returned ref. Below 6px there is no room for the gap + * and tick, so the bar draws neither, as the mobile bars do. + */ +export function usePaceMark(timeLeft: number | null) { + const [bar, setBar] = useState(null); + const [roomy, setRoomy] = useState(false); + useLayoutEffect(() => { + if (!bar) return; + const measure = () => setRoomy(bar.getBoundingClientRect().width >= 6); + measure(); + return observeResize(bar, measure); + }, [bar]); + return [setBar, roomy ? paceMarkStyles(timeLeft) : null] as const; +} + function WindowBar({ color, window, @@ -85,6 +123,7 @@ function WindowBar({ const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); const remaining = remainingPercent(window); const timeLeft = paceRemainingPercent(window, now); + const [barRef, paceMark] = usePaceMark(timeLeft); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) @@ -98,6 +137,7 @@ function WindowBar({ } > -
- {remaining > 0 ? ( -
- ) : null} - {timeLeft !== null ? ( + {/* Clip both layers so the gap exposes even a translucent composer surface. */} +
+
+ {remaining > 0 ? ( +
+ ) : null} +
+ {paceMark ? ( ) : null} diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index b5b6f0341510..b175bbd3b097 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -177,6 +177,23 @@ it("omits an empty reset plate but keeps countdowns and banked credits visible", expect(plate()?.textContent).toBe("1"); }); +it("cuts the pace gap through the segment track and keeps the tick inside it", async () => { + setup(); + // Five hours before the reset is the start of the window, the right-hand end of the bar. + await using _screen = await render( + , + ); + const segment = page.getByRole("button", { name: /^Alpha:/ }).element(); + const tick = segment.querySelector(':scope > span[style*="left"]')!.getBoundingClientRect(); + const rect = segment.getBoundingClientRect(); + expect(getComputedStyle(segment).backgroundImage).toMatch(/^linear-gradient/); + expect(tick.left).toBeGreaterThanOrEqual(rect.left); + expect(tick.right).toBeLessThanOrEqual(rect.right); +}); + it("moves the one limit popover between windows on hover", async () => { setup(); await using screen = await render(initial()); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 8fc4b775b850..76be2555ff8c 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -42,6 +42,7 @@ import { PaceIcon, ResetCreditDialog, barColor, + usePaceMark, resetCreditsSummary, useResetCredit, } from "./UsageLimits"; @@ -342,6 +343,7 @@ function SegmentTriggers({ const payload: SegmentPayload = { account, window, reset, redeem }; const remaining = remainingPercent(window); const timeLeft = paceRemainingPercent(window, now); + const [segmentRef, paceMark] = usePaceMark(timeLeft); const resetsIn = formatResetsIn(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; return ( @@ -352,35 +354,39 @@ function SegmentTriggers({ openOnHover render={ +
+ {filters.map((filter, index) => ( + // Always stacked: the dialog stays narrow on wide windows, so a single line + // would cut off typical paths and values. The grid wrappers size each control + // without restyling it; a grid item stretches to its minmax(0, 1fr) track. +
+
+
+ update(index, { path: event.target.value })} + /> +
+ +
+
+ + {filter.op === "exists" ? null : ( +
+ update(index, { value: event.target.value })} + /> +
+ )} +
+
+ ))} +
+ ); +} + function Field({ label, hint, @@ -232,14 +335,23 @@ const DELIVERY_OUTCOME_LABELS: Record void; }) { const deliveriesQuery = useEnvironmentQuery( - serverEnvironment.scheduledTaskWebhookDeliveries({ environmentId, input: { id: task.id } }), + serverEnvironment.scheduledTaskWebhookDeliveries({ + environmentId, + input: { id: task.id, acceptAnyOutcome: true }, + }), ); const [selectedId, setSelectedId] = useState( null, @@ -736,7 +851,7 @@ function WebhookDeliveriesDialog({
- {DELIVERY_OUTCOME_LABELS[selected.outcome]} + {deliveryOutcomeLabel(selected.outcome)} {selected.method} · @@ -745,7 +860,17 @@ function WebhookDeliveriesDialog({ Signature verified ) : null}
- {selected.error ?

{selected.error}

: null} + {selected.error ? ( +

+ {selected.error} +

+ ) : null} {selected.renderedPrompt ?? "No run was started for this request."} @@ -782,12 +907,15 @@ function WebhookDeliveriesDialog({ onClick={() => setSelectedId(delivery.id)} > - {DELIVERY_OUTCOME_LABELS[delivery.outcome]} + {deliveryOutcomeLabel(delivery.outcome)} {delivery.method} + {delivery.outcome === "filtered" && delivery.error ? ( + {delivery.error} + ) : null} {delivery.missingFields.length > 0 ? ( {delivery.missingFields.length} empty placeholder @@ -1058,6 +1186,11 @@ function ScheduledTaskEditorForm({ reportFailure("Scheduled task is incomplete", "Add a title, prompt, project, and model."); return; } + const parsedFilters = parseWebhookFilterDrafts(draft.filters); + if (draft.scheduleMode === "webhook" && "error" in parsedFilters) { + reportFailure("Invalid filter", parsedFilters.error); + return; + } const schedule = scheduleFromDraft(draft); if (schedule === null) { reportFailure( @@ -1142,7 +1275,8 @@ function ScheduledTaskEditorForm({ -
+ {/* Fieldsets default to min-width: min-content, which lets one wide control widen the form past the dialog. */} +
{!connected ? (

Reconnect this environment before saving.

) : null} @@ -1351,7 +1485,8 @@ function ScheduledTaskEditorForm({ minute.

) : null} -
+ {/* Wraps the toggle below the label when both don't fit, as on phones. */} +
+ setDraft((current) => ({ ...current, filters }))} + />
diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 9c67ec46e8e0..2b9264b9dd70 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -218,6 +218,31 @@ describe("webhook scheduled tasks", () => { expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: invalid })).toBeNull(); } }); + + it("sends filters only when the form changed them", () => { + const filters = [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "reopened"] }, + ] as const; + const draft = taskToDraft({ + ...webhookTask, + schedule: { type: "webhook", signature, filters }, + }); + expect(draft.filters.map((filter) => filter.op)).toEqual(["equals", "in"]); + // A title-only save leaves the key out, so the server keeps whatever is stored now. + expect(scheduleFromDraft({ ...draft, title: "Renamed" })).not.toHaveProperty("filters"); + expect(scheduleFromDraft({ ...draft, filters: draft.filters.slice(0, 1) })).toMatchObject({ + filters: filters.slice(0, 1), + }); + // Removing every row is a change, so it clears the stored filters. + expect(scheduleFromDraft({ ...draft, filters: [] })).toMatchObject({ filters: [] }); + expect( + scheduleFromDraft({ + ...draft, + filters: [{ id: "f1", path: "body.action", op: "in", value: " , " }], + }), + ).toBeNull(); + }); }); describe("scheduled task model defaults", () => { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 0767f99190c9..472ba9b998a8 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -9,7 +9,13 @@ import { type ProviderInteractionMode, type ServerSettings, } from "@t3tools/contracts"; -import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { + parseMaxDeliveryAge, + parseWebhookFilterDrafts, + webhookFilterDrafts, + webhookFilterDraftsChanged, + type WebhookFilterDraft, +} from "@t3tools/client-runtime/scheduled-task-webhook"; import { resolveProjectSettings, @@ -77,6 +83,9 @@ export interface DraftState { /** Write-only: empty keeps the secret already stored on the server. */ readonly signatureSecret: string; readonly maxDeliveryAgeMinutes: string; + readonly filters: ReadonlyArray; + /** The filter rows the form opened with. */ + readonly savedFilters: ReadonlyArray; } export const WEBHOOK_SIGNATURE_DEFAULTS = { @@ -89,6 +98,8 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul if (draft.scheduleMode === "webhook") { const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); if (maxDeliveryAgeMinutes === undefined) return null; + const parsedFilters = parseWebhookFilterDrafts(draft.filters); + if (!("filters" in parsedFilters)) return null; const secret = draft.signatureSecret.trim(); return { type: "webhook", @@ -101,6 +112,10 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul } : null, maxDeliveryAgeMinutes, + // Only edited filters are sent; the server keeps the stored ones otherwise. + ...(webhookFilterDraftsChanged(draft.savedFilters, draft.filters) + ? { filters: parsedFilters.filters } + : {}), }; } if (draft.scheduleMode === "interval") { @@ -117,6 +132,7 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul export function taskToDraft(task: ScheduledTask): DraftState { const schedule = task.schedule; + const filters = webhookFilterDrafts(schedule.type === "webhook" ? schedule.filters : undefined); const weekdays = schedule.type === "fixed_time" && schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) @@ -161,6 +177,8 @@ export function taskToDraft(task: ScheduledTask): DraftState { schedule.type === "webhook" && schedule.maxDeliveryAgeMinutes != null ? String(schedule.maxDeliveryAgeMinutes) : "", + filters, + savedFilters: filters, }; } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index bb89de6717e8..cdae02679be6 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -109,6 +109,15 @@ same secret in the repository's webhook settings with content type up on desktop or web; mobile keeps an existing signature check but can't turn one on. +To run only for some requests, add **Filters**. Each one tests a value named +like a placeholder, without the braces, such as `headers.x-github-event` equals +`pull_request` or `body.action` is one of `opened, reopened`. A task runs only +when every filter matches; other requests are answered normally, logged as +**Filtered** with the first filter that failed, and start nothing. Values +compare as text, and a value that is an object or a list only passes +**exists**. Separate **is one of** values with commas; a value can't contain +a comma or start or end with a space. + On desktop and web, pick **Deliveries** from a task's menu to see recent requests and the prompt each one produced. diff --git a/packages/client-runtime/src/scheduledTaskWebhook.test.ts b/packages/client-runtime/src/scheduledTaskWebhook.test.ts index c2de7d240bc7..6e35d8446964 100644 --- a/packages/client-runtime/src/scheduledTaskWebhook.test.ts +++ b/packages/client-runtime/src/scheduledTaskWebhook.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseMaxDeliveryAge } from "./scheduledTaskWebhook.ts"; +import { + parseMaxDeliveryAge, + emptyWebhookFilterDraft, + parseWebhookFilterDrafts, + removeWebhookFilterDraft, + webhookFilterDrafts, + webhookFilterDraftsChanged, +} from "./scheduledTaskWebhook.ts"; describe("parseMaxDeliveryAge", () => { it("treats blank as no limit and rejects values the server would not accept", () => { @@ -11,3 +18,57 @@ describe("parseMaxDeliveryAge", () => { } }); }); + +describe("webhook filter drafts", () => { + it("round-trips saved filters through the form rows", () => { + const filters = [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "reopened"] }, + { path: "body.label.name", op: "in", value: ["needs review", "a-b"] }, + { path: "query.source", op: "exists" }, + ] as const; + const drafts = webhookFilterDrafts(filters); + expect(drafts[1]).toMatchObject({ path: "body.action", op: "in", value: "opened, reopened" }); + expect(parseWebhookFilterDrafts(drafts)).toEqual({ filters }); + }); + + it("names the first row the server would refuse", () => { + expect( + parseWebhookFilterDrafts([ + { id: "f1", path: "body.action", op: "equals", value: "opened" }, + { id: "f2", path: "body.action", op: "in", value: " , " }, + { id: "f3", path: "", op: "exists", value: "" }, + ]), + ).toEqual({ + error: "Filter 2 needs a path under body, headers or query, and at least one value.", + }); + expect(parseWebhookFilterDrafts([{ id: "f1", path: " ", op: "exists", value: "" }])).toEqual({ + error: "Filter 1 needs a path under body, headers or query.", + }); + const seventeen = Array.from({ length: 17 }, (_, index) => ({ + id: `f${index}`, + path: "body.action", + op: "exists" as const, + value: "", + })); + expect(parseWebhookFilterDrafts(seventeen)).toEqual({ error: "Use at most 16 filters." }); + }); + + it("notices added, removed and edited rows but not an untouched list", () => { + const saved = webhookFilterDrafts([{ path: "body.action", op: "equals", value: "opened" }]); + expect(webhookFilterDraftsChanged(saved, [...saved])).toBe(false); + expect(webhookFilterDraftsChanged(saved, [])).toBe(true); + expect(webhookFilterDraftsChanged(saved, [{ ...saved[0]!, value: "closed" }])).toBe(true); + expect(webhookFilterDraftsChanged([], [])).toBe(false); + }); + + it("removing the first row keeps the second row's id", () => { + const drafts = [ + ...webhookFilterDrafts([{ path: "body.action", op: "equals", value: "opened" }]), + emptyWebhookFilterDraft(), + ]; + expect(new Set(drafts.map((draft) => draft.id)).size).toBe(2); + const remaining = removeWebhookFilterDraft(drafts, drafts[0]!.id); + expect(remaining.map((draft) => draft.id)).toEqual([drafts[1]!.id]); + }); +}); diff --git a/packages/client-runtime/src/scheduledTaskWebhook.ts b/packages/client-runtime/src/scheduledTaskWebhook.ts index 028591709659..2b19d282dceb 100644 --- a/packages/client-runtime/src/scheduledTaskWebhook.ts +++ b/packages/client-runtime/src/scheduledTaskWebhook.ts @@ -1,4 +1,11 @@ -import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES } from "@t3tools/contracts"; +import { + MAX_WEBHOOK_DELIVERY_AGE_MINUTES, + MAX_WEBHOOK_FILTERS, + ScheduledTaskWebhookFilter, + type ScheduledTaskWebhookFilterOp, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{body}}"; @@ -10,3 +17,118 @@ export function parseMaxDeliveryAge(value: string): number | null | undefined { ? minutes : undefined; } + +/** + * One editable filter row. `in` takes a comma-separated list, which is + * lossless because list values can't contain commas; `exists` ignores the value. + */ +export interface WebhookFilterDraft { + /** Stable row identity for the form; never sent to the server. */ + readonly id: string; + readonly path: string; + readonly op: ScheduledTaskWebhookFilterOp; + readonly value: string; +} + +export const WEBHOOK_FILTER_OPS: ReadonlyArray<{ + readonly op: ScheduledTaskWebhookFilterOp; + readonly label: string; +}> = [ + { op: "equals", label: "equals" }, + { op: "notEquals", label: "does not equal" }, + { op: "in", label: "is one of" }, + { op: "startsWith", label: "starts with" }, + { op: "exists", label: "exists" }, +]; + +let lastWebhookFilterDraftId = 0; +const nextWebhookFilterDraftId = () => `filter-${++lastWebhookFilterDraftId}`; + +export function emptyWebhookFilterDraft(): WebhookFilterDraft { + return { id: nextWebhookFilterDraftId(), path: "", op: "equals", value: "" }; +} + +/** By id, so a removed row's identity never passes to the row after it. */ +export function removeWebhookFilterDraft( + drafts: ReadonlyArray, + id: string, +): ReadonlyArray { + return drafts.filter((draft) => draft.id !== id); +} + +export function webhookFilterDrafts( + filters: ReadonlyArray | undefined, +): ReadonlyArray { + return (filters ?? []).map((filter) => ({ + id: nextWebhookFilterDraftId(), + path: filter.path, + op: filter.op, + value: + filter.op === "exists" ? "" : filter.op === "in" ? filter.value.join(", ") : filter.value, + })); +} + +const decodeFilter = Schema.decodeUnknownOption(ScheduledTaskWebhookFilter); + +/** + * Checks each row with the server's own schema, so a draft that parses here + * also saves. The error names the first row that would not. + */ +export function parseWebhookFilterDrafts( + drafts: ReadonlyArray, +): { readonly filters: ReadonlyArray } | { readonly error: string } { + if (drafts.length > MAX_WEBHOOK_FILTERS) { + return { error: `Use at most ${MAX_WEBHOOK_FILTERS} filters.` }; + } + const filters: ScheduledTaskWebhookFilter[] = []; + for (const [index, draft] of drafts.entries()) { + const path = draft.path.trim(); + const filter = decodeFilter( + draft.op === "exists" + ? { path, op: draft.op } + : draft.op === "in" + ? { + path, + op: draft.op, + value: draft.value + .split(",") + .map((item) => item.trim()) + .filter((item) => item !== ""), + } + : { path, op: draft.op, value: draft.value }, + ); + if (Option.isNone(filter)) { + return { + error: `Filter ${index + 1} needs a path under body, headers or query${ + draft.op === "exists" + ? "." + : draft.op === "in" + ? ", and at least one value." + : ", and a value." + }`, + }; + } + filters.push(filter.value); + } + return { filters }; +} + +/** + * Whether a form's filter rows differ from the ones it opened with. A save + * leaves `filters` out unless they did, so editing only the title can't + * overwrite filters another device changed in the meantime. + */ +export function webhookFilterDraftsChanged( + saved: ReadonlyArray, + current: ReadonlyArray, +): boolean { + return ( + saved.length !== current.length || + saved.some( + (filter, index) => + filter.path !== current[index]?.path || + filter.op !== current[index]?.op || + (filter.op !== "exists" && filter.value !== current[index]?.value), + ) + ); +} diff --git a/packages/contracts/src/scheduledTask.test.ts b/packages/contracts/src/scheduledTask.test.ts index c3373d08abf4..61af412b0555 100644 --- a/packages/contracts/src/scheduledTask.test.ts +++ b/packages/contracts/src/scheduledTask.test.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { MIN_SCHEDULED_TASK_INTERVAL_MS, + ScheduledTaskListWebhookDeliveriesResult, ScheduledTaskSchedule, ScheduledTaskUpsertSchedule, } from "./scheduledTask.ts"; @@ -50,3 +51,72 @@ describe("ScheduledTaskUpsertSchedule", () => { ).toThrow(); }); }); + +describe("webhook filters", () => { + const accepts = (filters: unknown) => { + try { + decodeUpsertSchedule({ type: "webhook", filters }); + return true; + } catch { + return false; + } + }; + + it("keeps valid filters", () => { + const filters = [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "labeled"] }, + { path: "body.label.name", op: "startsWith", value: "agent:" }, + { path: "query.source", op: "exists" }, + ]; + expect(decodeUpsertSchedule({ type: "webhook", filters })).toEqual({ + type: "webhook", + filters, + }); + expect(decodeSchedule({ type: "webhook", signature: null, filters })).toMatchObject({ + filters, + }); + }); + + it("rejects empty or unknown paths, unknown ops, and values that do not fit the op", () => { + expect(accepts([{ path: "", op: "exists" }])).toBe(false); + expect(accepts([{ path: " ", op: "exists" }])).toBe(false); + expect(accepts([{ path: "request", op: "exists" }])).toBe(false); + expect(accepts([{ path: "header.x-github-event", op: "exists" }])).toBe(false); + expect(accepts([{ path: "body.action", op: "in", value: "opened" }])).toBe(false); + expect(accepts([{ path: "body.action", op: "in", value: [] }])).toBe(false); + for (const value of ["", " opened", "opened ", "a,b"]) { + expect(accepts([{ path: "body.action", op: "in", value: [value] }])).toBe(false); + } + expect(accepts([{ path: "body.label.name", op: "in", value: ["needs review"] }])).toBe(true); + expect(accepts([{ path: "body.action", op: "equals" }])).toBe(false); + expect(accepts([{ path: "body.action", op: "matches", value: "^opened$" }])).toBe(false); + }); + + it("allows at most 16 filters", () => { + const sixteen = Array.from({ length: 16 }, () => ({ path: "body.action", op: "exists" })); + expect(accepts(sixteen)).toBe(true); + expect(accepts([...sixteen, { path: "body.action", op: "exists" }])).toBe(false); + }); +}); + +describe("ScheduledTaskWebhookDeliverySummary", () => { + it("decodes an outcome this client does not know yet", () => { + const summary = { + id: "delivery:1", + taskId: "scheduled-task:1", + receivedAt: "2026-10-20T00:00:00.000Z", + method: "POST", + contentType: null, + bodyBytes: 0, + outcome: "some_future_outcome", + signatureVerified: false, + missingFields: [], + error: null, + }; + expect( + Schema.decodeUnknownSync(ScheduledTaskListWebhookDeliveriesResult)({ deliveries: [summary] }) + .deliveries[0]?.outcome, + ).toBe("some_future_outcome"); + }); +}); diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 9a24e1b8abe3..77825113992f 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -83,12 +83,56 @@ const WebhookMaxDeliveryAgeMinutes = Schema.Int.check( "Skip requests the relay held longer than this many minutes while the environment was offline. Null runs every request.", }); +export const MAX_WEBHOOK_FILTERS = 16; + +const WebhookFilterPath = TrimmedNonEmptyString.check( + Schema.isPattern(/^(body|headers|query)(\.|$)/), +).annotate({ + description: + "Request value to test, in placeholder syntax without braces: headers.x-github-event, body.action, query.source.", +}); + +/** + * Forms edit an `in` list as comma-separated text, so a value that has no + * comma and no surrounding whitespace survives that round trip unchanged. + */ +const WebhookFilterListValue = Schema.String.check( + Schema.isPattern(/^[^,\s](?:[^,]*[^,\s])?$/), +).annotate({ description: "Non-empty, without commas or surrounding whitespace." }); + +export const ScheduledTaskWebhookFilter = Schema.Union([ + Schema.Struct({ + path: WebhookFilterPath, + op: Schema.Literals(["equals", "notEquals", "startsWith"]), + value: Schema.String, + }), + Schema.Struct({ + path: WebhookFilterPath, + op: Schema.Literal("in"), + value: Schema.Array(WebhookFilterListValue).check(Schema.isMinLength(1)), + }), + Schema.Struct({ path: WebhookFilterPath, op: Schema.Literal("exists") }), +]).annotate({ + description: + "Scalars compare as strings; objects and arrays only satisfy 'exists'. Every op but 'exists' fails when the path is missing.", +}); +export type ScheduledTaskWebhookFilter = typeof ScheduledTaskWebhookFilter.Type; +export type ScheduledTaskWebhookFilterOp = ScheduledTaskWebhookFilter["op"]; + +const ScheduledTaskWebhookFilters = Schema.Array(ScheduledTaskWebhookFilter) + .check(Schema.isMaxLength(MAX_WEBHOOK_FILTERS)) + .annotate({ + description: + "Run only when every filter matches the request; other requests are logged as filtered. Up to 16. Omit to keep the stored filters; pass [] to clear them.", + }); + const ScheduledTaskWebhookSchedule = Schema.Struct({ type: Schema.Literal("webhook").annotate({ description: "Run when the task's webhook URL receives a request.", }), signature: Schema.NullOr(ScheduledTaskWebhookSignature), maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)), + filters: Schema.optional(ScheduledTaskWebhookFilters), }).annotate({ description: "Run on each request to the task's webhook URL. The prompt may use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}} placeholders.", @@ -115,6 +159,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ description: "Signature check; omit or null to accept requests by URL token only.", }), maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)), + filters: Schema.optional(ScheduledTaskWebhookFilters), }).annotate({ description: "Run on each request to the task's webhook URL. The prompt may use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}} placeholders.", @@ -253,6 +298,7 @@ export const ScheduledTaskWebhookDeliveryOutcome = Schema.Literals([ "disabled", "rate_limited", "expired", + "filtered", ]); export type ScheduledTaskWebhookDeliveryOutcome = typeof ScheduledTaskWebhookDeliveryOutcome.Type; @@ -263,7 +309,8 @@ export const ScheduledTaskWebhookDeliverySummary = Schema.Struct({ method: TrimmedNonEmptyString, contentType: Schema.NullOr(Schema.String), bodyBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - outcome: ScheduledTaskWebhookDeliveryOutcome, + // Any string, so an outcome added later doesn't break a client's whole list. + outcome: Schema.Union([ScheduledTaskWebhookDeliveryOutcome, Schema.String]), signatureVerified: Schema.Boolean, missingFields: Schema.Array(Schema.String), error: Schema.NullOr(Schema.String), @@ -282,6 +329,12 @@ export type ScheduledTaskWebhookDelivery = typeof ScheduledTaskWebhookDelivery.T export const ScheduledTaskListWebhookDeliveriesInput = Schema.Struct({ id: ScheduledTaskId, + /** + * Sent by clients that decode any outcome. Shipped clients decode a closed + * list and fail on the whole response, so without it the server leaves out + * rows with outcomes they don't know. + */ + acceptAnyOutcome: Schema.optional(Schema.Boolean), }); export type ScheduledTaskListWebhookDeliveriesInput = typeof ScheduledTaskListWebhookDeliveriesInput.Type; From ec585729e0a48c318f4cfb0c232b816c029d0541 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 13:30:02 -0700 Subject: [PATCH 23/96] fix(mobile): prevent update checker from crashing iOS at launch (#22488) --- .../src/features/updates/app-updates.test.ts | 37 ++++++++ .../src/features/updates/app-updates.ts | 85 +++++++++---------- 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index e35f173a4729..d980164b32d3 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -1,7 +1,9 @@ +import { AppState } from "react-native"; import { describe, expect, it, vi } from "vite-plus/test"; import { createAppUpdateDeferral, + createAppUpdateForegroundRecheck, createAppUpdateLaunchCheck, FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, registerHiddenUpdateTap, @@ -19,6 +21,13 @@ vi.mock("expo-updates", () => ({ reloadAsync: vi.fn(), })); +vi.mock("react-native", () => ({ + AppState: { + currentState: "active", + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, +})); + function makeUpdateClient(overrides: Partial = {}): AppUpdateClient { return { isEnabled: true, @@ -640,3 +649,31 @@ describe("registerHiddenUpdateTap", () => { expect(count).toBe(0); }); }); + +describe("createAppUpdateForegroundRecheck", () => { + it("registers once and checks after returning from a long background stay", () => { + vi.stubGlobal("__DEV__", false); + const now = vi.spyOn(Date, "now"); + const listener = vi.mocked(AppState.addEventListener); + listener.mockClear(); + const client = makeUpdateClient(); + const start = createAppUpdateForegroundRecheck(client, createAppUpdateDeferral()); + + try { + start(); + start(); + expect(listener).toHaveBeenCalledOnce(); + const onChange = listener.mock.calls[0]![1]; + now.mockReturnValue(1000); + onChange("background"); + now.mockReturnValue(1000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS); + onChange("active"); + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + onChange("active"); + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + } finally { + now.mockRestore(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 2cfe3aa7aebf..6390256108ec 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -1,4 +1,6 @@ import * as Updates from "expo-updates"; +// Metro namespace imports evaluate unrelated React Native getters, including unavailable native modules. +import { AppState } from "react-native"; import { type AtomCommandResult, @@ -418,22 +420,19 @@ async function defaultFlushPendingWrites(): Promise { async function defaultIsSafeToRestartInBackground(): Promise { const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff"); if (isForegroundHandoffActive()) return false; - const { AppState } = await import("react-native"); return AppState.currentState === "background"; } function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void { - void import("react-native").then(({ AppState }) => { - const subscription = AppState.addEventListener("change", (state) => { - if (state !== "background") return; - subscription.remove(); - apply(); - }); - if (includeCurrent && AppState.currentState === "background") { - subscription.remove(); - apply(); - } + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "background") return; + subscription.remove(); + apply(); }); + if (includeCurrent && AppState.currentState === "background") { + subscription.remove(); + apply(); + } } export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; @@ -443,25 +442,23 @@ export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; * the timer running. */ function defaultOnForegroundStay(apply: () => void): void { - void import("react-native").then(({ AppState }) => { - let timer: ReturnType | undefined; - const arm = () => { - timer ??= setTimeout(() => { - subscription.remove(); - apply(); - }, DEFERRED_INSTALL_PROMPT_AFTER_MS); - }; - const disarm = () => { - if (timer === undefined) return; - clearTimeout(timer); - timer = undefined; - }; - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") arm(); - else if (state === "background") disarm(); - }); - if (AppState.currentState === "active") arm(); + let timer: ReturnType | undefined; + const arm = () => { + timer ??= setTimeout(() => { + subscription.remove(); + apply(); + }, DEFERRED_INSTALL_PROMPT_AFTER_MS); + }; + const disarm = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") arm(); + else if (state === "background") disarm(); }); + if (AppState.currentState === "active") arm(); } const defaultAppUpdateEnvironment: AppUpdateEnvironment = { @@ -528,22 +525,20 @@ export function createAppUpdateForegroundRecheck( return () => { if (started || !isAppUpdateCheckAvailable(client)) return; started = true; - void import("react-native").then(({ AppState }) => { - let backgroundedAtMs: number | null = null; - AppState.addEventListener("change", (state) => { - if (state === "background") { - backgroundedAtMs = Date.now(); - return; - } - if (state !== "active") return; - const shouldCheck = shouldRecheckAppUpdateOnForeground( - backgroundedAtMs, - Date.now(), - deferral.pendingInstall, - ); - backgroundedAtMs = null; - if (shouldCheck) void runAppUpdateCheck({ client, deferral }); - }); + let backgroundedAtMs: number | null = null; + AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state !== "active") return; + const shouldCheck = shouldRecheckAppUpdateOnForeground( + backgroundedAtMs, + Date.now(), + deferral.pendingInstall, + ); + backgroundedAtMs = null; + if (shouldCheck) void runAppUpdateCheck({ client, deferral }); }); }; } From 2ebe280e5602f23fc2742297da3d3b6f2b1b8fca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 13:49:47 -0700 Subject: [PATCH 24/96] feat(scheduled-tasks): replay dedupe and coalescing for webhook deliveries (#22234) --- .../SettingsScheduledTasksRouteScreen.tsx | 42 + .../settings/scheduledTaskDraft.test.ts | 44 +- .../features/settings/scheduledTaskDraft.ts | 38 + .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 4 +- .../Migrations/067_WebhookDedupe.ts | 29 + .../reconcileV2PreviewMigration.test.ts | 2 + .../scheduledTasks/ScheduledTaskService.ts | 742 ++++-- .../ScheduledTaskService.webhook.test.ts | 2028 ++++++++++++++++- .../src/scheduledTasks/webhookDedupe.ts | 128 ++ .../src/scheduledTasks/webhookRoute.test.ts | 1 + .../server/src/scheduledTasks/webhookRoute.ts | 2 +- .../src/scheduledTasks/webhookTemplate.ts | 4 +- .../settings/ScheduledTasksSettings.tsx | 117 +- .../scheduledTasksSettings.logic.test.ts | 98 + .../settings/scheduledTasksSettings.logic.ts | 73 + docs/user/project-settings.md | 14 + packages/contracts/src/scheduledTask.test.ts | 51 +- packages/contracts/src/scheduledTask.ts | 26 + 20 files changed, 3123 insertions(+), 326 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/067_WebhookDedupe.ts create mode 100644 apps/server/src/scheduledTasks/webhookDedupe.ts diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index aadc124ba4a5..36c58dc893d0 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -716,6 +716,13 @@ function TaskForm({ Dialog.alert("Invalid filter", parsedFilters.error); return; } + if (draft.schedule.mode === "webhook" && schedule === null) { + Dialog.alert( + "Invalid webhook settings", + "Each dedupe key must contain a valid request placeholder. The coalesce window must be whole seconds from 10 to 3600, or blank.", + ); + return; + } if ( !draft.title.trim() || !draft.prompt.trim() || @@ -1060,6 +1067,41 @@ function TaskForm({ setDraft({ ...draft, schedule: { ...draft.schedule, filters } }) } /> + + setDraft({ ...draft, schedule: { ...draft.schedule, replayKey } }) + } + /> + + setDraft({ ...draft, schedule: { ...draft.schedule, coalesceKey } }) + } + /> + + setDraft({ ...draft, schedule: { ...draft.schedule, coalesceWindowSeconds } }) + } + /> + + Replay keys suppress repeats for 7 days. Coalescing runs the latest payload when the + window from the first delivery closes. Empty keys run normally. + ) : ( <> diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index bfe363cf0652..aa7d36156e50 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -30,6 +30,37 @@ describe("scheduleDraftForTask", () => { expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule); }); + it("round-trips dedupe settings and rejects invalid windows", () => { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.id}}", + coalesceWindowSeconds: 3600, + }; + const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null, dedupe } }); + expect(scheduleFromDraft(draft)).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: " \n ", + coalesceKey: " \t ", + coalesceWindowSeconds: "", + }), + ).toMatchObject({ dedupe: {} }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: ` ${dedupe.replayKey} `, + coalesceKey: `\t${dedupe.coalesceKey}\n`, + }), + ).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ ...draft, replayKey: "", coalesceKey: "", coalesceWindowSeconds: "" }), + ).toMatchObject({ dedupe: {} }); + for (const coalesceWindowSeconds of ["9", "3601", "1.5", "abc"]) { + expect(scheduleFromDraft({ ...draft, coalesceWindowSeconds })).toBeNull(); + } + }); + it("round-trips a webhook schedule without a signature", () => { const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } }); expect(draft.mode).toBe("webhook"); @@ -37,6 +68,7 @@ describe("scheduleDraftForTask", () => { type: "webhook", signature: null, maxDeliveryAgeMinutes: null, + dedupe: {}, }); }); @@ -50,6 +82,7 @@ describe("scheduleDraftForTask", () => { expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "0" })).toBeNull(); expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "" })).toMatchObject({ maxDeliveryAgeMinutes: null, + dedupe: {}, }); }); @@ -62,7 +95,7 @@ describe("scheduleDraftForTask", () => { const saved = scheduleFromDraft( scheduleDraftForTask({ schedule: { type: "webhook", signature } }), ); - expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null }); + expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null, dedupe: {} }); expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false); }); @@ -369,3 +402,12 @@ describe("scheduled task model defaults", () => { ).toBeNull(); }); }); + +it("rejects dedupe keys without valid request placeholders", () => { + expect( + scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", replayKey: "literal" }), + ).toBeNull(); + expect( + scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", coalesceKey: "{{body.bad" }), + ).toBeNull(); +}); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 599fb4780e88..3ef1a2f3faf6 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -1,3 +1,4 @@ +import { isWebhookDedupeTemplate } from "@t3tools/contracts"; import type { ModelSelection, ServerConfig, @@ -56,6 +57,9 @@ export type ScheduleDraft = { readonly intervalMinutes: string; readonly signature: ScheduledTaskWebhookSignature | null; readonly maxDeliveryAgeMinutes: string; + readonly replayKey: string; + readonly coalesceKey: string; + readonly coalesceWindowSeconds: string; readonly filters: ReadonlyArray; /** The filter rows the form opened with. */ readonly savedFilters: ReadonlyArray; @@ -68,6 +72,9 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { intervalMinutes: "15", signature: null, maxDeliveryAgeMinutes: "", + replayKey: "", + coalesceKey: "", + coalesceWindowSeconds: "", filters: [], savedFilters: [], }; @@ -93,6 +100,12 @@ export function scheduleDraftForTask(task: Pick): Sch ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature, + replayKey: task.schedule.dedupe?.replayKey ?? "", + coalesceKey: task.schedule.dedupe?.coalesceKey ?? "", + coalesceWindowSeconds: + task.schedule.dedupe?.coalesceWindowSeconds === undefined + ? "" + : String(task.schedule.dedupe.coalesceWindowSeconds), maxDeliveryAgeMinutes: task.schedule.maxDeliveryAgeMinutes == null ? "" @@ -107,6 +120,19 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche if (draft.mode === "webhook") { const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); if (maxDeliveryAgeMinutes === undefined) return null; + const replayKey = draft.replayKey.trim(); + const coalesceKey = draft.coalesceKey.trim(); + if ([replayKey, coalesceKey].some((key) => key && !isWebhookDedupeTemplate(key))) return null; + const coalesceWindowSeconds = draft.coalesceWindowSeconds?.trim() + ? Number(draft.coalesceWindowSeconds) + : undefined; + if ( + coalesceWindowSeconds !== undefined && + (!Number.isInteger(coalesceWindowSeconds) || + coalesceWindowSeconds < 10 || + coalesceWindowSeconds > 3600) + ) + return null; const parsedFilters = parseWebhookFilterDrafts(draft.filters); if (!("filters" in parsedFilters)) return null; // No secret is sent, so the server keeps the stored one. @@ -121,6 +147,15 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche prefix: draft.signature.prefix, }, maxDeliveryAgeMinutes, + ...(replayKey || coalesceKey || coalesceWindowSeconds !== undefined + ? { + dedupe: { + ...(replayKey ? { replayKey } : {}), + ...(coalesceKey ? { coalesceKey } : {}), + ...(coalesceWindowSeconds === undefined ? {} : { coalesceWindowSeconds }), + }, + } + : { dedupe: {} }), // Only edited filters are sent; the server keeps the stored ones otherwise. ...(webhookFilterDraftsChanged(draft.savedFilters, draft.filters) ? { filters: parsedFilters.filters } @@ -179,6 +214,9 @@ function draftSignature(draft: ScheduledTaskDraft): string { [...draft.schedule.weekdays].sort((a, b) => a - b), draft.schedule.intervalMinutes, draft.schedule.maxDeliveryAgeMinutes, + draft.schedule.replayKey, + draft.schedule.coalesceKey, + draft.schedule.coalesceWindowSeconds, draft.schedule.filters.map(({ path, op, value }) => [path, op, value]), draft.workspace, draft.baseRef, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 2e74482f690a..91187bc2a131 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -116,7 +116,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { export const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -143,7 +143,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { description: - "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.", + "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10–3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.", parameters: OrchestratorMcpUpdateScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9a0dff477368..8dfbfe712a7d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -69,6 +69,7 @@ import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts"; import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts"; import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts"; import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts"; +import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -138,6 +139,7 @@ export const migrationEntries = [ [64, "CanonicalizeThreadPullRequestLinks", Migration0064], [65, "CodexUsageNativeTurnIndex", Migration0065], [66, "RepairClientSettlementTimestamps", Migration0066], + [67, "WebhookDedupe", Migration0067], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index cd926c899d97..5bbf3cd9a7e6 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -15,7 +15,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 66 }, (_, index) => index + 1), + Array.from({ length: 67 }, (_, index) => index + 1), ); }), ); @@ -40,6 +40,7 @@ layer("055_OrchestrationV2", (it) => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -72,6 +73,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" }, { migration_id: 65, name: "CodexUsageNativeTurnIndex" }, { migration_id: 66, name: "RepairClientSettlementTimestamps" }, + { migration_id: 67, name: "WebhookDedupe" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts b/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts new file mode 100644 index 000000000000..b132e7c6dd14 --- /dev/null +++ b/apps/server/src/persistence/Migrations/067_WebhookDedupe.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN last_run_webhook_delivery_id TEXT`; + yield* sql`CREATE TABLE scheduled_task_webhook_replay_keys ( + task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE, + replay_key TEXT NOT NULL, + delivery_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (task_id, replay_key) + )`; + yield* sql`CREATE INDEX idx_webhook_replay_expiry ON scheduled_task_webhook_replay_keys(expires_at)`; + yield* sql`CREATE INDEX idx_webhook_replay_delivery ON scheduled_task_webhook_replay_keys(delivery_id)`; + yield* sql`CREATE TABLE scheduled_task_webhook_pending ( + delivery_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES scheduled_tasks(task_id) ON DELETE CASCADE, + coalesce_key TEXT NOT NULL, + due_at INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + task_json TEXT NOT NULL, + request_json TEXT NOT NULL, + delivery_ids_json TEXT NOT NULL + )`; + yield* sql`CREATE INDEX idx_webhook_pending_due ON scheduled_task_webhook_pending(state, due_at)`; + yield* sql`CREATE UNIQUE INDEX idx_webhook_pending_key + ON scheduled_task_webhook_pending(task_id, coalesce_key) WHERE state = 'pending'`; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index f1495d1e62a4..990d450cde6b 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -46,6 +46,7 @@ describe("V2 preview upgrade", () => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -135,6 +136,7 @@ describe("V2 preview upgrade", () => { [64, "CanonicalizeThreadPullRequestLinks"], [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], + [67, "WebhookDedupe"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 89522077bae4..ea681a30afe0 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -29,6 +29,7 @@ import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Duration from "effect/Duration"; import * as Metric from "effect/Metric"; import * as Layer from "effect/Layer"; @@ -54,14 +55,30 @@ import { renderWebhookPrompt, type WebhookRequest, } from "./webhookTemplate.ts"; +import { + acceptWebhookDedupe, + acceptImmediateWebhookDedupe, + releaseWebhookReplay, + decodePendingTask, + decodePendingRequest, + dedupeError, +} from "./webhookDedupe.ts"; import { describeFailedWebhookFilter, findFailedWebhookFilter } from "./webhookFilter.ts"; import { constantTimeEquals, verifyWebhookSignature } from "./webhookVerification.ts"; export const WEBHOOK_ROUTE_PREFIX = "/api/hooks"; +const isScheduledTaskError = Schema.is(ScheduledTaskError); +const PROMPT_TOO_LONG = "The filled-in prompt is too long."; const WEBHOOK_DELIVERY_RETENTION = 50; -/** Kept apart from the rest, so a stream of filtered requests can't evict real deliveries. */ +const WEBHOOK_DEDUPE_DELIVERY_RETENTION = 20; const WEBHOOK_FILTERED_DELIVERY_RETENTION = 20; -/** The outcomes clients decoded before outcomes became open-ended. */ +const WEBHOOK_DEDUPE_LOG_PER_MINUTE = 10; +const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; +const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; +const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; +const WEBHOOK_MAX_QUEUED_PER_TASK = 20; +/** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ +const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ "accepted", "dispatch_failed", @@ -70,16 +87,6 @@ const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ "rate_limited", "expired", ]); -/** - * Filtered requests take no run slot, so this bounds the log writes they - * cause instead; past it a filtered request is only counted in metrics. - */ -const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; -const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; -const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; -const WEBHOOK_MAX_QUEUED_PER_TASK = 20; -/** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ -const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; interface WebhookOrigin { readonly relayHookBaseUrl: string | null; @@ -111,7 +118,7 @@ class WebhookDeliverySkipped extends Data.TaggedError("WebhookDeliverySkipped")< }> {} interface RateWindow { - readonly accepted: ReadonlyArray; + readonly accepted: ReadonlyArray<{ readonly at: number }>; readonly rejectedLogged: boolean; } @@ -126,6 +133,7 @@ export interface WebhookTriggerRequest extends WebhookRequest { export type WebhookDeliveryOutcome = | "accepted" | "duplicate" + | "coalesced" | "prompt_too_long" | "queue_full" | "rate_limited" @@ -140,7 +148,7 @@ export type WebhookTriggerResult = | { readonly _tag: "accepted"; readonly deliveryId: ScheduledTaskWebhookDeliveryId; - readonly outcome: "accepted" | "duplicate" | "prompt_too_long" | "filtered"; + readonly outcome: "accepted" | "duplicate" | "coalesced" | "prompt_too_long" | "filtered"; } | { readonly _tag: "not_found" } | { readonly _tag: "rejected_signature" } @@ -211,6 +219,7 @@ interface WebhookDeliveryRow { readonly signature_verified: number; readonly missing_fields_json: string; readonly rendered_prompt: string | null; + readonly pending_window: number; readonly error: string | null; } @@ -405,6 +414,13 @@ export const layer = Layer.effect( // finish late release their own count, never a recreated task's. const webhookQueued = yield* Ref.make>(new Map()); const webhookRateWindows = yield* Ref.make>(new Map()); + const webhookRejectedRateWindows = yield* Ref.make>( + new Map(), + ); + const webhookDedupeLogWindows = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const pendingInFlight = yield* Ref.make>(new Set()); const webhookFilteredLogWindows = yield* Ref.make< ReadonlyMap> >(new Map()); @@ -627,11 +643,12 @@ export const layer = Layer.effect( ), ); - const markRunning = (id: ScheduledTaskId, startedAtIso: string) => + const markRunning = (id: ScheduledTaskId, startedAtIso: string, webhookDeliveryId?: string) => sql<{ task_id: string }>` UPDATE scheduled_tasks SET updated_at = ${startedAtIso}, last_run_at = ${startedAtIso}, + last_run_webhook_delivery_id = ${webhookDeliveryId ?? null}, last_run_status = 'running', last_run_error = NULL WHERE task_id = ${id} @@ -673,9 +690,10 @@ export const layer = Layer.effect( // markCompleted, write a full terminal record so runDueTasks neither skips // the task forever (it filters out 'running' rows) nor re-fires it // immediately: the dispatch may already have gone out, so next_run_at must - // advance and run_count must count the attempt. + // advance. Pending webhook attempts count only with their completion state; + // other attempts count here. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( - function* (task: ScheduledTask, message: string) { + function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -686,12 +704,15 @@ export const layer = Layer.effect( last_run_error = ${message}, next_run_at = ${nextRunAt(source, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE delivery_id = ${pendingDeliveryId ?? null} AND state = 'running' + ) THEN 0 ELSE 1 END WHERE task_id = ${task.id} AND last_run_status = 'running' `; yield* notifyChanged; }, - (effect, task, _message) => + (effect, task, _message, _pendingDeliveryId) => effect.pipe( Effect.catch((cause) => Effect.logWarning("Could not release stuck schedule task run", { @@ -705,7 +726,11 @@ export const layer = Layer.effect( const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( task: ScheduledTask, trigger: "scheduled" | "manual" | "webhook", - webhook?: { readonly deliveryId: string; readonly prompt: string }, + webhook?: { + readonly deliveryId: string; + readonly prompt: string; + readonly dispatched: Ref.Ref; + }, ) { const reserved = yield* Ref.modify(activeRuns, (active) => { if (active.has(task.id)) return [false, active] as const; @@ -739,6 +764,12 @@ export const layer = Layer.effect( } return { task, threadId: null }; } + if ( + webhook !== undefined && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId} AND state = 'completed'`) + .length > 0 + ) + return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -764,7 +795,22 @@ export const layer = Layer.effect( if (reason !== null) return yield* new WebhookDeliverySkipped({ reason }); } - yield* markRunning(active.id, startedAtIso); + if (webhook === undefined) yield* markRunning(active.id, startedAtIso); + else + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* markRunning(active.id, startedAtIso, webhook.deliveryId); + // Recovery must distinguish this delivery from other windows waiting on the same task. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'running' WHERE delivery_id = ${webhook.deliveryId}`; + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not start webhook run.", { taskId: task.id, cause }), + ), + ); + yield* notifyChanged; const fireKey = @@ -821,8 +867,9 @@ export const layer = Layer.effect( .pipe(Effect.as(ThreadId.make(active.threadId))), ); - const completedAt = yield* localNow; const runSucceeded = result._tag === "Success"; + if (webhook !== undefined && runSucceeded) yield* Ref.set(webhook.dispatched, true); + const completedAt = yield* localNow; const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); const threadId = runSucceeded ? result.value : null; @@ -841,7 +888,7 @@ export const layer = Layer.effect( // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. - yield* markCompleted({ + const completion = markCompleted({ id: task.id, completedAtIso: completed.updatedAt, nextRunAtIso: completed.nextRunAt, @@ -849,11 +896,32 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); + if (webhook === undefined) yield* completion; + else + yield* sql + .withTransaction( + Effect.gen(function* () { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) yield* completion; + } + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not record webhook completion.", { taskId: task.id, cause }), + ), + ); yield* notifyChanged; } return { task: completed, threadId }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause))), + Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), Effect.ensuring( Ref.update(activeRuns, (active) => { const next = new Set(active); @@ -930,7 +998,11 @@ export const layer = Layer.effect( last_run_error = 'Run was interrupted by a server restart.', next_run_at = ${nextRunAt(decoded.success, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; return; @@ -944,7 +1016,11 @@ export const layer = Layer.effect( SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; }), @@ -1017,6 +1093,12 @@ export const layer = Layer.effect( prefix: input.schedule.signature.prefix, }, maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null, + ...(input.schedule.dedupe === undefined + ? existingTask?.schedule.type === "webhook" && + existingTask.schedule.dedupe !== undefined + ? { dedupe: existingTask.schedule.dedupe } + : {} + : { dedupe: input.schedule.dedupe }), ...webhookFilters( // Omitted keeps the stored filters, so a client that predates // them can re-save a task without clearing them. @@ -1139,6 +1221,16 @@ export const layer = Layer.effect( next.delete(id); return next; }), + Ref.update(webhookRejectedRateWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookDedupeLogWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), Ref.update(webhookFilteredLogWindows, (windows) => { const next = new Map(windows); next.delete(id); @@ -1243,6 +1335,7 @@ export const layer = Layer.effect( bodyBytes: row.body_bytes, outcome: row.outcome as ScheduledTaskWebhookDeliveryOutcome, signatureVerified: row.signature_verified === 1, + pendingWindow: row.outcome === "accepted" && row.pending_window === 1, missingFields: Option.getOrElse(decodeMissingFieldsJson(row.missing_fields_json), () => []), error: row.error, }); @@ -1251,15 +1344,17 @@ export const layer = Layer.effect( input, ) => sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} ORDER BY received_at DESC, rowid DESC `.pipe( Effect.map((rows) => ({ - deliveries: (input.acceptAnyOutcome === true - ? rows - : rows.filter((row) => LEGACY_DELIVERY_OUTCOMES.has(row.outcome)) - ).map(decodeDeliverySummary), + deliveries: rows + .filter( + (row) => input.acceptAnyOutcome === true || LEGACY_DELIVERY_OUTCOMES.has(row.outcome), + ) + .map(decodeDeliverySummary), })), Effect.mapError((cause) => taskError("Could not list webhook deliveries.", { taskId: input.id, cause }), @@ -1270,15 +1365,19 @@ export const layer = Layer.effect( "ScheduledTaskService.getWebhookDelivery", )(function* (input) { const rows = yield* sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} `.pipe( Effect.mapError((cause) => taskError("Could not load webhook delivery.", { taskId: input.id, cause }), ), ); const row = rows[0]; - if (row === undefined) { + if ( + row === undefined || + (input.acceptAnyOutcome !== true && !LEGACY_DELIVERY_OUTCOMES.has(row.outcome)) + ) { return yield* taskError("Webhook delivery not found.", { taskId: input.id }); } return { @@ -1293,6 +1392,24 @@ export const layer = Layer.effect( }; }); + const deliveryRequestBody = (request: WebhookTriggerRequest) => { + const truncated = request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; + return { + truncated, + body: truncated + ? new TextDecoder().decode(request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) + : request.bodyText, + }; + }; + const replaceDeliveryRequest = (deliveryId: string, request: WebhookTriggerRequest) => { + const logged = deliveryRequestBody(request); + return sql`UPDATE scheduled_task_webhook_deliveries SET + method = ${request.method}, query = ${redactQuery(request.query)}, + headers_json = ${encodeHeadersJson(redactHeaders(request.headers))}, body = ${logged.body}, + body_bytes = ${request.body.byteLength}, body_truncated = ${logged.truncated ? 1 : 0} + WHERE delivery_id = ${deliveryId}`.pipe(Effect.asVoid, Effect.mapError(dedupeError)); + }; + const recordDelivery = (input: { readonly id: string; readonly taskId: ScheduledTaskId; @@ -1304,10 +1421,7 @@ export const layer = Layer.effect( readonly renderedPrompt: string | null; readonly error?: string; }) => { - const truncated = input.request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; - const loggedBody = truncated - ? new TextDecoder().decode(input.request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) - : input.request.bodyText; + const { truncated, body: loggedBody } = deliveryRequestBody(input.request); return sql .withTransaction( Effect.gen(function* () { @@ -1338,17 +1452,28 @@ export const layer = Layer.effect( rendered_prompt = excluded.rendered_prompt, error = excluded.error `; - const filtered = input.outcome === "filtered" ? 1 : 0; + // Each suppression bucket retains its own rows, never real run history. + const bucket = (outcome: string, error: string | undefined) => + outcome === "filtered" + ? "filtered" + : outcome === "duplicate" || + outcome === "coalesced" || + outcome === "rate_limited" || + error === PROMPT_TOO_LONG + ? "dedupe" + : "main"; + const retentionBucket = bucket(input.outcome, input.error); + const rowBucket = sql.literal(`CASE WHEN outcome = 'filtered' THEN 'filtered' + WHEN outcome IN ('duplicate', 'coalesced', 'rate_limited') OR error = 'The filled-in prompt is too long.' THEN 'dedupe' + ELSE 'main' END`); yield* sql` DELETE FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} - AND (outcome = 'filtered') = ${filtered} + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} AND delivery_id NOT IN ( SELECT delivery_id FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} AND (outcome = 'filtered') = ${filtered} - -- rowid breaks timestamp ties in arrival order; delivery ids are random. + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} ORDER BY received_at DESC, rowid DESC - LIMIT ${filtered ? WEBHOOK_FILTERED_DELIVERY_RETENTION : WEBHOOK_DELIVERY_RETENTION} + LIMIT ${retentionBucket === "main" ? WEBHOOK_DELIVERY_RETENTION : retentionBucket === "filtered" ? WEBHOOK_FILTERED_DELIVERY_RETENTION : WEBHOOK_DEDUPE_DELIVERY_RETENTION} ) `; }), @@ -1367,13 +1492,15 @@ export const layer = Layer.effect( WHERE delivery_id = ${deliveryId} `.pipe(Effect.ignore); - /** - * Sliding one-minute window counting every request with a valid token, - * including ones the signature check later rejects. - */ - const takeRateSlot = (id: ScheduledTaskId, nowMs: number) => + /** Sliding one-minute window for deliveries that can start work. */ + const takeRateSlot = ( + id: ScheduledTaskId, + nowMs: number, + rateWindows = webhookRateWindows, + reservation = { at: nowMs }, + ) => Ref.modify( - webhookRateWindows, + rateWindows, ( windows, ): readonly [ @@ -1381,7 +1508,7 @@ export const layer = Layer.effect( ReadonlyMap, ] => { const current = windows.get(id); - const recent = (current?.accepted ?? []).filter((at) => nowMs - at < 60_000); + const recent = (current?.accepted ?? []).filter((entry) => nowMs - entry.at < 60_000); if (recent.length >= WEBHOOK_RATE_LIMIT_PER_MINUTE) { const first = current?.rejectedLogged !== true; return [ @@ -1391,11 +1518,23 @@ export const layer = Layer.effect( } return [ "allowed", - new Map(windows).set(id, { accepted: [...recent, nowMs], rejectedLogged: false }), + new Map(windows).set(id, { accepted: [...recent, reservation], rejectedLogged: false }), ]; }, ); + const takeDedupeLogSlot = (id: ScheduledTaskId, nowMs: number) => + Ref.modify(webhookDedupeLogWindows, (windows) => { + const recent = (windows.get(id) ?? []).filter((at) => nowMs - at < 60_000); + return recent.length >= WEBHOOK_DEDUPE_LOG_PER_MINUTE + ? ([false, windows] as const) + : ([true, new Map(windows).set(id, [...recent, nowMs])] as const); + }); + const releaseReplay = (deliveryId: string) => + releaseWebhookReplay(deliveryId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.mapError(dedupeError), + ); /** Sliding one-minute window of filtered requests written to the delivery log. */ const takeFilteredLogSlot = (id: ScheduledTaskId, nowMs: number) => Ref.modify( @@ -1424,6 +1563,83 @@ export const layer = Layer.effect( const serviceScope = yield* Effect.scope; + const releaseFailedDelivery = Effect.fn("ScheduledTaskService.releaseFailedDelivery")( + function* (deliveryId: string) { + yield* releaseReplay(deliveryId); + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id IN ( + SELECT substr(value, ${"delivery:relay:".length + 1}) FROM scheduled_task_webhook_pending, json_each(delivery_ids_json) + WHERE delivery_id = ${deliveryId} AND value LIKE 'delivery:relay:%' + )`.pipe(Effect.mapError(dedupeError)); + if (deliveryId.startsWith("delivery:relay:")) + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = ${deliveryId.slice("delivery:relay:".length)}`.pipe( + Effect.mapError(dedupeError), + ); + }, + (effect) => sql.withTransaction(effect).pipe(Effect.mapError(dedupeError)), + ); + + const runWebhookDelivery = Effect.fn("ScheduledTaskService.runWebhookDelivery")(function* ( + task: ScheduledTask, + deliveryId: string, + prompt: string, + ) { + const runOutcome = (outcome: "started" | "skipped" | "failed") => + Effect.all([ + Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), + Metrics.increment(Metrics.webhookRunsTotal, { outcome }), + ]); + const dispatched = yield* Ref.make(false); + return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( + Effect.flatMap(({ task: completed }) => + completed.lastRunStatus === "failed" + ? runOutcome("failed").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ) + : runOutcome("started"), + ), + Effect.catchTags({ + WebhookDeliverySkipped: (skipped) => + runOutcome("skipped").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ), + }), + Effect.as(true), + // Dispatch success owns the replay key even if later bookkeeping fails. + Effect.catchCause((cause) => + Effect.gen(function* () { + const started = yield* Ref.get(dispatched); + yield* Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }); + yield* runOutcome(started ? "started" : "failed"); + yield* markDeliveryFailed( + deliveryId, + started + ? "The run started, but completion could not be recorded." + : "The run failed to start.", + ); + if (!started) { + yield* sql + .withTransaction( + Effect.gen(function* () { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'failed' + WHERE delivery_id = ${deliveryId} AND state = 'running' RETURNING delivery_id`; + if (counted.length > 0) + yield* sql`UPDATE scheduled_tasks SET run_count = run_count + 1 + WHERE task_id = ${task.id} AND created_at = ${task.createdAt}`; + }), + ) + .pipe(Effect.mapError(dedupeError)); + yield* releaseFailedDelivery(deliveryId); + } + // Pending windows retry the same command id to finish bookkeeping. + return !started; + }), + ), + ); + }); + // Delivery attributes belong to the enclosing triggerWebhook span. const observeDelivery = Effect.fnUntraced(function* ( request: WebhookTriggerRequest, @@ -1571,71 +1787,168 @@ export const layer = Layer.effect( ...(details.error === undefined ? {} : { error: details.error }), }); + // Reject floods before signature work or rejection-log writes; valid + // deliveries give this reservation back once validation succeeds. + const rejectionReservation = { at: DateTime.toEpochMillis(now) }; + const rejectionSlot = yield* takeRateSlot( + task.id, + DateTime.toEpochMillis(now), + webhookRejectedRateWindows, + rejectionReservation, + ); + if (rejectionSlot !== "allowed") { + if (rejectionSlot === "first_rejected") yield* log("rate_limited"); + yield* observe("rate_limited"); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); + } + if (!task.enabled) { + yield* log("disabled"); + yield* observe("disabled"); + return { _tag: "disabled" as const }; + } const signature = schedule.signature; - const signatureVerified = - signature === null || - (row.webhook_secret !== null && + if (signature !== null) { + const verified = + row.webhook_secret !== null && verifyWebhookSignature({ signature, secret: row.webhook_secret, headers: request.headers, body: request.body, - })); + }); + if (!verified) { + yield* log("rejected_signature"); + yield* observe("rejected_signature"); + return { _tag: "rejected_signature" as const }; + } + } const maxAgeMinutes = schedule.maxDeliveryAgeMinutes ?? null; - const tooOld = + if ( maxAgeMinutes !== null && DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt) > - maxAgeMinutes * 60_000; - - // Filters: checked after the signature and age, and before the rate - // limit, so requests the task ignores never take a run's slot. The - // request is accepted so the sender doesn't retry it, and its relay - // claim is released because nothing ran. - if (task.enabled && signatureVerified && !tooOld) { - const failedFilter = findFailedWebhookFilter(schedule.filters ?? [], request); - if (failedFilter !== null) { - if (yield* takeFilteredLogSlot(task.id, DateTime.toEpochMillis(now))) { - yield* log("filtered", { - signatureVerified: signature !== null, - error: describeFailedWebhookFilter(failedFilter), - }); - } - yield* observe("filtered"); - return yield* releaseClaim({ - _tag: "accepted" as const, - deliveryId, - outcome: "filtered" as const, - }); - } + maxAgeMinutes * 60_000 + ) { + yield* log("expired", { signatureVerified: signature !== null }); + yield* observe("expired"); + return { _tag: "expired" as const }; } - // Only the first rejected request in a window is logged, so a flood - // cannot write rows or push the real deliveries out of the log. - const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(now)); - if (slot !== "allowed") { - if (slot === "first_rejected") yield* log("rate_limited"); - yield* observe("rate_limited"); + const refundRejectionSlot = Ref.update(webhookRejectedRateWindows, (windows) => { + const current = windows.get(task.id); + if (current === undefined) return windows; + return new Map(windows).set(task.id, { + ...current, + accepted: current.accepted.filter((entry) => entry !== rejectionReservation), + }); + }); + + const failedFilter = findFailedWebhookFilter(schedule.filters ?? [], request); + if (failedFilter !== null) { + if (yield* takeFilteredLogSlot(task.id, DateTime.toEpochMillis(now))) { + yield* log("filtered", { + signatureVerified: signature !== null, + error: describeFailedWebhookFilter(failedFilter), + }); + } + yield* refundRejectionSlot; + yield* observe("filtered"); return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "rate_limited" as const, + _tag: "accepted" as const, + deliveryId, + outcome: "filtered" as const, }); } - if (!task.enabled) { - yield* log("disabled"); - yield* observe("disabled"); - return { _tag: "disabled" as const }; + + const runWindow = (yield* Ref.get(webhookRateWindows)).get(task.id); + if ( + schedule.dedupe === undefined && + (runWindow?.accepted.filter((entry) => DateTime.toEpochMillis(now) - entry.at < 60_000) + .length ?? 0) >= WEBHOOK_RATE_LIMIT_PER_MINUTE + ) { + if ((yield* takeRateSlot(task.id, DateTime.toEpochMillis(now))) === "first_rejected") + yield* log("rate_limited"); + yield* observe("rate_limited"); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); } - if (!signatureVerified) { - yield* log("rejected_signature"); - yield* observe("rejected_signature"); - return { _tag: "rejected_signature" as const }; + const reserve = takeRateSlot(task.id, DateTime.toEpochMillis(now)).pipe( + Effect.flatMap((slot) => (slot === "allowed" ? Effect.void : Effect.fail(slot))), + ); + const queueKey = `${task.id}\u0000${task.createdAt}`; + const checkCapacity = Effect.gen(function* () { + const [row] = yield* sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND state IN ('pending', 'dispatching', 'running')`; + if ( + (row?.count ?? 0) + ((yield* Ref.get(webhookQueued)).get(queueKey) ?? 0) >= + WEBHOOK_MAX_QUEUED_PER_TASK + ) + return yield* Effect.fail("queue_full" as const); + }).pipe( + Effect.mapError((cause) => (cause === "queue_full" ? cause : dedupeError(cause))), + ); + const admit = (effect: Effect.Effect) => + sql.withTransaction(effect).pipe( + Effect.catch((cause) => + cause === "first_rejected" || cause === "rejected" || cause === "queue_full" + ? Effect.succeed({ + outcome: + cause === "queue_full" + ? ("queue_full" as const) + : ("rate_limited" as const), + log: cause === "first_rejected", + }) + : Effect.fail(isScheduledTaskError(cause) ? cause : dedupeError(cause)), + ), + ); + const deduped = + schedule.dedupe === undefined + ? "run" + : yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserve, + takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)), + checkCapacity, + (ownerId) => replaceDeliveryRequest(ownerId, request), + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if ( + outcome === "pending" || + outcome === "coalesced" || + (outcome === "duplicate" && + (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)))) + ) + yield* log(outcome === "pending" ? "accepted" : outcome, { + signatureVerified: signature !== null, + }); + return outcome; + }), + ); + if (typeof deduped !== "string") { + yield* refundRejectionSlot; + if (deduped.log) yield* log("rate_limited"); + yield* observe(deduped.outcome); + return yield* releaseClaim({ _tag: "rate_limited", outcome: deduped.outcome } as const); } - if (tooOld) { - yield* log("expired", { signatureVerified: signature !== null }); - yield* observe("expired"); - return { _tag: "expired" as const }; + if (deduped !== "run") { + if (deduped !== "coalesced" && deduped !== "coalesced_unlogged") + yield* refundRejectionSlot; + const outcome = + deduped === "pending" + ? "accepted" + : deduped === "coalesced_unlogged" + ? "coalesced" + : deduped; + yield* observe(outcome); + if (deduped === "duplicate") + return yield* releaseClaim({ _tag: "accepted", deliveryId, outcome } as const); + claimAcquired = false; + return { _tag: "accepted", deliveryId, outcome } as const; } - const frameId = yield* crypto.randomBytes(6).pipe( Effect.map((bytes) => Buffer.from(bytes).toString("hex")), Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), @@ -1648,85 +1961,192 @@ export const layer = Layer.effect( signatureVerified: signature !== null, missing: rendered.missing, renderedPrompt: rendered.prompt, - error: "The filled-in prompt is too long.", + error: PROMPT_TOO_LONG, }); + yield* releaseReplay(deliveryId); yield* observe("prompt_too_long"); - return { _tag: "accepted" as const, deliveryId, outcome: "prompt_too_long" as const }; + return yield* releaseClaim({ + _tag: "accepted" as const, + deliveryId, + outcome: "prompt_too_long" as const, + }); } // Bound the deliveries one task holds, so steady traffic to a stuck // task cannot pile up parked fibers. - const queueKey = `${task.id}\u0000${task.createdAt}`; - const queued = yield* Ref.modify(webhookQueued, (counts) => { - const count = counts.get(queueKey) ?? 0; - return count >= WEBHOOK_MAX_QUEUED_PER_TASK - ? ([false, counts] as const) - : ([true, new Map(counts).set(queueKey, count + 1)] as const); + yield* refundRejectionSlot; + let queueReserved = false; + const release = Effect.suspend(() => { + if (!queueReserved) return Effect.void; + queueReserved = false; + return Ref.update(webhookQueued, (counts) => { + const next = new Map(counts); + const count = (next.get(queueKey) ?? 1) - 1; + if (count <= 0) next.delete(queueKey); + else next.set(queueKey, count); + return next; + }); }); - if (!queued) { - yield* observe("queue_full"); + // Serialize immediate reservations with persisted window admission. + const reserveImmediate = Effect.gen(function* () { + yield* checkCapacity; + yield* reserve; + yield* Ref.update(webhookQueued, (counts) => + new Map(counts).set(queueKey, (counts.get(queueKey) ?? 0) + 1), + ); + queueReserved = true; + }); + const admitted = yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptImmediateWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserveImmediate, + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if (outcome === "duplicate") { + if (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now))) + yield* log("duplicate", { signatureVerified: signature !== null }); + } else + yield* log("accepted", { + signatureVerified: signature !== null, + missing: rendered.missing, + renderedPrompt: rendered.prompt, + }); + return outcome; + }), + ).pipe(Effect.onError(() => release)); + if (typeof admitted !== "string") { + yield* release; + if (admitted.log) yield* log("rate_limited"); + yield* observe(admitted.outcome); return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "queue_full" as const, - }); + _tag: "rate_limited", + outcome: admitted.outcome, + } as const); + } + if (admitted === "duplicate") { + yield* release; + yield* observe("duplicate"); + return yield* releaseClaim({ + _tag: "accepted", + deliveryId, + outcome: "duplicate", + } as const); } - const release = Ref.update(webhookQueued, (counts) => { - const next = new Map(counts); - const count = (next.get(queueKey) ?? 1) - 1; - if (count <= 0) next.delete(queueKey); - else next.set(queueKey, count); - return next; - }); - yield* log("accepted", { - signatureVerified: signature !== null, - missing: rendered.missing, - renderedPrompt: rendered.prompt, - }).pipe(Effect.onError(() => release)); yield* observe("accepted"); const permit = yield* webhookPermit(task.id); - const runOutcome = (outcome: "started" | "skipped" | "failed") => - Effect.all([ - Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), - Metrics.increment(Metrics.webhookRunsTotal, { outcome }), - ]); - yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe( - Effect.flatMap(({ task: completed }) => - completed.lastRunStatus === "failed" - ? runOutcome("failed").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ) - : runOutcome("started"), - ), - Effect.catchTags({ - WebhookDeliverySkipped: (skipped) => - runOutcome("skipped").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), - ), - }), - // The log is readable over RPC, so it gets a fixed reason; the - // cause, which can carry request data, stays in the server log. - Effect.catchCause((cause) => - Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe( - Effect.andThen(runOutcome("failed")), - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ), - ), + yield* runWebhookDelivery(task, deliveryId, rendered.prompt).pipe( permit.withPermits(1), - Effect.withSpan("ScheduledTaskService.runWebhookDelivery", { - root: true, - attributes: { - "scheduled_task.id": task.id, - "scheduled_task.webhook.delivery_id": deliveryId, - }, - }), Effect.ensuring(release), Effect.forkIn(serviceScope), ); // Dispatch owns the accepted delivery now; retries must keep its claim. claimAcquired = false; return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const }; - }).pipe(Effect.onError(() => releaseClaim(undefined))), + }).pipe( + Effect.onError(() => + Effect.all([ + releaseClaim(undefined), + (request.relayDeliveryId === undefined || claimAcquired + ? releaseReplay(deliveryId) + : Effect.void + ).pipe(Effect.ignore), + ]), + ), + ), + ); + }); + + const dispatchPending = Effect.fn("ScheduledTaskService.dispatchPendingWebhooks")(function* () { + const now = DateTime.toEpochMillis(yield* localNow); + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; + const tasks = yield* sql<{ task_id: ScheduledTaskId }>` + SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') + `; + yield* Effect.forEach( + tasks, + ({ task_id: taskId }) => + Effect.gen(function* () { + const reserved = yield* Ref.modify(pendingInFlight, (active) => + active.has(taskId) + ? ([false, active] as const) + : ([true, new Set(active).add(taskId)] as const), + ); + if (!reserved) return; + const permit = yield* webhookPermit(taskId); + // One worker per task waits on its permit, then drains persisted rows in order. + // Other tasks remain independent and ticks never reload an active task's payloads. + const dispatch = Effect.gen(function* () { + while (true) { + const [entry] = yield* sql<{ + delivery_id: string; + task_json: string; + request_json: string; + state: string; + }>` + SELECT delivery_id, task_json, request_json, state FROM scheduled_task_webhook_pending + WHERE task_id = ${taskId} AND state IN ('dispatching', 'running', 'completed', 'failed') ORDER BY due_at, rowid LIMIT 1 + `; + if (entry === undefined) return; + const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; + if (entry.state === "completed" || entry.state === "failed") { + if (entry.state === "failed") { + yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); + yield* releaseFailedDelivery(entry.delivery_id); + } + yield* remove; + continue; + } + const prepared = yield* Effect.gen(function* () { + const task = yield* decodePendingTask(entry.task_json); + const request = yield* decodePendingRequest(entry.request_json); + const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); + return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; + }).pipe(Effect.exit); + if (Exit.isFailure(prepared)) { + if (Cause.hasInterruptsOnly(prepared.cause)) + return yield* Effect.failCause(prepared.cause); + yield* markDeliveryFailed( + entry.delivery_id, + "The pending webhook payload could not be prepared.", + ); + yield* releaseFailedDelivery(entry.delivery_id); + yield* remove; + continue; + } + const { task, rendered } = prepared.value; + if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); + yield* releaseFailedDelivery(entry.delivery_id); + } else { + yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; + if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) + return; + } + yield* remove; + } + }); + yield* dispatch.pipe( + permit.withPermits(1), + Effect.catch((cause) => + Effect.logWarning("Pending webhook dispatch failed", { cause }), + ), + Effect.ensuring( + Ref.update(pendingInFlight, (active) => { + const next = new Set(active); + next.delete(taskId); + return next; + }), + ), + Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { root: true }), + Effect.forkIn(serviceScope), + ); + }), + { discard: true }, ); }); + yield* scheduler.register("webhook-coalescing", dispatchPending()); return ScheduledTaskService.of({ list, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 2485edf7e090..e8a4957e84c0 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -3,8 +3,13 @@ import * as NodeCrypto from "node:crypto"; import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it } from "@effect/vitest"; -import { ScheduledTaskUpsertInput, SecretRequestError } from "@t3tools/contracts"; +import { + ScheduledTaskUpsertInput, + ScheduledTaskWebhookDeliveryId, + SecretRequestError, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -24,9 +29,94 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import { renderWebhookPrompt } from "./webhookTemplate.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); +it.effect( + "preserves omitted dedupe settings, releases duplicate claims, and retains coalesced claims", + () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }; + const { task } = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe } }), + ); + const updated = yield* service.upsert(yield* webhookTaskInput()); + assert.deepEqual( + updated.task.schedule.type === "webhook" && updated.task.schedule.dedupe, + dedupe, + ); + const request = (id: string, bodyText = "{}", relayDeliveryId?: string) => + requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + ...(relayDeliveryId === undefined ? {} : { relayDeliveryId }), + }); + yield* service.triggerWebhook(request("A")); + yield* Queue.take(launches); + yield* service.triggerWebhook(request("B1", '{"group":"B"}')); + for (let i = 0; i < 5; i++) { + const duplicate = yield* service.triggerWebhook(request("A", "{}", `duplicate-${i}`)); + assert.equal(duplicate._tag === "accepted" && duplicate.outcome, "duplicate"); + const coalesced = yield* service.triggerWebhook( + request(`B${i + 2}`, '{"group":"B"}', `coalesced-${i}`), + ); + assert.equal(coalesced._tag === "accepted" && coalesced.outcome, "coalesced"); + } + const claims = yield* sql<{ + relay_delivery_id: string; + }>`SELECT relay_delivery_id FROM scheduled_task_webhook_relay_deliveries ORDER BY relay_delivery_id`; + assert.deepEqual( + claims.map((claim) => claim.relay_delivery_id), + Array.from({ length: 5 }, (_, i) => `coalesced-${i}`), + ); + assert.isTrue( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.every( + (delivery) => delivery.outcome !== "duplicate" && delivery.outcome !== "coalesced", + ), + ); + const fresh = yield* service.triggerWebhook(request("C", "{}", "duplicate-0")); + assert.equal(fresh._tag === "accepted" && fresh.outcome, "accepted"); + yield* Queue.take(launches); + const cleared = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe: {} } }), + ); + assert.deepEqual( + cleared.task.schedule.type === "webhook" && cleared.task.schedule.dedupe, + {}, + ); + }), + ), +); + +const waitForSpan = (work: Effect.Effect, name: string) => + Effect.gen(function* () { + const done = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === name) Deferred.doneUnsafe(done, Effect.void); + }; + return span; + }, + }); + const result = yield* work.pipe(Effect.withTracer(tracer)); + yield* Deferred.await(done); + return result; + }); + +const drainPendingDispatch = (workers: Map>) => + waitForSpan(workers.get("webhook-coalescing")!, "ScheduledTaskService.dispatchPendingWebhook"); + type LaunchInput = ThreadLaunchService.ThreadLaunchInput; const webhookTaskInput = (overrides: Record = {}) => @@ -70,20 +160,76 @@ const withService = ( readonly sql: SqlClient.SqlClient; readonly secretsByRef: Map; }) => Effect.Effect, - options: { readonly gate?: Deferred.Deferred; readonly relayHookBaseUrl?: string } = {}, + options: { + readonly onFrame?: () => void; + readonly frameGate?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + readonly completedLaunches?: Set; + readonly failLaunches?: { remaining: number }; + readonly gate?: Deferred.Deferred; + readonly gateForTask?: (input: LaunchInput) => Deferred.Deferred | undefined; + readonly relayHookBaseUrl?: string; + readonly sql?: SqlClient.SqlClient; + readonly workers?: Map>; + } = {}, ) => Effect.gen(function* () { const launches = yield* Queue.unbounded(); const secretsByRef = new Map(); const layerDependencies = Layer.mergeAll( - NodePlatformCrypto.layer, - Scheduler.layer, + options.frameGate === undefined && options.onFrame === undefined + ? NodePlatformCrypto.layer + : Layer.effect( + Crypto.Crypto, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return { + ...crypto, + randomBytes: (size: number) => + Effect.suspend(() => { + if (size === 6) options.onFrame?.(); + return size === 6 && options.frameGate !== undefined + ? Deferred.succeed(options.frameGate.started, undefined).pipe( + Effect.andThen(Deferred.await(options.frameGate.release)), + Effect.andThen(crypto.randomBytes(size)), + ) + : crypto.randomBytes(size); + }), + }; + }), + ).pipe(Layer.provide(NodePlatformCrypto.layer)), + options.workers === undefined + ? Scheduler.layer + : Layer.succeed(Scheduler.Scheduler, { + register: (name: string, work: Effect.Effect) => + Effect.gen(function* () { + const context = yield* Effect.context(); + options.workers!.set(name, work.pipe(Effect.provideContext(context), Effect.orDie)); + }), + }), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: (input) => - Queue.offer(launches, input).pipe( - Effect.andThen(options.gate ? Deferred.await(options.gate) : Effect.void), - Effect.as({ threadId: "thread-1", resumed: false } as never), - ), + Effect.suspend(() => { + if (options.completedLaunches?.has(input.commandId)) + return Effect.succeed({ threadId: "thread-1", resumed: false } as never); + options.completedLaunches?.add(input.commandId); + return Queue.offer(launches, input).pipe( + Effect.andThen( + (options.gateForTask?.(input) ?? options.gate) + ? Deferred.await((options.gateForTask?.(input) ?? options.gate)!) + : Effect.void, + ), + Effect.andThen( + Effect.sync(() => { + if (options.failLaunches && options.failLaunches.remaining-- > 0) + throw new Error("injected start failure"); + }), + ), + Effect.as({ threadId: "thread-1", resumed: false } as never), + ); + }), }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -108,8 +254,18 @@ const withService = ( const service = yield* ScheduledTaskService.ScheduledTaskService; const sql = yield* SqlClient.SqlClient; return yield* body({ service, launches, sql, secretsByRef }); - }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)); + }).pipe( + Effect.provide( + Layer.fresh(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))), + ), + ); + }).pipe( + Effect.provide( + options.sql === undefined + ? SqlitePersistence.layerMemory + : Layer.succeed(SqlClient.SqlClient, options.sql), + ), + ); it.effect("dispatches exactly the rendered prompt and logs the delivery", () => withService(({ service, launches }) => @@ -132,7 +288,10 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () => `scheduled-task:${task.id}:webhook:${result._tag === "accepted" ? result.deliveryId : ""}`, ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); + const { deliveries } = yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + }); assert.equal(deliveries.length, 1); assert.equal(deliveries[0]?.outcome, "accepted"); const { delivery } = yield* service.getWebhookDelivery({ @@ -188,7 +347,11 @@ it.effect("answers not found for a wrong token or unknown hook without logging", assert.equal(wrongToken._tag, "not_found"); const unknown = yield* service.triggerWebhook(requestFor(task, { hookId: "missing" })); assert.equal(unknown._tag, "not_found"); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 0, + ); }), ), ); @@ -253,9 +416,10 @@ it.effect("checks the configured signature and keeps the secret write-only", () ); assert.isTrue(resaved.task.webhook?.hasSecret); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => [delivery.outcome, delivery.signatureVerified], - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => [delivery.outcome, delivery.signatureVerified]); assert.deepEqual(outcomes.toSorted(), [ ["accepted", true], ["rejected_signature", false], @@ -300,23 +464,38 @@ it.effect("queues a burst of deliveries instead of dropping them", () => }), ); -it.effect("rate limits a hook past 60 deliveries a minute", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const results = yield* Effect.forEach(Array.from({ length: 61 }), () => - service.triggerWebhook(requestFor(task)), - ); - assert.equal(results.at(-2)?._tag, "disabled"); - assert.equal(results.at(-1)?._tag, "rate_limited"); - yield* Effect.forEach([1, 2, 3], () => service.triggerWebhook(requestFor(task))); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); - assert.equal(outcomes.filter((outcome) => outcome === "rate_limited").length, 1); - }), - ), -); +it.effect("rate limits a hook past 60 deliveries a minute", () => { + let frames = 0; + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const results = yield* Effect.forEach(Array.from({ length: 61 }), () => + service + .triggerWebhook(requestFor(task)) + .pipe( + Effect.tap((result) => + result._tag === "accepted" ? Queue.take(launches) : Effect.void, + ), + ), + ); + assert.equal(results.at(-2)?._tag, "accepted"); + assert.equal(results.at(-1)?._tag, "rate_limited"); + yield* Effect.forEach([1, 2, 3], () => service.triggerWebhook(requestFor(task))); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => delivery.outcome); + assert.equal(outcomes.filter((outcome) => outcome === "rate_limited").length, 1); + assert.equal(frames, 60); + }), + { + onFrame: () => { + frames++; + }, + }, + ); +}); it.effect("a save carrying a stale token cannot undo a rotation", () => withService(({ service }) => @@ -337,7 +516,10 @@ it.effect("keeps the newest 50 deliveries when they share a timestamp", () => const ids = yield* Effect.forEach(Array.from({ length: 55 }), (_, index) => service.triggerWebhook(requestFor(task, { query: `n=${index}` })).pipe(Effect.as(index)), ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); + const { deliveries } = yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + }); assert.equal(deliveries.length, 50); const first = yield* service.getWebhookDelivery({ id: task.id, @@ -368,7 +550,10 @@ it.effect("keeps credential headers and query values out of the delivery log", ( }, }), ); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [summary] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; const { delivery } = yield* service.getWebhookDelivery({ id: task.id, deliveryId: summary!.id, @@ -479,7 +664,10 @@ it.effect("caps deliveries waiting behind a stuck run", () => ); assert.equal(waiting.filter((result) => result._tag === "accepted").length, 19); assert.equal(waiting.at(-1)?._tag, "rate_limited"); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const logged = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; assert.equal(logged.length, 20); yield* Deferred.succeed(gate, undefined); }), @@ -495,7 +683,10 @@ it.effect("logs a body's first 64 KiB by bytes, not characters", () => const text = "界".repeat(30_000); const body = new TextEncoder().encode(text); yield* service.triggerWebhook(requestFor(task, { body, bodyText: text })); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [summary] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; const { delivery } = yield* service.getWebhookDelivery({ id: task.id, deliveryId: summary!.id, @@ -559,7 +750,8 @@ it.effect("a held request already delivered directly runs only once", () => assert.equal(direct._tag, "accepted"); assert.deepEqual(replayed, { ...direct, outcome: "duplicate" } as typeof replayed); yield* Queue.take(launches); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const logged = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; assert.equal(logged.length, 1); }), ), @@ -580,7 +772,9 @@ it.effect("relay retries recover failed acceptance without releasing another req const error = yield* service.triggerWebhook(request).pipe(Effect.flip); assert.equal(error.message, "Could not record webhook delivery."); assert.equal(yield* Queue.size(launches), 0); - assert.isEmpty((yield* service.listWebhookDeliveries({ id: task.id })).deliveries); + assert.isEmpty( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries, + ); yield* sql`DROP TRIGGER fail_webhook_delivery_log`; const retried = yield* Effect.all( @@ -606,7 +800,11 @@ it.effect("relay retries recover failed acceptance without releasing another req const replayed = yield* service.triggerWebhook(request); assert.equal(replayed._tag === "accepted" ? replayed.outcome : replayed._tag, "duplicate"); assert.equal(yield* Queue.size(launches), 0); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 1); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 1, + ); }), ), ); @@ -657,9 +855,9 @@ it.effect("a held request runs once even after the log has trimmed it", () => it.effect("a rate-limited held request can run on a later pass", () => withService(({ service, launches }) => Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); + const { task } = yield* service.upsert(yield* webhookTaskInput()); yield* Effect.forEach(Array.from({ length: 60 }), () => - service.triggerWebhook(requestFor(task)), + service.triggerWebhook(requestFor(task)).pipe(Effect.tap(() => Queue.take(launches))), ); const limited = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" })); assert.equal(limited._tag, "rate_limited"); @@ -678,7 +876,10 @@ it.effect("logs a held request at the time the relay received it", () => const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); const receivedAt = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 5 })); yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "relay-2", receivedAt })); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [delivery] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; assert.equal(delivery?.receivedAt, receivedAt); }), ), @@ -699,7 +900,10 @@ it.effect("a receive time in the future counts as now", () => ); assert.equal(result._tag, "accepted"); yield* Queue.take(launches); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [delivery] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; assert.equal(delivery?.receivedAt, DateTime.formatIso(now)); }), ), @@ -723,9 +927,10 @@ it.effect("skips a held request older than the task's max age", () => requestFor(task, { relayDeliveryId: "fresh", receivedAt: fresh }), ); assert.equal(ok._tag, "accepted"); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => delivery.outcome); assert.includeMembers(outcomes, ["expired", "accepted"]); }), ), @@ -753,7 +958,11 @@ it.effect("deleting a task removes its delivery log", () => const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); yield* service.triggerWebhook(requestFor(task)); yield* service.delete({ id: task.id }); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 0, + ); }), ), ); @@ -954,105 +1163,1602 @@ it.effect("counts each handled request by what happened to it", () => ), ); -it.effect("checks filters after the signature and max age, and runs only matching deliveries", () => +it.effect("dedupes replay keys per task, persists them, and expires them after seven days", () => + withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const schedule = { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }; + const { task } = yield* service.upsert(yield* webhookTaskInput({ schedule })); + const request = requestFor(task, { headers: { "delivery-id": "A" } }); + const first = yield* service.triggerWebhook(request); + assert.equal(first._tag === "accepted" && first.outcome, "accepted"); + yield* Queue.take(launches); + const replay = yield* service.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + const other = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:other", schedule }), + ); + yield* service.triggerWebhook(requestFor(other.task, { headers: request.headers })); + yield* Queue.take(launches); + yield* withService( + ({ service: restarted }) => + Effect.gen(function* () { + const replay = yield* restarted.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + }), + { sql, workers: new Map() }, + ); + yield* TestClock.adjust("7 days"); + const expired = yield* service.triggerWebhook(request); + assert.equal(expired._tag === "accepted" && expired.outcome, "accepted"); + yield* Queue.take(launches); + assert.include( + (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome), + "duplicate", + ); + }), + { workers: new Map() }, + ), +); + +it.effect.each([1, 3])( + "coalesces %i deliveries into one run with the latest body, headers and query", + (count) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}} {{headers.version}} {{query.version}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.key}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + for (let value = 1; value <= count; value++) { + const bodyText = JSON.stringify({ key: "B", value }); + yield* service.triggerWebhook( + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { version: String(value) }, + query: `version=${value}`, + }), + ); + if (value < count) yield* TestClock.adjust("2 seconds"); + } + const owner = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.find((delivery) => delivery.outcome === "accepted")!; + assert.isTrue(owner.pendingWindow); + const beforeDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isTrue(beforeDispatch.pendingWindow); + assert.equal(beforeDispatch.body, JSON.stringify({ key: "B", value: count })); + assert.equal(beforeDispatch.headers.version, String(count)); + assert.equal(beforeDispatch.query, `version=${count}`); + assert.equal( + beforeDispatch.bodyBytes, + new TextEncoder().encode(beforeDispatch.body).byteLength, + ); + assert.equal(yield* Queue.size(launches), 0); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust(count === 1 ? "10 seconds" : "6 seconds"); + yield* workers.get("webhook-coalescing")!; + const launched = yield* Queue.take(launches); + assert.equal( + (launched.initialMessage!.text.match(new RegExp(`>${count}<`, "g")) ?? []).length, + 3, + ); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome); + const afterDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isFalse(afterDispatch.pendingWindow); + assert.equal(afterDispatch.body, beforeDispatch.body); + assert.include(afterDispatch.renderedPrompt!, `>${count}<`); + assert.equal(outcomes.filter((o) => o === "accepted").length, 1); + assert.equal(outcomes.filter((o) => o === "coalesced").length, count - 1); + }), + { workers }, + ); + }, +); + +it.effect("recovers a persisted coalescing window after restarting the service", () => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("4 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ launches }) => + Effect.gen(function* () { + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("6 seconds"); + yield* restartedWorkers.get("webhook-coalescing")!; + yield* Queue.take(launches); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); +}); + +it.effect("empty dedupe key renders run normally and malformed templates fail at save time", () => withService(({ service, launches }) => Effect.gen(function* () { const { task } = yield* service.upsert( yield* webhookTaskInput({ schedule: { type: "webhook", - signature: { - header: "x-hub-signature-256", - encoding: "hex", - prefix: "sha256=", - secret: "s3cret", - }, - maxDeliveryAgeMinutes: 30, - filters: [ - { path: "headers.x-github-event", op: "equals", value: "pull_request" }, - { path: "body.action", op: "in", value: ["opened", "reopened"] }, - ], + dedupe: { replayKey: "{{headers.absent}}", coalesceKey: "{{body.absent}}" }, }, }), ); - const delivery = ( - event: string, - action: string, - options: { readonly signed?: boolean; readonly receivedAt?: string } = {}, - ) => { - const body = new TextEncoder().encode( - JSON.stringify({ action, pull_request: { url: "https://github.com/org/repo/pull/45" } }), + for (let i = 0; i < 2; i++) { + const result = yield* service.triggerWebhook(requestFor(task, { headers: { absent: "" } })); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + yield* Queue.take(launches); + } + for (const replayKey of ["literal", "{{body.bad"]) { + const invalid = yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey } }, + }).pipe(Effect.result); + assert.equal(invalid._tag, "Failure"); + } + for (const coalesceWindowSeconds of [9, 3601, 10.5]) { + const result = yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { coalesceWindowSeconds } }, + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + } + }), + ), +); + +it.effect("applies replay before coalescing and defaults the fixed window to 120 seconds", () => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { replayKey: "{{headers.delivery-id}}", coalesceKey: "{{body.group}}" }, + }, + }), ); - const signature = `sha256=${NodeCrypto.createHmac("sha256", "s3cret").update(body).digest("hex")}`; - return requestFor(task, { - headers: { - "content-type": "application/json", - "x-github-event": event, - ...(options.signed === false ? {} : { "x-hub-signature-256": signature }), - }, - body, - bodyText: new TextDecoder().decode(body), - ...(options.receivedAt === undefined - ? {} - : { relayDeliveryId: "old", receivedAt: options.receivedAt }), - }); - }; - const now = yield* DateTime.now; - const old = DateTime.formatIso(DateTime.subtract(now, { minutes: 31 })); + const request = (id: string, value: string) => { + const bodyText = JSON.stringify({ group: "B", value }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + }); + }; + yield* service.triggerWebhook(request("B1", "first")); + yield* service.triggerWebhook(request("B2", "latest")); + const replay = yield* service.triggerWebhook( + request("B2", "replay must not replace latest"), + ); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + yield* TestClock.adjust("119 seconds"); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("1 second"); + yield* workers.get("webhook-coalescing")!; + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">latest<"); + }), + { workers }, + ); +}); - // A request failing a filter is still refused first for its signature or age. - const unsigned = yield* service.triggerWebhook(delivery("push", "opened", { signed: false })); - assert.equal(unsigned._tag, "rejected_signature"); - const tooOld = yield* service.triggerWebhook(delivery("push", "opened", { receivedAt: old })); - assert.equal(tooOld._tag, "expired"); +it.effect("rolls back replay and pending state when recording a delivery fails", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.content-type}}", + coalesceKey: "{{headers.content-type}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_dedupe_log BEFORE INSERT ON scheduled_task_webhook_deliveries BEGIN SELECT RAISE(FAIL, 'injected'); END`; + yield* service.triggerWebhook(requestFor(task)).pipe(Effect.flip); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + yield* sql`DROP TRIGGER fail_dedupe_log`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + yield* Queue.take(launches); + }), + { workers }, + ); +}); - const wrongEvent = yield* service.triggerWebhook(delivery("push", "opened")); - const wrongAction = yield* service.triggerWebhook(delivery("pull_request", "closed")); - assert.deepInclude(wrongEvent, { _tag: "accepted", outcome: "filtered" }); - assert.deepInclude(wrongAction, { _tag: "accepted", outcome: "filtered" }); - assert.equal(yield* Queue.size(launches), 0); - const [listed] = (yield* service.list()).tasks; - assert.equal(listed?.runCount, 0); - assert.equal(listed?.lastRunStatus, "never"); +it.effect("starts a new fixed window at the deadline without replacing the expired payload", () => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = (bodyText: string) => + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }); + yield* service.triggerWebhook(request("first")); + yield* TestClock.adjust("10 seconds"); + yield* service.triggerWebhook(request("second")); + yield* workers.get("webhook-coalescing")!; + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">first<"); + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">second<"); + }), + { workers }, + ); +}); - const matched = yield* service.triggerWebhook(delivery("pull_request", "opened")); - assert.deepInclude(matched, { _tag: "accepted", outcome: "accepted" }); - assert.include((yield* Queue.take(launches)).initialMessage?.text ?? "", "pull/45"); +it.effect("pausing a pending task prevents dispatch and deletion clears its dedupe state", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.content-type}}", + coalesceKey: "{{headers.content-type}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* service.setEnabled({ id: task.id, enabled: false }); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal(yield* Queue.size(launches), 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries[0]?.outcome, + "dispatch_failed", + ); + yield* service.delete({ id: task.id }); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { workers }, + ); +}); - const deliveries = (yield* service.listWebhookDeliveries({ - id: task.id, - acceptAnyOutcome: true, - })).deliveries; - const filtered = deliveries - .filter((entry) => entry.outcome === "filtered") - .map((entry) => [entry.signatureVerified, entry.error]); - assert.sameDeepMembers(filtered, [ - [true, "Filter 1 (headers.x-github-event) did not match."], - [true, "Filter 2 (body.action) did not match."], - ]); - }), - ), +it.effect( + "caps duplicate writes at ten per minute without evicting runs or consuming their rate slots", + () => + withService(({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{body}}" } }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + const before = yield* deliveriesCounted("duplicate", "direct"); + for (let i = 0; i < 75; i++) { + const result = yield* service.triggerWebhook(requestFor(task)); + assert.equal(result._tag === "accepted" && result.outcome, "duplicate"); + } + assert.equal((yield* deliveriesCounted("duplicate", "direct")) - before, 75); + const rows = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; + assert.equal(rows.filter((row) => row.outcome === "duplicate").length, 10); + assert.equal(rows.filter((row) => row.outcome === "accepted").length, 1); + const keys = yield* sql<{ + replay_key: string; + }>`SELECT replay_key FROM scheduled_task_webhook_replay_keys`; + assert.match(keys[0]!.replay_key, /^[a-f0-9]{64}$/); + for (let i = 0; i < 59; i++) { + const bodyText = JSON.stringify({ i }); + const result = yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + assert.equal(result._tag, "accepted"); + yield* Queue.take(launches); + } + }), + ), ); -const pullRequestFilterInput = () => - webhookTaskInput({ - schedule: { - type: "webhook", - filters: [{ path: "headers.x-github-event", op: "equals", value: "pull_request" }], - }, - }); -const githubEvent = (event: string) => ({ - headers: { "content-type": "application/json", "x-github-event": event }, +it.effect("caps coalesced history while persisting the latest overflow payload", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { replayKey: "{{headers.delivery-id}}", coalesceKey: "{{body.group}}" }, + }, + }), + ); + const request = (i: number) => { + const bodyText = JSON.stringify({ group: "B", value: i }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": String(i) }, + }); + }; + for (let i = 0; i < 40; i++) + assert.equal((yield* service.triggerWebhook(request(i)))._tag, "accepted"); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 40); + const rows = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + assert.equal(rows.filter((row) => row.outcome === "coalesced").length, 10); + assert.equal(rows.filter((row) => row.outcome === "accepted").length, 1); + const pending = yield* sql<{ + coalesce_key: string; + }>`SELECT coalesce_key FROM scheduled_task_webhook_pending`; + assert.match(pending[0]!.coalesce_key, /^[a-f0-9]{64}$/); + yield* TestClock.adjust("120 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, ">39<"); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); }); -it.effect("filtered deliveries take no run slot and log at most 10 rows a minute", () => +it.effect("a partially missing key is empty even with a literal prefix", () => withService(({ service, launches }) => Effect.gen(function* () { - const { task } = yield* service.upsert(yield* pullRequestFilterInput()); - const ignored = yield* Effect.forEach(Array.from({ length: 70 }), () => - service.triggerWebhook(requestFor(task, githubEvent("push"))), + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "gh:{{headers.absent}}", coalesceKey: "pr:{{body.absent}}" }, + }, + }), ); - assert.isTrue( + for (let i = 0; i < 2; i++) { + const result = yield* service.triggerWebhook(requestFor(task, { headers: { absent: "" } })); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + yield* Queue.take(launches); + } + }), + ), +); + +it.effect("queue-full deliveries release replay keys so a later retry runs", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }, + }), + ); + const request = (i: number) => + requestFor(task, { headers: { "delivery-id": String(i) } }); + for (let i = 0; i < 20; i++) yield* service.triggerWebhook(request(i)); + yield* Queue.take(launches); + const full = yield* service.triggerWebhook(request(20)); + assert.equal(full._tag === "rate_limited" && full.outcome, "queue_full"); + yield* Deferred.succeed(gate, undefined); + for (let i = 1; i < 20; i++) yield* Queue.take(launches); + const retry = yield* service.triggerWebhook(request(20)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate }, + ); + }), +); + +it.effect.each(["task_json", "request_json"] as const)( + "removes corrupt pending %s instead of retrying forever", + (column) => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`UPDATE scheduled_task_webhook_pending SET ${sql(column)} = 'broken'`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!.outcome, + "dispatch_failed", + ); + yield* workers.get("webhook-coalescing")!; + }), + { workers }, + ); + }, +); + +it.effect( + "restarting after a coalesced launch before pending deletion does not launch or count it twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const accepted = yield* service.triggerWebhook(requestFor(task)); + yield* sql`CREATE TRIGGER fail_pending_delete BEFORE DELETE ON scheduled_task_webhook_pending BEGIN SELECT RAISE(FAIL,'crash after launch'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const launch = yield* Queue.take(launches); + assert.equal( + launch.commandId, + `scheduled-task:${task.id}:webhook:${accepted._tag === "accepted" && accepted.deliveryId}`, + ); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql`SELECT * FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 1, + ); + yield* sql`DROP TRIGGER fail_pending_delete`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: secondLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(secondLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); + }, +); + +it.effect("many windows on a busy task share one worker without delaying another task", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const workers = new Map>(); + yield* withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const schedule = { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }; + const { task: a } = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:a", title: "A", schedule }), + ); + const { task: b } = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:b", title: "B", schedule }), + ); + yield* service.triggerWebhook(requestFor(a)); + assert.equal((yield* Queue.take(launches)).title, "A"); + const pendingRequest = (task: typeof a, group: string) => { + const bodyText = JSON.stringify({ group }); + return requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }); + }; + for (let i = 0; i < 19; i++) { + const result = yield* service.triggerWebhook(pendingRequest(a, String(i))); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + } + const overflow = pendingRequest(a, "overflow"); + const full = yield* service.triggerWebhook({ ...overflow, relayDeliveryId: "overflow" }); + assert.equal(full._tag === "rate_limited" && full.outcome, "queue_full"); + assert.equal((yield* service.triggerWebhook(requestFor(a)))._tag, "rate_limited"); + yield* service.triggerWebhook(pendingRequest(b, "B")); + let dispatchWorkers = 0; + const drained = yield* Deferred.make(); + let finishedWorkers = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "ScheduledTaskService.dispatchPendingWebhook") dispatchWorkers++; + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if ( + options.name === "ScheduledTaskService.dispatchPendingWebhook" && + ++finishedWorkers === 2 + ) + Deferred.doneUnsafe(drained, Effect.void); + }; + return span; + }, + }); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!.pipe(Effect.withTracer(tracer)); + assert.equal((yield* Queue.take(launches)).title, "B"); + for (let i = 0; i < 10; i++) + yield* workers.get("webhook-coalescing")!.pipe(Effect.withTracer(tracer)); + assert.equal(dispatchWorkers, 2); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE task_id = ${a.id}`) + .length, + 19, + ); + yield* Deferred.succeed(gate, undefined); + yield* Deferred.await(drained); + for (let i = 0; i < 19; i++) assert.equal((yield* Queue.take(launches)).title, "A"); + const retried = yield* service.triggerWebhook({ + ...overflow, + relayDeliveryId: "overflow", + }); + assert.equal(retried._tag === "accepted" && retried.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal((yield* Queue.take(launches)).title, "A"); + }), + { workers, gateForTask: (input) => (input.title === "A" ? gate : undefined) }, + ); + }), +); + +it.effect("guards bad-signature floods separately from valid run admission", () => + withService(({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const results = yield* Effect.forEach(Array.from({ length: 75 }), () => + service.triggerWebhook(requestFor(task)), + ); + assert.equal(results.filter((result) => result._tag === "rejected_signature").length, 60); + assert.equal(results.filter((result) => result._tag === "rate_limited").length, 15); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_deliveries`).length, 51); + }), + ), +); + +it.effect("start failures release both replay keys and relay claims for redelivery", () => + withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{body}}" } }, + }), + ); + const request = requestFor(task, { relayDeliveryId: "failed-start" }); + yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.lastRunStatus, "succeeded"); + }), + { failLaunches: { remaining: 1 } }, + ), +); + +it.effect("bounds oversized prompt floods without evicting run or filtered history", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput({ prompt: "{{body}}" })); + const accepted = yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + assert.equal(accepted._tag, "accepted"); + // A sibling filter step owns these rows; suppression must preserve its bucket too. + yield* sql`INSERT INTO scheduled_task_webhook_deliveries + SELECT 'filtered-fixture', task_id, received_at, method, query, headers_json, body, + body_bytes, body_truncated, 'filtered', signature_verified, missing_fields_json, rendered_prompt, error + FROM scheduled_task_webhook_deliveries LIMIT 1`; + const bodyText = "x".repeat(200_000); + const request = requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }); + const outcomes = yield* Effect.forEach(Array.from({ length: 75 }), () => + service.triggerWebhook(request), + ); + assert.equal(outcomes.filter((result) => result._tag === "accepted").length, 60); + assert.equal(outcomes.filter((result) => result._tag === "rate_limited").length, 15); + const rows = yield* sql<{ + outcome: string; + }>`SELECT outcome FROM scheduled_task_webhook_deliveries`; + assert.equal(rows.filter((row) => row.outcome === "accepted").length, 1); + assert.equal(rows.filter((row) => row.outcome === "filtered").length, 1); + assert.equal(rows.length, 22); + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("61 seconds"); + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "accepted"); + yield* Queue.take(launches); + }), + ), +); + +it.effect("single delivery lookup hides new outcomes unless the client opts in", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}", coalesceKey: "{{body.group}}" }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + yield* service.triggerWebhook(requestFor(task)); + for (const value of [1, 2]) { + const bodyText = JSON.stringify({ group: "B", value }); + yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + } + const rows = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; + for (const row of rows.filter( + (row) => row.outcome === "duplicate" || row.outcome === "coalesced", + )) { + const input = { id: task.id, deliveryId: row.id }; + const hidden = yield* service.getWebhookDelivery(input).pipe(Effect.result); + assert.equal(hidden._tag, "Failure"); + assert.equal( + (yield* service.getWebhookDelivery({ ...input, acceptAnyOutcome: true })).delivery + .outcome, + row.outcome, + ); + } + assert.equal( + rows.filter((row) => row.outcome === "duplicate" || row.outcome === "coalesced").length, + 2, + ); + }), + ), +); + +it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + for (const outcome of ["filtered", "duplicate"]) { + yield* sql`WITH RECURSIVE n(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 20) + INSERT INTO scheduled_task_webhook_deliveries + SELECT ${outcome} || n.value, d.task_id, d.received_at, d.method, d.query, d.headers_json, d.body, + d.body_bytes, d.body_truncated, ${outcome}, d.signature_verified, d.missing_fields_json, d.rendered_prompt, d.error + FROM n CROSS JOIN (SELECT * FROM scheduled_task_webhook_deliveries WHERE outcome = 'accepted' LIMIT 1) d`; + } + for (let i = 0; i < 59; i++) { + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "accepted"); + yield* Queue.take(launches); + } + const counts = yield* sql<{ + outcome: string; + count: number; + }>`SELECT outcome, COUNT(*) AS count FROM scheduled_task_webhook_deliveries GROUP BY outcome ORDER BY outcome`; + assert.deepEqual(counts, [ + { outcome: "accepted", count: 50 }, + { outcome: "duplicate", count: 20 }, + { outcome: "filtered", count: 20 }, + ]); + }), + ), +); + +it.effect( + "a valid request refunds its own reservation without expiring an interleaved rejection", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const signature = NodeCrypto.createHmac("sha256", "secret") + .update(pullRequestBody) + .digest("hex"); + const valid = yield* service + .triggerWebhook(requestFor(task, { headers: { signature } })) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("30 seconds"); + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + yield* Deferred.succeed(release, undefined); + assert.equal((yield* Fiber.join(valid))._tag, "accepted"); + yield* Queue.take(launches); + // Only the valid reservation would have expired now; the rejection must still count. + yield* TestClock.adjust("31 seconds"); + for (let i = 0; i < 59; i++) + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "rate_limited"); + }), + { frameGate: { started, release } }, + ); + }), +); + +it.effect("keeps replay keys when dispatch succeeds but completion bookkeeping fails", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}" }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* waitForSpan( + service.triggerWebhook(requestFor(task)), + "ScheduledTaskService.runWebhookDelivery", + ); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 1); + yield* sql`DROP TRIGGER fail_completion`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + }), + ), +); + +it.effect("restart before a coalesced completion commits counts the delivery once", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const completedLaunches = new Set(); + const workers = new Map>(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + const launch = yield* Queue.take(launches); + assert.isTrue(completedLaunches.has(launch.commandId)); + assert.equal( + (yield* sql<{ state: string }>`SELECT state FROM scheduled_task_webhook_pending`)[0]! + .state, + "running", + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 0, + ); + assert.equal((yield* service.list()).tasks[0]!.lastRunStatus, "running"); + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: recoveredLaunches }) => + Effect.gen(function* () { + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 0); + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(recoveredLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* restarted.list()).tasks[0]!.lastRunStatus, "succeeded"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const retry = yield* restarted.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + }), + { sql, workers: restartedWorkers, completedLaunches }, + ); + }), + { workers, gate, completedLaunches }, + ); + }), +); + +it.effect.each([119_000, 120_000])( + "bounds coalesced prompt logs and checks trimmed length for %i body characters", + (length) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: `{{body.absent}}${" ".repeat(10_000)}{{body.text}}`, + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const bodyText = JSON.stringify({ group: "B", text: "x".repeat(length) }); + const accepted = yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + assert.equal(accepted._tag, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const delivery = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + if (length === 119_000) { + const launch = yield* Queue.take(launches); + assert.isAbove(launch.initialMessage!.text.length, 120_000); + assert.equal(delivery.outcome, "accepted"); + const detail = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: delivery.id, + }); + assert.equal(detail.delivery.renderedPrompt!.length, 64 * 1024); + } else { + assert.equal(yield* Queue.size(launches), 0); + assert.equal(delivery.outcome, "dispatch_failed"); + assert.equal(delivery.error, "The filled-in prompt is too long."); + } + }), + { workers }, + ); + }, +); + +it.effect("retries coalesced completion bookkeeping without launching or counting twice", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}", coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`CREATE TRIGGER fail_coalesced_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 1); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 0, + ); + assert.equal((yield* service.list()).tasks[0]!.runCount, 0); + yield* sql`DROP TRIGGER fail_coalesced_completion`; + yield* drainPendingDispatch(workers); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const delivery = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + assert.equal(delivery.outcome, "accepted"); + assert.equal(delivery.error, null); + }), + { workers, completedLaunches: new Set() }, + ); +}); + +it.effect("propagates coalescing log failures with their task id and rolls back acceptance", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}", coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = requestFor(task, { relayDeliveryId: "coalescing-log-failure" }); + yield* sql`CREATE TRIGGER fail_coalescing_log BEFORE INSERT ON scheduled_task_webhook_deliveries + BEGIN SELECT RAISE(FAIL, 'injected coalescing log failure'); END`; + const error = yield* service.triggerWebhook(request).pipe(Effect.flip); + assert.equal(error.message, "Could not record webhook delivery."); + assert.equal(error.taskId, task.id); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + yield* sql`DROP TRIGGER fail_coalescing_log`; + assert.equal((yield* service.triggerWebhook(request))._tag, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + }), + { workers }, + ); +}); + +it.effect.each([1, 11])( + "keeps relay claims for %i coalesced updates after their window closes", + (count) => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = (value: number) => { + const bodyText = JSON.stringify({ group: "B", value }); + return requestFor(task, { + relayDeliveryId: `coalesced-relay-${value}`, + bodyText, + body: new TextEncoder().encode(bodyText), + }); + }; + yield* service.triggerWebhook(request(0)); + for (let i = 1; i <= count; i++) { + const result = yield* service.triggerWebhook(request(i)); + assert.equal(result._tag === "accepted" && result.outcome, "coalesced"); + } + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + count + 1, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, `>${count}<`); + const retry = yield* service.triggerWebhook(request(count)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); + }, +); + +it.effect("failed windows release every member's replay key and relay claim", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => { + const bodyText = JSON.stringify({ group: "same", value: id }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + relayDeliveryId: id, + }); + }; + yield* service.triggerWebhook(request("A")); + for (let i = 0; i < 12; i++) yield* service.triggerWebhook(request(`B${i}`)); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 13, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* service.triggerWebhook(request("B11")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "B11"); + assert.equal((yield* service.list()).tasks[0]!.runCount, 2); + }), + { workers, failLaunches: { remaining: 1 } }, + ); +}); + +it.effect( + "rejects a twenty-first persisted window and accepts its relay retry after draining", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.group}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body.group}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (group: number) => { + const bodyText = JSON.stringify({ group }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + relayDeliveryId: `window-${group}`, + }); + }; + for (let i = 0; i < 20; i++) + assert.equal((yield* service.triggerWebhook(request(i)))._tag, "accepted"); + const overflow = yield* service.triggerWebhook(request(20)); + assert.equal(overflow._tag === "rate_limited" && overflow.outcome, "queue_full"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 20); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 20); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 20, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + for (let i = 0; i < 20; i++) yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + const retry = yield* service.triggerWebhook(request(20)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "20"); + assert.equal((yield* service.list()).tasks[0]!.runCount, 21); + }), + { workers }, + ); + }, +); + +it.effect( + "recovery finishes failed-window claim cleanup without counting the attempt twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => + requestFor(task, { headers: { "delivery-id": id }, relayDeliveryId: id }); + yield* service.triggerWebhook(request("A")); + yield* service.triggerWebhook(request("B")); + yield* sql`CREATE TRIGGER fail_claim_cleanup BEFORE DELETE ON scheduled_task_webhook_relay_deliveries + BEGIN SELECT RAISE(FAIL, 'cleanup unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql<{ state: string }>`SELECT state FROM scheduled_task_webhook_pending`)[0]! + .state, + "failed", + ); + yield* sql`DROP TRIGGER fail_claim_cleanup`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: restartedLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(restartedLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 0, + ); + const retry = yield* restarted.triggerWebhook(request("B")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(restartedLaunches); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers, failLaunches: { remaining: 1 } }, + ); + }, +); + +it.effect("a full run budget still accepts replay hits and the latest open-window payload", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, value: string, group?: string) => { + const bodyText = JSON.stringify({ value, ...(group === undefined ? {} : { group }) }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + }); + }; + for (let i = 0; i < 59; i++) { + const result = yield* waitForSpan( + service.triggerWebhook(request(String(i), String(i))), + "ScheduledTaskService.runWebhookDelivery", + ); + assert.equal(result._tag === "accepted" && result.outcome, "accepted"); + yield* Queue.take(launches); + } + const opened = yield* service.triggerWebhook(request("B1", "old", "B")); + assert.equal(opened._tag === "accepted" && opened.outcome, "accepted"); + const duplicate = yield* service.triggerWebhook(request("0", "duplicate")); + assert.equal(duplicate._tag === "accepted" && duplicate.outcome, "duplicate"); + const update = yield* service.triggerWebhook(request("B2", "latest", "B")); + assert.equal(update._tag === "accepted" && update.outcome, "coalesced"); + assert.equal( + (yield* service.triggerWebhook(request("C", "new window", "C")))._tag, + "rate_limited", + ); + assert.equal( + (yield* service.triggerWebhook(request("D", "new immediate")))._tag, + "rate_limited", + ); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 1); + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "latest"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 60); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + }), + { workers }, + ); +}); + +it.effect("dispatches an overdue window exactly once after its deadline passed while stopped", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const task = yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + assert.equal(yield* Queue.size(launches), 0); + return task; + }), + { sql, workers: new Map() }, + ); + // The first service scope is closed before the deadline advances. + yield* TestClock.adjust("30 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(launches); + for (let i = 0; i < 3; i++) yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + const secondRestartWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* secondRestartWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal( + (yield* service.list()).tasks.find((entry) => entry.id === task.id)!.runCount, + 1, + ); + }), + { sql, workers: secondRestartWorkers }, + ); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("removes a window with a render defect and dispatches the task's next window", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.deep}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{headers.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, bodyText: string) => + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id, group: id }, + relayDeliveryId: id, + }); + const bad = request( + "bad-render", + '{"deep":' + "[".repeat(20000) + "0" + "]".repeat(20000) + "}", + ); + assert.throws(() => renderWebhookPrompt(task.prompt, bad, "frame"), RangeError); + const accepted = yield* service.triggerWebhook(bad); + assert.equal(accepted._tag, "accepted"); + yield* service.triggerWebhook(request("good-render", '{"deep":"next window"}')); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "next window"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys WHERE delivery_id = 'delivery:relay:bad-render'`) + .length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = 'bad-render'`) + .length, + 0, + ); + const failed = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:relay:bad-render"), + acceptAnyOutcome: true, + })).delivery; + assert.equal(failed.outcome, "dispatch_failed"); + assert.equal(failed.error, "The pending webhook payload could not be prepared."); + yield* workers.get("webhook-coalescing")!; + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + }), + { workers }, + ); +}); + +it.effect.each([false, true])( + "counts an interrupted immediate run beside an unrelated running window (corrupt task: %s)", + (corrupt) => { + const workers = new Map>(); + const completedLaunches = new Set(); + return Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const window = yield* service.triggerWebhook( + requestFor(task, { headers: { group: "old" }, bodyText: '{"value":"old"}' }), + ); + yield* sql`CREATE TRIGGER fail_old_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + yield* sql`DROP TRIGGER fail_old_completion`; + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'running'`) + .length, + 1, + ); + const immediate = yield* service.triggerWebhook( + requestFor(task, { bodyText: '{"value":"immediate"}' }), + ); + yield* Queue.take(launches); + const [before] = yield* sql<{ + last_run_webhook_delivery_id: string; + model_selection_json: string; + run_count: number; + }>`SELECT last_run_webhook_delivery_id, model_selection_json, run_count FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal( + before!.last_run_webhook_delivery_id, + immediate._tag === "accepted" ? immediate.deliveryId : "", + ); + assert.notEqual( + before!.last_run_webhook_delivery_id, + window._tag === "accepted" ? window.deliveryId : "", + ); + assert.equal(before!.run_count, 0); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = 'broken' WHERE task_id = ${task.id}`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: recoveredLaunches }) => + Effect.gen(function* () { + const [recovered] = yield* sql<{ + run_count: number; + last_run_status: string; + }>`SELECT run_count, last_run_status FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal(recovered!.run_count, 1); + assert.equal(recovered!.last_run_status, "failed"); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = ${before!.model_selection_json} WHERE task_id = ${task.id}`; + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(recoveredLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, + 0, + ); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers, completedLaunches }, + ); + }), + { + workers, + completedLaunches, + gateForTask: (input) => + input.initialMessage?.text.includes(">immediate<") ? gate : undefined, + }, + ); + }); + }, +); + +it.effect("checks filters after the signature and max age, and runs only matching deliveries", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { + header: "x-hub-signature-256", + encoding: "hex", + prefix: "sha256=", + secret: "s3cret", + }, + maxDeliveryAgeMinutes: 30, + filters: [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "reopened"] }, + ], + }, + }), + ); + const delivery = ( + event: string, + action: string, + options: { readonly signed?: boolean; readonly receivedAt?: string } = {}, + ) => { + const body = new TextEncoder().encode( + JSON.stringify({ action, pull_request: { url: "https://github.com/org/repo/pull/45" } }), + ); + const signature = `sha256=${NodeCrypto.createHmac("sha256", "s3cret").update(body).digest("hex")}`; + return requestFor(task, { + headers: { + "content-type": "application/json", + "x-github-event": event, + ...(options.signed === false ? {} : { "x-hub-signature-256": signature }), + }, + body, + bodyText: new TextDecoder().decode(body), + ...(options.receivedAt === undefined + ? {} + : { relayDeliveryId: "old", receivedAt: options.receivedAt }), + }); + }; + const now = yield* DateTime.now; + const old = DateTime.formatIso(DateTime.subtract(now, { minutes: 31 })); + + // A request failing a filter is still refused first for its signature or age. + const unsigned = yield* service.triggerWebhook(delivery("push", "opened", { signed: false })); + assert.equal(unsigned._tag, "rejected_signature"); + const tooOld = yield* service.triggerWebhook(delivery("push", "opened", { receivedAt: old })); + assert.equal(tooOld._tag, "expired"); + + const wrongEvent = yield* service.triggerWebhook(delivery("push", "opened")); + const wrongAction = yield* service.triggerWebhook(delivery("pull_request", "closed")); + assert.deepInclude(wrongEvent, { _tag: "accepted", outcome: "filtered" }); + assert.deepInclude(wrongAction, { _tag: "accepted", outcome: "filtered" }); + assert.equal(yield* Queue.size(launches), 0); + const [listed] = (yield* service.list()).tasks; + assert.equal(listed?.runCount, 0); + assert.equal(listed?.lastRunStatus, "never"); + + const matched = yield* service.triggerWebhook(delivery("pull_request", "opened")); + assert.deepInclude(matched, { _tag: "accepted", outcome: "accepted" }); + assert.include((yield* Queue.take(launches)).initialMessage?.text ?? "", "pull/45"); + + const deliveries = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + const filtered = deliveries + .filter((entry) => entry.outcome === "filtered") + .map((entry) => [entry.signatureVerified, entry.error]); + assert.sameDeepMembers(filtered, [ + [true, "Filter 1 (headers.x-github-event) did not match."], + [true, "Filter 2 (body.action) did not match."], + ]); + }), + ), +); + +const pullRequestFilterInput = () => + webhookTaskInput({ + schedule: { + type: "webhook", + filters: [{ path: "headers.x-github-event", op: "equals", value: "pull_request" }], + }, + }); +const githubEvent = (event: string) => ({ + headers: { "content-type": "application/json", "x-github-event": event }, +}); + +it.effect("filtered deliveries take no run slot and log at most 10 rows a minute", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + const ignored = yield* Effect.forEach(Array.from({ length: 70 }), () => + service.triggerWebhook(requestFor(task, githubEvent("push"))), + ); + assert.isTrue( ignored.every((result) => result._tag === "accepted" && result.outcome === "filtered"), ); const matched = yield* service.triggerWebhook(requestFor(task, githubEvent("pull_request"))); @@ -1157,3 +2863,75 @@ it.effect("leaves filtered rows out of the list for clients that only decode old }), ), ); + +it.effect("filters run before replay and coalescing without claiming ignored deliveries", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + filters: [{ path: "body.run", op: "equals", value: "true" }], + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, run: boolean, value: string) => { + const bodyText = JSON.stringify({ run, group: "B", value }); + return requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + relayDeliveryId: id, + }); + }; + const ignored = yield* service.triggerWebhook(request("A", false, "ignored")); + assert.deepInclude(ignored, { _tag: "accepted", outcome: "filtered" }); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const filteredId = ignored._tag === "accepted" ? ignored.deliveryId : assert.fail(); + const oldClient = yield* service + .getWebhookDelivery({ id: task.id, deliveryId: filteredId }) + .pipe(Effect.result); + assert.equal(oldClient._tag, "Failure"); + const currentClient = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: filteredId, + acceptAnyOutcome: true, + }); + assert.equal(currentClient.delivery.outcome, "filtered"); + assert.deepInclude(yield* service.triggerWebhook(request("A", true, "first")), { + _tag: "accepted", + outcome: "accepted", + }); + assert.deepInclude( + yield* service.triggerWebhook({ + ...request("A", false, "ignored replay"), + relayDeliveryId: "ignored-replay", + }), + { _tag: "accepted", outcome: "filtered" }, + ); + assert.deepInclude(yield* service.triggerWebhook(request("B", false, "ignored update")), { + _tag: "accepted", + outcome: "filtered", + }); + assert.deepInclude(yield* service.triggerWebhook(request("B", true, "latest")), { + _tag: "accepted", + outcome: "coalesced", + }); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "latest"); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); +}); diff --git a/apps/server/src/scheduledTasks/webhookDedupe.ts b/apps/server/src/scheduledTasks/webhookDedupe.ts new file mode 100644 index 000000000000..21d40b25ac5e --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookDedupe.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Keys need a deterministic digest, not random bytes. +import * as NodeCrypto from "node:crypto"; +import { ScheduledTask, ScheduledTaskError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import { renderWebhookPrompt, type WebhookRequest } from "./webhookTemplate.ts"; + +function key(template: string | undefined, request: WebhookRequest): string { + if (template === undefined) return ""; + const rendered = renderWebhookPrompt(template, request, "", true); + return rendered.missing.length > 0 || rendered.prompt === "" + ? "" + : NodeCrypto.createHash("sha256").update(rendered.prompt).digest("hex"); +} + +export const releaseWebhookReplay = Effect.fn("ScheduledTaskService.releaseWebhookReplay")( + function* (deliveryId: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE delivery_id = ${deliveryId}`; + }, +); + +const hasReplayKey = Effect.fn(function* (taskId: string, replayKey: string, now: number) { + const sql = yield* SqlClient.SqlClient; + return ( + replayKey !== "" && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys + WHERE task_id = ${taskId} AND replay_key = ${replayKey} AND expires_at > ${now}`).length > 0 + ); +}); +const claimReplayKey = Effect.fn(function* ( + taskId: string, + replayKey: string, + deliveryId: string, + now: number, +) { + if (replayKey === "") return; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE expires_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_replay_keys (task_id, replay_key, delivery_id, expires_at) + VALUES (${taskId}, ${replayKey}, ${deliveryId}, ${now + 7 * 24 * 60 * 60 * 1000})`; +}); + +export const acceptWebhookDedupe = Effect.fn("ScheduledTaskService.acceptWebhookDedupe")(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, + reserveCoalescedLog: Effect.Effect, + checkCapacity: Effect.Effect, + replaceDeliveryRequest: (ownerId: string) => Effect.Effect, +) { + const sql = yield* SqlClient.SqlClient; + const dedupe = task.schedule.type === "webhook" ? task.schedule.dedupe : undefined; + if (dedupe === undefined) return "run" as const; + const replayKey = key(dedupe.replayKey, request); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + const coalesceKey = key(dedupe.coalesceKey, request); + if (coalesceKey === "") return "run" as const; + const pending = yield* sql<{ + delivery_id: string; + }>`SELECT delivery_id FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at > ${now}`; + const logCoalesced = pending.length > 0 ? yield* reserveCoalescedLog : true; + if (pending.length === 0) { + yield* checkCapacity; + yield* reserve; + } + // Coalesced requests retain their validation slot, bounding writes without dropping accepted payloads. + yield* claimReplayKey(task.id, replayKey, pending[0]?.delivery_id ?? deliveryId, now); + const requestJson = yield* encodePendingRequest({ + method: request.method, + path: request.path, + query: request.query, + headers: request.headers, + bodyText: request.bodyText, + }); + if (pending.length > 0) { + yield* sql`UPDATE scheduled_task_webhook_pending SET request_json = ${requestJson}, + delivery_ids_json = json_insert(delivery_ids_json, '$[#]', ${deliveryId}) WHERE delivery_id = ${pending[0]!.delivery_id}`; + yield* replaceDeliveryRequest(pending[0]!.delivery_id); + return logCoalesced ? ("coalesced" as const) : ("coalesced_unlogged" as const); + } + // Expired windows are immutable, even if the scheduler has not dispatched them yet. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_pending (delivery_id, task_id, coalesce_key, due_at, task_json, request_json, delivery_ids_json) + VALUES (${deliveryId}, ${task.id}, ${coalesceKey}, ${now + (dedupe.coalesceWindowSeconds ?? 120) * 1000}, ${yield* encodePendingTask(task)}, ${requestJson}, json_array(${deliveryId}))`; + return "pending" as const; +}); + +export const acceptImmediateWebhookDedupe = Effect.fn( + "ScheduledTaskService.acceptImmediateWebhookDedupe", +)(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, +) { + const replayKey = key( + task.schedule.type === "webhook" ? task.schedule.dedupe?.replayKey : undefined, + request, + ); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + yield* reserve; + yield* claimReplayKey(task.id, replayKey, deliveryId, now); + return "run" as const; +}); + +const PendingTaskJson = Schema.fromJsonString(ScheduledTask); +const encodePendingTask = Schema.encodeEffect(PendingTaskJson); +export const decodePendingTask = Schema.decodeUnknownEffect(PendingTaskJson); +const PendingRequestJson = Schema.fromJsonString( + Schema.Struct({ + method: Schema.String, + path: Schema.String, + query: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + bodyText: Schema.String, + }), +); +const encodePendingRequest = Schema.encodeEffect(PendingRequestJson); +export const decodePendingRequest = Schema.decodeUnknownEffect(PendingRequestJson); +export const dedupeError = (cause: unknown) => + new ScheduledTaskError({ message: "Could not persist webhook dedupe state.", cause }); diff --git a/apps/server/src/scheduledTasks/webhookRoute.test.ts b/apps/server/src/scheduledTasks/webhookRoute.test.ts index 218281053704..332e6778ba1b 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.test.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.test.ts @@ -236,6 +236,7 @@ describe("webhook route", () => { const response = await handler(post("/api/hooks/id/tok", "{}")); expect(response.status).toBe(status); expect(response.headers.get("x-t3-hook-outcome")).toBe(outcome); + if (status === 429) expect(await response.json()).toEqual({ error: outcome }); } finally { await dispose(); } diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index ce67fa157d33..2566349d273b 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -118,7 +118,7 @@ export const layer = HttpApiBuilder.group( case "disabled": return json(409, { error: "hook_disabled" }, "disabled"); case "rate_limited": - return json(429, { error: "rate_limited" }, result.outcome); + return json(429, { error: result.outcome }, result.outcome); case "expired": return json(410, { error: "delivery_too_old" }, "expired"); case "error": diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index 5f3ef760ad8b..063d41a7ec46 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -150,6 +150,7 @@ export function renderWebhookPrompt( template: string, request: WebhookRequest, frameId: string, + raw = false, ): RenderedWebhookPrompt { const missing: string[] = []; const resolve = webhookExpressionResolver(request); @@ -160,12 +161,13 @@ export function renderWebhookPrompt( const expression = rawExpression.trim(); const value = resolve(expression); const text = value === undefined || value === null ? undefined : stringify(value); - if (text === undefined) { + if (text === undefined || (raw && text === "")) { if (!missing.includes(expression)) missing.push(expression); return ""; } // An empty value carries no request data, so it needs no frame or note. if (text === "") return ""; + if (raw) return text; framed = true; return `<${tag}>${text.replace(MENTION, "$&\u2060")}`; }); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 5f5458166908..82d2092f61fb 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -22,7 +22,6 @@ import type { ScheduledTaskId, ScheduledTaskSchedule, ScheduledTaskUpsertInput, - ScheduledTaskWebhookDeliveryOutcome, ScheduledTaskWebhookDeliverySummary, ThreadId, } from "@t3tools/contracts"; @@ -81,6 +80,8 @@ import { SettingsGroup } from "./SettingsGroup"; import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { WEBHOOK_SIGNATURE_DEFAULTS, + webhookDraftErrors, + webhookDeliveryLabel, matchesScheduledTaskScope, scheduleFromDraft, scheduledTaskDefaultModel, @@ -152,6 +153,9 @@ const EMPTY_DRAFT: DraftState = { ...WEBHOOK_SIGNATURE_DEFAULTS, signatureSecret: "", maxDeliveryAgeMinutes: "", + replayKey: "", + coalesceKey: "", + coalesceWindowSeconds: "", filters: [], savedFilters: [], }; @@ -328,26 +332,15 @@ export function ScheduledRelativeLabel(props: { value: string | null }) { return relativeLabel(props.value, Date.parse(`${nowMinute}:00.000Z`)); } -const DELIVERY_OUTCOME_LABELS: Record = { - accepted: "Ran", - dispatch_failed: "Run failed", - rejected_signature: "Bad signature", - disabled: "Task paused", - rate_limited: "Rate limited", - expired: "Too old", - filtered: "Filtered", -}; - -// A newer server can report an outcome this client has no label for. -function deliveryOutcomeLabel(outcome: string): string { - return Object.hasOwn(DELIVERY_OUTCOME_LABELS, outcome) - ? DELIVERY_OUTCOME_LABELS[outcome as ScheduledTaskWebhookDeliveryOutcome] - : outcome.replaceAll("_", " "); -} - function deliveryOutcomeVariant(outcome: string) { if (outcome === "accepted") return "success"; - if (outcome === "disabled" || outcome === "rate_limited" || outcome === "expired") { + if ( + outcome === "duplicate" || + outcome === "coalesced" || + outcome === "disabled" || + outcome === "rate_limited" || + outcome === "expired" + ) { return "warning"; } if (outcome === "dispatch_failed" || outcome === "rejected_signature") return "error"; @@ -822,7 +815,7 @@ function WebhookDeliveriesDialog({ ? null : serverEnvironment.scheduledTaskWebhookDelivery({ environmentId, - input: { id: task.id, deliveryId: selectedId }, + input: { id: task.id, deliveryId: selectedId, acceptAnyOutcome: true }, }), ); const deliveries = deliveriesQuery.data?.deliveries ?? null; @@ -850,8 +843,12 @@ function WebhookDeliveriesDialog({ ) : selected ? (
- - {deliveryOutcomeLabel(selected.outcome)} + + {webhookDeliveryLabel(selected)} {selected.method} · @@ -871,8 +868,13 @@ function WebhookDeliveriesDialog({ {selected.error}

) : null} - - {selected.renderedPrompt ?? "No run was started for this request."} + + {selected.renderedPrompt ?? + (selected.pendingWindow + ? "Run scheduled when the window closes; no run started yet." + : "No run was started for this request.")} {selected.missingFields.length > 0 ? (

@@ -906,8 +908,12 @@ function WebhookDeliveriesDialog({ className="flex w-full flex-wrap items-center gap-2 py-2 text-left text-sm hover:bg-accent/40" onClick={() => setSelectedId(delivery.id)} > - - {deliveryOutcomeLabel(delivery.outcome)} + + {webhookDeliveryLabel(delivery)} {delivery.method} @@ -1193,10 +1199,7 @@ function ScheduledTaskEditorForm({ } const schedule = scheduleFromDraft(draft); if (schedule === null) { - reportFailure( - "Invalid age limit", - `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`, - ); + for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); return; } if ( @@ -1543,6 +1546,60 @@ function ScheduledTaskEditorForm({ filters={draft.filters} onChange={(filters) => setDraft((current) => ({ ...current, filters }))} /> + + + setDraft((current) => ({ ...current, replayKey: event.target.value })) + } + /> + + + + setDraft((current) => ({ ...current, coalesceKey: event.target.value })) + } + /> + + + + setDraft((current) => ({ + ...current, + coalesceWindowSeconds: event.target.value, + })) + } + /> + +

+ Replay keys suppress repeat deliveries for 7 days. Coalescing runs the latest + payload once when the window from the first delivery closes. Empty keys run + normally. +

diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 2b9264b9dd70..4be149ac9d71 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -20,6 +20,8 @@ import { scheduledTaskDefaultModel, matchesScheduledTaskScope, scheduleFromDraft, + webhookDraftErrors, + webhookDeliveryLabel, taskToDraft, } from "./scheduledTasksSettings.logic"; @@ -175,6 +177,43 @@ describe("webhook scheduled tasks", () => { webhook: { path: "/api/hooks/legacy-task/token", url: null, hasSecret: true }, }; + it("round-trips dedupe settings, clears them, and rejects invalid windows", () => { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.id}}", + coalesceWindowSeconds: 10, + }; + const draft = taskToDraft({ + ...webhookTask, + schedule: { type: "webhook", signature: null, dedupe }, + }); + expect(scheduleFromDraft(draft)).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: " \n ", + coalesceKey: " \t ", + coalesceWindowSeconds: "", + }), + ).toMatchObject({ dedupe: {} }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: ` ${dedupe.replayKey} `, + coalesceKey: `\t${dedupe.coalesceKey}\n`, + }), + ).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ ...draft, replayKey: "", coalesceKey: "", coalesceWindowSeconds: "" }), + ).toMatchObject({ dedupe: {} }); + expect(scheduleFromDraft({ ...draft, coalesceWindowSeconds: "" })).toMatchObject({ + dedupe: { replayKey: dedupe.replayKey, coalesceKey: dedupe.coalesceKey }, + }); + for (const coalesceWindowSeconds of ["9", "3601", "1.5", "abc"]) { + expect(scheduleFromDraft({ ...draft, coalesceWindowSeconds })).toBeNull(); + } + }); + it("keeps a stored secret when the secret field is left blank", () => { const draft = taskToDraft(webhookTask); expect(draft.scheduleMode).toBe("webhook"); @@ -183,11 +222,13 @@ describe("webhook scheduled tasks", () => { type: "webhook", signature, maxDeliveryAgeMinutes: null, + dedupe: {}, }); expect(scheduleFromDraft({ ...draft, signatureSecret: " new " })).toEqual({ type: "webhook", signature: { ...signature, secret: "new" }, maxDeliveryAgeMinutes: null, + dedupe: {}, }); }); @@ -199,6 +240,7 @@ describe("webhook scheduled tasks", () => { type: "webhook", signature: null, maxDeliveryAgeMinutes: null, + dedupe: {}, }); }); @@ -212,6 +254,7 @@ describe("webhook scheduled tasks", () => { for (const blank of ["", " "]) { expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: blank })).toMatchObject({ maxDeliveryAgeMinutes: null, + dedupe: {}, }); } for (const invalid of ["0", "-5", "1.5", "abc", "1441"]) { @@ -339,3 +382,58 @@ describe("scheduled task model defaults", () => { ).toBeNull(); }); }); + +it("rejects dedupe templates without a request placeholder", () => { + const draft = taskToDraft({ ...legacyTask, schedule: { type: "webhook", signature: null } }); + expect(scheduleFromDraft({ ...draft, replayKey: "literal" })).toBeNull(); + expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull(); +}); + +describe("webhook delivery labels and validation messages", () => { + it("preserves Ran for legacy and dispatched deliveries and distinguishes pending windows", () => { + expect(webhookDeliveryLabel({ outcome: "accepted" })).toBe("Ran"); + expect(webhookDeliveryLabel({ outcome: "accepted", pendingWindow: false })).toBe("Ran"); + expect(webhookDeliveryLabel({ outcome: "accepted", pendingWindow: true })).toBe( + "Opened window", + ); + }); + + it("keeps the existing age-limit title and body", () => { + const draft = taskToDraft({ ...legacyTask, schedule: { type: "webhook", signature: null } }); + expect( + webhookDraftErrors({ ...draft, maxDeliveryAgeMinutes: "0", replayKey: "literal" }), + ).toEqual([ + { + title: "Invalid age limit", + description: "Enter whole minutes from 1 to 1440, or leave it blank.", + }, + ]); + }); + + it.each([ + [{ replayKey: "literal" }, "Replay key must contain a valid request placeholder."], + [{ coalesceKey: "{{body.bad" }, "Coalesce key must contain a valid request placeholder."], + [{ coalesceWindowSeconds: "9" }, "Coalesce window must be whole seconds from 10 to 3600."], + ])("names only the invalid webhook field for %o", (invalid, description) => { + const draft = taskToDraft({ ...legacyTask, schedule: { type: "webhook", signature: null } }); + expect(webhookDraftErrors({ ...draft, ...invalid })).toEqual([ + { title: "Invalid webhook settings", description }, + ]); + }); + + it("reports separate short errors when several dedupe fields fail", () => { + const draft = taskToDraft({ ...legacyTask, schedule: { type: "webhook", signature: null } }); + expect( + webhookDraftErrors({ + ...draft, + replayKey: "bad", + coalesceKey: "bad", + coalesceWindowSeconds: "0", + }).map((error) => error.description), + ).toEqual([ + "Replay key must contain a valid request placeholder.", + "Coalesce key must contain a valid request placeholder.", + "Coalesce window must be whole seconds from 10 to 3600.", + ]); + }); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 472ba9b998a8..43030010c5ae 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -1,5 +1,7 @@ +import { isWebhookDedupeTemplate } from "@t3tools/contracts"; import { EnvironmentId, + MAX_WEBHOOK_DELIVERY_AGE_MINUTES, type ProjectId, ScheduledTaskId, type ScheduledTask, @@ -83,6 +85,9 @@ export interface DraftState { /** Write-only: empty keeps the secret already stored on the server. */ readonly signatureSecret: string; readonly maxDeliveryAgeMinutes: string; + readonly replayKey: string; + readonly coalesceKey: string; + readonly coalesceWindowSeconds: string; readonly filters: ReadonlyArray; /** The filter rows the form opened with. */ readonly savedFilters: ReadonlyArray; @@ -94,9 +99,62 @@ export const WEBHOOK_SIGNATURE_DEFAULTS = { signaturePrefix: "sha256=", } as const; +export function webhookDraftErrors(draft: DraftState) { + if (draft.scheduleMode !== "webhook") return []; + if (parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes) === undefined) + return [ + { + title: "Invalid age limit", + description: `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`, + }, + ]; + const errors: Array<{ title: string; description: string }> = []; + for (const [label, value] of [ + ["Replay key", draft.replayKey], + ["Coalesce key", draft.coalesceKey], + ] as const) { + if (value.trim() && !isWebhookDedupeTemplate(value.trim())) + errors.push({ + title: "Invalid webhook settings", + description: `${label} must contain a valid request placeholder.`, + }); + } + const window = draft.coalesceWindowSeconds.trim(); + if (window && (!Number.isInteger(Number(window)) || Number(window) < 10 || Number(window) > 3600)) + errors.push({ + title: "Invalid webhook settings", + description: "Coalesce window must be whole seconds from 10 to 3600.", + }); + return errors; +} + +export function webhookDeliveryLabel(delivery: { + readonly outcome: string; + readonly pendingWindow?: boolean | undefined; +}) { + if (delivery.outcome === "accepted") return delivery.pendingWindow ? "Opened window" : "Ran"; + const labels: Record = { + dispatch_failed: "Run failed", + duplicate: "Duplicate", + coalesced: "Coalesced", + filtered: "Filtered", + rejected_signature: "Bad signature", + disabled: "Task paused", + rate_limited: "Rate limited", + expired: "Too old", + }; + return labels[delivery.outcome] ?? delivery.outcome.replaceAll("_", " "); +} + export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule | null { if (draft.scheduleMode === "webhook") { + if (webhookDraftErrors(draft).length > 0) return null; const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); + const replayKey = draft.replayKey.trim(); + const coalesceKey = draft.coalesceKey.trim(); + const coalesceWindowSeconds = draft.coalesceWindowSeconds.trim() + ? Number(draft.coalesceWindowSeconds) + : undefined; if (maxDeliveryAgeMinutes === undefined) return null; const parsedFilters = parseWebhookFilterDrafts(draft.filters); if (!("filters" in parsedFilters)) return null; @@ -112,6 +170,15 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul } : null, maxDeliveryAgeMinutes, + ...(replayKey || coalesceKey || coalesceWindowSeconds !== undefined + ? { + dedupe: { + ...(replayKey ? { replayKey } : {}), + ...(coalesceKey ? { coalesceKey } : {}), + ...(coalesceWindowSeconds === undefined ? {} : { coalesceWindowSeconds }), + }, + } + : { dedupe: {} }), // Only edited filters are sent; the server keeps the stored ones otherwise. ...(webhookFilterDraftsChanged(draft.savedFilters, draft.filters) ? { filters: parsedFilters.filters } @@ -173,6 +240,12 @@ export function taskToDraft(task: ScheduledTask): DraftState { } : { signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS }), signatureSecret: "", + replayKey: schedule.type === "webhook" ? (schedule.dedupe?.replayKey ?? "") : "", + coalesceKey: schedule.type === "webhook" ? (schedule.dedupe?.coalesceKey ?? "") : "", + coalesceWindowSeconds: + schedule.type === "webhook" && schedule.dedupe?.coalesceWindowSeconds !== undefined + ? String(schedule.dedupe.coalesceWindowSeconds) + : "", maxDeliveryAgeMinutes: schedule.type === "webhook" && schedule.maxDeliveryAgeMinutes != null ? String(schedule.maxDeliveryAgeMinutes) diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index cdae02679be6..db994a7fe8de 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -118,6 +118,20 @@ compare as text, and a value that is an object or a list only passes **exists**. Separate **is one of** values with commas; a value can't contain a comma or start or end with a space. +To suppress repeated deliveries, set a **Replay key** template, such as +`{{headers.delivery-id}}`. The same key is ignored for seven days per task. +To combine a burst, set a **Coalesce key**, such as `{{body.id}}`, and a window +of 10–3600 seconds (120 by default). One run uses the latest body, headers, +and query when the window from the first delivery closes; later requests +do not extend it. Pending windows survive a server restart. Keys must contain +request placeholders; if any placeholder is missing, or the render is empty, +that key's rule is skipped. Clear the templates to turn dedupe off. + +Duplicate and coalesced log entries share a limit of 10 per task per minute. +Additional deliveries are accepted without a log entry, and coalesced deliveries +always replace the persisted payload. Coalesced updates count toward a separate +60-per-minute validation limit; exceeding it receives HTTP 429. + On desktop and web, pick **Deliveries** from a task's menu to see recent requests and the prompt each one produced. diff --git a/packages/contracts/src/scheduledTask.test.ts b/packages/contracts/src/scheduledTask.test.ts index 61af412b0555..4e781d3c6200 100644 --- a/packages/contracts/src/scheduledTask.test.ts +++ b/packages/contracts/src/scheduledTask.test.ts @@ -2,14 +2,49 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { + isWebhookDedupeTemplate, MIN_SCHEDULED_TASK_INTERVAL_MS, ScheduledTaskListWebhookDeliveriesResult, ScheduledTaskSchedule, ScheduledTaskUpsertSchedule, + ScheduledTaskWebhookDeliverySummary, } from "./scheduledTask.ts"; const decodeSchedule = Schema.decodeUnknownSync(ScheduledTaskSchedule); const decodeUpsertSchedule = Schema.decodeUnknownSync(ScheduledTaskUpsertSchedule); +const decodeDeliverySummary = Schema.decodeSync(ScheduledTaskWebhookDeliverySummary); +const decodeDeliveryList = Schema.decodeUnknownSync(ScheduledTaskListWebhookDeliveriesResult); + +it("rejects a long unterminated dotted dedupe key without excessive backtracking", () => { + const template = "{{body." + "a.".repeat(5000) + "x"; + const startedAt = performance.now(); + const valid = isWebhookDedupeTemplate(template); + const elapsedMs = performance.now() - startedAt; + expect(valid).toBe(false); + expect(elapsedMs).toBeLessThan(50); + for (const field of ["replayKey", "coalesceKey"]) { + expect(() => + decodeUpsertSchedule({ type: "webhook", dedupe: { [field]: template } }), + ).toThrow(); + } +}); + +it.each(["duplicate", "coalesced", "future_outcome"])("decodes delivery outcome %s", (outcome) => { + expect( + decodeDeliverySummary({ + id: "delivery:test", + taskId: "scheduled-task:test", + receivedAt: "2026-10-20T00:00:00.000Z", + method: "POST", + contentType: null, + bodyBytes: 0, + outcome, + signatureVerified: false, + missingFields: [], + error: null, + }).outcome, + ).toBe(outcome); +}); describe("ScheduledTaskSchedule", () => { it("keeps legacy sub-minute persisted schedules readable", () => { @@ -52,6 +87,15 @@ describe("ScheduledTaskUpsertSchedule", () => { }); }); +it.each(["literal", " ", "\t\n", "{{body.bad", "{{unknown.field}}"])( + "rejects invalid dedupe template %s", + (replayKey) => { + expect(() => decodeUpsertSchedule({ type: "webhook", dedupe: { replayKey } })).toThrow(); + expect(() => + decodeUpsertSchedule({ type: "webhook", dedupe: { coalesceKey: replayKey } }), + ).toThrow(); + }, +); describe("webhook filters", () => { const accepts = (filters: unknown) => { try { @@ -114,9 +158,8 @@ describe("ScheduledTaskWebhookDeliverySummary", () => { missingFields: [], error: null, }; - expect( - Schema.decodeUnknownSync(ScheduledTaskListWebhookDeliveriesResult)({ deliveries: [summary] }) - .deliveries[0]?.outcome, - ).toBe("some_future_outcome"); + expect(decodeDeliveryList({ deliveries: [summary] }).deliveries[0]?.outcome).toBe( + "some_future_outcome", + ); }); }); diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 77825113992f..67e2314a8b01 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -83,6 +83,26 @@ const WebhookMaxDeliveryAgeMinutes = Schema.Int.check( "Skip requests the relay held longer than this many minutes while the environment was offline. Null runs every request.", }); +export function isWebhookDedupeTemplate(template: string): boolean { + const placeholders = /\{\{\s*(?:(?:body|headers|query)(?:\.[^{}.\s]+)*|request)\s*\}\}/g; + const matches = [...template.matchAll(placeholders)]; + const remainder = template.replace(placeholders, ""); + return matches.length > 0 && !remainder.includes("{{") && !remainder.includes("}}"); +} + +const WebhookDedupeTemplate = Schema.String.check(Schema.makeFilter(isWebhookDedupeTemplate)); + +const ScheduledTaskWebhookDedupe = Schema.Struct({ + replayKey: Schema.optional(WebhookDedupeTemplate), + coalesceKey: Schema.optional(WebhookDedupeTemplate), + coalesceWindowSeconds: Schema.optional( + Schema.Int.check(Schema.isBetween({ minimum: 10, maximum: 3600 })), + ), +}).annotate({ + description: + "Replay key template (7 days), coalesce key template, and fixed window in seconds (default 120).", +}); + export const MAX_WEBHOOK_FILTERS = 16; const WebhookFilterPath = TrimmedNonEmptyString.check( @@ -132,6 +152,7 @@ const ScheduledTaskWebhookSchedule = Schema.Struct({ }), signature: Schema.NullOr(ScheduledTaskWebhookSignature), maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)), + dedupe: Schema.optional(ScheduledTaskWebhookDedupe), filters: Schema.optional(ScheduledTaskWebhookFilters), }).annotate({ description: @@ -159,6 +180,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ description: "Signature check; omit or null to accept requests by URL token only.", }), maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)), + dedupe: Schema.optional(ScheduledTaskWebhookDedupe), filters: Schema.optional(ScheduledTaskWebhookFilters), }).annotate({ description: @@ -294,6 +316,8 @@ export type ScheduledTaskWebhookDeliveryId = typeof ScheduledTaskWebhookDelivery export const ScheduledTaskWebhookDeliveryOutcome = Schema.Literals([ "accepted", "dispatch_failed", + "duplicate", + "coalesced", "rejected_signature", "disabled", "rate_limited", @@ -312,6 +336,7 @@ export const ScheduledTaskWebhookDeliverySummary = Schema.Struct({ // Any string, so an outcome added later doesn't break a client's whole list. outcome: Schema.Union([ScheduledTaskWebhookDeliveryOutcome, Schema.String]), signatureVerified: Schema.Boolean, + pendingWindow: Schema.optional(Schema.Boolean), missingFields: Schema.Array(Schema.String), error: Schema.NullOr(Schema.String), }); @@ -348,6 +373,7 @@ export type ScheduledTaskListWebhookDeliveriesResult = export const ScheduledTaskGetWebhookDeliveryInput = Schema.Struct({ id: ScheduledTaskId, deliveryId: ScheduledTaskWebhookDeliveryId, + acceptAnyOutcome: Schema.optional(Schema.Boolean), }); export type ScheduledTaskGetWebhookDeliveryInput = typeof ScheduledTaskGetWebhookDeliveryInput.Type; From 14b47ee1b83aa302cb8c5cc511b56da16f2bcbc2 Mon Sep 17 00:00:00 2001 From: Jacob Hands Date: Tue, 20 Oct 2026 16:51:21 -0400 Subject: [PATCH 25/96] ci(release): Make Windows arm64 CI job 50% faster (#22428) --- .github/workflows/release-desktop.yml | 402 +++++++++++++-------- .github/workflows/release.yml | 5 +- docs/operations/release.md | 6 +- knip.jsonc | 11 +- patches/app-builder-lib@26.16.1.patch | 15 + pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 1 + scripts/build-cli-archive.ts | 21 +- scripts/build-desktop-artifact.test.ts | 70 +++- scripts/build-desktop-artifact.ts | 53 ++- scripts/build-npm-launcher.ts | 2 +- scripts/lib/windows-jsign.ts | 65 ++++ scripts/lib/windows-tar.ts | 4 + scripts/package.json | 1 + scripts/smoke-cli-archive.ts | 2 +- scripts/verify-windows-desktop-artifact.ts | 48 +++ 16 files changed, 537 insertions(+), 181 deletions(-) create mode 100644 patches/app-builder-lib@26.16.1.patch create mode 100644 scripts/lib/windows-jsign.ts create mode 100644 scripts/lib/windows-tar.ts create mode 100644 scripts/verify-windows-desktop-artifact.ts diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 08552d1cd53a..36738a03cf9d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -64,6 +64,13 @@ on: required: false default: false type: boolean + # A runner of the target's own platform and architecture, for a `runner` + # that cannot execute the target. The build job skips everything that + # runs the packaged binaries, and a second job runs it there. + verify_runner: + required: false + default: "" + type: string version: required: true type: string @@ -129,39 +136,6 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - # The runner's PowerShell 7.6/.NET 10 intermittently crashes during signing: - # https://github.com/dotnet/runtime/issues/131636 - # Put the .NET 8 release first for both workflow shells and electron-builder. - - name: Use PowerShell 7.4 for Windows ARM builds - if: inputs.platform == 'win' && inputs.arch == 'arm64' - shell: powershell - run: | - $ErrorActionPreference = "Stop" - $ProgressPreference = "SilentlyContinue" - $version = "7.4.20" - $archive = Join-Path $env:RUNNER_TEMP "PowerShell-$version-win-arm64.zip" - $installDir = Join-Path $env:RUNNER_TEMP "powershell-$version-arm64" - Invoke-WebRequest -UseBasicParsing ` - -Uri "https://github.com/PowerShell/PowerShell/releases/download/v$version/PowerShell-$version-win-arm64.zip" ` - -OutFile $archive - $expectedHash = "67eff749d5dae357501c95a3eec1dcd93f669824e3ca2b1d2adcdecdebf04f35" - if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $expectedHash) { - throw "PowerShell archive checksum mismatch" - } - Expand-Archive -Path $archive -DestinationPath $installDir - $installDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - & "$installDir\pwsh.exe" -NoProfile -NonInteractive -Command '$PSVersionTable; [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture' - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Verify Windows ARM PowerShell selection - if: inputs.platform == 'win' && inputs.arch == 'arm64' - shell: pwsh - run: | - $expectedPath = Join-Path $env:RUNNER_TEMP "powershell-7.4.20-arm64\pwsh.exe" - if ((Get-Command pwsh.exe).Source -ne $expectedPath -or $PSVersionTable.PSVersion -ne [version]"7.4.20") { - throw "Windows ARM builds must use the pinned PowerShell runtime" - } - # Windows installs straight from the registry. There, linking the packages # takes the time, not downloading them, so a ~700 MB package cache did not # pay for its own restore. Across recent nightlies, restore plus install @@ -172,7 +146,7 @@ jobs: uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json - cache: ${{ inputs.platform != 'win' }} + cache: ${{ runner.os != 'Windows' }} run-install: false # pnpm checks the lockfile and policy before reusing this result. A missing @@ -245,7 +219,7 @@ jobs: # monitor, the desktop build's preflight skips its MSVC check, and node-pty # ships prebuilt Windows binaries, so the 30-60s install is skipped too. - name: Install Spectre-mitigated MSVC libs - if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' + if: runner.os == 'Windows' && steps.resource_monitor_cache.outputs.cache-hit != 'true' shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" @@ -261,27 +235,21 @@ jobs: exit $code } - - uses: ./.github/actions/setup-apt-mirrors - if: inputs.platform == 'linux' + - name: Install cargo-xwin + if: runner.os != 'Windows' && inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + with: + tool: cargo-xwin - - name: Install Linux desktop build libraries - if: inputs.platform == 'linux' + # Built here rather than inside the desktop build so the CLI archive, + # which bundles the same binary, can be built and uploaded first. + - name: Build resource monitor + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get install -y imagemagick - fi - - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi + run: cargo ${{ runner.os != 'Windows' && inputs.platform == 'win' && 'xwin build' || 'build' }} --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ inputs.rust_target }}" - name: Prepare Azure Trusted Signing - if: inputs.platform == 'win' + if: inputs.platform == 'win' && runner.os == 'Windows' shell: pwsh env: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -342,6 +310,153 @@ jobs: Select-Object -Unique "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + # Off Windows, scripts/lib/windows-jsign.ts signs through jsign instead of + # the TrustedSigning PowerShell module. + - name: Install jsign + if: inputs.platform == 'win' && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + dir="$RUNNER_TEMP/jsign" + mkdir -p "$dir" + curl -fsSL --retry 3 -o "$dir/jsign.jar" https://github.com/ebourg/jsign/releases/download/7.6/jsign-7.6.jar + echo "6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 $dir/jsign.jar" | sha256sum -c - + printf '#!/bin/sh\nexec java -jar "%s" "$@"\n' "$dir/jsign.jar" > "$dir/jsign" + chmod +x "$dir/jsign" + echo "T3CODE_JSIGN=$dir/jsign" >> "$GITHUB_ENV" + + # The desktop build applies the same branding to apps/server/dist/client + # (resolveDesktopWebAssetBrand in scripts/build-desktop-artifact.ts); the + # CLI archive below ships that client and is now built first. + - name: Apply web client branding + if: inputs.cli_archive + shell: bash + run: | + brand=production + if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi + node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. A native + # build injects into the runner's own Node: tsdown's cross-target download + # path runs `tar` on a drive-letter path on Windows, which GNU tar reads as + # a remote host. Off Windows, a Windows target downloads its node.exe. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + # The executable bundles the server again, so it needs the same stamp + # as release.yml's JS bundle. + T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} + run: node apps/server/scripts/cli.ts build-exe --verbose ${{ runner.os != 'Windows' && inputs.platform == 'win' && format('--target win-{0}', inputs.arch) || '' }} + + - name: Import macOS signing certificate for the CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: inputs.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: inputs.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + set -euo pipefail + if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ inputs.platform }}" \ + --arch "${{ inputs.arch }}" \ + --version "${{ inputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: inputs.cli_archive && inputs.verify_runner == '' + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + + - name: Upload CLI archive + if: inputs.cli_archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + - name: Install Linux desktop build libraries + if: inputs.platform == 'linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi + # The WSL backend runs the Linux CLI archive inside the distro, so the # Windows desktop embeds the same-arch archive the release attaches. # This job starts alongside that Linux job instead of after it, and the @@ -407,7 +522,7 @@ jobs: shell: bash env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -433,6 +548,9 @@ jobs: --skip-build --verbose ) + if [[ -n "${{ inputs.verify_runner }}" ]]; then + args+=(--keep-stage) + fi has_all() { for value in "$@"; do @@ -488,107 +606,35 @@ jobs: vp run dist:desktop:artifact "${args[@]}" - # The single-executable is built with a Node that supports --build-sea - # (25.7+); the repo itself stays on the engines.node version. It always - # injects into the runner's own Node: tsdown's cross-target download path - # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a - # remote host, and a cross-built macOS binary cannot be smoke-tested. - - name: Build CLI single-executable - if: inputs.cli_archive - shell: bash - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - # The executable bundles the server again, so it needs the same stamp - # as release.yml's JS bundle. - T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Import macOS signing certificate for the CLI archive - if: inputs.cli_archive && inputs.platform == 'mac' - shell: bash - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then - echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." - exit 0 - fi - keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" - keychain_password="$(openssl rand -hex 16)" - cert_path="$RUNNER_TEMP/t3-cli-signing.p12" - printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" - security create-keychain -p "$keychain_password" "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$keychain_password" "$keychain" - security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null - security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" - if [[ -z "$identity" ]]; then - echo "No Developer ID Application identity found in CSC_LINK." >&2 - exit 1 - fi - echo "::add-mask::$keychain_password" - echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" - echo "macOS CLI signing enabled." - - - name: Stage resource monitor for the CLI archive - if: inputs.cli_archive + # The verify job runs what this host could not: the packaged natives and + # the CLI. Its scripts are bundled so that job needs no install. + - name: Stage verification inputs + if: inputs.verify_runner != '' shell: bash run: | set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ inputs.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" - mkdir -p "$target_dir" - cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" - - - name: Build CLI archive - if: inputs.cli_archive - shell: bash - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - run: | - set -euo pipefail - if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - fi - node scripts/build-cli-archive.ts \ - --platform "${{ inputs.platform }}" \ - --arch "${{ inputs.arch }}" \ - --version "${{ inputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - if: inputs.cli_archive - shell: bash - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" - - - name: Upload CLI archive - if: inputs.cli_archive + mkdir -p verify + unpacked=$(ls -d "${TMPDIR:-/tmp}"/t3code-desktop-${{ inputs.platform }}-stage-*/app/dist/${{ inputs.platform }}-${{ inputs.arch }}-unpacked) + tar -C "$(dirname "$unpacked")" -cf verify/unpacked.tar "$(basename "$unpacked")" + esbuild=scripts/node_modules/.bin/esbuild + banner="import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" + # build-desktop-artifact.ts's own import.meta.main guard must not run + # its CLI inside the verify bundle. + "$esbuild" scripts/verify-windows-desktop-artifact.ts --bundle --platform=node --format=esm \ + --target=node24 --define:import.meta.main=false --banner:js="$banner" \ + --outfile=verify/verify-windows-desktop-artifact.mjs + "$esbuild" scripts/smoke-cli-archive.ts --bundle --platform=node --format=esm \ + --target=node24 --banner:js="$banner" --outfile=verify/smoke-cli-archive.mjs + + - name: Upload verification inputs + if: inputs.verify_runner != '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: cli-${{ inputs.platform }}-${{ inputs.arch }} - path: release-cli/* + name: verify-${{ inputs.platform }}-${{ inputs.arch }} + path: verify/* if-no-files-found: error + retention-days: 1 + compression-level: 1 - name: Collect release assets shell: bash @@ -661,3 +707,63 @@ jobs: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* if-no-files-found: error + + verify: + name: Verify + needs: [build] + if: inputs.verify_runner != '' + runs-on: ${{ inputs.verify_runner }} + timeout-minutes: 15 + steps: + - name: Download verification inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verify-${{ inputs.platform }}-${{ inputs.arch }} + path: verify + + - name: Download CLI archive + if: inputs.cli_archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli + + - name: Verify packaged natives + shell: bash + run: | + set -euo pipefail + tar -C verify -xf verify/unpacked.tar + node verify/verify-windows-desktop-artifact.mjs \ + --app-dir "$(cygpath -w "$PWD/verify/${{ inputs.platform }}-${{ inputs.arch }}-unpacked")" \ + --arch "${{ inputs.arch }}" --build-version "${{ inputs.version }}" --verbose + + - name: Smoke-test CLI archive + if: inputs.cli_archive + shell: bash + run: node verify/smoke-cli-archive.mjs --archive release-cli/* --expect-version "${{ inputs.version }}" + + # cargo-xwin links this binary on Linux, and nothing else runs it before users do. + - name: Smoke-test resource monitor + shell: pwsh + run: | + $startInfo = [System.Diagnostics.ProcessStartInfo]::new( + "verify\${{ inputs.platform }}-${{ inputs.arch }}-unpacked\resources\resource-monitor\t3-resource-monitor.exe") + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $monitor = [System.Diagnostics.Process]::Start($startInfo) + $hello = $monitor.StandardOutput.ReadLine() | ConvertFrom-Json + if ($hello.type -ne "hello" -or $hello.arch -ne "${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }}") { + throw "Unexpected hello: $($hello | ConvertTo-Json -Compress)" + } + $monitor.StandardInput.WriteLine((@{ version = $hello.version; type = "configure"; rootPid = $PID; sampleIntervalMs = 1000 } | ConvertTo-Json -Compress)) + $monitor.StandardInput.WriteLine((@{ version = $hello.version; type = "sampleNow"; requestId = "verify" } | ConvertTo-Json -Compress)) + $monitor.StandardInput.Close() + $output = $monitor.StandardOutput.ReadToEnd() + $monitor.WaitForExit() + $sample = $output -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.requestId -eq "verify" } + if ($monitor.ExitCode -ne 0 -or $sample.type -ne "snapshot" -or -not ($sample.processes | Where-Object { $_.pid -eq $PID })) { + throw "Resource monitor exited $($monitor.ExitCode) with: $output" + } + "Resource monitor sampled $($sample.scannedProcessCount) processes." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21e9e7ba99c5..df8a719f9e79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -659,6 +659,8 @@ jobs: resource_key: win32-x64 cli_archive: true + # Windows arm64 runners have 4 vCPUs, and the packaging is mostly CPU-bound, + # so the build runs on Linux and only the native checks run on arm64. desktop_win_arm64: name: Desktop Windows arm64 needs: [preflight, relay_public_config, build_bundle] @@ -674,7 +676,8 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows arm64 - runner: windows-11-arm + runner: blacksmith-16vcpu-ubuntu-2404 + verify_runner: windows-11-arm platform: win target: nsis arch: arm64 diff --git a/docs/operations/release.md b/docs/operations/release.md index c96551bc00c0..c6e151434ca3 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -31,7 +31,7 @@ the version. - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. - Builds the platform-independent JS (server bundle, web client, Electron main) once in the `build_bundle` job and hands it to every platform job as the `js-bundle` artifact; the platform jobs only package it, so no runner rebuilds it. -- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`) on hardware of its own architecture, gated only on the bundle. The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime and wait for that artifact partway through, not for the whole Linux job: +- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`), gated only on the bundle. Every target builds on hardware of its own architecture except Windows arm64: its runners have 4 vCPUs, so it builds and signs (with jsign) on a Linux runner, and a `windows-11-arm` job runs the packaged natives and the CLI archive (`verify_runner`). The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime and wait for that artifact partway through, not for the whole Linux job: - macOS `arm64` DMG - macOS `x64` DMG - Linux `x64` and `arm64` AppImage and `.deb`, from one electron-builder run. The `.deb` updates in the app through electron-updater, which installs it with `dpkg`. @@ -42,11 +42,11 @@ the version. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but Windows arm64 is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. - - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. + - Each archive is extracted and executed (`scripts/smoke-cli-archive.ts`) on its build runner, or on the `verify_runner` for Windows arm64. - After the GitHub Release is published, publishes the `t3` npm package with OIDC trusted publishing from the same workflow file. It is only a launcher (`scripts/npm-launcher/t3.mjs`): on first run it downloads its exact version's CLI archive from the GitHub Release, verifies it against `SHA256SUMS`, and unpacks it into `/runtime/versions/`, the layout the install scripts and `t3 update` share. The executables are not on npm. They used to be, as `@t3code/t3--` optional dependencies, but npm's publish-time scanning held those 80-250 MB packages back for up to an hour after the launcher was live, and every install in that window got a launcher with no executable. Those packages stay on npm at their old versions and get no new ones. Because the launcher reads from the release, `publish_cli` runs after `release`, never before. - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` diff --git a/knip.jsonc b/knip.jsonc index fd2587116159..0815e45e4626 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -18,8 +18,15 @@ }, "scripts": { // Knip loads its preprocessor through a CLI option; native verification and - // worktree setup (from t3.json) run directly. - "entry": ["knip-schemas.ts", "mobile-native-client.ts", "setup-worktree.ts"], + // worktree setup (from t3.json) run directly. release-desktop.yml bundles the + // Windows verify script with esbuild through a path knip does not trace. + "entry": [ + "knip-schemas.ts", + "mobile-native-client.ts", + "setup-worktree.ts", + "verify-windows-desktop-artifact.ts", + ], + "ignoreDependencies": ["esbuild"], // The `t3` npm package's files: copied into the package, never imported. "ignore": ["npm-launcher/**"], }, diff --git a/patches/app-builder-lib@26.16.1.patch b/patches/app-builder-lib@26.16.1.patch new file mode 100644 index 000000000000..72655d9f0a62 --- /dev/null +++ b/patches/app-builder-lib@26.16.1.patch @@ -0,0 +1,15 @@ +diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js +index 8a3462b185b078a260483635e6d774e2ece6e269..105778c145ab63a26444179215c643a7f22148a0 100644 +--- a/out/targets/nsis/NsisTarget.js ++++ b/out/targets/nsis/NsisTarget.js +@@ -366,7 +366,9 @@ class NsisTarget extends core_1.Target { + await this.executeMakensis(defines, commands, sharedHeader + (await this.computeFinalScript(script, false, archs))); + // http://forums.winamp.com/showthread.php?p=3078545 + // TODO: remove workaround when wine is fully upgraded to 11 +- if ((0, macosVersion_1.isMacOsCatalina)()) { ++ // T3 Code builds Windows installers on Linux without wine; read the ++ // uninstaller out of the stub the way macOS hosts already do. ++ if ((0, macosVersion_1.isMacOsCatalina)() || process.platform === "linux") { + try { + await nsisUtil_1.UninstallerReader.exec(installerPath, uninstallerPath); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1455a1b206b..d71e2422a3b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,7 @@ patchedDependencies: '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.21.0': caae6552051bce91a85f567f3a08f9c8d25ce4c53f65a9391a67f016bb1d798e alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd + app-builder-lib@26.16.1: a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 expo-audio@58.0.9: 2c2f8c73a23d48804c8b537b8cd73c25eb331aa47d82eaa481c6766cb81e5add expo-modules-core@58.0.22: f0b0fd100550ed76e5c25b4d13eb7c9ff62825f8f197c53edf7bf447c50c9fb7 @@ -1962,6 +1963,9 @@ importers: '@types/pngjs': specifier: 6.0.5 version: 6.0.5 + esbuild: + specifier: 0.28.2 + version: 0.28.2 typescript: specifier: 'catalog:' version: 7.0.2 @@ -18181,7 +18185,7 @@ snapshots: anynum@1.0.1: optional: true - app-builder-lib@26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1): + app-builder-lib@26.16.1(patch_hash=a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3)(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1): dependencies: '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 @@ -19234,7 +19238,7 @@ snapshots: dmg-builder@26.16.1(electron-builder-squirrel-windows@26.16.1): dependencies: - app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) + app-builder-lib: 26.16.1(patch_hash=a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3)(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) builder-util: 26.16.0 fs-extra: 10.1.0 js-yaml: 4.3.2 @@ -19326,7 +19330,7 @@ snapshots: electron-builder-squirrel-windows@26.16.1(dmg-builder@26.16.1): dependencies: - app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) + app-builder-lib: 26.16.1(patch_hash=a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3)(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) builder-util: 26.16.0 electron-winstaller: 5.4.0 transitivePeerDependencies: @@ -19335,7 +19339,7 @@ snapshots: electron-builder@26.16.1(electron-builder-squirrel-windows@26.16.1): dependencies: - app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) + app-builder-lib: 26.16.1(patch_hash=a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3)(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1) builder-util: 26.16.0 builder-util-runtime: 9.7.0 chalk: 4.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b7f755edc3b4..6e0d596dd743 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -357,6 +357,7 @@ patchedDependencies: # Android: Maven-resolved gradle plugins (expo-widgets) have no sourceDir; without this # skip, the fingerprint silently drops every Expo native module and OTAs reach stale binaries. "@expo/fingerprint@0.21.5": patches/@expo__fingerprint@0.21.5.patch + app-builder-lib@26.16.1: patches/app-builder-lib@26.16.1.patch peerDependencyRules: allowAny: diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index c56106871385..1367c27c180d 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -30,6 +30,8 @@ import { STAGE_INSTALL_ARGS, } from "./build-desktop-artifact.ts"; import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts"; +import { signWithJsign } from "./lib/windows-jsign.ts"; +import { windowsSystemTar } from "./lib/windows-tar.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); @@ -95,11 +97,6 @@ export function cliArchiveFileName(version: string, platform: BuildPlatform, arc return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`; } -export function windowsSystemTar(): string { - const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows"; - return `${systemRoot}\\System32\\tar.exe`; -} - const runCommand = Effect.fn("runCommand")(function* ( command: ChildProcess.Command, label: string, @@ -411,6 +408,11 @@ const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* ( return; } yield* stripStaleAuthenticodeEntry(executablePath); + if ((yield* HostProcess.Platform) !== "win32") { + yield* signWithJsign(executablePath); + yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing via jsign)."); + return; + } const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; const script = [ "$ErrorActionPreference = 'Stop';", @@ -507,7 +509,14 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { cliArchiveFileName(input.version, input.platform, input.arch), ); yield* fs.remove(archivePath, { force: true }); - if (input.platform === "win") { + if (input.platform === "win" && hostPlatform !== "win32") { + yield* runCommand( + ChildProcess.make("zip", ["-q", "-r", "-X", path.resolve(archivePath), stem], { + cwd: stageRoot, + }), + "zip", + ); + } else if (input.platform === "win") { // Windows ships bsdtar, which writes zip natively. Name it by path: under // the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither // writes zip nor accepts a drive-letter path. diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 77cf9ba3a26e..7bf7fbe173e0 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -83,6 +83,7 @@ import { LinuxBrowserSecretHostError, stageBrowserSecret, validateWindowsPackagedPayload, + verifyPackagedBundleIsSelfContained, WindowsPrimaryNativeProbeError, WindowsDesktopBuildPrerequisitesMissingError, WindowsPackagedPayloadValidationError, @@ -2063,21 +2064,44 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it.effect("rejects a sidecar whose extracted server bundle cannot resolve", () => Effect.scoped( Effect.gen(function* () { + const path = yield* Path.Path; const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true, serverEntrySource: 'import "t3code-deliberately-missing-package";\n', }); - const error = yield* validateWindowsPackagedPayload({ - stageDistDir: fixture.stageDistDir, - appExecutableName: fixture.appExecutableName, - targetArch: "x64", - appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + const error = yield* verifyPackagedBundleIsSelfContained({ + asarPath: path.join(fixture.packagedAppDir, "resources", WINDOWS_SERVER_ASAR_RESOURCE), + verbose: false, }).pipe(Effect.flip); assert.instanceOf(error, BundleNotSelfContainedError); assert.include(error.output, "t3code-deliberately-missing-package"); }), - ).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ), + ); + + it.effect("leaves the bundle self-check of a cross-built payload to the verify job", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + serverEntrySource: 'import "t3code-deliberately-missing-package";\n', + }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "arm64", + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + }); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Architecture, "x64"), + ), + ), + ), ); it.effect("preserves both Linux icon resize failures with structural context", () => { @@ -2438,6 +2462,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it.effect("signs Windows builds with signtool on Windows and the jsign hook elsewhere", () => + Effect.gen(function* () { + const signedWinConfig = (hostPlatform: string) => + createBuildConfig("win", "nsis", "1.2.3", true, false, undefined, undefined).pipe( + Effect.map((config) => config.win as Record), + Effect.provideService(HostProcess.Platform, hostPlatform), + ); + + const onWindows = yield* signedWinConfig("win32"); + assert.equal((onWindows.azureSignOptions as Record).publisherName, "T3"); + assert.notProperty(onWindows, "signtoolOptions"); + + const onLinux = yield* signedWinConfig("linux"); + assert.notProperty(onLinux, "azureSignOptions"); + const signtoolOptions = onLinux.signtoolOptions as Record; + assert.match(signtoolOptions.sign as string, /scripts[\\/]lib[\\/]windows-jsign\.ts$/); + assert.deepStrictEqual(signtoolOptions.signingHashAlgorithms, ["sha256"]); + assert.deepStrictEqual(signtoolOptions.publisherName, ["T3"]); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: "T3", + AZURE_TRUSTED_SIGNING_ENDPOINT: "https://eus.codesigning.azure.net/", + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: "profile", + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: "account", + }, + }), + ), + ), + ), + ); + it("stages the resource monitor as an external executable resource", () => { assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [ { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 66f05377c323..d2cff4726345 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2247,7 +2247,7 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin }, ); -const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( +export const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( function* (input: { readonly asarPath: string; readonly verbose: boolean }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -3017,8 +3017,16 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // packaged executable with Electron's stock icon. signAndEditExecutable: true, }; - if (signed) { + if (signed && (yield* HostProcess.Platform) === "win32") { winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; + } else if (signed) { + const path = yield* Path.Path; + const { publisherName } = yield* AzureTrustedSigningOptionsConfig; + winConfig.signtoolOptions = { + sign: yield* path.fromFileUrl(new URL("./lib/windows-jsign.ts", import.meta.url)), + signingHashAlgorithms: ["sha256"], + publisherName: [publisherName], + }; } buildConfig.win = winConfig; } @@ -3345,6 +3353,32 @@ export const verifyWindowsPrimaryFffNativeLoad = Effect.fn( ); }); +/** + * The fff probe needs a host of the target's platform and architecture; the + * bundle self-check needs a Windows host. A cross build defers both to + * scripts/verify-windows-desktop-artifact.ts. + */ +export const verifyWindowsPackagedRuntime = Effect.fn( + "desktopArtifact.verifyWindowsPackagedRuntime", +)(function* (input: { + readonly packagedAppDir: string; + readonly appExecutableName: string; + readonly targetArch: typeof BuildArch.Type; + readonly verbose: boolean; +}) { + const path = yield* Path.Path; + const asarPath = path.join(input.packagedAppDir, "resources", WINDOWS_SERVER_ASAR_RESOURCE); + yield* verifyWindowsPrimaryFffNativeLoad({ ...input, asarPath }); + const hostPlatform = yield* HostProcess.Platform; + if (hostPlatform !== "win32") { + yield* Effect.log( + `[desktop-artifact] Skipped the bundle self-check on a ${hostPlatform} host.`, + ); + return; + } + yield* verifyPackagedBundleIsSelfContained({ asarPath, verbose: input.verbose }); +}); + export const validateWindowsPackagedPayload = Effect.fn( "desktopArtifact.validateWindowsPackagedPayload", )(function* (input: { @@ -3558,19 +3592,13 @@ export const validateWindowsPackagedPayload = Effect.fn( }); } - yield* verifyWindowsPrimaryFffNativeLoad({ + yield* verifyWindowsPackagedRuntime({ packagedAppDir, - asarPath, appExecutableName: input.appExecutableName, targetArch: input.targetArch, verbose: input.verbose ?? false, }); - yield* verifyPackagedBundleIsSelfContained({ - asarPath, - verbose: input.verbose ?? false, - }); - yield* Effect.log( `[desktop-artifact] Validated Windows payload (${String(fileCount)} files, ${String(unpackedFiles.length)} sidecar natives).`, ); @@ -3993,6 +4021,13 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f // takes seconds on a many-core runner instead of about two minutes. buildEnv.XZ_DEFAULTS = "-T0"; } + if (options.platform === "win") { + // Windows signs only through Azure Trusted Signing. Off Windows, + // electron-builder's signtool manager would read CSC_LINK (the macOS + // certificate) as a Windows certificate. + delete buildEnv.CSC_LINK; + delete buildEnv.CSC_KEY_PASSWORD; + } if (!options.signed) { buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; delete buildEnv.CSC_LINK; diff --git a/scripts/build-npm-launcher.ts b/scripts/build-npm-launcher.ts index e12186e90abb..0f226a943f5e 100644 --- a/scripts/build-npm-launcher.ts +++ b/scripts/build-npm-launcher.ts @@ -26,7 +26,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; -import { windowsSystemTar } from "./build-cli-archive.ts"; +import { windowsSystemTar } from "./lib/windows-tar.ts"; export const NPM_LAUNCHER_PACKAGE_NAME = "t3"; diff --git a/scripts/lib/windows-jsign.ts b/scripts/lib/windows-jsign.ts new file mode 100644 index 000000000000..9b3d1fcfcfe6 --- /dev/null +++ b/scripts/lib/windows-jsign.ts @@ -0,0 +1,65 @@ +// Azure Trusted Signing from a non-Windows host. The Windows-only path is +// electron-builder's azureSignOptions (PowerShell + signtool.exe); this one is +// electron-builder's `win.signtoolOptions.sign` hook and the CLI archive's +// signer. jsign reads AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET +// itself when no --storepass is given. + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +export class JsignFailedError extends Schema.TaggedError()("JsignFailedError", { + file: Schema.String, + exitCode: Schema.Number, +}) { + override get message() { + return `jsign exited with code ${String(this.exitCode)} signing ${this.file}.`; + } +} + +export const signWithJsign = Effect.fn("signWithJsign")(function* (file: string) { + const { jsign, endpoint, account, profile } = yield* Config.all({ + jsign: Config.String("T3CODE_JSIGN").pipe(Config.withDefault("jsign")), + endpoint: Config.String("AZURE_TRUSTED_SIGNING_ENDPOINT"), + account: Config.String("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"), + profile: Config.String("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"), + }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + jsign, + [ + "--storetype", + "TRUSTEDSIGNING", + "--keystore", + new URL(endpoint).host, + "--alias", + `${account}/${profile}`, + "--alg", + "SHA-256", + "--tsaurl", + "http://timestamp.acs.microsoft.com", + "--tsmode", + "RFC3161", + // jsign appends a nested signature by default; signtool replaces. + "--replace", + file, + ], + { stdout: "inherit", stderr: "inherit" }, + ), + ); + const exitCode = Number(yield* child.exitCode); + if (exitCode !== 0) { + return yield* new JsignFailedError({ file, exitCode }); + } +}); + +export default function sign(configuration: { readonly path: string }): Promise { + return signWithJsign(configuration.path).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.runPromise, + ); +} diff --git a/scripts/lib/windows-tar.ts b/scripts/lib/windows-tar.ts new file mode 100644 index 000000000000..8d2417f90d02 --- /dev/null +++ b/scripts/lib/windows-tar.ts @@ -0,0 +1,4 @@ +export function windowsSystemTar(): string { + const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows"; + return `${systemRoot}\\System32\\tar.exe`; +} diff --git a/scripts/package.json b/scripts/package.json index 23a3c194cecd..29029cef9d3e 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -21,6 +21,7 @@ "@effect/vitest": "catalog:", "@types/plist": "catalog:", "@types/pngjs": "6.0.5", + "esbuild": "0.28.2", "typescript": "catalog:", "typescript-legacy": "npm:typescript@~6.0.3", "vite-plus": "catalog:" diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index dd545b967175..7c994153469b 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -16,7 +16,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as NetService from "@t3tools/shared/Net"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { windowsSystemTar } from "./build-cli-archive.ts"; +import { windowsSystemTar } from "./lib/windows-tar.ts"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; export class CliArchiveSmokeError extends Schema.TaggedError()( diff --git a/scripts/verify-windows-desktop-artifact.ts b/scripts/verify-windows-desktop-artifact.ts new file mode 100644 index 000000000000..2867a9389ed6 --- /dev/null +++ b/scripts/verify-windows-desktop-artifact.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +// Runs the native-load probes a cross-built Windows desktop artifact skipped, +// on a host of the target's architecture. + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import { Command, Flag } from "effect/cli"; + +import { + resolveDesktopProductName, + verifyWindowsPackagedRuntime, +} from "./build-desktop-artifact.ts"; + +const verifyWindowsDesktopArtifactCli = Command.make("verify-windows-desktop-artifact", { + appDir: Flag.String("app-dir").pipe( + Flag.withDescription("The electron-builder `win--unpacked` directory."), + ), + arch: Flag.Literals("arch", ["arm64", "x64"] as const), + buildVersion: Flag.String("build-version").pipe( + Flag.withDescription("The artifact's build version."), + ), + verbose: Flag.Boolean("verbose").pipe(Flag.withDefault(false)), +}).pipe( + Command.withDescription("Verify a Windows desktop artifact's packaged natives load."), + Command.withHandler(({ appDir, arch, buildVersion, verbose }) => + Effect.gen(function* () { + yield* verifyWindowsPackagedRuntime({ + packagedAppDir: appDir, + appExecutableName: `${resolveDesktopProductName(buildVersion)}.exe`, + targetArch: arch, + verbose, + }); + yield* Effect.log(`[verify-windows-desktop-artifact] ${appDir} passed.`); + }), + ), +); + +// No import.meta.main guard: CI bundles this file into one module together with +// build-desktop-artifact.ts, whose own guard is defined false in that bundle. +Command.run(verifyWindowsDesktopArtifactCli, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), + NodeRuntime.runMain, +); From 4c48118da2fa45c5cbd10a87b8fc6db003cad907 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 14:00:24 -0700 Subject: [PATCH 26/96] ci(release): Windows arm64 verify no longer runs the crashing ARM PowerShell (#22494) Co-authored-by: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 60 +++++++++++++++++---------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 36738a03cf9d..cbd61e0604ef 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -239,7 +239,7 @@ jobs: if: runner.os != 'Windows' && inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: - tool: cargo-xwin + tool: cargo-xwin@0.23.1 # Built here rather than inside the desktop build so the CLI archive, # which bundles the same binary, can be built and uploaded first. @@ -743,27 +743,45 @@ jobs: run: node verify/smoke-cli-archive.mjs --archive release-cli/* --expect-version "${{ inputs.version }}" # cargo-xwin links this binary on Linux, and nothing else runs it before users do. + # Node rather than pwsh: PowerShell on Windows ARM crashes intermittently + # (https://github.com/dotnet/runtime/issues/131636), which is why this + # target stopped building on Windows. - name: Smoke-test resource monitor - shell: pwsh + shell: bash + env: + MONITOR: verify/${{ inputs.platform }}-${{ inputs.arch }}-unpacked/resources/resource-monitor/t3-resource-monitor.exe + EXPECTED_ARCH: ${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }} run: | - $startInfo = [System.Diagnostics.ProcessStartInfo]::new( - "verify\${{ inputs.platform }}-${{ inputs.arch }}-unpacked\resources\resource-monitor\t3-resource-monitor.exe") - $startInfo.UseShellExecute = $false - $startInfo.RedirectStandardInput = $true - $startInfo.RedirectStandardOutput = $true - $monitor = [System.Diagnostics.Process]::Start($startInfo) - $hello = $monitor.StandardOutput.ReadLine() | ConvertFrom-Json - if ($hello.type -ne "hello" -or $hello.arch -ne "${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }}") { - throw "Unexpected hello: $($hello | ConvertTo-Json -Compress)" + node --input-type=module <<'EOF' + import { spawn } from "node:child_process"; + const monitor = spawn(process.env.MONITOR, { stdio: ["pipe", "pipe", "inherit"] }); + monitor.stdout.setEncoding("utf8"); + let output = ""; + const exited = new Promise((resolve) => monitor.on("close", resolve)); + const hello = await new Promise((resolve, reject) => { + monitor.on("error", reject); + monitor.on("close", () => reject(new Error(`Resource monitor exited before hello: ${output}`))); + monitor.stdout.on("data", (chunk) => { + output += chunk; + const end = output.indexOf("\n"); + if (end !== -1) resolve(JSON.parse(output.slice(0, end))); + }); + }); + if (hello.type !== "hello" || hello.arch !== process.env.EXPECTED_ARCH) { + throw new Error(`Unexpected hello: ${JSON.stringify(hello)}`); } - $monitor.StandardInput.WriteLine((@{ version = $hello.version; type = "configure"; rootPid = $PID; sampleIntervalMs = 1000 } | ConvertTo-Json -Compress)) - $monitor.StandardInput.WriteLine((@{ version = $hello.version; type = "sampleNow"; requestId = "verify" } | ConvertTo-Json -Compress)) - $monitor.StandardInput.Close() - $output = $monitor.StandardOutput.ReadToEnd() - $monitor.WaitForExit() - $sample = $output -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $_ | ConvertFrom-Json } | - Where-Object { $_.requestId -eq "verify" } - if ($monitor.ExitCode -ne 0 -or $sample.type -ne "snapshot" -or -not ($sample.processes | Where-Object { $_.pid -eq $PID })) { - throw "Resource monitor exited $($monitor.ExitCode) with: $output" + const send = (message) => monitor.stdin.write(`${JSON.stringify({ version: hello.version, ...message })}\n`); + send({ type: "configure", rootPid: process.pid, sampleIntervalMs: 1000 }); + send({ type: "sampleNow", requestId: "verify" }); + monitor.stdin.end(); + const exitCode = await exited; + const sample = output + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + .find((message) => message.requestId === "verify"); + if (exitCode !== 0 || sample?.type !== "snapshot" || !sample.processes.some((p) => p.pid === process.pid)) { + throw new Error(`Resource monitor exited ${exitCode} with: ${output}`); } - "Resource monitor sampled $($sample.scannedProcessCount) processes." + console.log(`Resource monitor sampled ${sample.scannedProcessCount} processes.`); + EOF From ca140c435f38612656dd655fc42e2f78efbfb4c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 14:09:42 -0700 Subject: [PATCH 27/96] refactor(source-control): GitCafe creates repositories and pull requests over REST (#22495) Co-authored-by: Claude Opus 5.5 --- .../GitCafeSourceControlProvider.test.ts | 170 ++++++++++++------ .../server/GitCafeSourceControlProvider.ts | 122 ++++++------- 2 files changed, 170 insertions(+), 122 deletions(-) diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts index 20f8ab3941f9..266d63079e5f 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -2,6 +2,7 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { GitCommandError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -18,6 +19,7 @@ import * as GitCafeCredentials from "./GitCafeCredentials.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({ stdout: input.stdout === undefined ? "" : encodeJson(input.stdout), @@ -29,6 +31,36 @@ const failure = (code: string, status: number | null) => ({ error: { code, status, message: `Failure ${code}` }, }); +/** Most calls touch no file; one that does fails the test. */ +const NoFiles = FileSystem.layerNoop({}); + +/** A provider on git.cafe whose checkout has no remote and which may not run `cafe`. */ +const layerFor = ( + client: HttpClient.HttpClient, + options: { readonly files?: Layer.Layer } = {}, +) => + Layer.merge(GitCafeApi.layer, options.files ?? NoFiles).pipe( + Layer.provideMerge(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ); + describe("GitCafeSourceControlProvider", () => { it("reads a signed-in account and keeps transient failures apart from sign-out", () => { const { parseAuth } = GitCafeSourceControlProvider; @@ -94,7 +126,7 @@ describe("GitCafeSourceControlProvider", () => { ]); }).pipe( Effect.provide( - GitCafeApi.layer.pipe( + Layer.merge(GitCafeApi.layer, NoFiles).pipe( Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge(TestSourceControlHost.layer()), Layer.provide( @@ -140,7 +172,7 @@ describe("GitCafeSourceControlProvider", () => { assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]); }).pipe( Effect.provide( - GitCafeApi.layer.pipe( + Layer.merge(GitCafeApi.layer, NoFiles).pipe( Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ @@ -222,7 +254,7 @@ describe("GitCafeSourceControlProvider", () => { assert.deepStrictEqual(remotes, [expected]); }).pipe( Effect.provide( - GitCafeApi.layer.pipe( + Layer.merge(GitCafeApi.layer, NoFiles).pipe( Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ @@ -246,22 +278,19 @@ describe("GitCafeSourceControlProvider", () => { ); }); - it.effect("waits for a created repository's admission before handing out its URLs", () => { - const urls: Array = []; - const states = ["running", "complete"]; + it.effect("creates a repository over REST and waits for its admission", () => { + const sent: Array<{ method: string; url: string; body: unknown }> = []; const client = HttpClient.make((request) => { - urls.push(request.url); + const body = + request.body._tag === "Uint8Array" + ? decodeJson(new TextDecoder().decode(request.body.body)) + : undefined; + sent.push({ method: request.method, url: request.url, body }); + const state = request.method === "POST" ? "running" : "complete"; return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response( - encodeJson({ - repoId: "repo_1", - state: states.shift(), - owner: "team", - name: "project", - }), - ), + new Response(encodeJson({ repoId: "repo_1", state, owner: "team", name: "project" })), ), ); }); @@ -276,51 +305,76 @@ describe("GitCafeSourceControlProvider", () => { url: "https://git.cafe/team/project", sshUrl: "ssh@git.cafe:team/project.git", }); - assert.deepStrictEqual(urls, [ - "https://git.cafe/api/orgs/team/admissions/repo_1", - "https://git.cafe/api/orgs/team/admissions/repo_1", - ]); - }).pipe( - Effect.provide( - GitCafeApi.layer.pipe( - Layer.provideMerge(GitCafeCredentials.layer), - Layer.provideMerge( - TestSourceControlHost.layer({ - process: { - run: () => - Effect.succeed( - TestSourceControlHost.processOutput( - encodeJson({ - schemaVersion: 1, - data: { - resource: { - repoId: "repo_1", - state: "running", - owner: "team", - name: "project", - }, - }, - }), - ), - ), - }, - git: { - resolvePrimaryRemoteName: () => - Effect.fail( - new GitCommandError({ - operation: "resolvePrimaryRemoteName", - command: "git remote", - cwd: "/repo", - detail: "No git remote is configured.", - }), - ), - }, - }), + assert.deepStrictEqual( + sent.map((request) => `${request.method} ${request.url}`), + [ + "POST https://git.cafe/api/orgs/team/repos", + "GET https://git.cafe/api/orgs/team/admissions/repo_1", + ], + ); + const { requestKey, ...body } = sent[0]?.body as { requestKey: string }; + assert.match(requestKey, /^[0-9a-f-]{36}$/u); + assert.deepStrictEqual(body, { + name: "project", + visibility: "private", + defaultBranch: "main", + }); + }).pipe(Effect.provide(layerFor(client))); + }); + + it.effect("opens a pull request from a fork over REST with the body file's text", () => { + const sent: Array<{ method: string; url: string; body: unknown }> = []; + const client = HttpClient.make((request) => { + const body = + request.body._tag === "Uint8Array" + ? decodeJson(new TextDecoder().decode(request.body.body)) + : undefined; + sent.push({ method: request.method, url: request.url, body }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson( + request.method === "POST" + ? { number: 3 } + : { id: "repo_fork", name: "project", defaultBranch: "main" }, + ), ), - Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), - Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), - Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + yield* provider.createChangeRequest({ + cwd: "/repo", + target: { repository: "team/project", refName: "main" }, + baseRefName: "main", + headSelector: "fork:feature", + title: "Add a thing", + bodyFile: "/tmp/body.md", + }); + assert.deepStrictEqual( + sent.map((request) => `${request.method} ${request.url}`), + [ + "GET https://git.cafe/api/repos/fork/project", + "POST https://git.cafe/api/repos/team/project/pulls", + ], + ); + assert.deepStrictEqual(sent[1]?.body, { + repo: "repo_fork", + source: "feature", + target: "main", + title: "Add a thing", + description: "Body", + }); + }).pipe( + Effect.provide( + layerFor(client, { + files: FileSystem.layerNoop({ + readFileString: (path) => + path === "/tmp/body.md" ? Effect.succeed("Body") : Effect.die(path), + }), + }), ), ); }); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index 57304f2d4ad8..fe624532f74b 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -1,5 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -16,6 +17,7 @@ import { type SourceControlCliDiscoverySpec, type SourceControlManagedCliDiscoverySpec, } from "@t3tools/source-control-core/server/discovery"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; @@ -146,10 +148,8 @@ const RepositoryAdmission = Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString, }); -const CreatedRepository = Schema.Struct({ - data: Schema.Struct({ resource: RepositoryAdmission }), -}); -const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository)); +const RepositoryId = Schema.Struct({ id: TrimmedNonEmptyString }); +const CreatedPull = Schema.Struct({ number: PositiveInt }); const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); interface Target { @@ -171,7 +171,7 @@ const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[? export const make = Effect.gen(function* () { const api = yield* GitCafeApi.GitCafeApi; const host = yield* SourceControlHost.SourceControlHost; - const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const fileSystem = yield* FileSystem.FileSystem; const error = (operation: string, cwd: string, detail: string, cause?: unknown) => new SourceControlProviderError({ @@ -197,33 +197,6 @@ export const make = Effect.gen(function* () { ), ), ); - const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray) => - credentials - .cliEnv(target.host) - .pipe( - Effect.flatMap((env) => - host.process.run({ - operation: `GitCafeSourceControlProvider.${operation}`, - command: "cafe", - cwd, - args: [...cliArgs(target.host), ...args], - env: { CAFE_OUTPUT: "json", ...env }, - timeoutMs: 30_000, - }), - ), - ) - .pipe( - Effect.mapError((cause) => - error( - operation, - cwd, - (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) || - "GitCafe CLI command failed.", - cause, - ), - ), - ); - const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* ( operation: string, input: { @@ -371,21 +344,42 @@ export const make = Effect.gen(function* () { ? `${source.owner}/${target.repository.split("/")[1]}` : target.repository); const branch = SourceControlProvider.sourceBranch(input); - yield* cafe("createChangeRequest", input.cwd, target, [ - "pr", - "create", - "--json", - "--repo", - target.repository, - "--head", - sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`, - "--base", - input.target?.refName ?? input.baseRefName, - "--title", - input.title, - "--body-file", - input.bodyFile, - ]); + // GitCafe names the head by its repository's id, so the head repository is looked up first. + const head = yield* read( + "createChangeRequest", + input.cwd, + { host: target.host, path: `/repos/${sourceRepository}` }, + RepositoryId, + ); + const description = yield* fileSystem + .readFileString(input.bodyFile) + .pipe( + Effect.mapError((cause) => + error( + "createChangeRequest", + input.cwd, + "Could not read the pull request description.", + cause, + ), + ), + ); + yield* read( + "createChangeRequest", + input.cwd, + { + host: target.host, + method: "POST", + path: `/repos/${target.repository}/pulls`, + body: { + repo: head.id, + source: branch, + target: input.target?.refName ?? input.baseRefName, + title: input.title, + ...(description.trim().length === 0 ? {} : { description }), + }, + }, + CreatedPull, + ); }, ), getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")( @@ -403,25 +397,25 @@ export const make = Effect.gen(function* () { createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) { const target = yield* resolveTarget("createRepository", input); const [owner = "", name = ""] = target.repository.split("/"); - const output = yield* cafe("createRepository", input.cwd, target, [ - "repo", - "create", - name, - "--org", - owner, - "--visibility", - input.visibility, - "--json", - ]); - const created = yield* decodeCreatedRepository(output.stdout).pipe( - Effect.mapError((cause) => - error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause), - ), + const pending = yield* read( + "createRepository", + input.cwd, + { + host: target.host, + method: "POST", + path: `/orgs/${owner}/repos`, + body: { + name, + requestKey: yield* randomUuidV4, + visibility: input.visibility, + defaultBranch: "main", + }, + }, + RepositoryAdmission, ); - // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is - // still running, so wait for it to settle the way `cafe repo create --clone` does. + // GitCafe admits a repository asynchronously and answers while admission is still running, + // so wait for it to settle the way `cafe repo create --clone` does. const settled = (state: string) => state === "complete" || state === "blocked"; - const pending = created.data.resource; const resource = settled(pending.state) ? pending : yield* read( From 5bcc90e1116e396a86cb1802fd26361457c9548a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 14:47:19 -0700 Subject: [PATCH 28/96] test(web): fix release browser test failures (#22508) --- .../chat/ComposerBannerStack.browser.test.tsx | 14 ++++++++++++-- .../diffs/StyledDiffCodeView.browser.test.tsx | 3 ++- .../PullRequestDetailPanel.browser.test.tsx | 3 ++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index 01a1ed13fdcb..757f3db20c02 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -77,14 +77,19 @@ it("keeps only the usage limit attached while an active goal waits in the collap const limit = page.getByText("Usage limit reached", { exact: true }); const goal = page.getByText("Goal active", { exact: true }); - expect(visibleBanners()).toHaveLength(1); + // A retained pointer can expand the stack as it mounts; start outside the hover target. + await userEvent.hover(limit); + await expect.poll(visibleBanners).toHaveLength(1); await expect.element(limit).toBeVisible(); await expect.element(goal).not.toBeVisible(); expect(container.querySelector('[data-slot="composer-banner-attachment"]')?.textContent).toBe( "Usage limit reached", ); - await page.getByRole("button", { name: "Show other notices" }).click(); + const peek = page.getByRole("button", { name: "Show other notices" }); + // Hover opens the stack and hides the peek before a pointer click can finish. + await act(() => peek.element().focus()); + await userEvent.keyboard("{Enter}"); await expect.element(goal).toBeVisible(); await expect.element(limit).toBeVisible(); await expect.poll(visibleBanners).toHaveLength(2); @@ -93,6 +98,11 @@ it("keeps only the usage limit attached while an active goal waits in the collap await expect.element(goal).not.toBeVisible(); await expect.element(limit).toBeVisible(); expect(visibleBanners()).toHaveLength(1); + + await userEvent.hover(peek); + await expect.element(goal).toBeVisible(); + await expect.element(limit).toBeVisible(); + await expect.poll(visibleBanners).toHaveLength(2); }); it("reveals the full running command only while its title is clipped", async () => { diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx index 8fbd98a35860..71dd1e89ee49 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx @@ -200,7 +200,8 @@ describe("code-view worker lifecycle", () => { }; } - it("starts on demand, keeps quick reopens warm, and expires the last closed pool", async () => { + // Two cold starts can each use Pierre's 10-second initialization budget. + it("starts workers on demand and expires idle pools", { timeout: 30_000 }, async () => { setup(); await using workers = await workerEnvironment(); const view = await workers.render(renderViews(0)); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx index 323297b7309c..24e2a1955ee2 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx @@ -398,6 +398,7 @@ describe.each([ ); }); + // The first Code-tab action starts real diff workers as well as driving the panel. it.each([ "Resolve conflicts", "Ask a question", @@ -405,7 +406,7 @@ describe.each([ "Fix findings in this thread", "Fix check", "Add to agent", - ])("%s writes to the correct composer", async (action) => { + ])("%s writes to the correct composer", { timeout: 30_000 }, async (action) => { setup(); // Cover both a host without inline comments and an account that cannot write them. currentDetail = From c8667fd5f4cd635b82b70d9e082c98c06eb78dcc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 15:07:43 -0700 Subject: [PATCH 29/96] fix(mobile): connecting an environment no longer crashes the header (#22521) Co-authored-by: Claude Opus 5.5 --- .../src/native/NativeHeaderToolbar.test.tsx | 51 ++++++++++++++++++- apps/mobile/src/native/StackHeader.tsx | 32 +++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx index 41d8777f6dfb..9dba4f4a4f88 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx @@ -1,5 +1,5 @@ import type { NativeStackHeaderItem } from "@react-navigation/native-stack"; -import { act } from "react"; +import { act, createContext, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vite-plus/test"; @@ -56,6 +56,43 @@ function renderDocument(path: string, placement: Placement) { act(() => root!.render()); } +// Like React Navigation's NavigationStateContext, whose default value throws when +// read. React restores a context's default outside render, so layout effects see it. +const ContainerStateContext = createContext({ + get getIsInitial(): never { + throw new Error("Couldn't find a navigation context."); + }, +}); +const containerState = {}; + +function ConnectingToolbar(props: { readonly label: string }) { + return ( + + + + + + ); +} + +function renderConnecting(label: string) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root ??= createRoot(document.createElement("div")); + act(() => + root!.render( + + + , + ), + ); +} + +function renderedCustomLabel() { + const item = harness.renderedItems[0]; + if (item?.type !== "custom") throw new Error("Expected the custom header element"); + return (item.element as ReactElement<{ "aria-label": string }>).props["aria-label"]; +} + function openRenderedDocument() { const menu = harness.renderedItems[0]; if (menu?.type !== "menu") throw new Error("Expected the native file menu"); @@ -98,3 +135,15 @@ it.each(["left", "right", "bottom"] as const)( expect(harness.openedDocuments).toEqual(["first/guide.pdf"]); }, ); + +it("applies a custom header element rendered inside a navigation container", () => { + renderConnecting("Connecting"); + expect(renderedCustomLabel()).toBe("Connecting"); + + const applied = harness.setOptionsCalls; + renderConnecting("Connecting"); + expect(harness.setOptionsCalls).toBe(applied); + + renderConnecting("Reconnecting"); + expect(renderedCustomLabel()).toBe("Reconnecting"); +}); diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 8240fbc74df4..698a345035cc 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -4,7 +4,7 @@ import type { NativeStackNavigationOptions, NativeStackNavigationProp, } from "@react-navigation/native-stack"; -import { use, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { isValidElement, use, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { NativeWorkspaceTabActivityContext } from "./v5-workspace-context"; import type { ColorValue } from "react-native"; @@ -55,6 +55,29 @@ function normalizeScreenOptions( return normalized as NativeStackNavigationOptions; } +// React tags elements, component types and contexts with `$$typeof`; their other +// fields are React internals. In development an element's `_owner` is a fiber that +// reaches the whole tree, including contexts that hold their default value outside +// render, and React Navigation's NavigationStateContext default throws when read. +function isReactObject(value: object): boolean { + return "$$typeof" in value; +} + +const identityIds = new WeakMap(); +let nextIdentityId = 0; + +function identitySignature(value: unknown): string { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + return String(value); + } + let id = identityIds.get(value); + if (id === undefined) { + id = nextIdentityId++; + identityIds.set(value, id); + } + return `identity:${id}`; +} + function optionsSignature(value: unknown, seen = new WeakSet()): string { if (value === null) return "null"; switch (typeof value) { @@ -75,6 +98,11 @@ function optionsSignature(value: unknown, seen = new WeakSet()): string return `bigint:${String(value)}`; case "object": { const object = value as object; + if (isValidElement(object)) { + // Elements are recreated on every render, so compare what they render. + return `element:${identitySignature(object.type)}:${String(object.key)}:${optionsSignature(object.props, seen)}`; + } + if (isReactObject(object)) return identitySignature(object); if (seen.has(object)) return "[circular]"; seen.add(object); if (Array.isArray(value)) { @@ -121,7 +149,7 @@ function stabilizeOptionFunctions( ); } if (value !== null && typeof value === "object") { - if (seen.has(value) || "current" in value) return value; + if (seen.has(value) || "current" in value || isReactObject(value)) return value; seen.add(value); return Object.fromEntries( Object.entries(value as Record).map(([key, entry]) => [ From 4af32312db6eebad26b158e65a0f2771abecd543 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 15:07:44 -0700 Subject: [PATCH 30/96] fix(mobile): folded Back clears the detail route before the next selection (#22526) Co-authored-by: Claude Opus 5.5 --- .../createWorkspaceStackNavigator.ios.tsx | 54 +++++++-- .../native/workspace-stack-projection.test.ts | 107 ++++++++++++++++++ .../src/native/workspace-stack-projection.ts | 47 ++++++++ 3 files changed, 196 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 2b23ad865f04..c4c0946fffd0 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -44,9 +44,11 @@ import { import { V5StackHeader } from "../../native/V5StackHeader.ios"; import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; import { + collapsedSplitTransition, nativeWorkspacePopAction, projectWorkspaceStack, partitionStackPresentations, + type CollapsedSplitColumn, } from "../../native/workspace-stack-projection"; import { NativePrimaryColumnContext, @@ -65,6 +67,7 @@ function ColumnScreen(props: { readonly selectedPullRequestKey?: string | null; readonly sidebarFilter?: NativeStackHeaderItemMenu; readonly onDismiss: (routeKey: string) => void; + readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void; readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; @@ -85,15 +88,18 @@ function ColumnScreen(props: { onDismiss={(event) => { if (event.nativeEvent.isNativeDismiss) props.onDismiss(key); }} - onWillAppear={() => - props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }) - } - onDidAppear={() => - props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }) - } - onWillDisappear={() => - props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }) - } + onWillAppear={() => { + props.onAppearance("willAppear", key); + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }); + }} + onDidAppear={() => { + props.onAppearance("didAppear", key); + props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }); + }} + onWillDisappear={() => { + props.onAppearance("willDisappear", key); + props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }); + }} onDidDisappear={() => props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: true } }) } @@ -130,7 +136,8 @@ function WorkspaceColumns( const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; const compactColumn = activeDetailKey ? "secondary" : "primary"; - const shownCompactColumn = useRef(compactColumn); + const collapsed = useRef(!layout.usesSplitView); + const collapsedColumn = useRef({ visible: compactColumn, leaving: null }); const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params; const pullRequestParams = props.detail.findLast( (route) => @@ -169,10 +176,24 @@ function WorkspaceColumns( }, [props.navigation], ); + const followCollapsedSplit = ( + type: "willAppear" | "didAppear" | "willDisappear", + key: string, + ) => { + if (!collapsed.current || layout.usesSplitView) return; + const transition = collapsedSplitTransition( + collapsedColumn.current, + { type, key }, + props.navigation.getState(), + props.primary.key, + ); + collapsedColumn.current = transition.column; + if (transition.pop) props.navigation.dispatch(transition.pop); + }; useEffect(() => { - if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return; - shownCompactColumn.current = compactColumn; + if (layout.usesSplitView || collapsedColumn.current.visible === compactColumn) return; + collapsedColumn.current = { ...collapsedColumn.current, visible: compactColumn }; hostRef.current?.show(compactColumn); }, [compactColumn, layout.usesSplitView]); @@ -185,6 +206,13 @@ function WorkspaceColumns( testID="adaptive-workspace-layout" preferredSplitBehavior="tile" topColumnForCollapsing={compactColumn} + onCollapse={() => { + collapsed.current = true; + collapsedColumn.current = { visible: compactColumn, leaving: null }; + }} + onExpand={() => { + collapsed.current = false; + }} preferredDisplayMode={ panes.primarySidebarVisible || (!layout.usesSplitView && !activeDetailKey) ? "oneBesideSecondary" @@ -211,6 +239,7 @@ function WorkspaceColumns( selectedThreadKey={selectedThreadKey} selectedPullRequestKey={selectedPullRequestKey} onDismiss={handleNativeDismiss} + onAppearance={followCollapsedSplit} navigation={props.navigation} /> @@ -252,6 +281,7 @@ function WorkspaceColumns( key={route.key} descriptor={descriptor} onDismiss={handleNativeDismiss} + onAppearance={followCollapsedSplit} navigation={props.navigation} sidebarFilter={sidebarFilter} /> diff --git a/apps/mobile/src/native/workspace-stack-projection.test.ts b/apps/mobile/src/native/workspace-stack-projection.test.ts index e813c48faa6b..99901652473d 100644 --- a/apps/mobile/src/native/workspace-stack-projection.test.ts +++ b/apps/mobile/src/native/workspace-stack-projection.test.ts @@ -3,12 +3,14 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati import { describe, expect, it } from "vite-plus/test"; import { + collapsedSplitTransition, nativeStackPopAction, nativeWorkspacePopAction, nativeWorkspacePopCount, projectWorkspaceStack, partitionStackPresentations, reconcileStackScreens, + type CollapsedSplitColumn, } from "./workspace-stack-projection"; const requireNavigation = NodeModule.createRequire( @@ -185,6 +187,111 @@ describe("native card pop handoff", () => { }); }); +describe("collapsed split Back", () => { + const routerOptions = { + routeNames: history([home]).routeNames, + routeParamList: {}, + routeGetIdList: {}, + }; + type Appearance = "willAppear" | "didAppear" | "willDisappear"; + // UIKit's appearance callbacks for one collapsed transition, against the router at each step. + function replay( + steps: ReadonlyArray]>, + ) { + let column: CollapsedSplitColumn = { visible: "secondary", leaving: null }; + let pop: ReturnType = null; + for (const [type, key, state] of steps) { + const transition = collapsedSplitTransition(column, { type, key }, state, home.key); + column = transition.column; + pop ??= transition.pop; + } + return { visible: column.visible, pop }; + } + + it("returns the router to the list so the next selection opens a new detail", () => { + const state = history([home, thread]); + const back = replay([ + ["willDisappear", thread.key, state], + ["willAppear", home.key, state], + ["didAppear", home.key, state], + ]); + expect(back.visible).toBe("primary"); + const router = StackRouter({}); + const listed = router.getStateForAction(state, back.pop!, routerOptions); + expect(listed?.routes).toEqual([home]); + if (!listed || listed.stale !== false) throw new Error("Expected a rehydrated router history"); + const select = { + type: "NAVIGATE", + payload: { name: "Thread", params: { threadId: "next" } }, + } as const; + expect(router.getStateForAction(listed, select, routerOptions)?.routes[1]?.key).not.toBe( + thread.key, + ); + // Left on the hidden detail, the selection would only rename it and never show the column. + expect(router.getStateForAction(state, select, routerOptions)?.routes[1]?.key).toBe(thread.key); + }); + + it("clears every detail while keeping a presentation opened during the Back animation", () => { + const leaving = history([home, thread, files]); + const presented = history([home, thread, files, settings]); + const { pop } = replay([ + ["willDisappear", files.key, leaving], + ["didAppear", home.key, presented], + ]); + expect(StackRouter({}).getStateForAction(presented, pop!, routerOptions)?.routes).toEqual([ + home, + settings, + ]); + }); + + it("keeps the detail when it appears again before the list finishes appearing", () => { + const state = history([home, thread]); + const cancelledSwipe = replay([ + ["willDisappear", thread.key, state], + ["willAppear", home.key, state], + ["willAppear", thread.key, state], + ["didAppear", thread.key, state], + ]); + expect(cancelledSwipe).toEqual({ visible: "secondary", pop: null }); + expect( + replay([ + ["willDisappear", thread.key, state], + ["willAppear", thread.key, state], + ["didAppear", home.key, state], + ]).pop, + ).toBeNull(); + }); + + it("does not mistake JS navigation or a cold link for native Back", () => { + const pushed = history([home, thread, files]); + expect( + replay([ + ["willDisappear", thread.key, pushed], + ["didAppear", files.key, pushed], + ]), + ).toEqual({ visible: "secondary", pop: null }); + const presented = history([home, thread, settings]); + expect( + replay([ + ["willDisappear", thread.key, presented], + ["didAppear", home.key, presented], + ]).pop, + ).toBeNull(); + const popped = history([home]); + expect( + replay([ + ["willDisappear", thread.key, popped], + ["didAppear", home.key, popped], + ]).pop, + ).toBeNull(); + // A linked thread whose list appears first still needs its detail column shown. + expect(replay([["didAppear", home.key, history([home, thread])]])).toEqual({ + visible: "primary", + pop: null, + }); + }); +}); + describe("v5 stack handoff", () => { it("releases a completed native pop when the router acknowledges it", () => { const completed = new Set([files.key]); diff --git a/apps/mobile/src/native/workspace-stack-projection.ts b/apps/mobile/src/native/workspace-stack-projection.ts index 2d6afae419ee..71ebec0ed9c2 100644 --- a/apps/mobile/src/native/workspace-stack-projection.ts +++ b/apps/mobile/src/native/workspace-stack-projection.ts @@ -70,6 +70,53 @@ export function nativeStackPopAction( }; } +/** The column UIKit shows while a split is collapsed, and the active detail it began removing. */ +export interface CollapsedSplitColumn { + readonly visible: "primary" | "secondary"; + readonly leaving: string | null; +} + +/** + * A collapsed split nests the detail column's navigation controller in the list column's. Back + * from the detail root pops that controller whole, so no screen leaves its own stack or reports a + * dismissal: the list appearing after the active detail began leaving is the only sign of it. + */ +export function collapsedSplitTransition( + column: CollapsedSplitColumn, + event: { readonly type: "willAppear" | "didAppear" | "willDisappear"; readonly key: string }, + state: Pick, + primaryKey: string, +): { + readonly column: CollapsedSplitColumn; + readonly pop: ReturnType; +} { + switch (event.type) { + case "willDisappear": + // JS pushes, pops, and presentations have already moved the router past this screen. + return { + column: + state.index > 0 && state.routes[state.index]?.key === event.key + ? { ...column, leaving: event.key } + : column, + pop: null, + }; + case "willAppear": + // A cancelled swipe, or an expansion that keeps the detail on screen. + return { + column: column.leaving === event.key ? { ...column, leaving: null } : column, + pop: null, + }; + case "didAppear": + return { + column: { visible: event.key === primaryKey ? "primary" : "secondary", leaving: null }, + pop: + event.key === primaryKey && column.leaving + ? nativeStackPopAction(state, column.leaving, primaryKey) + : null, + }; + } +} + export function partitionStackPresentations( routes: readonly T[], isModal: (route: T) => boolean, From fe27e8e3dc12dcbb108e9a28fd9b8cb65f34ae92 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 15:08:35 -0700 Subject: [PATCH 31/96] ci(release): release tests stop timing out and finish in about 5 minutes (#22524) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 59 ++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b333973261..d29d970c75ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,6 +252,7 @@ jobs: # The web suite is the largest outside the server. In the shared Test job it # started last and ran alone for about 40s after everything else finished. + # release.yml mirrors this test, test_web, and test_server split. test_web: name: Test Web needs: changes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df8a719f9e79..5d387de16b83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,7 +256,7 @@ jobs: run: vp run typecheck # Keep tests on their own runners, using the same package split and server - # shards as CI, so the release check job does not spend its budget on tests. + # shards as CI (test, test_web, test_server in ci.yml). Keep the two in sync. test: name: Release tests needs: [preflight] @@ -286,23 +286,67 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential - # The web package's browser project runs React tests in headless Chromium. + - name: Test + run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test + + # Web browser tests time out when they share a runner with the other package + # suites: Chromium gets too little CPU and diff workers miss their 10s start budget. + test_web: + name: Release web tests + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Resolve Playwright version + id: playwright_version + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + - name: Install Chromium for web browser tests - run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium + if: steps.chromium_cache.outputs.cache-hit != 'true' + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi - name: Test - run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + run: vp run --filter @t3tools/web test + # Server test files run one at a time, so more small runners beat fewer big ones. test_server: name: Release server tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout uses: actions/checkout@v6 @@ -693,6 +737,7 @@ jobs: relay_public_config, quality, test, + test_web, test_server, desktop_mac_arm64, desktop_mac_x64, @@ -701,7 +746,7 @@ jobs: desktop_win_x64, desktop_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: From 5d27ecd79c33a642efcdf259e89e3eba5a560247 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 15:28:08 -0700 Subject: [PATCH 32/96] test(server): Claude steer test no longer hangs for 120s under CI load (#22533) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 138528263fba..9c681f605ae4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -17500,6 +17500,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; const calls: Array = []; let beforeAnswer: Effect.Effect = Effect.void; + const lateAnswer = yield* Deferred.make(); const harness = yield* makeWakeHarnessWithOptions({ backgroundTasks: (toolUseId) => Effect.sync(() => calls.push(toolUseId)).pipe( @@ -17517,7 +17518,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { : backgrounding === "never answers" ? Effect.never : backgrounding === "answers after the steer went out" - ? Effect.sleep("6 seconds").pipe(Effect.as(true)) + ? Deferred.await(lateAnswer).pipe(Effect.as(true)) : Effect.succeed(true), ), ), @@ -17670,16 +17671,29 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); } + // The steer arms its 5-second backgrounding timeout after the request + // starts on another fiber, so one TestClock.adjust can land before that + // timer exists and leave the steer waiting forever. Tick until it ends. + const awaitSteerTimeout = (fiber: Fiber.Fiber) => + Effect.gen(function* () { + for (let tick = 0; tick < 100 && fiber.pollUnsafe() === undefined; tick++) { + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + } + if (fiber.pollUnsafe() === undefined) { + return yield* Effect.die("Timed out waiting for the steer."); + } + return yield* Fiber.await(fiber); + }); const steering = yield* Effect.forkScoped(steer); - if ( + const timesOut = backgrounding === "never answers" || - backgrounding === "answers after the steer went out" - ) { + backgrounding === "answers after the steer went out"; + if (timesOut) { yield* awaitUntil(() => calls.length === 1, "background request"); assert.equal(harness.offeredMessages.length, offered); - yield* TestClock.adjust("5 seconds"); } - const steered = yield* Fiber.await(steering); + const steered = yield* timesOut ? awaitSteerTimeout(steering) : Fiber.await(steering); assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); if (backgrounding === "answers after the turn ended") { @@ -17693,17 +17707,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (backgrounding === "never answers") { for (let repeat = 1; repeat <= 2; repeat++) { const again = yield* Effect.forkScoped(steer); - for (let tick = 0; tick < 100 && again.pollUnsafe() === undefined; tick++) { - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - } - assert.isTrue(Exit.isSuccess(yield* Fiber.await(again))); + assert.isTrue(Exit.isSuccess(yield* awaitSteerTimeout(again))); assert.equal(harness.offeredMessages.length, offered + 1 + repeat); } assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); } if (backgrounding === "answers after the steer went out") { - yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(lateAnswer, undefined); } else if ( backgrounding !== "succeeds" && backgrounding !== "answers after the subagent ran again" From f9dd25914dbd5f061f2685965bcbe2db8cfb912e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 16:08:57 -0700 Subject: [PATCH 33/96] test: remove the timing races behind recurring CI and release failures (#22523) Co-authored-by: Claude Opus 5.5 --- .../transferBudgetV2.integration.test.ts | 12 ++- apps/server/scripts/acp-mock-agent.ts | 17 ++-- .../Adapters/ClaudeAdapterV2.test.ts | 37 ++++----- .../Adapters/ClaudeAdapterV2.ts | 15 +++- .../chat/ComposerBannerStack.browser.test.tsx | 4 +- .../chat/MessagesTimeline.browser.test.tsx | 9 +++ .../diffs/StyledDiffCodeView.browser.test.tsx | 78 ++++++++++++------- .../PullRequestDetailPanel.browser.test.tsx | 11 ++- ...rojectFaviconPickerDialog.browser.test.tsx | 7 +- .../ProjectSettingsPanel.browser.test.tsx | 35 +++++---- apps/web/test/render.tsx | 13 +++- apps/web/vite.config.ts | 7 ++ .../src/server/OpenCodeServerLedger.test.ts | 18 ++++- 13 files changed, 176 insertions(+), 87 deletions(-) diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 0ec8b1c37b29..b4dd358c17b1 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Measures real local HTTP/WebSocket sockets. import * as NodeHttp from "node:http"; +import * as NodeUtil from "node:util"; import { assert, it } from "@effect/vitest"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; @@ -299,9 +300,16 @@ it.live( const events = turnEvents(provider, TRANSFER_HISTORY_TURN_COUNT, true); const stored = yield* sink.write({ events }); const last = stored.at(-1)!; - const terminal = (item: StreamItem) => + const threadTerminal = (item: StreamItem) => "sequence" in item && item.sequence >= last.sequence; - for (const queue of [tq, sq, ttq, ssq]) yield* collectUntil(queue, terminal); + // Shell items carry the shell read when their batch is projected, and unchanged + // shells are skipped, so a turn split across batches may never yield an item at + // the last sequence. Wait for the final shell instead. + const finalShell = yield* projection.getThreadShell(THREAD_ID); + const shellTerminal = (item: StreamItem) => + item.kind === "thread.updated" && NodeUtil.isDeepStrictEqual(item.thread, finalShell); + for (const queue of [tq, ttq]) yield* collectUntil(queue, threadTerminal); + for (const queue of [sq, ssq]) yield* collectUntil(queue, shellTerminal); const measuredTurnWebSocket = difference(thread.recorder.totals(), threadBefore); const measuredTurnShellWebSocket = difference(shell.recorder.totals(), shellBefore); const measuredTurnSecondClientWebSocket = difference( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c2d29e98047e..ae06aba0355d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1383,6 +1383,7 @@ const program = Effect.gen(function* () { (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1) ) { const toolCallId = "tool-call-running-1"; + let runningCommandPublished: Promise = Promise.resolve(); if (runningCommandPidPath !== undefined) { const command = runningCommandIgnoresTerm ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s\n" "$$" "$child" > "$1"; wait "$child"' @@ -1390,19 +1391,24 @@ const program = Effect.gen(function* () { if (runningCommandSeparateSession) { const launcher = [ 'const { spawn } = require("node:child_process");', - "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: 'ignore' });", + "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: ['ignore', 'inherit', 'ignore'] });", "child.once('exit', (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));", ].join(" "); const detachedCommand = runningCommandIgnoresTerm - ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; wait "$child"' - : 'sleep 120 & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; wait "$child"'; + ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; echo published; wait "$child"' + : 'sleep 120 & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; echo published; wait "$child"'; // Nested bash publishes "$PPID $$ $child" once it starts. Do not // write the launcher PID alone here: that races with bash and can - // clobber the triple that interrupt tests wait for. + // clobber the triple that interrupt tests wait for. It then prints a + // line, so an agent that exits after launch only exits once the + // triple exists instead of racing a slow launcher start. const detachedLauncher = NodeChildProcess.spawn( process.execPath, ["-e", launcher, "bash", "-c", detachedCommand, "bash", runningCommandPidPath], - { detached: true, stdio: "ignore" }, + { detached: true, stdio: ["ignore", "pipe", "ignore"] }, + ); + runningCommandPublished = new Promise((resolve) => + detachedLauncher.stdout.once("data", () => resolve()), ); detachedLauncher.unref(); } else { @@ -1460,6 +1466,7 @@ const program = Effect.gen(function* () { }, }); if (exitAfterRunningCommandLaunch) { + yield* Effect.promise(() => runningCommandPublished); yield* Effect.sleep("100 millis"); return yield* Effect.sync(() => process.exit(0)); } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9c681f605ae4..acdec8cf3d03 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -17499,11 +17499,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { const NESTED_TOOL_USE_ID = "toolu-steer-nested"; const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; const calls: Array = []; + const backgroundRequested = yield* Deferred.make(); let beforeAnswer: Effect.Effect = Effect.void; const lateAnswer = yield* Deferred.make(); const harness = yield* makeWakeHarnessWithOptions({ backgroundTasks: (toolUseId) => Effect.sync(() => calls.push(toolUseId)).pipe( + Effect.andThen(Deferred.succeed(backgroundRequested, undefined)), Effect.andThen( backgrounding === "answers after the turn ended" || backgrounding === "answers after the subagent ran again" @@ -17671,29 +17673,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); } - // The steer arms its 5-second backgrounding timeout after the request - // starts on another fiber, so one TestClock.adjust can land before that - // timer exists and leave the steer waiting forever. Tick until it ends. - const awaitSteerTimeout = (fiber: Fiber.Fiber) => - Effect.gen(function* () { - for (let tick = 0; tick < 100 && fiber.pollUnsafe() === undefined; tick++) { - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - } - if (fiber.pollUnsafe() === undefined) { - return yield* Effect.die("Timed out waiting for the steer."); - } - return yield* Fiber.await(fiber); - }); - const steering = yield* Effect.forkScoped(steer); - const timesOut = + // Started at once so the steer reads its backgrounding deadline before + // this test moves the clock. Its message is built with real file I/O, + // so a scheduled start could read the clock after the moves below. + const steering = yield* Effect.forkScoped(steer, { startImmediately: true }); + if ( backgrounding === "never answers" || - backgrounding === "answers after the steer went out"; - if (timesOut) { - yield* awaitUntil(() => calls.length === 1, "background request"); + backgrounding === "answers after the steer went out" + ) { + yield* Deferred.await(backgroundRequested); assert.equal(harness.offeredMessages.length, offered); + yield* TestClock.adjust("5 seconds"); } - const steered = yield* timesOut ? awaitSteerTimeout(steering) : Fiber.await(steering); + const steered = yield* Fiber.await(steering); assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); if (backgrounding === "answers after the turn ended") { @@ -17706,8 +17698,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); if (backgrounding === "never answers") { for (let repeat = 1; repeat <= 2; repeat++) { - const again = yield* Effect.forkScoped(steer); - assert.isTrue(Exit.isSuccess(yield* awaitSteerTimeout(again))); + const again = yield* Effect.forkScoped(steer, { startImmediately: true }); + yield* TestClock.adjust("5 seconds"); + assert.isTrue(Exit.isSuccess(yield* Fiber.await(again))); assert.equal(harness.offeredMessages.length, offered + 1 + repeat); } assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 53c71b9eed71..f1795704844c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -97,6 +97,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -304,6 +305,9 @@ const CLAUDE_WAKE_TURN_START_DEADLINE = "2 minutes"; // reply can land after the progress frame that settles it. Its transcript // stays the source of its answer this long after it settles. const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500; +// A steer waits at most this long, from its request, for its foreground +// subagents to move to the background before it is sent. +const BACKGROUND_MOVE_TIMEOUT_MS = 5_000; /** * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's @@ -9684,6 +9688,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // its result arrives as a task notification like any background agent. const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, + deadline: number, ) { const foreground: Array<{ readonly taskId: string; @@ -9739,11 +9744,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( backgroundMovesByToolUseId.set(toolUseId, move); moves.push(move); } - yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption("5 seconds")); + const remaining = deadline - (yield* Clock.currentTimeMillis); + yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption(Math.max(0, remaining))); }); const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2SteerInput) { + // Measured from the request, not from when the moves are forked: + // building the message and the moves can take clock time a timer + // started later would not count, and the steer would wait past it. + const backgroundDeadline = + (yield* Clock.currentTimeMillis) + BACKGROUND_MOVE_TIMEOUT_MS; const existing = yield* Ref.get(queryContext); if (existing === null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ @@ -9769,7 +9780,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fileSystem, skills: yield* userInvocableSkills(currentTurn.input.runtimePolicy.cwd), }); - yield* backgroundForegroundSubagents(existing); + yield* backgroundForegroundSubagents(existing, backgroundDeadline); // The turn can end while the moves are answered. A steer offered // then would start a new turn the orchestrator never asked for, so // the steer fails and the orchestrator starts the follow-up turn. diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index 757f3db20c02..c6551d4fcb7d 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -77,9 +77,7 @@ it("keeps only the usage limit attached while an active goal waits in the collap const limit = page.getByText("Usage limit reached", { exact: true }); const goal = page.getByText("Goal active", { exact: true }); - // A retained pointer can expand the stack as it mounts; start outside the hover target. - await userEvent.hover(limit); - await expect.poll(visibleBanners).toHaveLength(1); + expect(visibleBanners()).toHaveLength(1); await expect.element(limit).toBeVisible(); await expect.element(goal).not.toBeVisible(); expect(container.querySelector('[data-slot="composer-banner-attachment"]')?.textContent).toBe( diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 665eb5e16f69..3452282be49c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -43,6 +43,15 @@ vi.mock(import("~/state/query"), async (original) => ({ })) as never, })); +// The first Mermaid render loads the library and its flowchart chunks, which took over +// 10 seconds on a busy release runner. Pay that while the file loads, not inside a test. +{ + const { default: mermaid } = await import("mermaid"); + mermaid.initialize({ startOnLoad: false }); + await mermaid.render("mermaid-warmup", "flowchart LR\n A --> B"); + document.getElementById("dmermaid-warmup")?.remove(); +} + const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const CREATED_AT = "2026-03-17T19:12:28.000Z"; diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx index 71dd1e89ee49..62fd79f6706c 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx @@ -1,4 +1,4 @@ -import { act, render, fakeTimers } from "../../../test/render"; +import { act, render, fakeTimers, type Screen } from "../../../test/render"; import { CodeView, FileDiff, @@ -146,9 +146,16 @@ function renderViews(count: number) { ); } -const codeFile = (name: string) => - document.querySelector(`[data-code-file="${name}"]`)?.textContent ?? ""; -const loadingViews = () => document.querySelectorAll('[role="status"]').length; +// Pierre rejects a pool whose workers take 10s to start. Real workers share the runner with the +// rest of the suite, so outside the test of that limit the test's own timeout is the only deadline. +const UNLIMITED_WORKER_STARTUP_MS = 120_000; + +// Queries stay inside the test's own container: a timed-out test keeps running and leaves its +// views mounted while the next test starts. +const codeFile = (container: HTMLElement, name: string) => + container.querySelector(`[data-code-file="${name}"]`)?.textContent ?? ""; +const loadingViews = (container: HTMLElement) => + container.querySelectorAll('[role="status"]').length; describe("code-view worker lifecycle", () => { function setup() { @@ -165,7 +172,7 @@ describe("code-view worker lifecycle", () => { // Worker startup runs on real time, so only the idle expiry checks use this clock. // Faking startup too would let advancing the idle clock time out a worker startup. const idleClock = () => fakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - async function workerEnvironment() { + async function workerEnvironment({ startupLimit = UNLIMITED_WORKER_STARTUP_MS } = {}) { await using stack = new AsyncDisposableStack(); // Pins the pool at two workers whatever machine runs the suite. stack.use(vi.spyOn(navigator, "hardwareConcurrency", "get").mockReturnValue(2)); @@ -175,6 +182,9 @@ describe("code-view worker lifecycle", () => { this: WorkerPoolManager, ...args ) { + // The provider builds the pool without this option, so it is set before startup reads it. + const options: { workerInitializationTimeout?: number } = Reflect.get(this, "options"); + options.workerInitializationTimeout = startupLimit; const pending = initialize.apply(this, args); testState.pools.set(this, pending); return pending; @@ -200,12 +210,13 @@ describe("code-view worker lifecycle", () => { }; } - // Two cold starts can each use Pierre's 10-second initialization budget. + // Two cold starts of real workers can outlast the default timeout on a loaded runner. it("starts workers on demand and expires idle pools", { timeout: 30_000 }, async () => { setup(); await using workers = await workerEnvironment(); const view = await workers.render(renderViews(0)); - const button = document.querySelector("button")!; + const { container } = view; + const button = container.querySelector("button")!; expect(testState.workers).toHaveLength(0); expect(testState.pools.size).toBe(0); await act(async () => button.click()); @@ -214,7 +225,9 @@ describe("code-view worker lifecycle", () => { await act(async () => testState.pools.get(pool)); expect(pool.isInitialized()).toBe(true); expect(testState.workers).toHaveLength(2); - await vi.waitFor(() => expect(codeFile("notes.txt")).toContain("Plain text is ready.")); + await vi.waitFor(() => + expect(codeFile(container, "notes.txt")).toContain("Plain text is ready."), + ); expect(testState.requests.filter((request) => request.type === "file")).toHaveLength(0); await view.rerender(renderViews(2)); @@ -223,7 +236,7 @@ describe("code-view worker lifecycle", () => { expect(testState.workers).toHaveLength(2); expect(new Set(testState.renderPools)).toEqual(new Set([pool])); expect(pool.getFileResultCache(files[1]!)).toBeDefined(); - await vi.waitFor(() => expect(codeFile("answer.ts")).toContain("answer")); + await vi.waitFor(() => expect(codeFile(container, "answer.ts")).toContain("answer")); { using _idleClock = idleClock(); @@ -238,7 +251,7 @@ describe("code-view worker lifecycle", () => { await view.rerender(renderViews(1)); expect(testState.pools.size).toBe(1); expect(testState.workers).toHaveLength(2); - expect(loadingViews()).toBe(0); + expect(loadingViews(container)).toBe(0); expect(pool.getFileResultCache(files[1]!)).toBeDefined(); await vi.advanceTimersByTimeAsync(30_000); expect(pool.getStats().totalWorkers).toBe(2); @@ -254,16 +267,18 @@ describe("code-view worker lifecycle", () => { await act(async () => testState.pools.get(reopenedPool)); expect(reopenedPool.isInitialized()).toBe(true); expect(testState.workers).toHaveLength(4); - expect(document.querySelector("button")).toBe(button); + expect(container.querySelector("button")).toBe(button); expect(button.textContent).toBe("Edited draft"); - await vi.waitFor(() => expect(codeFile("notes.txt")).toContain("Plain text is ready.")); + await vi.waitFor(() => + expect(codeFile(container, "notes.txt")).toContain("Plain text is ready."), + ); }); it("renders through Pierre's main-thread fallback when worker creation fails", async () => { setup(); await using workers = await workerEnvironment(); testState.failWorkers = true; - await workers.render(renderViews(1)); + const { container } = await workers.render(renderViews(1)); const pool = [...testState.pools.keys()][0]!; await act(async () => { await expect(testState.pools.get(pool)).rejects.toMatchObject({ _tag: "DiffWorkerError" }); @@ -275,13 +290,16 @@ describe("code-view worker lifecycle", () => { }); expect(pool.isWorkingPool()).toBe(false); expect(testState.workers).toHaveLength(0); - await vi.waitFor(() => expect(codeFile("notes.txt")).toContain("Plain text is ready.")); + await vi.waitFor(() => + expect(codeFile(container, "notes.txt")).toContain("Plain text is ready."), + ); }); it("keeps an initializing pool until its last view closes and ignores late responses", async () => { setup(); using _idleClock = idleClock(); - await using workers = await workerEnvironment(); + // Runs Pierre's own startup limit on the fake clock. + await using workers = await workerEnvironment({ startupLimit: 10_000 }); testState.holdInitialization = true; const held = new Promise((resolve) => { testState.onInitializationHeld = resolve; @@ -292,14 +310,14 @@ describe("code-view worker lifecycle", () => { await held; expect(pool.isInitialized()).toBe(false); expect(testState.pools.size).toBe(1); - expect(loadingViews()).toBe(2); + expect(loadingViews(view.container)).toBe(2); expect(testState.renderPools).toHaveLength(0); await view.rerender(renderViews(1)); expect(pool.getStats().totalWorkers).toBe(2); expect(testState.terminations).toBe(0); await view.rerender(renderViews(0)); - // Pierre gives up on a held startup after 10 seconds; the idle timer then tears the pool down. + // Pierre gives up on the held startup after 10 seconds; the idle timer then tears the pool down. const timedOut = expect(pending).rejects.toThrow("worker initialization timed out"); await vi.advanceTimersByTimeAsync(30_000); await timedOut; @@ -310,7 +328,7 @@ describe("code-view worker lifecycle", () => { expect(testState.workers).toHaveLength(2); expect(testState.terminations).toBe(2); expect(testState.renderPools).toHaveLength(0); - expect(document.querySelectorAll("pre")).toHaveLength(0); + expect(view.container.querySelectorAll("pre")).toHaveLength(0); }); }); @@ -346,7 +364,11 @@ describe("collapsed diff content", () => { testState.failWorkers = false; testState.holdInitialization = false; const pool = new WorkerPoolManager( - { workerFactory: () => new DiffsWorker(), poolSize: 1 }, + { + workerFactory: () => new DiffsWorker(), + poolSize: 1, + workerInitializationTimeout: UNLIMITED_WORKER_STARTUP_MS, + }, { theme: "pierre-dark", langs: ["typescript"], preferredHighlighter: "shiki-wasm" }, ); stack.defer(() => pool.terminate()); @@ -569,7 +591,7 @@ function RevealViews({ } describe("code-view file reveals", () => { - const reveal = () => act(async () => document.querySelector("button")!.click()); + const reveal = (view: Screen) => act(async () => view.container.querySelector("button")!.click()); function setup() { revealCalls.length = 0; @@ -580,7 +602,7 @@ describe("code-view file reveals", () => { await using view = await render( , ); - await reveal(); + await reveal(view); expect(revealCalls.at(-1)?.fileKey).toBe("tree-file"); revealCalls.length = 0; @@ -597,7 +619,7 @@ describe("code-view file reveals", () => { await using view = await render( , ); - await reveal(); + await reveal(view); expect(revealCalls).toEqual([]); await view.rerender(); @@ -612,7 +634,7 @@ describe("code-view file reveals", () => { revealCalls.length = 0; // A click that lands in the same update that hides the viewer waits for it to return. await act(async () => { - document.querySelector("button")!.click(); + view.container.querySelector("button")!.click(); await view.rerender(); }); expect(revealCalls).toEqual([]); @@ -628,7 +650,7 @@ describe("code-view file reveals", () => { await using view = await render( , ); - await reveal(); + await reveal(view); await view.rerender( , ); @@ -636,7 +658,7 @@ describe("code-view file reveals", () => { revealCalls.length = 0; await view.rerender(); - await reveal(); + await reveal(view); await view.rerender(); await view.rerender(); expect(revealCalls).toEqual([]); @@ -644,9 +666,9 @@ describe("code-view file reveals", () => { it("reveals after a collapsed file expands and honors another click on the same file", async () => { setup(); - await using _view = await render(); - await reveal(); - await reveal(); + await using view = await render(); + await reveal(view); + await reveal(view); expect(revealCalls).toEqual([ { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false }, { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false }, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx index 24e2a1955ee2..27eae0e29766 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx @@ -153,6 +153,14 @@ vi.mock(import("./PullRequestSummaryTab"), async (original) => { }) as never, }; }); +// The Code tab waits for the diff worker pool, and a cold pool starts real Shiki workers that +// a loaded runner can keep past Pierre's 10s startup limit. These tests are about the panel, +// so the pool falls back to main-thread highlighting at once. +vi.mock("@pierre/diffs/worker/worker.js?worker", () => ({ + default: function UnavailableDiffsWorker() { + throw new Error("Diff workers are disabled in panel tests"); + }, +})); vi.mock(import("../ui/toast"), async (original) => { const actual = await original(); return { @@ -398,7 +406,6 @@ describe.each([ ); }); - // The first Code-tab action starts real diff workers as well as driving the panel. it.each([ "Resolve conflicts", "Ask a question", @@ -406,7 +413,7 @@ describe.each([ "Fix findings in this thread", "Fix check", "Add to agent", - ])("%s writes to the correct composer", { timeout: 30_000 }, async (action) => { + ])("%s writes to the correct composer", async (action) => { setup(); // Cover both a host without inline comments and an account that cannot write them. currentDetail = diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx index 6d0de80caa48..39aa4a19cc17 100644 --- a/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx @@ -129,9 +129,14 @@ const imageUrl = `data:image/svg+xml,${encodeURIComponent( '', )}`; +// The registry drops an unsubscribed atom on its next task. A row calls createUrl while it +// renders but subscribes only after React commits, so on a busy runner a URL published +// in between would be dropped. A server keeps its answer; keepAlive models that. function addFiles(paths: string[]) { state.entries = paths.map((path) => ({ path, kind: "file" })); - for (const path of paths) state.assets.set(path, Atom.make(AsyncResult.initial())); + for (const path of paths) { + state.assets.set(path, Atom.make(AsyncResult.initial()).pipe(Atom.keepAlive)); + } } function fileRow(path: string) { diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 5f7137ca5b33..8d39c065f579 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -429,35 +429,36 @@ describe("project settings permissions", () => { }); describe("project settings scope", () => { - it("offers project checkout environments while keeping unselected and unknown projects recoverable", async () => { + const environmentMenu = () => page.getByRole("button", { name: /^Environment scope:/ }); + const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ }); + const remoteOption = () => page.getByRole("menuitemradio", { name: /^remote/ }); + + it("offers every environment while no project is selected", async () => { setup(); state.projects = state.projects.filter((project) => project.environmentId === primaryId); - const key = projectKey(); - await using screen = await mountSettings({}); - const environmentMenu = () => page.getByRole("button", { name: /^Environment scope:/ }); - const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ }); - const remoteOption = () => page.getByRole("menuitemradio", { name: /^remote/ }); + await using _screen = await mountSettings({}); await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).toBeVisible(); - await userEvent.keyboard("{Escape}"); + }); + + it("offers only the selected project's checkout environments", async () => { + setup(); + state.projects = state.projects.filter((project) => project.environmentId === primaryId); + await using _screen = await mountSettings({ project: projectKey() }); - await act(async () => - screen.router.navigate({ to: "/settings/projects", search: () => ({ project: key }) }), - ); await expect.element(page.getByRole("textbox", { name: "Project name" })).toBeVisible(); await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).not.toBeInTheDocument(); - await userEvent.keyboard("{Escape}"); + }); + + it("keeps an unknown project recoverable from any environment", async () => { + setup(); + state.projects = state.projects.filter((project) => project.environmentId === primaryId); + await using screen = await mountSettings({ project: "removed-project" }); - await act(async () => - screen.router.navigate({ - to: "/settings/projects", - search: () => ({ project: "removed-project" }), - }), - ); await userEvent.click(environmentMenu()); await expect.element(primaryOption()).toBeVisible(); await expect.element(remoteOption()).toBeVisible(); diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index 0ad52c0ec228..05ce93e024b3 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -8,12 +8,19 @@ import { import { act as reactAct, createContext, use, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { vi } from "vite-plus/test"; -import { page, utils, type Locator, type LocatorSelectors } from "vite-plus/test/browser"; +import { commands, page, utils, type Locator, type LocatorSelectors } from "vite-plus/test/browser"; declare global { var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } +declare module "vite-plus/test/browser" { + interface BrowserCommands { + /** Defined in vite.config.ts. Moves Playwright's mouse off the page. */ + resetPointer: () => Promise; + } +} + // React warns about every update outside act() while this flag is on, and a real // browser drives updates from userEvent, timers and network that act() cannot wrap. // So it is on only while act() runs, and nested calls restore the outer value. @@ -97,6 +104,10 @@ export async function render( await unmount(); } finally { container.remove(); + // The real mouse outlives the test, and whatever the next test mounts under it + // starts hovered. Parking it here keeps render() itself free of awaits before + // the first mount, which tests rely on. + await commands.resetPointer(); } }, }; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6e550f681467..e58708100d6b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -108,6 +108,13 @@ const browserTestProject = { provider: playwright(), instances: [{ browser: "chromium" }], screenshotFailures: false, + commands: { + // Playwright's mouse outlives each test, so whatever mounts under its last + // position starts hovered. test/render.tsx parks it off the page after each test. + resetPointer: async ({ page }) => { + await page.mouse.move(-1, -1); + }, + }, }, }, } satisfies TestProjectInlineConfiguration; diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 040e4ead2b59..90c72cf49130 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -28,9 +28,14 @@ const groupExists = (pgid: number) => { } }; +// A group of zombies can be reaped between a check and the kill, so ESRCH just means it is gone. const killGroup = (pgid: number) => Effect.sync(() => { - if (groupExists(pgid)) process.kill(-pgid, "SIGKILL"); + try { + process.kill(-pgid, "SIGKILL"); + } catch (cause) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) throw cause; + } }); const spawnGroup = (args: ReadonlyArray, script = "sleep 600 & wait") => @@ -38,13 +43,15 @@ const spawnGroup = (args: ReadonlyArray, script = "sleep 600 & wait") => Effect.gen(function* () { const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], { detached: true, - stdio: "ignore", + stdio: ["ignore", "pipe", "ignore"], }); const exited = yield* Deferred.make(); + const printed = yield* Deferred.make(); child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal))); + child.stdout.once("data", () => Deferred.doneUnsafe(printed, Effect.void)); const pid = child.pid; if (pid === undefined) return yield* Effect.die("spawn failed"); - return { pid, exited: Deferred.await(exited) }; + return { pid, exited: Deferred.await(exited), printed: Deferred.await(printed) }; }), ({ pid }) => killGroup(pid), ); @@ -96,11 +103,14 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + // The inner shell prints once it has exec'd. Until then it still carries the wrapper's + // command line, and recording that would make the reaper skip the group as recycled. const wrapper = yield* spawnGroup( SERVE_ARGS, - '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0', + '/bin/sh -c "echo ready; sleep 600 & wait" "$0" "$@" & exit 0', ); yield* wrapper.exited; + yield* wrapper.printed; expect(groupExists(wrapper.pid)).toBe(true); yield* recordFromDeadServer(stateDir, wrapper); From fcb20e49aa0c1c5fc736034687c42e20cd2637b3 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 20 Oct 2026 23:36:45 +0000 Subject: [PATCH 34/96] feat(web): render Mermaid diagrams with pele (#21472) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/package.json | 2 +- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../chat/MermaidDiagram.browser.test.tsx | 66 -- .../src/components/chat/MermaidDiagram.tsx | 379 +++++---- apps/web/src/index.css | 14 + pnpm-lock.yaml | 789 +----------------- pnpm-workspace.yaml | 1 + 7 files changed, 233 insertions(+), 1020 deletions(-) delete mode 100644 apps/web/src/components/chat/MermaidDiagram.browser.test.tsx diff --git a/apps/web/package.json b/apps/web/package.json index dbb889a1bb8e..17f40f695472 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,8 +65,8 @@ "katex": "^0.16.47", "lucide": "^0.564.0", "lucide-react": "^0.564.0", - "mermaid": "^11.17.2", "morphicons": "^1.7.1", + "pele": "^1.8.0", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index baf23d2b4cec..78468b0c8580 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1107,7 +1107,7 @@ function MarkdownMermaidCodeBlock({ } > - + {searching ? (