From 41b513d99aac6ba4d4a4000a3a3a1320b0f79dad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 01:30:23 -0700 Subject: [PATCH 001/146] revert: "fix(desktop): saved connections survive the Beta rename on macOS and Linux" (#20907) (#22219) Co-authored-by: Julius Marminge --- .../app/DesktopConnectionCatalogStore.test.ts | 263 +----------------- .../src/app/DesktopConnectionCatalogStore.ts | 58 +--- apps/desktop/src/app/DesktopEnvironment.ts | 12 +- .../src/app/DesktopPreReadyPlatform.test.ts | 45 +-- .../src/app/DesktopPreReadyPlatform.ts | 7 +- .../src/app/DesktopSafeStorageIdentity.ts | 63 ----- apps/desktop/src/main.ts | 6 +- 7 files changed, 17 insertions(+), 437 deletions(-) delete mode 100644 apps/desktop/src/app/DesktopSafeStorageIdentity.ts diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c0dc12e634fae..a8ac69b345358 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -1,14 +1,9 @@ -// @effect-diagnostics nodeBuiltinImport:off - The recovery fixtures use Chromium OSCrypt ciphertext. -import * as NodeCrypto from "node:crypto"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; @@ -16,65 +11,12 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; -import { beforeEach, vi } from "vite-plus/test"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import { LinuxBrowserSecretPath } from "../preview/BrowserImport/LinuxBrowserSecret.ts"; import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopSafeStorageIdentity from "./DesktopSafeStorageIdentity.ts"; - -const { getBetaPassword } = vi.hoisted(() => ({ - getBetaPassword: vi.fn<() => string | null>(), -})); - -vi.mock("@napi-rs/keyring", () => ({ - Entry: class { - readonly getPassword; - constructor(service: string, account: string) { - this.getPassword = () => - service === "T3 Code (Beta) Safe Storage" && account === "T3 Code (Beta)" - ? getBetaPassword() - : null; - } - }, -})); - -beforeEach(() => { - getBetaPassword.mockReset().mockReturnValue(null); -}); - -const savedCatalog = JSON.stringify({ - schemaVersion: 1, - targets: [ - { _tag: "RelayConnectionTarget", environmentId: "relay-environment", label: "Saved relay" }, - ], - profiles: [], - credentials: [], - remoteDpopTokens: [], - disabledEnvironmentIds: [], -}); - -const encryptBetaCatalog = (platform: NodeJS.Platform, catalog = savedCatalog) => { - const key = NodeCrypto.pbkdf2Sync( - "beta-secret", - "saltysalt", - platform === "darwin" ? 1003 : 1, - 16, - "sha1", - ); - const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, " ")); - return Buffer.concat([ - Buffer.from(platform === "darwin" ? "v10" : "v11"), - cipher.update(catalog, "utf8"), - cipher.final(), - ]); -}; const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); @@ -86,11 +28,7 @@ const encodeLegacySavedEnvironments = Schema.encodeEffect( Schema.Struct({ version: Schema.Literal(1), records: Schema.Array(Schema.Unknown) }), ), ); -function layerSafeStorageFor( - available: boolean, - failDecrypt: Ref.Ref | null = null, - currentSecret?: string, -) { +function layerSafeStorageFor(available: boolean, failDecrypt: Ref.Ref | null = null) { return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { isEncryptionAvailable: Effect.succeed(available), encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)), @@ -101,23 +39,6 @@ function layerSafeStorageFor( !decoded.startsWith("encrypted:") || (failDecrypt !== null && (yield* Ref.get(failDecrypt))) ) { - if (currentSecret !== undefined) { - return yield* Effect.try({ - try: () => { - const key = NodeCrypto.pbkdf2Sync(currentSecret, "saltysalt", 1003, 16, "sha1"); - const decipher = NodeCrypto.createDecipheriv( - "aes-128-cbc", - key, - Buffer.alloc(16, " "), - ); - return Buffer.concat([ - decipher.update(value.subarray(3)), - decipher.final(), - ]).toString("utf8"); - }, - catch: (cause) => new ElectronSafeStorage.ElectronSafeStorageDecryptError({ cause }), - }); - } return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ cause: new Error("invalid encrypted catalog"), }); @@ -129,52 +50,16 @@ function layerSafeStorageFor( } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } -const layerLinuxKeyAccess = (lookup: Effect.Effect) => - Layer.merge( - Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const secret = - command._tag === "StandardCommand" && command.args[0] === "T3 Code (Beta)" - ? yield* lookup - : null; - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(secret === null ? 2 : 0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText(Stream.make(secret ?? "")), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - ); - -interface RecoveryOptions { - readonly platform?: NodeJS.Platform; - readonly linuxKeyLookup?: Effect.Effect; - readonly currentSecret?: string | undefined; -} - function layerFor( baseDir: string, encryptionAvailable = true, failDecrypt: Ref.Ref | null = null, fileSystemLayer: Layer.Layer = NodeServices.layer, - recovery: RecoveryOptions = {}, ) { const layerEnvironment = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, - platform: recovery.platform ?? "darwin", + platform: "darwin", processArch: "arm64", appVersion: "1.2.3", appPath: "/repo", @@ -186,18 +71,10 @@ function layerFor( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), ), ); - const layerSafeStorage = layerSafeStorageFor( - encryptionAvailable, - failDecrypt, - recovery.currentSecret, - ); + const layerSafeStorage = layerSafeStorageFor(encryptionAvailable, failDecrypt); const layerDependencies = Layer.mergeAll( layerEnvironment, layerSafeStorage, - DesktopSafeStorageIdentity.layer.pipe( - Layer.provide(layerEnvironment), - Layer.provide(layerLinuxKeyAccess(recovery.linuxKeyLookup ?? Effect.succeed(null))), - ), NodeServices.layer, fileSystemLayer, ); @@ -214,16 +91,13 @@ function layerFor( const withStore = ( effect: Effect.Effect, encryptionAvailable = true, - recovery: RecoveryOptions = {}, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); - return yield* effect.pipe( - Effect.provide(layerFor(baseDir, encryptionAvailable, null, NodeServices.layer, recovery)), - ); + return yield* effect.pipe(Effect.provide(layerFor(baseDir, encryptionAvailable))); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopConnectionCatalogStore", () => { @@ -566,133 +440,4 @@ describe("DesktopConnectionCatalogStore", () => { assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - - const writeBetaCatalog = ( - platform: NodeJS.Platform, - encryptedCatalog = encryptBetaCatalog(platform), - ) => - Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); - const contents = `${JSON.stringify({ - version: 1, - encryptedCatalog: Base64.encode(encryptedCatalog), - })}\n`; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(catalogPath, contents); - return { catalogPath, contents }; - }); - - it.effect.each([ - { platform: "darwin" as const, currentSecret: undefined, label: "macOS" }, - { platform: "linux" as const, currentSecret: undefined, label: "Linux" }, - { - platform: "darwin" as const, - currentSecret: "wrong-key-494", - label: "macOS with valid padding under the wrong current key", - }, - ])( - "recovers a Beta catalog on $label and reopens it without the Beta key", - ({ platform, currentSecret }) => - withStore( - Effect.gen(function* () { - getBetaPassword.mockReturnValue("beta-secret"); - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const { catalogPath, contents } = yield* writeBetaCatalog(platform); - - assert.deepStrictEqual(yield* store.get, Option.some(savedCatalog)); - assert.notEqual(yield* fileSystem.readFileString(catalogPath), contents); - - getBetaPassword.mockReturnValue(null); - const reopened = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( - Effect.provide( - layerFor(environment.homeDirectory, true, null, NodeServices.layer, { platform }), - ), - ); - assert.deepStrictEqual(yield* reopened.get, Option.some(savedCatalog)); - }), - true, - { platform, currentSecret, linuxKeyLookup: Effect.succeed("beta-secret") }, - ), - ); - - it.effect.each([ - // This wrong key has valid CBC padding, so decryption alone cannot validate recovery. - { platform: "darwin" as const, secret: "wrong-key-494", label: "a wrong Beta key" }, - { platform: "linux" as const, secret: null, label: "a missing Beta key" }, - { - platform: "darwin" as const, - secret: "beta-secret", - label: "unencrypted input", - encryptedCatalog: Buffer.from(savedCatalog), - }, - { - platform: "linux" as const, - secret: "beta-secret", - label: "an invalid catalog", - encryptedCatalog: encryptBetaCatalog("linux", '{"schemaVersion":1,"targets":null}'), - }, - ])("leaves the catalog bytes intact for $label", ({ platform, secret, encryptedCatalog }) => - withStore( - Effect.gen(function* () { - getBetaPassword.mockReturnValue(secret); - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const { catalogPath, contents } = yield* writeBetaCatalog(platform, encryptedCatalog); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, - ); - assert.equal(error.operation, "decrypt-catalog"); - assert.equal(yield* fileSystem.readFileString(catalogPath), contents); - }), - true, - { platform, linuxKeyLookup: Effect.succeed(secret) }, - ), - ); - - it.effect.each(["save", "clear"] as const)( - "preserves a concurrent %s while Beta key recovery is waiting", - (operation) => - Effect.gen(function* () { - const lookupStarted = yield* Deferred.make(); - const lookupReleased = yield* Deferred.make(); - const linuxKeyLookup = Deferred.succeed(lookupStarted, undefined).pipe( - Effect.andThen(Deferred.await(lookupReleased)), - Effect.as("beta-secret"), - ); - const newer = savedCatalog.replace("Saved relay", "Updated relay"); - - yield* withStore( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const { catalogPath } = yield* writeBetaCatalog("linux"); - const recovery = yield* store.get.pipe(Effect.forkChild); - yield* Deferred.await(lookupStarted); - if (operation === "save") { - assert.isTrue(yield* store.set(newer)); - } else { - yield* store.clear; - } - yield* Deferred.succeed(lookupReleased, undefined); - yield* Fiber.join(recovery); - - assert.deepStrictEqual( - yield* store.get, - operation === "save" ? Option.some(newer) : Option.none(), - ); - assert.equal(yield* fileSystem.exists(catalogPath), operation === "save"); - }), - true, - { platform: "linux", linuxKeyLookup }, - ); - }), - ); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 35800e67865b6..a9020c36d1c89 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -21,12 +21,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopSafeStorageIdentity from "./DesktopSafeStorageIdentity.ts"; const EncryptedConnectionCatalogDocument = Schema.Struct({ version: Schema.Literal(1), @@ -47,8 +45,6 @@ const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString( const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( RuntimeConnectionCatalogDocumentJson, ); -const decodeCatalogJsonOption = Schema.decodeOption(RuntimeConnectionCatalogDocumentJson); -const decodeNativeCatalogJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "create-temporary-file-name", @@ -386,11 +382,9 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const safeStorageIdentity = yield* DesktopSafeStorageIdentity.DesktopSafeStorageIdentity; const crypto = yield* Crypto.Crypto; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); - const writeLock = yield* Semaphore.make(1); const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( Effect.mapError( (cause) => @@ -477,40 +471,6 @@ export const make = Effect.gen(function* () { return Option.some(encoded); }); - const recoverPreviousIdentityCatalog = Effect.fn( - "desktop.connectionCatalogStore.recoverPreviousIdentityCatalog", - )(function* ( - document: EncryptedConnectionCatalogDocument, - encryptedCatalog: Uint8Array, - cause: ElectronSafeStorage.ElectronSafeStorageDecryptError | Schema.SchemaError, - ) { - const recovered = Option.filter( - yield* safeStorageIdentity.decryptWithPreviousName(encryptedCatalog), - (catalog) => Option.isSome(decodeCatalogJsonOption(catalog)), - ); - if (Option.isNone(recovered)) { - return yield* new DesktopConnectionCatalogStoreProtectionError({ - operation: "decrypt-catalog", - catalogPath, - cause, - }); - } - // Reading the previous key can wait on a Keychain prompt, so a newer catalog - // may have been saved since this one was read. - yield* Effect.gen(function* () { - const current = yield* readDocument(fileSystem, catalogPath); - if (Option.isSome(current) && current.value.encryptedCatalog === document.encryptedCatalog) { - yield* writeCatalog(recovered.value); - } - }).pipe( - writeLock.withPermits(1), - Effect.catch((error) => - Effect.logWarning("Could not re-encrypt the connection catalog.", { catalogPath, error }), - ), - ); - return recovered.value; - }); - return DesktopConnectionCatalogStore.of({ get: Effect.gen(function* () { const document = yield* readDocument(fileSystem, catalogPath); @@ -523,14 +483,13 @@ export const make = Effect.gen(function* () { const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( Effect.flatMap((encryptedCatalog) => safeStorage.decryptString(encryptedCatalog).pipe( - // CBC can decrypt with the wrong key without failing its padding check. - Effect.tap((catalog) => - environment.platform === "darwin" || environment.platform === "linux" - ? decodeNativeCatalogJson(catalog) - : Effect.void, - ), - Effect.catch((cause) => - recoverPreviousIdentityCatalog(document.value, encryptedCatalog, cause), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), ), ), ), @@ -541,11 +500,10 @@ export const make = Effect.gen(function* () { if (!(yield* encryptionAvailable)) { return false; } - yield* writeCatalog(catalog).pipe(writeLock.withPermits(1)); + yield* writeCatalog(catalog); return true; }), clear: fileSystem.remove(catalogPath, { force: true }).pipe( - writeLock.withPermits(1), Effect.catch((error) => Effect.logWarning("Could not clear the desktop connection catalog.", { catalogPath, diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e3509448515ce..ecf08255feb3f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -15,10 +15,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; -import { - isNightlyDesktopVersion, - resolveDefaultDesktopUpdateChannel, -} from "../updates/updateChannels.ts"; +import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; import type { OtlpProtocol } from "@t3tools/shared/observability"; export interface MakeDesktopEnvironmentInput { @@ -113,13 +110,6 @@ export function resolveDesktopAppBranding(input: { }; } -// Keep Chromium's encryption identity on the names shipped before branding changed. -export function resolveDesktopSafeStorageName(appVersion: string): string { - return resolveDefaultDesktopUpdateChannel(appVersion) === "nightly" - ? "T3 Code (Nightly)" - : "T3 Code (Alpha)"; -} - function normalizeDesktopArch(arch: string): DesktopRuntimeArch { if (arch === "arm64") return "arm64"; if (arch === "x64") return "x64"; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index c4e13b4de524c..1612c7bbaac98 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -11,8 +11,6 @@ const { hasSwitchMock, registerSchemesMock, setDesktopNameMock, - setNameMock, - appState, mkdirSyncMock, readFileSyncMock, writeFileSyncMock, @@ -24,8 +22,6 @@ const { hasSwitchMock: vi.fn(), registerSchemesMock: vi.fn(), setDesktopNameMock: vi.fn(), - setNameMock: vi.fn(), - appState: { version: "0.0.37", isPackaged: true }, mkdirSyncMock: vi.fn(), readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), @@ -36,11 +32,8 @@ const { vi.mock("electron", () => ({ app: { setDesktopName: setDesktopNameMock, - setName: setNameMock, - getVersion: () => appState.version, - get isPackaged() { - return appState.isPackaged; - }, + getVersion: () => "0.0.37", + isPackaged: true, getAppPath: () => "/tmp/.mount_T3/resources/app.asar", commandLine: { appendSwitch: appendSwitchMock, @@ -78,9 +71,6 @@ describe("DesktopPreReadyPlatform", () => { hasSwitchMock.mockReset(); registerSchemesMock.mockReset(); setDesktopNameMock.mockReset(); - setNameMock.mockReset(); - appState.version = "0.0.37"; - appState.isPackaged = true; mkdirSyncMock.mockReset(); readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); @@ -276,37 +266,6 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); - it.effect.each([ - { platform: "linux" as const, version: "0.1.0", isPackaged: true }, - { platform: "darwin" as const, version: "0.1.1-preview.20261017.3", isPackaged: false }, - { platform: "win32" as const, version: "0.1.0", isPackaged: true }, - ])( - "preserves the $platform encryption identity before startup yields", - ({ platform, version, isPackaged }) => { - appState.version = version; - appState.isPackaged = isPackaged; - getSwitchValueMock.mockReturnValue(""); - let appName = "T3 Code (Beta)"; - setNameMock.mockImplementation((name: string) => { - appName = name; - }); - return Effect.scoped( - Effect.gen(function* () { - const nameBeforeYield = Promise.resolve().then(() => appName); - yield* Layer.build( - DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, platform)), - ), - ); - assert.equal( - yield* Effect.promise(() => nameBeforeYield), - platform === "win32" ? "T3 Code (Beta)" : "T3 Code (Alpha)", - ); - }), - ); - }, - ); - it.effect("keeps startup available when the early desktop entry cannot be written", () => { getSwitchValueMock.mockReturnValue(""); mkdirSyncMock.mockImplementation(() => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 275367bab92ab..848fcfca0a73a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -10,7 +10,7 @@ import * as Electron from "electron"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; -import { resolveDesktopAppBranding, resolveDesktopSafeStorageName } from "./DesktopEnvironment.ts"; +import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -54,11 +54,6 @@ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { - // Chromium reads the safeStorage key's name before startup first yields. - // DesktopAppIdentity's later rename only changes the branded app name. - if (platform === "darwin" || platform === "linux") { - Electron.app.setName(resolveDesktopSafeStorageName(Electron.app.getVersion())); - } const linuxPasswordStoreCommandLine = platform === "linux" ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") diff --git a/apps/desktop/src/app/DesktopSafeStorageIdentity.ts b/apps/desktop/src/app/DesktopSafeStorageIdentity.ts deleted file mode 100644 index d02fdb19f650c..0000000000000 --- a/apps/desktop/src/app/DesktopSafeStorageIdentity.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { ChildProcessSpawner } from "effect/process"; - -import { decryptChromiumValue } from "../preview/BrowserImport/ChromiumCookies.ts"; -import { resolveChromiumKeys } from "../preview/BrowserImport/ChromiumKeys.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -export class DesktopSafeStorageIdentity extends Context.Service< - DesktopSafeStorageIdentity, - { - readonly decryptWithPreviousName: ( - encrypted: Uint8Array, - ) => Effect.Effect>; - } ->()("@t3tools/desktop/app/DesktopSafeStorageIdentity") {} - -const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const services = yield* Effect.context(); - - const decryptWithPreviousName = Effect.fn("desktop.safeStorageIdentity.decryptWithPreviousName")( - function* (encrypted: Uint8Array) { - if (environment.platform !== "darwin" && environment.platform !== "linux") { - return Option.none(); - } - if ( - DesktopEnvironment.resolveDesktopSafeStorageName(environment.appVersion) !== - "T3 Code (Alpha)" - ) { - return Option.none(); - } - // Cookie imports also accept plaintext; safeStorage requires an OSCrypt prefix. - const prefix = Buffer.from(encrypted.subarray(0, 3)).toString("latin1"); - if (prefix !== (environment.platform === "darwin" ? "v10" : "v11")) { - return Option.none(); - } - const keys = yield* resolveChromiumKeys({ - platform: environment.platform, - keychainService: "T3 Code (Beta) Safe Storage", - keychainAccount: "T3 Code (Beta)", - linuxSecretApplication: "T3 Code (Beta)", - }).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not read the Beta safe storage key.", { - reason: error.reason, - }), - ), - Effect.option, - ); - return Option.flatMapNullishOr(keys, (material) => - decryptChromiumValue(encrypted, material, "", undefined, environment.platform), - ); - }, - Effect.provide(services), - ); - - return DesktopSafeStorageIdentity.of({ decryptWithPreviousName }); -}); - -export const layer = Layer.effect(DesktopSafeStorageIdentity, make); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e9477cd322bdb..a089720b6c044 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -38,7 +38,6 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; -import * as DesktopSafeStorageIdentity from "./app/DesktopSafeStorageIdentity.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; @@ -147,10 +146,7 @@ const layerDesktopFoundation = Layer.mergeAll( DesktopLegacyLocalStorage.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, - DesktopConnectionCatalogStore.layer.pipe( - Layer.provideMerge(DesktopSavedEnvironments.layer), - Layer.provide(DesktopSafeStorageIdentity.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), - ), + DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, DesktopObservability.layer, DesktopRendererHistory.layer, From 2d1119681a6d92de3ad7a0d6a0cf3d7f3301bf22 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 01:42:33 -0700 Subject: [PATCH 002/146] revert: "fix(web): pinned thread marker stays put when hovering the row" (#11122) (#22223) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0c4b6f5a08789..b2c355bda4202 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2213,7 +2213,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onPointerDown={handleActionPointerDown} className={cn( "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", - isWoke && "static", + isWoke && "group-any-hover/sidebar-row:static", )} > @@ -2230,7 +2230,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onPointerDown={handleActionPointerDown} className={cn( "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", - isWoke && "static", + isWoke && "group-any-hover/sidebar-row:static", )} /> } @@ -2247,7 +2247,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onPointerDown={handleActionPointerDown} className={cn( "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", - isWoke && "static", + isWoke && "group-any-hover/sidebar-row:static", )} > @@ -2315,13 +2315,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {pinIndicator} - {/* Reserve both states' widths so revealing actions cannot move the pin or Woke label. */} {sortable?.isDragging ? ( dragDestination ) : ( @@ -2329,9 +2328,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( isWokeStatus ? "pointer-events-auto" - : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:opacity-0", - "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity [grid-area:1/1]", - snoozeMenuOpen && "pointer-events-none opacity-0", + : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:absolute group-any-hover/sidebar-row:right-0 group-any-hover/sidebar-row:opacity-0", + "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity", + snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", )} > {topStatus ? ( @@ -2402,9 +2401,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP // would keep the controls pinned over the status label // once the pointer moves away (e.g. after a failed // settle) instead of cross-fading back. - "pointer-events-none flex items-stretch justify-self-end opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", - isWokeStatus ? "[grid-area:1/2]" : "[grid-area:1/1]", - snoozeMenuOpen && "pointer-events-auto opacity-100", + "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:static group-any-hover/sidebar-row:opacity-100", + snoozeMenuOpen && "pointer-events-auto static opacity-100", )} > {hasUnsentDraft ? ( From 8fc72daee4fd63615e2c1eb2dcbe070f20ec907b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 01:45:45 -0700 Subject: [PATCH 003/146] revert: fourteen unverified desktop fixes from the takeover campaign (#22222) Co-authored-by: Julius Marminge --- apps/desktop/src/app/DesktopApp.ts | 7 +- apps/desktop/src/app/DesktopClerk.test.ts | 70 +--- apps/desktop/src/app/DesktopClerk.ts | 14 - apps/desktop/src/app/DesktopLifecycle.test.ts | 119 +----- apps/desktop/src/app/DesktopLifecycle.ts | 203 +++++---- .../src/app/DesktopLinuxUrlHandler.test.ts | 118 ++---- .../desktop/src/app/DesktopLinuxUrlHandler.ts | 8 +- .../src/app/DesktopPreReadyPlatform.test.ts | 90 +--- .../src/app/DesktopPreReadyPlatform.ts | 13 +- apps/desktop/src/app/DesktopRuntime.ts | 11 - .../src/backend/DesktopBackendManager.test.ts | 31 +- .../src/backend/DesktopBackendManager.ts | 5 +- .../src/electron/ElectronUpdater.test.ts | 310 +------------- apps/desktop/src/electron/ElectronUpdater.ts | 54 --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 - apps/desktop/src/ipc/methods/window.ts | 105 +++-- apps/desktop/src/ipc/methods/wsl.test.ts | 37 -- apps/desktop/src/ipc/methods/wsl.ts | 6 +- apps/desktop/src/main.ts | 4 +- apps/desktop/src/preload.ts | 25 +- apps/desktop/src/preloadSnapshot.test.ts | 45 -- apps/desktop/src/preloadSnapshot.ts | 23 - .../BrowserImport/ChromiumCookies.test.ts | 95 +++-- .../preview/BrowserImport/ChromiumCookies.ts | 6 +- apps/desktop/src/preview/Manager.test.ts | 394 +----------------- apps/desktop/src/preview/Manager.ts | 103 +---- .../src/settings/DesktopAppSettings.test.ts | 13 +- .../src/settings/DesktopAppSettings.ts | 4 +- .../src/shell/DesktopShellEnvironment.test.ts | 132 +----- .../src/shell/DesktopShellEnvironment.ts | 41 +- .../desktop/src/snapShot/ActiveWindow.test.ts | 141 +------ apps/desktop/src/snapShot/ActiveWindow.ts | 87 ++-- .../src/updates/DesktopUpdates.test.ts | 110 ----- apps/desktop/src/updates/DesktopUpdates.ts | 89 +--- .../desktop/src/updates/updatesTestHarness.ts | 58 +-- apps/desktop/src/window/DesktopWindow.test.ts | 214 +--------- apps/desktop/src/window/DesktopWindow.ts | 21 +- apps/web/src/components/AppSidebarLayout.tsx | 1 - apps/web/src/components/LegacySidebar.tsx | 3 +- .../settings/ConnectionsSettings.tsx | 13 +- .../src/components/sidebar/SidebarChrome.tsx | 21 +- apps/web/src/components/ui/sidebar.tsx | 16 +- apps/web/src/connection/desktopLocal.test.ts | 54 +-- apps/web/src/connection/desktopLocal.ts | 64 ++- apps/web/src/connection/platform.ts | 2 +- apps/web/src/hooks/useMediaQuery.ts | 4 + apps/web/src/workspaceTitlebar.ts | 2 +- docs/internals/performance-regressions.md | 5 +- packages/contracts/src/ipc.ts | 2 - 49 files changed, 416 insertions(+), 2579 deletions(-) delete mode 100644 apps/desktop/src/app/DesktopRuntime.ts delete mode 100644 apps/desktop/src/preloadSnapshot.test.ts delete mode 100644 apps/desktop/src/preloadSnapshot.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7f5bdb8081774..68febbc6386ff 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -153,8 +153,7 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances" const instances = yield* pool.list; yield* Effect.forEach( instances, - // Exceeds the backend's 30s graceful termination and output drain budget. - (instance) => instance.stop({ timeout: Duration.seconds(45) }), + (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); }, @@ -240,7 +239,9 @@ const bootstrap = Effect.gen(function* () { } if (!(yield* Ref.get(state.quitting))) { - yield* desktopWindow.showConnectingSplash; + if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + yield* desktopWindow.showConnectingSplash; + } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); yield* appActivation.start.pipe( diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index e415a15489dcb..58fff8297898b 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,7 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. import * as NodeHttp from "node:http"; -import * as NodePath from "node:path"; -import * as PlatformNodePath from "@effect/platform-node/NodePath"; +import * as NodePath from "@effect/platform-node/NodePath"; import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -60,15 +59,11 @@ const layerDesktopClerk = ( copyText: () => Effect.void, }, listeners = new Map void>(), - isPackaged = false, ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", - isPackaged, - dirname: platform === "win32" ? String.raw`C:\T3 Code\dist-electron` : "/tmp/dist-electron", - path: platform === "win32" ? NodePath.win32 : NodePath.posix, platform, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -100,7 +95,7 @@ const layerDesktopClerk = ( return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( - PlatformNodePath.layerPosix, + NodePath.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(ElectronShell.ElectronShell, shell), @@ -117,67 +112,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - it.effect.each([ - { platform: "win32", isPackaged: false, isDevelopment: false, registers: true }, - { platform: "win32", isPackaged: false, isDevelopment: true, registers: false }, - { platform: "win32", isPackaged: true, isDevelopment: false, registers: false }, - { platform: "darwin", isPackaged: false, isDevelopment: false, registers: false }, - { platform: "linux", isPackaged: false, isDevelopment: false, registers: false }, - ] as const)( - "registers an entry point only for unpackaged non-development Windows ($platform, packaged=$isPackaged, development=$isDevelopment)", - ({ platform, isPackaged, isDevelopment, registers }) => { - const events: string[] = []; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementation(() => { - events.push("bridge"); - return { cleanup: vi.fn() }; - }); - const setAsDefaultProtocolClient = vi.fn(() => - Effect.sync(() => { - events.push("register"); - return true; - }), - ); - const electronApp = { - on: () => Effect.void, - setAsDefaultProtocolClient, - } as unknown as ElectronApp.ElectronApp["Service"]; - - return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - yield* clerk.configure; - - assert.deepEqual( - setAsDefaultProtocolClient.mock.calls, - registers - ? [["t3code", process.execPath, [String.raw`C:\T3 Code\dist-electron\main.cjs`]]] - : [], - ); - assert.deepEqual(events, registers ? ["bridge", "register"] : ["bridge"]); - }).pipe( - Effect.scoped, - Effect.provide( - layerDesktopClerk( - isDevelopment, - [], - platform, - undefined, - undefined, - undefined, - isPackaged, - ), - ), - Effect.provideService(ElectronApp.ElectronApp, electronApp), - Effect.provideService( - ElectronWindow.ElectronWindow, - {} as ElectronWindow.ElectronWindow["Service"], - ), - Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), - Effect.provideService(HostProcess.Arguments, ["electron"]), - ); - }, - ); - it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); const events: string[] = []; diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index f4de0b4ebaf59..505b48194cf7c 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -151,20 +151,6 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - // Clerk's bare scheme registration omits the app entry on unpackaged Windows. - // Development callbacks also need dev server environment this command cannot restore. - if ( - !environment.isPackaged && - !environment.isDevelopment && - environment.platform === "win32" - ) { - yield* electronApp.setAsDefaultProtocolClient( - ElectronProtocol.getDesktopScheme(environment.isDevelopment), - process.execPath, - [environment.path.join(environment.dirname, "main.cjs")], - ); - } - const startProviderAuthHandoff = (value: string | undefined) => { if (!value) return false; const request = readCodexAuthHandoff(value, environment.isDevelopment); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 43fb27cac6cd1..f9f38dea3dd46 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -1,13 +1,6 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off - A real Node process verifies OS signal ownership outside Effect's process runtime. -import * as NodeChildProcess from "node:child_process"; -import * as NodeReadline from "node:readline"; -import * as NodeURL from "node:url"; - import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -110,67 +103,6 @@ function layerDesktopWindow( } describe("DesktopLifecycle", () => { - it.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "leaves SIGTERM to the lifecycle until its graceful shutdown completes", - async ({ onTestFinished }) => { - const runner = new URL("./DesktopRuntime.ts", import.meta.url).href; - const child = NodeChildProcess.spawn( - process.execPath, - [ - "--experimental-strip-types", - "--input-type=module", - "-e", - ` - import * as Effect from 'effect/Effect'; - import * as Deferred from 'effect/Deferred'; - import { runMain } from ${JSON.stringify(runner)}; - process.stdin.resume(); - let released = false; - runMain(Effect.scoped(Effect.gen(function* () { - const done = yield* Deferred.make(); - yield* Effect.addFinalizer(() => Effect.sync(() => console.log(released ? 'CLEANED' : 'PREMATURE'))); - process.once('SIGTERM', () => { - console.log('STOPPING'); - process.stdin.once('data', () => { - released = true; - void Effect.runPromise(Deferred.succeed(done, undefined)).then(() => process.stdin.destroy()); - }); - }); - console.log('READY'); - yield* Deferred.await(done); - }))); - `, - ], - { - cwd: NodeURL.fileURLToPath(new URL("../../", import.meta.url)), - stdio: ["pipe", "pipe", "pipe"], - }, - ); - const exited = new Promise((resolve, reject) => { - child.once("exit", (code, signal) => resolve([code, signal])); - child.once("error", reject); - }); - const lines: string[] = []; - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - onTestFinished(async () => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - await exited; - } - }); - for await (const line of NodeReadline.createInterface({ input: child.stdout })) { - lines.push(line); - if (line === "READY") child.kill("SIGTERM"); - if (line === "STOPPING") child.stdin.write("finish"); - } - assert.deepEqual(await exited, [0, null], stderr); - assert.deepEqual(lines, ["READY", "STOPPING", "CLEANED"]); - }, - ); - it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray)( "lets the updater's quit event proceed on %s", (platform) => { @@ -226,18 +158,12 @@ describe("DesktopLifecycle", () => { }, ); - it.effect("destroys windows and retains signal ownership until backend shutdown completes", () => + it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { const appListeners = new Map void>(); const shutdownRequested = yield* Deferred.make(); const allowShutdown = yield* Deferred.make(); const quitRequested = yield* Deferred.make(); - const backendStopping = yield* Deferred.make(); - const finishBackendShutdown = yield* Deferred.make(); - const originalSignals = { - SIGINT: process.listeners("SIGINT"), - SIGTERM: process.listeners("SIGTERM"), - }; const events: string[] = []; const quit = Effect.sync(() => { @@ -278,47 +204,20 @@ describe("DesktopLifecycle", () => { yield* Effect.scoped( Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const shutdown = yield* DesktopShutdown.DesktopShutdown; - const program = yield* Effect.scoped( - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Deferred.succeed(backendStopping, undefined).pipe( - Effect.andThen(Deferred.await(finishBackendShutdown)), - Effect.andThen(shutdown.markComplete), - ), - ); - yield* lifecycle.register; - const event = { preventDefault: () => undefined } as Electron.Event; - appListeners.get("before-quit")?.(event); - yield* shutdown.awaitRequest; - }), - ).pipe(Effect.forkScoped); - - yield* Deferred.await(backendStopping); - yield* Effect.addFinalizer(() => - Deferred.succeed(finishBackendShutdown, undefined).pipe(Effect.asVoid), - ); + yield* lifecycle.register; - for (const signal of ["SIGINT", "SIGTERM"] as const) { - const listeners = process - .listeners(signal) - .filter((listener) => !originalSignals[signal].includes(listener)); - assert.lengthOf(listeners, 1, `${signal} must remain owned during backend cleanup`); - listeners[0]?.(signal); - } - assert.deepEqual(events, ["flush", "destroy", "request"]); - assert.equal(appListeners.size, 0, "Electron listeners still follow the program scope"); - assert.isFalse(yield* shutdown.isComplete); + const event = { preventDefault: () => undefined } as Electron.Event; + appListeners.get("before-quit")?.(event); - yield* Deferred.succeed(finishBackendShutdown, undefined); - yield* Fiber.join(program); + yield* Deferred.await(shutdownRequested); + const eventsBeforeCleanup = [...events]; + yield* Deferred.succeed(allowShutdown, undefined); yield* Deferred.await(quitRequested); + + assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]); assert.deepEqual(events, ["flush", "destroy", "request", "quit"]); }), ).pipe(Effect.provide(layer)); - - assert.deepEqual(process.listeners("SIGINT"), originalSignals.SIGINT); - assert.deepEqual(process.listeners("SIGTERM"), originalSignals.SIGTERM); }), ); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 6a464021171f5..924341781aa6b 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -160,113 +160,108 @@ function quitFromSignal( } /** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const signalScope = yield* Scope.Scope; - return DesktopLifecycle.of({ - relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { - const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const state = yield* DesktopState.DesktopState; - yield* logLifecycleInfo("desktop relaunch requested", { reason }); - yield* Effect.gen(function* () { - yield* Effect.yieldNow; - yield* Ref.set(state.quitting, true); - yield* requestDesktopShutdownAndWait(); - if (environment.isDevelopment) { - yield* electronApp.exit(75); - return; - } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - yield* electronApp.exit(0); - }).pipe( - Effect.catchCause((cause) => { - const error = new DesktopLifecycleRelaunchError({ reason, cause }); - return logLifecycleError(error.message, { error }); - }), - Effect.forkDetach, - Effect.asVoid, - ); - }), - register: Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const electronApp = yield* ElectronApp.ElectronApp; - const electronTheme = yield* ElectronTheme.ElectronTheme; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const context = yield* Effect.context(); - const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - let updaterQuitAllowed = false; - yield* electronTheme.onUpdated(() => { - void runEffect( - desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), - ); +export const make = DesktopLifecycle.of({ + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const state = yield* DesktopState.DesktopState; + yield* logLifecycleInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), }); - yield* electronApp.onBeforeQuitForUpdate(() => { - // Electron's updater owns the remaining quit/install/relaunch sequence. - // Cancelling the following app "before-quit" event breaks that sequence, - // most visibly on macOS where the native updater performs the relaunch. - updaterQuitAllowed = true; - // This event is synchronous and the updater's quit proceeds as soon as - // the listener returns, so a forked destroyAll would race the quit - // and windows could still be open when the process exits (visible on - // macOS). Destroy them inline. - Effect.runSyncWith(context)( - electronWindow.destroyAll.pipe( - Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")), - Effect.catchCause((cause) => - logLifecycleError("failed to destroy windows before updater quit", { cause }), - ), - Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => { + const error = new DesktopLifecycleRelaunchError({ reason, cause }); + return logLifecycleError(error.message, { error }); + }), + Effect.forkDetach, + Effect.asVoid, + ); + }), + register: Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = Effect.runPromiseWith(context); + let quitAllowed = false; + let updaterQuitAllowed = false; + yield* electronTheme.onUpdated(() => { + void runEffect( + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), + ); + }); + yield* electronApp.onBeforeQuitForUpdate(() => { + // Electron's updater owns the remaining quit/install/relaunch sequence. + // Cancelling the following app "before-quit" event breaks that sequence, + // most visibly on macOS where the native updater performs the relaunch. + updaterQuitAllowed = true; + // This event is synchronous and the updater's quit proceeds as soon as + // the listener returns, so a forked destroyAll would race the quit + // and windows could still be open when the process exits (visible on + // macOS). Destroy them inline. + Effect.runSyncWith(context)( + electronWindow.destroyAll.pipe( + Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")), + Effect.catchCause((cause) => + logLifecycleError("failed to destroy windows before updater quit", { cause }), ), - ); - }); - yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed || updaterQuitAllowed, - () => { - quitAllowed = true; - }, - ); - }); - yield* electronApp.on("activate", () => { - void runEffect( - Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - if (yield* Ref.get(state.quitting)) return; - yield* desktopWindow.activate; - }).pipe(Effect.withSpan("desktop.lifecycle.activate")), - ); + Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), + ), + ); + }); + yield* electronApp.on("before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed || updaterQuitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* electronApp.on("activate", () => { + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + if (yield* Ref.get(state.quitting)) return; + yield* desktopWindow.activate; + }).pipe(Effect.withSpan("desktop.lifecycle.activate")), + ); + }); + yield* electronApp.on("window-all-closed", () => { + void runEffect( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* app.quit; + } + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), + ); + }); + + if (environment.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); }); - yield* electronApp.on("window-all-closed", () => { - void runEffect( - Effect.gen(function* () { - const app = yield* ElectronApp.ElectronApp; - const state = yield* DesktopState.DesktopState; - if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { - yield* app.quit; - } - }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), - ); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); }); - - if (environment.platform !== "win32") { - // The layer outlives program finalizers, so repeated signals stay owned - // while backends stop. Electron listeners still follow the program scope. - yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); - }).pipe(Scope.provide(signalScope)); - yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); - }).pipe(Scope.provide(signalScope)); - } - }).pipe(Effect.withSpan("desktop.lifecycle.register")), - }); + } + }).pipe(Effect.withSpan("desktop.lifecycle.register")), }); -export const layer = Layer.effect(DesktopLifecycle, make); +export const layer = Layer.succeed(DesktopLifecycle, make); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 41f20ef984db5..295f63eafe481 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -22,31 +22,8 @@ interface RecordedRegistration { readonly files: Array<{ readonly path: string; readonly content: string }>; readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; readonly copies: Array<{ readonly source: string; readonly destination: string }>; - readonly binaryReads: string[]; - readonly binaryWrites: Array<{ - readonly path: string; - readonly bytes: Uint8Array; - readonly mode: number | undefined; - }>; } -const iconSource = "/tmp/.mount_T3/resources/app.asar/apps/desktop/prod-resources/icon.png"; -const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; -const iconBytes = new Uint8Array([0x89, 0x50, 0x00, 0xff, 0x80, 0x0a]); -const currentDesktopEntry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code (Alpha)", - execTarget: "/home/alice/Applications/T3-Code.AppImage", - scheme: "t3code", - iconPath, -}); -const registrationCommands = [ - { command: "update-desktop-database", args: ["/home/alice/.local/share/applications"] }, - { - command: "xdg-mime", - args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], - }, -]; - const makeEnvironment = (path: Path.Path, overrides: Record = {}) => DesktopEnvironment.DesktopEnvironment.of({ platform: "linux", @@ -87,8 +64,7 @@ const layerHandler = ( readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; readonly iconSource?: string; - readonly existingIcon?: Uint8Array; - readonly iconReadError?: PlatformError.PlatformError; + readonly iconCopyError?: PlatformError.PlatformError; } = {}, ) => DesktopLinuxUrlHandler.layer.pipe( @@ -111,34 +87,11 @@ const layerHandler = ( }), FileSystem.layerNoop({ copyFile: (source, destination) => - Effect.sync(() => { - recorded.copies.push({ source, destination }); - }), - readFile: (path) => - Effect.gen(function* () { - recorded.binaryReads.push(path); - if (path === input.iconSource) { - if (input.iconReadError) return yield* input.iconReadError; - return new Uint8Array(iconBytes); - } - if (path === iconPath && input.existingIcon !== undefined) { - return input.existingIcon; - } - return yield* PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: path, - }); - }), - writeFile: (path, bytes, options) => - Effect.sync(() => { - recorded.binaryWrites.push({ - path, - bytes: new Uint8Array(bytes), - mode: options?.mode, - }); - }), + input.iconCopyError + ? Effect.fail(input.iconCopyError) + : Effect.sync(() => { + recorded.copies.push({ source, destination }); + }), readFileString: () => Effect.succeed(input.existingEntry ?? ""), makeDirectory: (path) => Effect.sync(() => { @@ -203,8 +156,6 @@ const emptyRecording = (): RecordedRegistration => ({ files: [], commands: [], copies: [], - binaryReads: [], - binaryWrites: [], }); describe("DesktopLinuxUrlHandler", () => { @@ -355,57 +306,42 @@ describe("DesktopLinuxUrlHandler", () => { it.effect("installs a persistent icon even when the desktop entry is already current", () => { const recorded = emptyRecording(); + const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; return Effect.gen(function* () { yield* runRegister(recorded, { - iconSource, - existingEntry: currentDesktopEntry, - }); - assert.deepEqual(recorded.files, []); - assert.deepEqual(recorded.copies, []); - assert.deepEqual(recorded.binaryReads, [iconSource, iconPath]); - assert.deepEqual(recorded.binaryWrites, [{ path: iconPath, bytes: iconBytes, mode: 0o600 }]); - assert.deepEqual(recorded.commands, registrationCommands); - }); - }); - - it.effect("refreshes a stale icon and skips equal bytes on the next registration", () => { - const recorded = emptyRecording(); - return Effect.gen(function* () { - yield* runRegister(recorded, { - iconSource, - existingEntry: currentDesktopEntry, - existingIcon: new Uint8Array([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]), - }); - assert.deepEqual(recorded.binaryWrites, [{ path: iconPath, bytes: iconBytes, mode: 0o600 }]); - yield* runRegister(recorded, { - iconSource, - existingEntry: currentDesktopEntry, - existingIcon: new Uint8Array(recorded.binaryWrites[0]!.bytes), + iconSource: "/tmp/.mount_T3/resources/icon.png", + existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: "/home/alice/Applications/T3-Code.AppImage", + scheme: "t3code", + iconPath, + }), }); - assert.equal(recorded.binaryWrites.length, 1); - assert.deepEqual(recorded.copies, []); assert.deepEqual(recorded.files, []); - assert.deepEqual(recorded.commands, [...registrationCommands, ...registrationCommands]); + assert.deepEqual(recorded.copies, [ + { source: "/tmp/.mount_T3/resources/icon.png", destination: iconPath }, + ]); + assert.equal(recorded.commands.at(-1)?.command, "xdg-mime"); }); }); - it.effect("still registers the handler when reading its icon fails", () => { + it.effect("still registers the handler when copying its icon fails", () => { const recorded = emptyRecording(); return Effect.gen(function* () { yield* runRegister(recorded, { - iconSource, - iconReadError: PlatformError.systemError({ + iconSource: "/tmp/.mount_T3/resources/icon.png", + iconCopyError: PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", - method: "readFile", - description: "unreadable bundled icon", + method: "copyFile", + description: "read-only icon directory", }), }); - assert.deepEqual(recorded.binaryReads, [iconSource]); - assert.deepEqual(recorded.binaryWrites, []); - assert.deepEqual(recorded.copies, []); assert.equal(recorded.files.length, 1); - assert.deepEqual(recorded.commands, registrationCommands); + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); }); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 83c595ea678cf..e03418964e33b 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -301,16 +301,12 @@ export const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { png } = yield* assets.iconPaths; if (Option.isNone(png)) return; - // Electron's copyFile extracts ASAR entries to temporary files it never removes. - const icon = yield* fileSystem.readFile(png.value); - const current = yield* fileSystem.readFile(iconPath).pipe(Effect.option); - if (Option.isSome(current) && sameBytes(current.value, icon)) return; // The AppImage mount is temporary; the chooser needs the icon after exit. yield* fileSystem.makeDirectory(iconsDir, { recursive: true }); - yield* fileSystem.writeFile(iconPath, icon, { mode: 0o600 }); + yield* fileSystem.copyFile(png.value, iconPath); }).pipe( Effect.catch((error) => - logWarning("URL handler icon install failed", { iconPath, category: error.reason._tag }), + logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), ), ); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 1612c7bbaac98..d27251d7b2f92 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -12,10 +12,10 @@ const { registerSchemesMock, setDesktopNameMock, mkdirSyncMock, - readFileSyncMock, writeFileSyncMock, copyFileSyncMock, execFileSyncMock, + readFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -23,10 +23,10 @@ const { registerSchemesMock: vi.fn(), setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), - readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ @@ -59,11 +59,6 @@ vi.mock("node:child_process", () => ({ import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; -const iconSource = "/tmp/.mount_T3/resources/app.asar/apps/desktop/prod-resources/icon.png"; -const iconPath = "/xdg/icons/com.t3tools.T3Code.desktop.png"; -const desktopEntryPath = "/xdg/applications/com.t3tools.T3Code.desktop"; -const iconBytes = Buffer.from([0x89, 0x50, 0x00, 0xff, 0x80, 0x0a]); - describe("DesktopPreReadyPlatform", () => { beforeEach(() => { appendSwitchMock.mockReset(); @@ -72,15 +67,10 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); - readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); - readFileSyncMock.mockReset().mockImplementation((path: string) => { - if (path.endsWith("/desktop-settings.json")) return "{}"; - if (path === iconSource) return Buffer.from(iconBytes); - throw new Error(`missing file: ${path}`); - }); + readFileSyncMock.mockReset().mockReturnValue("{}"); }); it.effect("persists Linux scaling synchronously across relaunches and settings rewrites", () => { @@ -222,13 +212,14 @@ describe("DesktopPreReadyPlatform", () => { let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; let iconInstalled = false; + copyFileSyncMock.mockImplementation((_source: string, destination: string) => { + iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; + }); setDesktopNameMock.mockImplementation((name: string) => { desktopName = name; }); - writeFileSyncMock.mockImplementation((path: string, contents: string | Buffer) => { - if (path === iconPath) - iconInstalled = Buffer.isBuffer(contents) && contents.equals(iconBytes); - if (path === desktopEntryPath && typeof contents === "string") desktopEntry = contents; + writeFileSyncMock.mockImplementation((path: string, contents: string) => { + if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; }); return Effect.scoped( @@ -253,15 +244,6 @@ describe("DesktopPreReadyPlatform", () => { "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", ); assert.isTrue(identity.iconInstalled); - assert.deepEqual( - readFileSyncMock.mock.calls.filter(([path]) => path === iconSource), - [[iconSource]], - ); - assert.deepEqual( - writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath), - [[iconPath, iconBytes, { mode: 0o600 }]], - ); - assert.equal(copyFileSyncMock.mock.calls.length, 0); }), ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); @@ -278,62 +260,18 @@ describe("DesktopPreReadyPlatform", () => { ); }); - it.effect("refreshes a stale icon once across repeated launches", () => { - vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); + it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => { getSwitchValueMock.mockReturnValue(""); - let installedIcon = Buffer.from([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]); - readFileSyncMock.mockImplementation((path: string) => { - if (path === iconSource) return Buffer.from(iconBytes); - if (path === iconPath) return Buffer.from(installedIcon); - return "{}"; + copyFileSyncMock.mockImplementation(() => { + throw new Error("missing bundled icon"); }); - writeFileSyncMock.mockImplementation((path: string, contents: Buffer) => { - if (path === iconPath) installedIcon = Buffer.from(contents); - }); - return Effect.gen(function* () { yield* DesktopPreReadyPlatform.make; - assert.deepEqual(installedIcon, iconBytes); - yield* DesktopPreReadyPlatform.make; - assert.deepEqual( - writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath), - [[iconPath, iconBytes, { mode: 0o600 }]], - ); - assert.equal(copyFileSyncMock.mock.calls.length, 0); - assert.equal( - writeFileSyncMock.mock.calls.filter(([path]) => path === desktopEntryPath).length, - 2, - ); - assert.equal(setDesktopNameMock.mock.calls.length, 2); - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); - }); - - it.effect("still prepares the portal entry when the bundled icon cannot be read", () => { - vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - getSwitchValueMock.mockReturnValue(""); - readFileSyncMock.mockImplementation((path: string) => { - if (path === iconSource) throw new Error("unreadable bundled icon"); - return "{}"; - }); - return Effect.gen(function* () { - yield* DesktopPreReadyPlatform.make; - assert.isTrue(readFileSyncMock.mock.calls.some(([path]) => path === iconSource)); - assert.equal(copyFileSyncMock.mock.calls.length, 0); - assert.equal(writeFileSyncMock.mock.calls.length, 1); - const [path, contents] = writeFileSyncMock.mock.calls[0]!; - assert.equal(path, desktopEntryPath); + const contents = writeFileSyncMock.mock.calls[0]?.[1]; assert.include(contents, "MimeType=x-scheme-handler/t3code;"); - assert.include(contents, `Icon=${iconPath}`); + assert.include(contents, "Icon="); assert.equal(setDesktopNameMock.mock.calls.length, 1); - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")); }); it.effect( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 848fcfca0a73a..58670439a5255 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -92,21 +92,14 @@ export const make = Effect.gen(function* () { : undefined; if (iconPath !== undefined) { try { - // Electron's copyFile extracts ASAR entries to temporary files it never removes. - const icon = NodeFS.readFileSync( + NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); + NodeFS.copyFileSync( NodePath.posix.join( Electron.app.getAppPath(), "apps/desktop/prod-resources/icon.png", ), + iconPath, ); - let current: Buffer | null = null; - try { - current = NodeFS.readFileSync(iconPath); - } catch {} - if (current === null || !current.equals(icon)) { - NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); - NodeFS.writeFileSync(iconPath, icon, { mode: 0o600 }); - } } catch {} } NodeFS.writeFileSync( diff --git a/apps/desktop/src/app/DesktopRuntime.ts b/apps/desktop/src/app/DesktopRuntime.ts deleted file mode 100644 index 17fc96b93b6e0..0000000000000 --- a/apps/desktop/src/app/DesktopRuntime.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as Runtime from "effect/Runtime"; - -// DesktopLifecycle owns signals and waits for backend shutdown before quitting -// Electron. NodeRuntime's competing signal handler interrupts that sequence. -export const runMain = Runtime.makeRunMain(({ fiber, teardown }) => { - fiber.addObserver((exit) => { - teardown(exit, (code) => { - if (code !== 0) process.exit(code); - }); - }); -}); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 725779de47688..eaf7fc3084567 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -262,7 +262,7 @@ describe("DesktopBackendManager", () => { assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); assert.equal( Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), - 30_000, + 5_000, ); assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); @@ -1546,15 +1546,14 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("stopAllPoolInstances allows slow cleanup but bounds hung backends", () => + it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () => Effect.scoped( Effect.gen(function* () { const teardownStarted = yield* Queue.unbounded(); const teardownFinished = yield* Queue.unbounded(); - const allowSlowTeardown = yield* Deferred.make(); - const allowHungTeardown = yield* Deferred.make(); + const allowTeardown = yield* Deferred.make(); - const makeInstance = (name: string, allowTeardown: Deferred.Deferred) => + const makeInstance = (name: string) => makeTestInstance({ spawnerLayer: Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -1576,8 +1575,8 @@ describe("DesktopBackendManager", () => { httpClientLayer: layerHttpClient(() => Effect.never), }); - const instance1 = yield* makeInstance("instance1", allowSlowTeardown); - const instance2 = yield* makeInstance("instance2", allowHungTeardown); + const instance1 = yield* makeInstance("instance1"); + const instance2 = yield* makeInstance("instance2"); yield* instance1.start; yield* instance2.start; @@ -1595,25 +1594,15 @@ describe("DesktopBackendManager", () => { ).pipe(Effect.provide(layerMockPool), Effect.forkChild); const started = yield* Queue.takeN(teardownStarted, 2); - yield* Effect.addFinalizer(() => - Deferred.succeed(allowSlowTeardown, undefined).pipe( - Effect.andThen(Deferred.succeed(allowHungTeardown, undefined)), - ), - ); assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); - yield* TestClock.adjust(Duration.seconds(6)); - assert.isUndefined(quitFiber.pollUnsafe()); - yield* Deferred.succeed(allowSlowTeardown, undefined); - assert.equal(yield* Queue.take(teardownFinished), "instance1"); - assert.isUndefined(quitFiber.pollUnsafe()); - yield* TestClock.adjust(Duration.seconds(39)); - + yield* TestClock.adjust(Duration.seconds(5)); yield* Fiber.join(quitFiber); assert.equal(yield* Queue.size(teardownFinished), 0); - yield* Deferred.succeed(allowHungTeardown, undefined); - assert.equal(yield* Queue.take(teardownFinished), "instance2"); + yield* Deferred.succeed(allowTeardown, undefined); + const finished = yield* Queue.takeN(teardownFinished, 2); + assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); }).pipe(Effect.provide(TestClock.layer())), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 50587ac26f877..e74e98a079b91 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -37,8 +37,9 @@ const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); -// Give busy backends time to stop provider sessions before forcing termination. -const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(30); +// Same as the service launcher. A killed backend leaves its agents running, +// and a busy one needs a few seconds to stop every provider session. +const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; const { logWarning: logBackendProcessWarning } = diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 969cd6fb40b62..1e005d26fdf5d 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,27 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Build real electron-updater disk fixtures with its SHA-512 encoding. -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeCrypto from "node:crypto"; -import * as NodeEvents from "node:events"; -import * as NodeModule from "node:module"; -import * as NodePath from "node:path"; -import * as Deferred from "effect/Deferred"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import { AppImageUpdater } from "electron-updater/out/AppImageUpdater.js"; -import { DebUpdater } from "electron-updater/out/DebUpdater.js"; -import { MacUpdater } from "electron-updater/out/MacUpdater.js"; -import { GenericProvider } from "electron-updater/out/providers/GenericProvider.js"; -import type { UpdateDownloadedEvent } from "electron-updater/out/types.js"; -import { NsisUpdater } from "electron-updater/out/NsisUpdater.js"; -import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; -import { flushCallbacks, makeHarness } from "../updates/updatesTestHarness.ts"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import type { VerifyUpdateCodeSignature } from "electron-updater"; import { beforeEach, vi } from "vite-plus/test"; -const { autoUpdaterMock, updaterOverride } = vi.hoisted(() => ({ - updaterOverride: { current: undefined as import("electron-updater").AppUpdater | undefined }, +const { autoUpdaterMock } = vi.hoisted(() => ({ autoUpdaterMock: { allowDowngrade: false, allowPrerelease: false, @@ -32,8 +13,6 @@ const { autoUpdaterMock, updaterOverride } = vi.hoisted(() => ({ fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - verifyUpdateCodeSignature: undefined as VerifyUpdateCodeSignature | undefined, on: vi.fn(), quitAndInstall: vi.fn(), removeListener: vi.fn(), @@ -42,9 +21,7 @@ const { autoUpdaterMock, updaterOverride } = vi.hoisted(() => ({ })); vi.mock("electron-updater", () => ({ - get autoUpdater() { - return updaterOverride.current ?? autoUpdaterMock; - }, + autoUpdater: autoUpdaterMock, })); import * as ElectronUpdater from "./ElectronUpdater.ts"; @@ -66,7 +43,6 @@ describe("ElectronUpdater", () => { autoUpdaterMock.quitAndInstall.mockClear(); autoUpdaterMock.removeListener.mockClear(); autoUpdaterMock.setFeedURL.mockClear(); - autoUpdaterMock.verifyUpdateCodeSignature = undefined; }); it.effect("scopes updater event listeners", () => @@ -160,286 +136,4 @@ describe("ElectronUpdater", () => { assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); }).pipe(Effect.provide(ElectronUpdater.layer)), ); - - describe("Windows update signature check", () => { - const publisherNames = ["T3 Tools Inc"]; - const installerPath = "C:\\updater\\pending\\temp-T3-Code-x64.exe"; - // execFile marks the error killed when the verifier exceeds its 20-second timeout. - const timedOut = () => - Object.assign(new Error("Command failed: powershell.exe"), { killed: true }); - - const downloadVerifyingSignature = () => { - autoUpdaterMock.downloadUpdate.mockImplementationOnce(async () => { - const rejection = await autoUpdaterMock.verifyUpdateCodeSignature!( - publisherNames, - installerPath, - ); - // NSIS accepts only null as a successful signature check. - if (rejection !== null) throw new Error(`Invalid signature: ${rejection}`); - return []; - }); - return Effect.flatMap(ElectronUpdater.ElectronUpdater, (updater) => updater.downloadUpdate); - }; - - it.effect("retries a timed-out signature check with the same verifier and installer", () => - Effect.gen(function* () { - const verify = vi - .fn() - .mockRejectedValueOnce(timedOut()) - .mockResolvedValueOnce(null); - autoUpdaterMock.verifyUpdateCodeSignature = verify; - - yield* downloadVerifyingSignature(); - - assert.deepEqual(verify.mock.calls, [ - [publisherNames, installerPath], - [publisherNames, installerPath], - ]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("fails on invalid signatures, other errors and two timeouts on each download", () => - Effect.gen(function* () { - const cases = [ - { outcome: () => Promise.resolve("Invalid publisher"), calls: 1 }, - { outcome: () => Promise.reject(new Error("Cannot execute PowerShell")), calls: 1 }, - { outcome: () => Promise.reject(timedOut()), calls: 2 }, - ]; - for (const { outcome, calls } of cases) { - const verify = vi.fn(outcome); - autoUpdaterMock.verifyUpdateCodeSignature = verify; - - // Reusing the installed verifier must neither stack retries nor exhaust future retries. - for (let download = 1; download <= 2; download++) { - const error = yield* downloadVerifyingSignature().pipe(Effect.flip); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.equal(verify.mock.calls.length, calls * download); - } - } - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("rejects a retry when the upstream capability probe skips verification", () => - Effect.gen(function* () { - const verify = vi - .fn() - .mockRejectedValueOnce(timedOut()) - .mockImplementationOnce(async () => { - // Upstream resolves null even though its failed capability probe skips verification. - autoUpdaterMock.logger.warn( - "Cannot execute ConvertTo-Json: spawnSync powershell.exe ETIMEDOUT. Ignoring signature validation due to unsupported powershell version. Please upgrade to powershell 3 or higher.", - ); - return null; - }); - autoUpdaterMock.verifyUpdateCodeSignature = verify; - - const error = yield* downloadVerifyingSignature().pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.equal(verify.mock.calls.length, 2); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - }); -}); - -describe("installed electron-updater cache recovery", () => { - it.effect.each([ - { - name: "Intel Mac", - platform: "darwin", - Updater: MacUpdater, - selected: "x64.zip", - other: "arm64.zip", - rosetta: false, - }, - { - name: "Apple Silicon running Intel", - platform: "darwin", - Updater: MacUpdater, - selected: "arm64.zip", - other: "x64.zip", - rosetta: true, - }, - { - name: "Windows", - platform: "win32", - Updater: NsisUpdater, - selected: "x64.exe", - other: "arm64.exe", - rosetta: false, - }, - { - name: "Debian", - platform: "linux", - Updater: DebUpdater, - selected: "x64.deb", - other: "x64.AppImage", - rosetta: false, - }, - { - name: "AppImage", - platform: "linux", - Updater: AppImageUpdater, - selected: "x64.AppImage", - other: "x64.deb", - rosetta: false, - }, - ] as const)("restores only the package selected by $name", (scenario) => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped(); - const cacheDirectory = NodePath.join(directory, "custom-updater-cache", "pending"); - const configPath = NodePath.join(directory, "app-update.yml"); - yield* fs.writeFileString(configPath, "updaterCacheDirName: custom-updater-cache\n"); - yield* fs.makeDirectory(cacheDirectory, { recursive: true }); - - const require = NodeModule.createRequire(import.meta.url); - require("electron"); - const electronModule = require.cache[require.resolve("electron")]!; - const childProcess = require("node:child_process") as typeof import("node:child_process"); - const originalElectron = electronModule.exports; - const originalArch = Object.getOwnPropertyDescriptor(process, "arch")!; - yield* Effect.acquireRelease( - Effect.sync(() => { - // MacUpdater's native Squirrel bridge and host probes are OS boundaries. - electronModule.exports = { autoUpdater: new NodeEvents.EventEmitter() }; - Object.defineProperty(process, "arch", { value: "x64", configurable: true }); - return vi - .spyOn(childProcess, "execFileSync") - .mockReturnValue(scenario.rosetta ? "sysctl.proc_translated: 1" : "x86_64"); - }), - (probe) => - Effect.sync(() => { - probe.mockRestore(); - Object.defineProperty(process, "arch", originalArch); - electronModule.exports = originalElectron; - updaterOverride.current = undefined; - }), - ); - - const extensions = - scenario.platform === "darwin" - ? ["arm64.dmg", "x64.dmg", "arm64.zip", "x64.zip"] - : scenario.platform === "win32" - ? ["arm64.exe", "x64.exe"] - : ["arm64.AppImage", "x64.AppImage", "arm64.deb", "x64.deb"]; - const files = extensions.map((extension) => { - const url = `T3-Code-1.2.4-${extension}`; - return { url, sha512: NodeCrypto.createHash("sha512").update(url).digest("base64") }; - }); - const info = { - version: "1.2.4", - releaseDate: "2026-10-01T00:00:00Z", - files, - path: files[0]!.url, - sha512: files[0]!.sha512, - }; - yield* Effect.acquireRelease( - Effect.sync(() => - vi.spyOn(GenericProvider.prototype, "getLatestVersion").mockResolvedValue(info), - ), - (feed) => Effect.sync(() => feed.mockRestore()), - ); - - // A fresh instance for each launch forces disk validation rather than in-memory reuse. - yield* Effect.forEach([scenario.other, scenario.selected], (cachedExtension) => - Effect.scoped( - Effect.gen(function* () { - const cached = files.find((file) => file.url.endsWith(`-${cachedExtension}`))!; - yield* fs.writeFileString(NodePath.join(cacheDirectory, cached.url), cached.url); - yield* fs.writeFileString( - NodePath.join(cacheDirectory, "update-info.json"), - JSON.stringify({ - fileName: cached.url, - sha512: cached.sha512, - isAdminRightsRequired: false, - }), - ); - const updater = new scenario.Updater(undefined, { - version: "1.2.3", - name: "T3 Code", - isPackaged: true, - appUpdateConfigPath: configPath, - userDataPath: directory, - baseCachePath: directory, - whenReady: () => Promise.resolve(), - relaunch: vi.fn(), - quit: vi.fn(), - onQuit: vi.fn(), - }); - updater.logger = null; - const networkDownload = vi.fn(() => - Promise.reject(new Error("unexpected network download")), - ); - Object.assign(updater, { httpExecutor: { download: networkDownload } }); - if (updater instanceof MacUpdater) { - // Keep real selection, executeDownload, and hash validation; stop at native handoff. - Object.assign(updater, { - updateDownloaded: async (_file: unknown, event: UpdateDownloadedEvent) => { - updater.emit("update-downloaded", event); - return []; - }, - }); - } - updaterOverride.current = updater; - assert.deepEqual( - yield* ElectronUpdater.make.pendingDownloadDirectory, - Option.some(cacheDirectory), - ); - - const inspected = Deferred.makeUnsafe(); - const downloadFinished = Deferred.makeUnsafe(); - const download = vi.spyOn(updater, "downloadUpdate"); - const harness = makeHarness({ - platform: scenario.platform, - runningUnderArm64Translation: scenario.rosetta, - env: scenario.selected.endsWith("AppImage") - ? { APPIMAGE: "/test/T3-Code.AppImage" } - : {}, - updater: { - ...ElectronUpdater.make, - downloadUpdate: ElectronUpdater.make.downloadUpdate.pipe( - Effect.ensuring(Deferred.succeed(downloadFinished, undefined)), - ), - }, - fileSystem: { - ...fs, - readFileString: (path, encoding) => - path === "/missing/resources/package-type" && scenario.selected.endsWith("deb") - ? Effect.succeed("deb") - : fs.readFileString(path, encoding), - exists: (path) => - fs.exists(path).pipe(Effect.tap(() => Deferred.succeed(inspected, undefined))), - }, - }); - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - // AppImage activation reads the host environment; the fixture is intentionally not installed. - vi.spyOn(updater, "isUpdaterActive").mockReturnValue(true); - assert.isTrue((yield* updates.check("startup")).checked); - yield* Deferred.await(inspected); - yield* flushCallbacks; - - if (cachedExtension === scenario.other) { - assert.equal(download.mock.calls.length, 0); - assert.equal((yield* updates.getState).status, "available"); - } else { - yield* Deferred.await(downloadFinished); - yield* flushCallbacks; - assert.equal(download.mock.calls.length, 1); - assert.equal((yield* updates.getState).status, "downloaded"); - assert.equal((yield* updates.getState).downloadedVersion, "1.2.4"); - } - assert.equal(networkDownload.mock.calls.length, 0); - }), - ).pipe(Effect.provide(harness.layer)); - }), - ), - ); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 77709c4a2f358..e42ebc1003ab4 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -1,12 +1,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; -import type { NsisUpdater, VerifyUpdateCodeSignature } from "electron-updater"; type AutoUpdater = typeof autoUpdater; @@ -69,7 +67,6 @@ export class ElectronUpdater extends Context.Service< readonly setAllowDowngrade: (value: boolean) => Effect.Effect; readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly pendingDownloadDirectory: Effect.Effect>; readonly checkForUpdates: Effect.Effect; readonly downloadUpdate: Effect.Effect; readonly quitAndInstall: (options: { @@ -83,45 +80,6 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -const signatureChecksWithRetry = new WeakSet(); - -const retrySignatureCheckAfterTimeout = () => { - // Only the Windows NSIS updater exposes this hook; its PowerShell check times out after 20 s. - const updater = autoUpdater as AutoUpdater & - Partial>; - const verify = updater.verifyUpdateCodeSignature; - if (verify === undefined || signatureChecksWithRetry.has(verify)) return; - - const verifyWithRetry: VerifyUpdateCodeSignature = async (publisherNames, path) => { - try { - return await verify(publisherNames, path); - } catch (error) { - if (!(error instanceof Error && "killed" in error && error.killed === true)) throw error; - } - - const logger = updater.logger; - let skipped = false; - updater.logger = { - info: (message) => logger?.info(message), - warn: (message) => { - // A failed capability probe makes electron-updater warn and return null without checking. - skipped ||= String(message).includes("Ignoring signature validation"); - logger?.warn(message); - }, - error: (message) => logger?.error(message), - debug: (message) => logger?.debug?.(message), - }; - try { - const result = await verify(publisherNames, path); - return skipped ? "Signature validation was skipped after a timed-out check." : result; - } finally { - updater.logger = logger; - } - }; - signatureChecksWithRetry.add(verifyWithRetry); - updater.verifyUpdateCodeSignature = verifyWithRetry; -}; - /** @public Service construction is part of the canonical Effect module API. */ export const make = ElectronUpdater.of({ setFeedURL: (options) => @@ -165,17 +123,6 @@ export const make = ElectronUpdater.of({ autoUpdater.disableDifferentialDownload = value; return Effect.void; }), - // Use the updater's helper so cache lookup follows the same configuration as downloads. - pendingDownloadDirectory: Effect.tryPromise(() => - ( - autoUpdater as unknown as { - getOrCreateDownloadHelper: () => Promise<{ readonly cacheDirForPendingUpdate: string }>; - } - ).getOrCreateDownloadHelper(), - ).pipe( - Effect.map((helper) => helper.cacheDirForPendingUpdate), - Effect.option, - ), checkForUpdates: Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.tryPromise({ @@ -184,7 +131,6 @@ export const make = ElectronUpdater.of({ }).pipe(Effect.asVoid); }), downloadUpdate: Effect.suspend(() => { - retrySignatureCheckAfterTimeout(); const channel = autoUpdater.channel; return Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e2e477e81ac7b..2d67aa56e6ccd 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,7 +40,6 @@ import { import { getAppBranding, getLocalEnvironmentBootstraps, - readLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getRelayTelemetryEnabled, getSystemLocale, @@ -101,7 +100,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); - yield* ipc.handle(readLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 924d20775cdb5..9066cf6f1a67c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -99,68 +99,57 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); -const readLocalEnvironmentBootstrapSnapshot = Effect.fn( - "desktop.ipc.window.getLocalEnvironmentBootstraps", -)(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const instances = yield* pool.list; - const bootstraps: DesktopEnvironmentBootstrap[] = []; - for (const instance of instances) { - const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; - const config = yield* instance.currentConfig; - const snapshot = yield* instance.snapshot; - if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { - const fatalPreflight = - Option.isSome(config) && - Option.isSome(config.value.preflightFailure) && - config.value.preflightFailure.value.fatal; - const stoppedPreflight = - Option.isSome(config) && - Option.isSome(config.value.preflightFailure) && - (!snapshot.desiredRunning || !snapshot.restartScheduled); - if (isPrimary || fatalPreflight || stoppedPreflight) continue; +export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, + result: Schema.Array(DesktopEnvironmentBootstrapSchema), + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const instances = yield* pool.list; + const bootstraps: DesktopEnvironmentBootstrap[] = []; + for (const instance of instances) { + const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; + const config = yield* instance.currentConfig; + const snapshot = yield* instance.snapshot; + if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { + const fatalPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + config.value.preflightFailure.value.fatal; + const stoppedPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + (!snapshot.desiredRunning || !snapshot.restartScheduled); + if (isPrimary || fatalPreflight || stoppedPreflight) continue; + bootstraps.push({ + id: instance.id, + label: yield* instance.label, + runningDistro: null, + httpBaseUrl: null, + wsBaseUrl: null, + }); + continue; + } + const { bootstrap, httpBaseUrl } = config.value; + const runningDistro = config.value.runningDistro ?? null; bootstraps.push({ id: instance.id, - label: yield* instance.label, - runningDistro: null, - httpBaseUrl: null, - wsBaseUrl: null, + label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, + runningDistro, + httpBaseUrl: httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), + // A backend launched with the desktop secret accepts whichever token the secret derives + // for the current window, so hand out that one rather than the token frozen into its + // launch config. + ...(bootstrap.desktopBootstrapSecret + ? { bootstrapToken: yield* configuration.currentBootstrapToken } + : bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), }); - continue; } - const { bootstrap, httpBaseUrl } = config.value; - const runningDistro = config.value.runningDistro ?? null; - bootstraps.push({ - id: instance.id, - label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, - runningDistro, - httpBaseUrl: httpBaseUrl.href, - wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token the secret derives - // for the current window, so hand out that one rather than the token frozen into its - // launch config. - ...(bootstrap.desktopBootstrapSecret - ? { bootstrapToken: yield* configuration.currentBootstrapToken } - : bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), - }); - } - return bootstraps; -}); - -export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, - result: Schema.Array(DesktopEnvironmentBootstrapSchema), - handler: readLocalEnvironmentBootstrapSnapshot, -}); - -export const readLocalEnvironmentBootstraps = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, - payload: Schema.Void, - result: Schema.Array(DesktopEnvironmentBootstrapSchema), - handler: readLocalEnvironmentBootstrapSnapshot, + return bootstraps; + }), }); function extractWslDistroFromEnvironmentId(envId: string): string | null { diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 34c3c9080eac0..644597eaf451d 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -120,43 +120,6 @@ describe("WSL IPC", () => { ), ); - it.effect("does not enumerate distros until the WSL backend is enabled", () => { - const distros = [ - { name: "Ubuntu", isDefault: true, version: 2 as const }, - { name: "Debian", isDefault: false, version: 2 as const }, - ]; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslDistro: "Debian", - }), - DesktopWslEnvironment.layerTest({ isAvailable: true, distros }), - layerWslBackend(), - layerLifecycle([]), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; - const state = yield* getWslState.handler(undefined).pipe( - Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { - ...wsl, - listDistros: Effect.die("must not enumerate distros"), - }), - Effect.flatMap(decodeWslState), - ); - assert.equal(state.enabled, false); - assert.equal(state.available, true); - assert.equal(state.distro, "Debian"); - assert.deepEqual(state.distros, []); - - const enabledState = yield* invokeSetWslBackendEnabled(true); - assert.equal(enabledState.enabled, true); - assert.equal(enabledState.distro, "Debian"); - assert.deepEqual(enabledState.distros, distros); - }).pipe(Effect.provide(layer)); - }); - it.effect("stages dual-backend preferences before enabling without relaunching", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 6a36e87bbfcbf..3c10f04512b4c 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -22,9 +22,9 @@ const readWslState: Effect.Effect< const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); - // Windows ships a wsl.exe stub that can prompt to install WSL when invoked. - // Keep disabled state reads from launching it to enumerate distros. - const distros = available && settings.wslBackendEnabled ? yield* wslEnvironment.listDistros : []; + // Only enumerate distros when WSL is actually available — listDistros on a + // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + const distros = available ? yield* wslEnvironment.listDistros : []; const preflightError = yield* wslBackend.lastPreflightError; return { enabled: settings.wslBackendEnabled, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a089720b6c044..3056ce1ea2637 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -6,6 +6,7 @@ for (const stream of [process.stdout, process.stderr]) { } import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; // @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. import * as NodeFS from "node:fs"; @@ -21,7 +22,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; -import * as DesktopRuntime from "./app/DesktopRuntime.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; @@ -242,4 +242,4 @@ const layerDesktopRuntime = layerDesktopClerk.pipe( Layer.provideMerge(DesktopPreReadyPlatform.layer), ); -DesktopApp.program.pipe(Effect.provide(layerDesktopRuntime), DesktopRuntime.runMain); +DesktopApp.program.pipe(Effect.provide(layerDesktopRuntime), NodeRuntime.runMain); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9fe0e9efa1597..bffa02d9ca435 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -12,7 +12,6 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; -import { createPreloadSnapshot } from "./preloadSnapshot.ts"; const SNAP_SHOT_EVENT_TYPES = new Set([ "requested", @@ -75,18 +74,6 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { return result as Awaited>; } -const readBootstraps = ( - result: unknown, -): ReturnType => - Array.isArray(result) ? result : []; -const localEnvironmentBootstraps = createPreloadSnapshot( - () => readBootstraps(ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), - async () => - readBootstraps(await ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), -); -// Changes take effect on relaunch, so renderer reads can reuse the startup value. -let localEnvironmentEnabled: boolean | undefined; - contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); @@ -116,13 +103,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, - getLocalEnvironmentBootstraps: localEnvironmentBootstraps.read, - readLocalEnvironmentBootstraps: localEnvironmentBootstraps.refresh, + getLocalEnvironmentBootstraps: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); + if (!Array.isArray(result)) { + return []; + } + return result as ReturnType; + }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getLocalEnvironmentEnabled: () => - (localEnvironmentEnabled ??= - ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false), + ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), diff --git a/apps/desktop/src/preloadSnapshot.test.ts b/apps/desktop/src/preloadSnapshot.test.ts deleted file mode 100644 index 05783e8300329..0000000000000 --- a/apps/desktop/src/preloadSnapshot.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, it, vi } from "vite-plus/test"; - -import { createPreloadSnapshot } from "./preloadSnapshot.ts"; - -it("serves the startup snapshot while a shared asynchronous refresh is pending", async () => { - const initial = ["primary"]; - const latest = Promise.withResolvers(); - const readInitial = vi.fn(() => initial); - const readLatest = vi.fn(() => latest.promise); - const snapshot = createPreloadSnapshot(readInitial, readLatest); - - expect(snapshot.read()).toBe(initial); - const first = snapshot.refresh(); - const second = snapshot.refresh(); - expect(snapshot.read()).toBe(initial); - expect(snapshot.read()).toBe(initial); - expect(readInitial).toHaveBeenCalledTimes(1); - expect(readLatest).toHaveBeenCalledTimes(1); - - const connected = ["primary", "wsl"]; - latest.resolve(connected); - expect(await first).toBe(connected); - expect(await second).toBe(connected); - expect(snapshot.read()).toBe(connected); - expect(readInitial).toHaveBeenCalledTimes(1); -}); - -it("retains an async snapshot through failure and replaces it on successful recovery", async () => { - const connected = ["primary", "wsl"]; - const readInitial = vi.fn(() => ["stale"]); - const readLatest = vi - .fn<() => Promise>() - .mockResolvedValueOnce(connected) - .mockRejectedValueOnce(new Error("IPC unavailable")) - .mockResolvedValueOnce([]); - const snapshot = createPreloadSnapshot(readInitial, readLatest); - - await snapshot.refresh(); - expect(snapshot.read()).toBe(connected); - await expect(snapshot.refresh()).rejects.toThrow("IPC unavailable"); - expect(snapshot.read()).toBe(connected); - await expect(snapshot.refresh()).resolves.toEqual([]); - expect(snapshot.read()).toEqual([]); - expect(readInitial).not.toHaveBeenCalled(); -}); diff --git a/apps/desktop/src/preloadSnapshot.ts b/apps/desktop/src/preloadSnapshot.ts deleted file mode 100644 index 4fe34b2719f68..0000000000000 --- a/apps/desktop/src/preloadSnapshot.ts +++ /dev/null @@ -1,23 +0,0 @@ -export function createPreloadSnapshot(readInitial: () => T, readLatest: () => Promise) { - let snapshot: { readonly value: T } | undefined; - let pending: Promise | undefined; - - return { - read: () => { - snapshot ??= { value: readInitial() }; - return snapshot.value; - }, - refresh: () => { - if (pending !== undefined) return pending; - pending = readLatest() - .then((value) => { - snapshot = { value }; - return value; - }) - .finally(() => { - pending = undefined; - }); - return pending; - }, - }; -} diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 7cf73ef633b05..eace39003e39d 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -151,57 +151,56 @@ describe("readChromiumCookieDatabase", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.effect.each([14, 24])( - "imports mixed TEXT and BLOB cookie values, including empty values, in schema %i", - (schemaVersion) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-chromium-cookies-", - }); - const filename = `${directory}/Cookies`; - const key = Buffer.from("0123456789abcdef"); - const host = "secure.example"; - const value = "stored encrypted"; - const payload = - schemaVersion >= 24 - ? Buffer.concat([ - NodeCrypto.createHash("sha256").update(host).digest(), - Buffer.from(value), - ]) - : Buffer.from(value); - const encrypted = encryptV10(payload, key); + it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`create table meta (key text primary key, value text not null)`; - yield* sql`insert into meta values ('version', ${schemaVersion})`; - yield* sql`create table cookies ( - host_key text not null, name text not null, value text not null, - encrypted_value blob not null, path text not null, expires_utc integer not null, - is_secure integer not null, is_httponly integer not null, samesite integer not null - )`; - if (schemaVersion >= 15) { - yield* sql`alter table cookies add column top_frame_site_key text not null default ''`; - } - yield* sql`insert into cookies - (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values - ('plain.example', 'plain', 'stored plaintext', '', '/', 0, 0, 0, -1), - (${host}, 'text-encrypted', '', cast(${encrypted} as text), '/', 0, 1, 1, 2), - (${host}, 'blob-encrypted', '', ${encrypted}, '/', 0, 1, 1, 2), - ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0)`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, + name text not null, + value text not null, + encrypted_value blob not null, + path text not null, + expires_utc integer not null, + is_secure integer not null, + is_httponly integer not null, + samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0) + `; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); - expect(result.undecryptable).toBe(0); - expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ - { name: "plain", value: "stored plaintext" }, - { name: "text-encrypted", value }, - { name: "blob-encrypted", value }, - { name: "empty", value: "" }, - ]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + expect(result.undecryptable).toBe(0); + expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "plain", value: "stored plaintext" }, + { name: "encrypted", value: "stored encrypted" }, + { name: "empty", value: "" }, + ]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("enforces domain binding only for schema 24 and newer", () => diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index a3720f962b650..d5f7592b26068 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -200,14 +200,12 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu Effect.flatMap(decodeSchemaVersion), Effect.map(([row]) => row.value), ); - // SQLite's BLOB affinity still permits TEXT. Cast before Node decodes - // those bytes as UTF-8, which can corrupt an encrypted payload. const raw = schemaVersion >= 15 - ? yield* sql`select host_key, name, value, cast(encrypted_value as blob) as encrypted_value, path, + ? yield* sql`select host_key, name, value, encrypted_value, path, expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, samesite, top_frame_site_key from cookies` - : yield* sql`select host_key, name, value, cast(encrypted_value as blob) as encrypted_value, path, + : yield* sql`select host_key, name, value, encrypted_value, path, expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, samesite, '' as top_frame_site_key from cookies`; return { rows: yield* decodeCookieRows(raw), schemaVersion }; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index af45365b237d3..ad8ba85c9cbe9 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,4 +1,3 @@ -import * as NodeVM from "node:vm"; import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; @@ -412,44 +411,6 @@ const makeTestPreviewWebContents = ( } as unknown as TestPreviewWebContents; }; -const makeStillImage = (pixels = "fresh-png") => ({ - toPNG: () => Buffer.from(pixels), - toJPEG: () => Buffer.from(pixels), - getSize: () => ({ width: 100, height: 80 }), -}); - -const makeTestStillImageWebContents = (...args: Parameters) => { - const wc = makeTestPreviewWebContents(...args); - Object.assign(wc, { isDevToolsOpened: () => false }); - holdStillImagePaint(wc, Promise.resolve()); - return wc; -}; - -const holdStillImagePaint = (wc: TestPreviewWebContents, paint: Promise) => { - vi.mocked(wc.debugger.sendCommand).mockImplementation(async (method) => { - if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; - if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; - if (method === "Runtime.evaluate") { - await paint; - return { result: { value: true } }; - } - return undefined; - }); -}; - -const setupStill = (manager: PreviewManager.PreviewManager["Service"], paint = Promise.resolve()) => - Effect.gen(function* () { - const image = makeStillImage(); - const capturePage = vi.fn<() => Promise>().mockResolvedValue(image); - const wc = makeTestStillImageWebContents(capturePage); - holdStillImagePaint(wc, paint); - fromId.mockReturnValue(wc); - yield* manager.createTab("tab_still"); - yield* manager.registerWebview("tab_still", wc.id); - yield* manager.setColorScheme("tab_still", "system"); - return { image, capturePage, wc, capture: manager.captureScreenshot("tab_still") }; - }); - const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => Effect.gen(function* () { const capturePage = vi.fn(async () => ({ @@ -2540,7 +2501,6 @@ describe("PreviewManager", () => { isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com:8443/path?query=value", - setBackgroundThrottling: vi.fn(), getTitle: () => "Example", isLoadingMainFrame: () => false, getZoomFactor: () => 1, @@ -2579,7 +2539,7 @@ describe("PreviewManager", () => { const artifact = yield* manager.captureScreenshot("tab_1"); - expect(capturePage).toHaveBeenCalledTimes(2); + expect(capturePage).toHaveBeenCalledOnce(); expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts"); expect(writeFile).toHaveBeenCalledWith(artifact.path, png); expect(artifact).toMatchObject({ @@ -2602,21 +2562,18 @@ describe("PreviewManager", () => { yield* TestClock.adjust(1_000); const retriedExit = yield* Fiber.join(retriedFiber); expect(Exit.isSuccess(retriedExit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(4); + expect(capturePage).toHaveBeenCalledTimes(3); capturePage.mockClear(); const captureCause = new Error("capture failed"); capturePage.mockRejectedValue(captureCause); - const writesBeforeFailure = writeFile.mock.calls.length; const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( Effect.forkChild({ startImmediately: true }), ); yield* TestClock.adjust(1_000); const exit = yield* Fiber.join(failingFiber); expect(Exit.isFailure(exit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(6); - expect(writeFile).toHaveBeenCalledTimes(writesBeforeFailure); - expect(fromId(42)?.setBackgroundThrottling).toHaveBeenLastCalledWith(true); + expect(capturePage).toHaveBeenCalledTimes(3); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); expect(error).toMatchObject({ @@ -2630,30 +2587,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("captures final pixels after warmup fails and paint readiness settles", () => - withManager((manager) => - Effect.gen(function* () { - const paint = Promise.withResolvers(); - const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise); - capturePage - .mockRejectedValueOnce(new Error("UnknownVizError")) - .mockRejectedValueOnce(new Error("UnknownVizError")) - .mockRejectedValueOnce(new Error("UnknownVizError")) - .mockResolvedValue(makeStillImage("final-png")); - const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(300); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(writeFile).not.toHaveBeenCalled(); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - paint.resolve(); - const artifact = yield* Fiber.join(fiber); - expect(capturePage).toHaveBeenCalledTimes(4); - expect(writeFile.mock.calls).toEqual([[artifact.path, Buffer.from("final-png")]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - effectIt.effect("preserves both screenshots of the same site in the same millisecond", () => withManager((manager) => Effect.gen(function* () { @@ -2667,8 +2600,6 @@ describe("PreviewManager", () => { const capturePage = vi .fn<() => Promise>>() .mockResolvedValueOnce(pngImage(firstPng)) - .mockResolvedValueOnce(pngImage(firstPng)) - .mockResolvedValueOnce(pngImage(secondPng)) .mockResolvedValueOnce(pngImage(secondPng)); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); @@ -2690,325 +2621,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect( - "waits for paint before capturing and restores rendering after overlapping requests", - () => - withManager((manager) => - Effect.gen(function* () { - const paint = Promise.withResolvers(); - const { image, capturePage, wc, capture } = yield* setupStill(manager, paint.promise); - const first = Promise.withResolvers(); - const second = Promise.withResolvers(); - capturePage - .mockResolvedValueOnce(makeStillImage("discarded-warmup")) - .mockResolvedValueOnce(makeStillImage("discarded-warmup")) - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise); - const hostThrottling = vi.fn(); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: hostThrottling }, - } as never); - const one = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - const two = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(writeFile).not.toHaveBeenCalled(); - expect(hostThrottling.mock.calls).toEqual([[false]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - paint.resolve(); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledTimes(4); - first.resolve(image); - yield* Fiber.join(one); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - second.resolve(image); - yield* Fiber.join(two); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([ - Buffer.from("fresh-png"), - Buffer.from("fresh-png"), - ]); - expect(hostThrottling.mock.calls).toEqual([[false], [true]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("keeps picture-in-picture rendering after a still image completes", () => - withManager((manager) => - Effect.gen(function* () { - const { image, capturePage, wc, capture } = yield* setupStill(manager); - const pending = Promise.withResolvers(); - capturePage - .mockResolvedValueOnce(image) - .mockResolvedValueOnce(makeStillImage("discarded-warmup")) - .mockReturnValueOnce(pending.promise); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - yield* manager.openPictureInPicture("tab_still"); - const still = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(writeFile).not.toHaveBeenCalled(); - pending.resolve(image); - yield* Fiber.join(still); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - yield* manager.closePictureInPicture("tab_still"); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("keeps a pending still image rendering when recording ends", () => - withManager((manager) => - Effect.gen(function* () { - const { image, capturePage, wc, capture } = yield* setupStill(manager); - const pending = Promise.withResolvers(); - capturePage.mockResolvedValueOnce(image).mockReturnValue(pending.promise); - yield* manager.startRecording("tab_still"); - const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - yield* manager.stopRecording("tab_still"); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - pending.resolve(image); - yield* Fiber.join(fiber); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect( - "ignores late native warmup completion after interruption while recording owns rendering", - () => - withManager((manager) => - Effect.gen(function* () { - const { image, capturePage, wc, capture } = yield* setupStill(manager); - const warmup = Promise.withResolvers(); - let nativeCompleted = false; - const nativePromise = warmup.promise.then((result) => { - nativeCompleted = true; - return result; - }); - capturePage.mockReturnValueOnce(nativePromise); - const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - yield* manager.startRecording("tab_still"); - yield* Fiber.interrupt(fiber); - expect(nativeCompleted).toBe(false); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - warmup.resolve(image); - yield* TestClock.adjust(0); - expect(nativeCompleted).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(writeFile).not.toHaveBeenCalled(); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - yield* manager.stopRecording("tab_still"); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect( - "ignores an interrupted warmup that resolves while the replacement guest is capturing", - () => - withManager((manager) => - Effect.gen(function* () { - const fresh = makeStillImage("replacement-png"); - const late = Promise.withResolvers(); - const finalCapture = Promise.withResolvers(); - const oldCapture = vi.fn(() => late.promise); - const replacementCapture = vi - .fn() - .mockResolvedValueOnce(fresh) - .mockReturnValue(finalCapture.promise); - const hostThrottling = vi.fn(); - const host = Object.assign(makeTestHostWebContents(), { - setBackgroundThrottling: hostThrottling, - }); - const original = makeTestStillImageWebContents(oldCapture, 42, host); - const replacement = makeTestStillImageWebContents(replacementCapture, 43, host); - holdStillImagePaint(original, new Promise(() => {})); - fromId.mockImplementation((id) => (id === 42 ? original : replacement)); - yield* manager.createTab("tab_still"); - yield* manager.registerWebview("tab_still", 42); - yield* manager.setColorScheme("tab_still", "system"); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: host, - } as never); - const failed = yield* Effect.exit(manager.captureScreenshot("tab_still")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(0); - yield* Fiber.interrupt(failed); - yield* manager.registerWebview("tab_still", 43); - yield* manager.setColorScheme("tab_still", "system"); - const healthy = yield* manager - .captureScreenshot("tab_still") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - late.resolve({ ...fresh, toPNG: () => Buffer.from("late-old-warmup") }); - yield* TestClock.adjust(0); - expect(writeFile).not.toHaveBeenCalled(); - expect(oldCapture).toHaveBeenCalledOnce(); - expect(replacementCapture).toHaveBeenCalledTimes(2); - expect(hostThrottling.mock.calls).toEqual([[false], [true], [false]]); - expect(replacement.setBackgroundThrottling.mock.calls).toEqual([[false]]); - finalCapture.resolve(fresh); - yield* Fiber.join(healthy); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([ - Buffer.from("replacement-png"), - ]); - expect(hostThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); - expect(replacement.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("paints a loading document with isolated scheduling despite page overrides", () => - withManager((manager) => - Effect.gen(function* () { - for (const mode of ["suppressed", "synchronous", "timeout-before", "timeout-after"]) { - writeFile.mockClear(); - let pixels = "old-pixels"; - const frames: Array<() => void> = []; - let expire = () => {}; - let paintConfirmed: unknown; - const clearTimeout = vi.fn(); - const probeStarted = Promise.withResolvers(); - const pageFrame = vi.fn((callback: () => void) => { - if (mode === "synchronous") callback(); - }); - const page = NodeVM.createContext({ - Promise: undefined, - requestAnimationFrame: pageFrame, - }); - const isolated = NodeVM.createContext({ - requestAnimationFrame: (callback: () => void) => frames.push(callback), - cancelAnimationFrame: (id: number) => { - frames.splice(id - 1, 1); - }, - setTimeout: (callback: () => void, delay: number) => { - expect(delay).toBe(1_000); - expire = callback; - return 1; - }, - clearTimeout, - }); - const capturePage = vi.fn(async () => makeStillImage(pixels)); - const wc = makeTestStillImageWebContents(capturePage); - Object.assign(wc, { isLoading: () => true }); - vi.mocked(wc.executeJavaScript).mockImplementation(async (expression) => - NodeVM.runInContext(expression, page), - ); - vi.mocked(wc.debugger.sendCommand).mockImplementation(async (method, params) => { - if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; - if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; - if (method === "Runtime.evaluate") { - const pending = NodeVM.runInContext(params?.["expression"] as string, isolated); - probeStarted.resolve(); - paintConfirmed = await pending; - return { result: { value: paintConfirmed } }; - } - return undefined; - }); - fromId.mockReturnValue(wc); - const tabId = `tab_loading_${mode}`; - yield* manager.createTab(tabId); - yield* manager.registerWebview(tabId, wc.id); - yield* manager.setColorScheme(tabId, "system"); - const still = yield* manager - .captureScreenshot(tabId) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => probeStarted.promise); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledOnce(); - expect(writeFile).not.toHaveBeenCalled(); - if (mode !== "timeout-before") frames.shift()?.(); - pixels = "new-pixels"; - yield* TestClock.adjust(0); - expect(writeFile).not.toHaveBeenCalled(); - if (mode.startsWith("timeout")) expire(); - else frames.shift()?.(); - yield* Fiber.join(still); - expect(paintConfirmed).toBe(!mode.startsWith("timeout")); - expect(frames).toHaveLength(0); - expect(clearTimeout.mock.calls).toEqual(mode.startsWith("timeout") ? [] : [[1]]); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("new-pixels")]); - expect(pageFrame).not.toHaveBeenCalled(); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - yield* manager.closeTab(tabId); - } - }), - ), - ); - - effectIt.effect("captures available pixels without taking another debugger's ownership", () => - withManager((manager) => - Effect.gen(function* () { - for (const devToolsOpen of [true, false]) { - const capturePage = vi.fn(async () => makeStillImage("available-pixels")); - const wc = makeTestStillImageWebContents(capturePage); - Object.assign(wc, { isDevToolsOpened: () => devToolsOpen }); - vi.spyOn(wc.debugger, "isAttached").mockReturnValue(true); - fromId.mockReturnValue(wc); - const tabId = `tab_foreign_debugger_${devToolsOpen}`; - yield* manager.createTab(tabId); - yield* manager.registerWebview(tabId, wc.id); - yield* manager.captureScreenshot(tabId); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(wc.debugger.attach).not.toHaveBeenCalled(); - expect(wc.debugger.sendCommand).not.toHaveBeenCalled(); - expect(writeFile).toHaveBeenLastCalledWith( - expect.any(String), - Buffer.from("available-pixels"), - ); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - yield* manager.closeTab(tabId); - } - }), - ), - ); - - effectIt.effect( - "bounds paint readiness and ignores its late completion during another capture", - () => - withManager((manager) => - Effect.gen(function* () { - const paint = Promise.withResolvers(); - const { image, capturePage, wc, capture } = yield* setupStill(manager, paint.promise); - const first = yield* Effect.exit(capture).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(1_001); - expect(Exit.isSuccess(yield* Fiber.join(first))).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - - holdStillImagePaint(wc, Promise.resolve()); - const finalCapture = Promise.withResolvers(); - capturePage.mockResolvedValueOnce(image).mockReturnValueOnce(finalCapture.promise); - const second = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - paint.resolve(); - yield* TestClock.adjust(0); - expect(writeFile).toHaveBeenCalledOnce(); - expect(capturePage).toHaveBeenCalledTimes(4); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false]]); - finalCapture.resolve(image); - yield* Fiber.join(second); - expect(writeFile).toHaveBeenCalledTimes(2); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); - }), - ), - ); - effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 17d8990fe170c..b036a67ac8ba0 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -391,8 +391,7 @@ interface ManagedListeners { readonly webContents: Electron.WebContents; } -// Each still-image request owns a separate, short-lived rendering lease. -type FrameCaptureConsumer = "picture-in-picture" | "recording" | symbol; +type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { readonly recordingInputOptions?: RecordingInputOptions; @@ -2488,7 +2487,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - captureStillImage( + capturePageWithRetry( { operation: "captureScreenshot.capturePage", tabId, @@ -2536,104 +2535,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); - const captureStillImage = Effect.fn("PreviewManager.captureStillImage")(function* ( - errorContext: PreviewOperationContext, - tabId: string, - wc: Electron.WebContents, - ) { - const consumer = Symbol("still-image"); - return yield* Effect.acquireUseRelease( - startFrameCapture(tabId, consumer), - () => - Effect.gen(function* () { - // A DOM mutation can precede its compositor frame even in a shown - // window. Keep the original guest rendering through the next paint; - // a hidden guest otherwise returns old pixels or never completes. - const probe = Effect.fn("PreviewManager.probeStillImagePaint")(function* ( - send: ( - method: string, - params?: Record, - ) => Effect.Effect, - ) { - // CDP executes against the current document without Electron's - // did-stop-loading gate. Isolate scheduling globals from page code. - const tree = (yield* send("Page.getFrameTree")) as - | { frameTree?: { frame?: { id?: string } } } - | undefined; - const frameId = tree?.frameTree?.frame?.id; - if (typeof frameId !== "string") - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint frame is unavailable."), - }); - const world = (yield* send("Page.createIsolatedWorld", { - frameId, - worldName: "t3-preview-still-image", - })) as { executionContextId?: number } | undefined; - if (typeof world?.executionContextId !== "number") - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint context is unavailable."), - }); - const response = (yield* send("Runtime.evaluate", { - // Settle and release scheduled work when only timers keep running. - // The outer deadline still bounds a frozen renderer. - expression: `new Promise(resolve => { let frame; const timer = setTimeout(() => { cancelAnimationFrame(frame); resolve(false); }, ${CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS}); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { clearTimeout(timer); resolve(true); }); }); })`, - contextId: world.executionContextId, - awaitPromise: true, - returnByValue: true, - })) as { result?: { value?: unknown }; exceptionDetails?: unknown } | undefined; - if (response?.exceptionDetails || response?.result?.value !== true) - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint boundary was not confirmed."), - }); - }); - const paintReady = Effect.gen(function* () { - // Only use an existing owned session; never attach or displace - // another debugger to obtain a screenshot. - const control = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id); - if (!control) - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint session is unavailable."), - }); - return yield* control.semaphore.withPermit( - probe((method, params) => - attemptPromise(errorContext, () => control.debugger.sendCommand(method, params)), - ), - ); - }).pipe( - Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), - Effect.catch((error) => - Effect.logWarning( - "Preview paint readiness was not confirmed; capturing available pixels with mutation freshness unconfirmed.", - { tabId, webContentsId: wc.id, error }, - ), - ), - ); - // capturePage temporarily marks the original guest as captured. A - // minimized page can otherwise keep animation frames suspended even - // with scheduler throttling disabled. Discard this warmup image and - // accept its output only after readiness settles. If the bounded - // probe cannot confirm paint, retain the native capture fallback. - const warmup = capturePageWithRetry(errorContext, tabId, wc).pipe( - Effect.catchTags({ - PreviewOperationError: (error) => - Effect.logWarning("Preview warmup capture failed; continuing with final capture.", { - tabId, - webContentsId: wc.id, - error, - }), - }), - ); - yield* Effect.all([paintReady, warmup], { concurrency: 2, discard: true }); - return yield* capturePageWithRetry(errorContext, tabId, wc); - }), - () => stopFrameCapture(tabId, consumer), - ); - }); - const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( tabId: string, ) { diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 7313b9b859e39..a98aa96079e67 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -283,7 +283,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ - mainWindowBounds: { x: 10, y: 20, width: 599, height: 620 }, + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", }); @@ -342,24 +342,17 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 600, height: 620 }, true); + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { - mainWindowBounds: { x: -1200, y: 40, width: 600, height: 620 }, + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); - - const restored = yield* Effect.gen(function* () { - const freshSettings = yield* DesktopAppSettings.DesktopAppSettings; - return yield* freshSettings.load; - }).pipe(Effect.provide(DesktopAppSettings.layer)); - assert.deepEqual(restored.mainWindowBounds, persisted.mainWindowBounds); - assert.isTrue(restored.mainWindowMaximized); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a26892d6984a1..1c131681295fa 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -48,8 +48,8 @@ export interface DesktopSettingsChange { } const DEFAULT_TAILSCALE_SERVE_PORT = 443; -export const MIN_MAIN_WINDOW_SIZE = { - width: 600, +const MIN_MAIN_WINDOW_SIZE = { + width: 840, height: 620, } as const; export const DesktopWindowBoundsSchema = Schema.Struct({ diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 6ff500a3eb3e4..3adf74bf76f8d 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,10 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off -- The process fixture runs real shell commands with isolated config files. import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Clock from "effect/Clock"; import { assert, describe, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; @@ -15,7 +12,6 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; -import * as TestClock from "effect/testing/TestClock"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { vi } from "vite-plus/test"; @@ -82,10 +78,7 @@ function runShellEnvironment(input: { readonly userShell?: string; readonly handler: ( command: ChildProcess.Command, - ) => - | string - | { output: string; exitCode: number } - | Effect.Effect; + ) => string | { output: string; exitCode: number }; readonly failure?: PlatformError.PlatformError; }) { const layerEnvironment = Layer.succeed( @@ -99,7 +92,6 @@ function runShellEnvironment(input: { ChildProcessSpawner.make((command) => { if (input.failure !== undefined) return Effect.fail(input.failure); const result = input.handler(command); - if (Effect.isEffect(result)) return result; return Effect.succeed( typeof result === "string" ? makeProcess(result) @@ -572,85 +564,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("waits at most 5 seconds in total when the PowerShell profile hangs", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; - const profileShells: string[] = []; - const started = yield* Deferred.make(); - const completed = yield* Deferred.make(); - const baseClock = yield* Clock.Clock; - let wallTime = 0; - const clockWithBackwardWallJump: Clock.Clock = { - ...baseClock, - currentTimeMillisUnsafe: () => wallTime, - currentTimeMillis: Effect.sync(() => wallTime), - }; - - const install = yield* Effect.forkChild( - runShellEnvironment({ - env, - platform: "win32", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - if (command.args.includes("-NoProfile")) { - return envOutput({ PATH: "C:\\NoProfile\\Bin;C:\\Windows\\System32" }); - } - profileShells.push(command.command); - return Deferred.succeed(started, undefined).pipe( - Effect.as( - ChildProcessSpawner.makeHandle({ - ...makeProcess(""), - stdout: Stream.never, - all: Stream.never, - exitCode: Effect.never, - isRunning: Effect.succeed(true), - }), - ), - ); - }, - }).pipe( - Effect.tap(() => Deferred.succeed(completed, undefined)), - Effect.provide(Logger.layer([])), - Effect.provideService(Clock.Clock, clockWithBackwardWallJump), - ), - ); - yield* Deferred.await(started); - wallTime = -60_000; - yield* TestClock.adjust("5 seconds"); - assert.isTrue(yield* Deferred.isDone(completed), "finishes within the shared budget"); - assert.isFalse(yield* Fiber.join(install)); - assert.deepEqual(profileShells, ["pwsh.exe"]); - assert.equal(env.PATH, "C:\\NoProfile\\Bin;C:\\Windows\\System32"); - }), - ); - - it.effect("falls back to Windows PowerShell when pwsh.exe is not installed", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; - const missing = PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - pathOrDescriptor: "pwsh.exe", - }); - - const prepared = yield* runShellEnvironment({ - env, - platform: "win32", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - if (command.command === "pwsh.exe") return Effect.fail(missing); - return command.args.includes("-NoProfile") - ? envOutput({ PATH: "C:\\Windows\\System32" }) - : envOutput({ PATH: "C:\\Profile\\Node;C:\\Windows\\System32" }); - }, - }).pipe(Effect.provide(Logger.layer([]))); - - assert.isTrue(prepared); - assert.equal(env.PATH, "C:\\Profile\\Node;C:\\Windows\\System32"); - }), - ); - it.effect.each([false, true])( "leaves Windows preparation incomplete when loadProfile=%s fails", (loadProfile) => @@ -692,49 +605,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("drops earlier AppImage launches' mount dirs from the search paths on linux", () => - Effect.gen(function* () { - const current = "/var/tmp/.mount_cur"; - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - APPIMAGE: "/home/me/T3-Code.AppImage", - APPDIR: `${current}/`, - PATH: `${current}/usr/bin:/tmp/.mount_old/usr/bin:/usr/bin`, - LD_LIBRARY_PATH: `/tmp/.mount_old/usr/lib:${current}/usr/lib::/opt/lib`, - XDG_DATA_DIRS: "/tmp/.mount_old/usr/share:/var/tmp/.mount_cur2/usr/share", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => envOutput({ PATH: "/tmp/.mount_older/usr/bin:/home/me/.local/bin" }), - }); - - assert.equal(env.PATH, `/home/me/.local/bin:${current}/usr/bin:/usr/bin`); - assert.equal(env.LD_LIBRARY_PATH, `${current}/usr/lib::/opt/lib`); - assert.equal(env.XDG_DATA_DIRS, undefined); - }), - ); - - it.effect("keeps AppImage-looking search paths outside an AppImage launch", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/tmp/.mount_old/usr/bin:/usr/bin", - LD_LIBRARY_PATH: "/tmp/.mount_old/usr/lib", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => envOutput({ PATH: "/usr/bin" }), - }); - - assert.equal(env.PATH, "/usr/bin:/tmp/.mount_old/usr/bin"); - assert.equal(env.LD_LIBRARY_PATH, "/tmp/.mount_old/usr/lib"); - }), - ); - it.effect("prefers login-shell desktop session hints over inherited values on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 51b4c1a90863c..3fdec7932366b 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,7 +3,6 @@ import { listLoginShellCandidates, setWindowsPath, } from "@t3tools/shared/shell"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -119,37 +118,6 @@ const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); -const APPIMAGE_SEARCH_PATH_ENV_NAMES = ["PATH", "LD_LIBRARY_PATH", "XDG_DATA_DIRS"] as const; - -// The AppImage runtime mounts each launch at `$TMPDIR/.mount_` and prepends -// it to the search paths above. A launch started from a shell that inherited an -// earlier launch's env carries that mount too, and once it is unmounted every -// spawn that walks PATH (git first of all) fails with ENOTCONN. -const APPIMAGE_MOUNT_ROOT = /^(.*?\/\.mount_[^/]+)(?:\/|$)/u; - -const stripStaleAppImageMounts = (env: NodeJS.ProcessEnv): void => { - if (env.APPIMAGE === undefined && env.APPDIR === undefined) return; - const appDir = env.APPDIR === undefined ? undefined : normalizeRuntimeDir(env.APPDIR); - for (const name of APPIMAGE_SEARCH_PATH_ENV_NAMES) { - const value = env[name]; - if (value === undefined) continue; - // glibc's loader also splits LD_LIBRARY_PATH on `;`. - const segments = value.split(name === "LD_LIBRARY_PATH" ? /[:;]/u : ":"); - const kept = segments.filter((segment) => { - const mountRoot = APPIMAGE_MOUNT_ROOT.exec(segment)?.[1]; - return mountRoot === undefined || mountRoot === appDir; - }); - if (kept.length === segments.length) continue; - if (kept.length === 0) { - delete env[name]; - } else { - // An empty segment is the current dir to the loader and to PATH lookup, - // but a lone one joins to "", which reads as unset. - env[name] = kept.join(":") || ":"; - } - } -}; - const linuxRuntimeDirCandidates = ( env: NodeJS.ProcessEnv, uid: number | undefined, @@ -371,16 +339,12 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn captureWindowsEnvironmentCommand(names), ]; - const deadline = - (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(LOGIN_SHELL_TIMEOUT); for (const command of WINDOWS_SHELL_CANDIDATES) { - const remainingNanos = deadline - (yield* Clock.monotonicTimeNanos); - if (remainingNanos <= 0n) break; const output = yield* runCommandOutput({ probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, - timeout: Duration.nanos(remainingNanos), + timeout: LOGIN_SHELL_TIMEOUT, }); const environment = extractEnvironment(output, names); if (Object.keys(environment).length > 0) { @@ -459,9 +423,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix if (Option.isSome(mergedPath)) { config.env.PATH = mergedPath.value; } - // The server, relaunch and updater all inherit process.env, so scrubbing - // here keeps earlier AppImage launches' mounts out of every one of them. - stripStaleAppImageMounts(config.env); if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } diff --git a/apps/desktop/src/snapShot/ActiveWindow.test.ts b/apps/desktop/src/snapShot/ActiveWindow.test.ts index c1849b3eed6e9..ddc760cbb9d09 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.test.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.test.ts @@ -28,34 +28,19 @@ function stubMacLookup(stdout: string) { ); } -function macLookup( - windows: ReadonlyArray<{ - id: number; - title: string; - bounds: { x: number; y: number; width: number; height: number }; - alpha?: number; - }>, -) { - return JSON.stringify({ - owner: { - name: "Editor", - processId: 123, - path: "/Applications/Editor.app", - bundleId: "com.example.editor", - }, - windows: windows.map((window) => ({ ...window, alpha: window.alpha ?? 1 })), - }); -} - it("parses the frontmost macOS window from the osascript lookup", async () => { stubMacLookup( - macLookup([ - { - id: 42, - title: "main.ts", - bounds: { x: 10, y: 20, width: 800, height: 600 }, + JSON.stringify({ + id: 42, + title: "main.ts", + bounds: { x: 10, y: 20, width: 800, height: 600 }, + owner: { + name: "Editor", + processId: 123, + path: "/Applications/Editor.app", + bundleId: "com.example.editor", }, - ]) + "\n", + }) + "\n", ); const window = await activeWindow("darwin"); @@ -81,15 +66,10 @@ it("parses the frontmost macOS window from the osascript lookup", async () => { it("omits an empty macOS bundle identifier", async () => { stubMacLookup( JSON.stringify({ + id: 7, + title: "", + bounds: { x: 0, y: 0, width: 1, height: 1 }, owner: { name: "cli", processId: 9, path: "", bundleId: "" }, - windows: [ - { - id: 7, - title: "", - bounds: { x: 0, y: 0, width: 1, height: 1 }, - alpha: 1, - }, - ], }), ); @@ -98,107 +78,12 @@ it("omits an empty macOS bundle identifier", async () => { assert.deepEqual(window?.owner, { name: "cli", processId: 9, path: "" }); }); -it.each([ - { - name: "transparent and untitled helpers", - helpers: [ - { - id: 1, - title: "", - bounds: { x: -1, y: -44, width: 1_680, height: 44 }, - alpha: 0, - }, - { id: 2, title: "", bounds: { x: 0, y: 0, width: 1_680, height: 68 } }, - ], - content: { - id: 3, - title: "Document", - bounds: { x: 0, y: 0, width: 1_680, height: 1_050 }, - }, - }, - { - name: "Chrome strips stacked above the content window", - helpers: [ - { id: 75, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 41 } }, - { id: 74, title: "", bounds: { x: 0, y: 80, width: 1_800, height: 81 } }, - { id: 1691, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 158 } }, - ], - content: { - id: 71, - title: "(10) Video - YouTube", - bounds: { x: 0, y: 161, width: 1_800, height: 1_008 }, - }, - }, - { - name: "a smaller front window's strips with a larger background window", - helpers: [ - { id: 75, title: "", bounds: { x: 100, y: 139, width: 1_200, height: 41 } }, - { id: 74, title: "", bounds: { x: 100, y: 180, width: 1_200, height: 81 } }, - ], - content: { - id: 71, - title: "Video - YouTube", - bounds: { x: 100, y: 261, width: 1_200, height: 700 }, - }, - background: [ - { id: 80, title: "Other document", bounds: { x: 0, y: 39, width: 1_800, height: 1_130 } }, - ], - }, -])("selects the content window behind $name", async ({ helpers, content, background = [] }) => { - stubMacLookup(macLookup([...helpers, content, ...background])); - - const window = await activeWindow("darwin"); - - assert.strictEqual(window?.id, content.id); - assert.deepEqual(window?.bounds, content.bounds); -}); - -it.each([ - { name: "untitled dialog", title: "", bounds: { x: 300, y: 200, width: 400, height: 240 } }, - { name: "titled palette", title: "Palette", bounds: { x: 0, y: 0, width: 800, height: 50 } }, - { name: "offset untitled strip", title: "", bounds: { x: 100, y: 100, width: 800, height: 50 } }, - { name: "tall untitled window", title: "", bounds: { x: 0, y: 0, width: 800, height: 900 } }, -])("keeps a frontmost $name ahead of a larger document", async ({ title, bounds }) => { - stubMacLookup( - macLookup([ - { id: 1, title, bounds }, - { id: 2, title: "Document", bounds: { x: 0, y: 0, width: 800, height: 1_000 } }, - ]), - ); - - const window = await activeWindow("darwin"); - - assert.strictEqual(window?.id, 1); - assert.deepEqual(window?.bounds, bounds); -}); - -it("keeps an untitled content window ahead of a much taller background window", async () => { - const bounds = { x: 0, y: 0, width: 800, height: 900 }; - stubMacLookup( - macLookup([ - { id: 1, title: "", bounds }, - { id: 2, title: "Background document", bounds: { x: 0, y: 0, width: 800, height: 4_000 } }, - ]), - ); - - const window = await activeWindow("darwin"); - - assert.strictEqual(window?.id, 1); - assert.deepEqual(window?.bounds, bounds); -}); - it("resolves undefined when macOS has no frontmost window", async () => { stubMacLookup("\n"); assert.isUndefined(await activeWindow("darwin")); }); -it("resolves undefined when macOS reports no visible windows", async () => { - stubMacLookup(macLookup([])); - - assert.isUndefined(await activeWindow("darwin")); -}); - it("composes the Windows foreground window from Win32 calls", async () => { const api = { getForegroundWindow: vi.fn(() => 0x1_f123_4567n), diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index 08c6cfa6ba04d..f62669a39bf36 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -30,32 +30,23 @@ export type ActiveWindow = { const MAC_LOOKUP_TIMEOUT_MS = 5_000; -const MAC_HELPER_STRIP_MAX_THICKNESS_RATIO = 0.25; - -const MacActiveWindowLookup = Schema.Struct({ +const MacActiveWindow = Schema.Struct({ + id: Schema.Number, + title: Schema.String, + bounds: Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), owner: Schema.Struct({ name: Schema.String, processId: Schema.Number, path: Schema.String, bundleId: Schema.String, }), - windows: Schema.Array( - Schema.Struct({ - id: Schema.Number, - title: Schema.String, - bounds: Schema.Struct({ - x: Schema.Number, - y: Schema.Number, - width: Schema.Number, - height: Schema.Number, - }), - alpha: Schema.Number, - }), - ), }); -const decodeMacActiveWindowLookup = Schema.decodeUnknownSync( - Schema.fromJsonString(MacActiveWindowLookup), -); +const decodeMacActiveWindow = Schema.decodeUnknownSync(Schema.fromJsonString(MacActiveWindow)); const MAC_LOOKUP_SCRIPT = ` ObjC.import("CoreGraphics"); @@ -64,38 +55,30 @@ function run() { const app = $.NSWorkspace.sharedWorkspace.frontmostApplication; if (app.isNil()) return ""; const pid = app.processIdentifier; - let ownerName = String(app.localizedName.js || ""); const list = $.CGWindowListCopyWindowInfo( $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, $.kCGNullWindowID, ); $.CFMakeCollectable(list); const count = $.CFArrayGetCount(list); - const windows = []; for (let i = 0; i < count; i++) { const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)); if (w.objectForKey("kCGWindowOwnerPID").js !== pid) continue; if (w.objectForKey("kCGWindowLayer").js !== 0) continue; - if (!ownerName) { - ownerName = String(ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""); - } const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds")); - windows.push({ + return JSON.stringify({ id: w.objectForKey("kCGWindowNumber").js, title: String(ObjC.unwrap(w.objectForKey("kCGWindowName")) || ""), bounds: { x: b.X, y: b.Y, width: b.Width, height: b.Height }, - alpha: w.objectForKey("kCGWindowAlpha").js, + owner: { + name: String(app.localizedName.js || ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""), + processId: pid, + path: String(app.bundleURL.path.js || ""), + bundleId: String(app.bundleIdentifier.js || ""), + }, }); } - return JSON.stringify({ - owner: { - name: ownerName, - processId: pid, - path: String(app.bundleURL.path.js || ""), - bundleId: String(app.bundleIdentifier.js || ""), - }, - windows, - }); + return ""; }`; function runMacLookup(): Promise { @@ -103,7 +86,7 @@ function runMacLookup(): Promise { NodeChildProcess.execFile( "/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_LOOKUP_SCRIPT], - { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, + { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 64 * 1024 }, (error, stdout) => { if (error) reject(error); else resolve(stdout); @@ -115,39 +98,17 @@ function runMacLookup(): Promise { async function macActiveWindow(): Promise { const output = (await runMacLookup()).trim(); if (!output) return undefined; - const lookup = decodeMacActiveWindowLookup(output); - const visible = lookup.windows.filter( - (window) => window.alpha > 0 && window.bounds.width > 0 && window.bounds.height > 0, - ); - // Helpers belong to the content behind them, which may not be the app's largest window. - const window = visible.find((candidate, index) => { - if (candidate.title.trim() !== "") return true; - const bounds = candidate.bounds; - return !visible.some((reference, referenceIndex) => { - if (referenceIndex <= index) return false; - return ( - (bounds.x === reference.bounds.x && - bounds.width === reference.bounds.width && - bounds.height <= bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && - bounds.height <= reference.bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) || - (bounds.y === reference.bounds.y && - bounds.height === reference.bounds.height && - bounds.width <= bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && - bounds.width <= reference.bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) - ); - }); - }); - if (!window) return undefined; + const window = decodeMacActiveWindow(output); return { platform: "macos", id: window.id, title: window.title, bounds: window.bounds, owner: { - name: lookup.owner.name, - processId: lookup.owner.processId, - path: lookup.owner.path, - ...(lookup.owner.bundleId ? { bundleId: lookup.owner.bundleId } : {}), + name: window.owner.name, + processId: window.owner.processId, + path: window.owner.path, + ...(window.owner.bundleId ? { bundleId: window.owner.bundleId } : {}), }, }; } diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5233f370f7e2d..94bfbe44c3682 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -273,116 +273,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("reuses an update an earlier launch downloaded once its check finishes", () => { - const releaseCheck = Deferred.makeUnsafe(); - const harness = makeHarness({ - cachedDownloadSha512: "sha-1.2.4", - // electron-updater reports update-available while the check is running. - checkForUpdates: Effect.sync(() => - harness.emit("update-available", { - version: "1.2.4", - files: [{ url: "T3-Code-1.2.4.zip", sha512: "sha-1.2.4" }], - }), - ).pipe(Effect.andThen(Deferred.await(releaseCheck))), - downloadUpdate: Effect.sync(() => harness.emit("update-downloaded", { version: "1.2.4" })), - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const check = yield* Effect.forkChild(updates.check("startup")); - yield* flushCallbacks; - assert.equal(harness.downloadCount(), 0); - assert.equal((yield* updates.getState).status, "available"); - - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(check); - yield* flushCallbacks; - - assert.equal(harness.downloadCount(), 1); - const state = yield* updates.getState; - assert.equal(state.status, "downloaded"); - assert.equal(state.downloadedVersion, "1.2.4"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect.each([ - { name: "a newer release", cachedDownloadSha512: "older-release" }, - { name: "an interrupted download", cachedFileExists: false }, - { name: "unfinished metadata", cachedDownloadInfo: "{" }, - ])("keeps $name click-to-download", (cache) => { - const harness = makeHarness({ cachedDownloadSha512: "release-sha", ...cache }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-available", { - version: "1.2.5", - files: [{ url: "T3-Code-1.2.5.zip", sha512: "release-sha" }], - }); - yield* flushCallbacks; - - assert.equal(harness.downloadCount(), 0); - assert.equal((yield* updates.getState).status, "available"); - assert.isTrue((yield* updates.download).accepted); - assert.equal(harness.downloadCount(), 1); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect.each(["quitting", "newer release", "no longer available"] as const)( - "abandons pending cache recovery after %s", - (change) => { - const releaseCheck = Deferred.makeUnsafe(); - const harness = makeHarness({ - cachedDownloadSha512: "cached-release", - checkForUpdates: Effect.sync(() => - harness.emit("update-available", { - version: "1.2.4", - files: [{ url: "T3-Code-1.2.4.zip", sha512: "cached-release" }], - }), - ).pipe(Effect.andThen(Deferred.await(releaseCheck))), - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - const check = yield* Effect.forkChild(updates.check("startup")); - yield* flushCallbacks; - assert.equal((yield* updates.getState).status, "available"); - assert.equal(harness.downloadCount(), 0); - - if (change === "quitting") { - const desktop = yield* DesktopState.DesktopState; - yield* Ref.set(desktop.quitting, true); - } else if (change === "newer release") { - harness.emit("update-available", { - version: "1.2.5", - files: [{ url: "T3-Code-1.2.5.zip", sha512: "new-release" }], - }); - } else { - harness.emit("update-not-available"); - } - yield* flushCallbacks; - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(check); - yield* flushCallbacks; - - assert.equal(harness.downloadCount(), 0); - assert.equal( - (yield* updates.getState).status, - change === "no longer available" ? "up-to-date" : "available", - ); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }, - ); - it.effect("preserves a queued installer when the feed has no update", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 592d453cf60cd..ae3c2588fab2b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -68,17 +68,8 @@ const UpdateInfo = Schema.Struct({ // fail the decode and block the update state transition. The shape is // validated defensively in normalizeDesktopUpdateReleaseNotes. releaseNotes: Schema.optional(Schema.Unknown), - files: Schema.optional(Schema.Unknown), }); -const ReleaseFile = Schema.Struct({ url: Schema.String, sha512: Schema.String }); -type ReleaseFile = typeof ReleaseFile.Type; -const ReleaseFiles = Schema.Array(ReleaseFile); - -const CachedDownloadInfo = Schema.fromJsonString( - Schema.Struct({ fileName: Schema.String, sha512: Schema.String }), -); - const DownloadProgressInfo = Schema.Struct({ percent: Schema.Number, }); @@ -92,8 +83,6 @@ const encodeInstallRecord = Schema.encodeEffect(DesktopUpdateInstallRecord); const decodeAppUpdateYmlConfig = Schema.decodeUnknownEffect(AppUpdateYmlConfig); const decodeUpdateInfo = Schema.decodeUnknownEffect(UpdateInfo); const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressInfo); -const decodeReleaseFiles = Schema.decodeUnknownOption(ReleaseFiles); -const decodeCachedDownloadInfo = Schema.decodeUnknownEffect(CachedDownloadInfo); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); @@ -346,32 +335,6 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } -// Match electron-updater 6.8.9's MacUpdater.filterFilesForArch and Provider.findFile -// for T3's package feeds. Matching any artifact can trigger a different download. -function selectUpdateFile( - files: ReadonlyArray, - platform: NodeJS.Platform, - isDebPackage: boolean, - runtimeInfo: DesktopRuntimeInfo, -): ReleaseFile | undefined { - let candidates = files; - if (platform === "darwin") { - const isArm64File = (file: ReleaseFile) => file.url.includes("arm64"); - const preferArm64 = runtimeInfo.hostArch === "arm64" && files.some(isArm64File); - candidates = files.filter((file) => isArm64File(file) === preferArm64); - } - const extension = - platform === "darwin" - ? ".zip" - : platform === "win32" - ? ".exe" - : isDebPackage - ? ".deb" - : ".appimage"; - const packageFiles = candidates.filter((file) => file.url.toLowerCase().endsWith(extension)); - return packageFiles.find((file) => file.url.includes(runtimeInfo.appArch)) ?? packageFiles[0]; -} - /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; @@ -490,15 +453,6 @@ export const make = Effect.gen(function* () { ), ); - const awaitIdleUpdateAction = Effect.scoped( - Effect.gen(function* () { - const completions = yield* PubSub.subscribe(finishedUpdateActions); - while (Option.isSome(yield* activeUpdateAction)) { - yield* PubSub.take(completions); - } - }), - ); - const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( channel: DesktopUpdateChannel, ) { @@ -801,7 +755,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(installRunningBackendsRef, runningInstances); yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(45) }), + (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); yield* electronUpdater.quitAndInstall({ @@ -899,44 +853,6 @@ export const make = Effect.gen(function* () { ); }).pipe(Effect.withSpan("desktop.updates.startPollers")); - const cachedDownloadSha512 = Effect.gen(function* () { - const pendingDirectory = yield* electronUpdater.pendingDownloadDirectory; - if (Option.isNone(pendingDirectory)) return Option.none(); - const info = yield* fileSystem - .readFileString(environment.path.join(pendingDirectory.value, "update-info.json")) - .pipe(Effect.flatMap(decodeCachedDownloadInfo)); - const fileExists = yield* fileSystem.exists( - environment.path.join(pendingDirectory.value, info.fileName), - ); - return fileExists ? Option.some(info.sha512) : Option.none(); - }).pipe(Effect.orElseSucceed(() => Option.none())); - - // downloadUpdate validates the cached bytes and restores install readiness. - // Only resume the selected artifact; a cache miss would start a network download. - const resumeCachedDownload = Effect.fn("desktop.updates.resumeCachedDownload")(function* ( - version: string, - files: unknown, - ) { - const releaseFiles = decodeReleaseFiles(files); - if (Option.isNone(releaseFiles)) return; - const updateFile = selectUpdateFile( - releaseFiles.value, - environment.platform, - isDebPackage, - environment.runtimeInfo, - ); - if (!updateFile) return; - const cachedSha512 = yield* cachedDownloadSha512; - if (Option.isNone(cachedSha512) || updateFile.sha512 !== cachedSha512.value) return; - - // update-available arrives while its check still holds the action reservation. - yield* awaitIdleUpdateAction; - if (yield* Ref.get(desktopState.quitting)) return; - const state = yield* Ref.get(updateStateRef); - if (state.status !== "available" || state.availableVersion !== version) return; - yield* downloadAvailableUpdate; - }); - const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* ( raw: unknown, ) { @@ -976,9 +892,6 @@ export const make = Effect.gen(function* () { releaseNoteGroups: releaseNotes.length, omittedReleaseCount, }); - if (state.downloadedVersion !== info.version) { - yield* resumeCachedDownload(info.version, info.files); - } }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 2c016c2c4d6cd..1d8c21a42dfe5 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -26,13 +26,6 @@ export interface UpdatesHarnessOptions { readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; readonly downloadUpdate?: Effect.Effect; - /** sha512 of a download an earlier launch left in electron-updater's cache. */ - readonly cachedDownloadSha512?: string; - readonly cachedDownloadInfo?: string; - readonly cachedFileExists?: boolean; - readonly updater?: ElectronUpdater.ElectronUpdater["Service"]; - readonly fileSystem?: FileSystem.FileSystem; - readonly runningUnderArm64Translation?: boolean; readonly quitAndInstall?: Effect.Effect; readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; @@ -44,8 +37,6 @@ export interface UpdatesHarnessOptions { readonly installRecord?: string | undefined; } -const PENDING_DOWNLOAD_DIRECTORY = "/cache/t3code-updater/pending"; - export function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let quitAndInstallCount = 0; @@ -74,7 +65,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } }; - const stubUpdater = { + const layerUpdater = Layer.succeed(ElectronUpdater.ElectronUpdater, { setFeedURL: (options) => Effect.sync(() => { feedUrls.push(options); @@ -93,11 +84,6 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { fullChangelog = value; }), setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, - pendingDownloadDirectory: Effect.succeed( - options.cachedDownloadSha512 === undefined - ? Option.none() - : Option.some(PENDING_DOWNLOAD_DIRECTORY), - ), checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), @@ -119,11 +105,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); }), ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdater["Service"]; - const layerUpdater = Layer.succeed( - ElectronUpdater.ElectronUpdater, - options.updater ?? stubUpdater, - ); + } satisfies ElectronUpdater.ElectronUpdater["Service"]); const layerWindow = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), @@ -179,7 +161,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { appPath: "/repo", isPackaged: true, resourcesPath: "/missing/resources", - runningUnderArm64Translation: options.runningUnderArm64Translation ?? false, + runningUnderArm64Translation: false, }).pipe( Layer.provide( Layer.mergeAll( @@ -236,7 +218,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let previousInstallRecord = options.installRecord; const isInstallRecord = (path: string) => path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE); - const stubFileSystem = FileSystem.layerNoop({ + const layerFileSystem = FileSystem.layerNoop({ readFileString: (path) => { const installRecord = isInstallRecord(path) ? (installRecords.get(path) ?? previousInstallRecord) @@ -244,28 +226,15 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { if (installRecord !== undefined) return Effect.succeed(installRecord); return path === "/missing/resources/package-type" && options.packageType !== undefined ? Effect.succeed(options.packageType) - : path === `${PENDING_DOWNLOAD_DIRECTORY}/update-info.json` && - options.cachedDownloadSha512 !== undefined - ? Effect.succeed( - options.cachedDownloadInfo ?? - JSON.stringify({ - fileName: "update.zip", - sha512: options.cachedDownloadSha512, - }), - ) - : Effect.fail( - PlatformError.systemError({ - module: "FileSystem", - method: "readFileString", - _tag: "NotFound", - pathOrDescriptor: path, - }), - ); + : Effect.fail( + PlatformError.systemError({ + module: "FileSystem", + method: "readFileString", + _tag: "NotFound", + pathOrDescriptor: path, + }), + ); }, - exists: (path) => - Effect.succeed( - options.cachedFileExists !== false && path === `${PENDING_DOWNLOAD_DIRECTORY}/update.zip`, - ), makeDirectory: () => Effect.void, writeFileString: (path, data) => Effect.sync(() => { @@ -280,9 +249,6 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), }); - const layerFileSystem = options.fileSystem - ? Layer.succeed(FileSystem.FileSystem, options.fileSystem) - : stubFileSystem; const layer = DesktopUpdates.layer.pipe( Layer.provide(layerFileSystem), Layer.provideMerge(layerUpdater), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 2746ec9338c8c..56206a2279631 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -27,8 +27,6 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, - BrowserWindow: { getAllWindows: () => [], getFocusedWindow: () => null }, - app: { setBadgeCount: vi.fn() }, screen: { getAllDisplays: vi.fn(() => [ { @@ -38,42 +36,6 @@ vi.mock("electron", async (importOriginal) => ({ }, })); -import * as NetService from "@t3tools/shared/Net"; -import { HttpClient } from "effect/http"; -import * as BrowserImport from "../preview/BrowserImport/BrowserImport.ts"; -import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; -import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; -import * as DesktopCliCommand from "../app/DesktopCliCommand.ts"; -import * as DesktopConnectionCatalogStore from "../app/DesktopConnectionCatalogStore.ts"; -import * as DesktopWebLinks from "../app/DesktopWebLinks.ts"; -import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "../ssh/DesktopSshPasswordPrompts.ts"; -import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; -import * as MacPermissions from "../permissions/MacPermissions.ts"; - -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Stream from "effect/Stream"; -import * as DesktopApp from "../app/DesktopApp.ts"; -import * as DesktopAppActivation from "../app/DesktopAppActivation.ts"; -import * as DesktopAppIdentity from "../app/DesktopAppIdentity.ts"; -import * as DesktopClerk from "../app/DesktopClerk.ts"; -import * as DesktopLegacyLocalStorage from "../app/DesktopLegacyLocalStorage.ts"; -import * as DesktopLifecycle from "../app/DesktopLifecycle.ts"; -import * as DesktopLinuxUrlHandler from "../app/DesktopLinuxUrlHandler.ts"; -import * as DesktopPreReadyPlatform from "../app/DesktopPreReadyPlatform.ts"; -import * as DesktopShutdown from "../app/DesktopShutdown.ts"; -import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; -import * as ElectronDialog from "../electron/ElectronDialog.ts"; -import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import * as DesktopIpc from "../ipc/DesktopIpc.ts"; -import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; -import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; -import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; -import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; -import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; -import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; - import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -170,7 +132,6 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, - close: window.close, getBounds: window.getBounds, getNormalBounds: window.getNormalBounds, isDestroyed: window.isDestroyed, @@ -392,10 +353,7 @@ function layerTest(input: { ); } -const makeSplashScenario = ( - createOutcomes: readonly (Electron.BrowserWindow | null)[], - settings = DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, -) => +const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => Effect.gen(function* () { const createdWindows = yield* Ref.make([]); const createCalls = yield* Ref.make(0); @@ -461,7 +419,7 @@ const makeSplashScenario = ( } satisfies ElectronWindow.ElectronWindow["Service"]; const layer = DesktopWindow.layer.pipe( - Layer.provideMerge( + Layer.provide( Layer.mergeAll( layerDesktopAssets, Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { @@ -470,7 +428,7 @@ const makeSplashScenario = ( shutdown: Effect.void, }), layerDesktopEnvironment, - DesktopAppSettings.layerTest(settings), + DesktopAppSettings.layerTest(), layerDesktopClientSettings, layerDesktopServerExposure, DesktopState.layer, @@ -484,17 +442,11 @@ const makeSplashScenario = ( layerElectronTheme, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), Layer.mock(PreviewPasskeys.PreviewPasskeys)({ - configure: Effect.void, bridgeEnabled: false, installSessionHandlers: () => {}, attachGuest: () => () => {}, }), Layer.mock(PreviewManager.PreviewManager)({ - subscribeStateChanges: () => Effect.void, - subscribeRecordingFrames: () => Effect.void, - subscribeRecordingInputs: () => Effect.void, - subscribePointerEvents: () => Effect.void, - subscribeOpenLinks: () => Effect.void, getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), @@ -511,152 +463,6 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111" const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); describe("DesktopWindow", () => { - it.effect.each([ - { mode: "native", localEnvironmentEnabled: true, wslBackendEnabled: false, wslOnly: false }, - { mode: "WSL-only", localEnvironmentEnabled: true, wslBackendEnabled: true, wslOnly: true }, - { mode: "dual", localEnvironmentEnabled: true, wslBackendEnabled: true, wslOnly: false }, - { mode: "disabled", localEnvironmentEnabled: false, wslBackendEnabled: false, wslOnly: false }, - ])("hands startup from the splash to the main window in $mode mode", (settings) => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const main = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario( - settings.localEnvironmentEnabled ? [splash.window, main.window] : [main.window], - { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, ...settings }, - ); - const bootstrapped = yield* Deferred.make(); - const windowsAtBackendStart: number[] = []; - const primary = { - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: Effect.succeed("Primary"), - start: Ref.get(scenario.createCalls).pipe( - Effect.tap((count) => Effect.sync(() => windowsAtBackendStart.push(count))), - Effect.asVoid, - ), - stop: () => Effect.void, - currentConfig: Effect.succeedNone, - snapshot: Effect.never, - waitForReady: () => Effect.succeed(false), - } satisfies DesktopBackendPool.DesktopBackendInstance; - const startupLayer = Layer.mergeAll( - // IPC registration captures these services but startup does not invoke their handlers. - Layer.mock(BrowserImport.BrowserImport)({}), - Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration)({}), - Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth)({}), - Layer.mock(DesktopCliCommand.DesktopCliCommand)({}), - Layer.mock(DesktopConnectionCatalogStore.DesktopConnectionCatalogStore)({}), - Layer.mock(DesktopWebLinks.DesktopWebLinks)({}), - Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment)({}), - Layer.mock(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts)({}), - Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment)({}), - Layer.mock(MacPermissions.MacPermissions)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - Layer.mock(NetService.NetService)({}), - Layer.mock(ElectronApp.ElectronApp)({ - whenReady: Effect.void, - setPath: () => Effect.void, - on: () => Effect.void, - }), - Layer.mock(DesktopAppIdentity.DesktopAppIdentity)({ - resolveUserDataPath: Effect.succeed("/unused"), - configure: Effect.void, - }), - Layer.mock(DesktopLifecycle.DesktopLifecycle)({ register: Effect.void }), - Layer.mock(DesktopClerk.DesktopClerk)({ configure: Effect.void }), - Layer.mock(DesktopApplicationMenu.DesktopApplicationMenu)({ configure: Effect.void }), - Layer.mock(DesktopLinuxUrlHandler.DesktopLinuxUrlHandler)({ register: Effect.void }), - Layer.mock(DesktopShellEnvironment.DesktopShellEnvironment)({ - installIntoProcess: Effect.succeed(true), - }), - Layer.succeed(DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, { - linux: null, - linuxPasswordStoreCommandLine: null, - }), - Layer.mock(ElectronSafeStorage.ElectronSafeStorage)({}), - Layer.mock(ElectronProtocol.ElectronProtocol)({ - registerDesktopProtocol: () => Effect.void, - }), - Layer.mock(ElectronDialog.ElectronDialog)({}), - Layer.mock(DesktopIpc.DesktopIpc)({ - handle: () => Effect.void, - handleSync: () => Effect.void, - }), - Layer.mock(DesktopLegacyLocalStorage.DesktopLegacyLocalStorage)({ - load: () => Effect.void, - }), - Layer.mock(DesktopSnapShot.DesktopSnapShot)({ initialize: Effect.void }), - Layer.mock(DesktopAppActivation.DesktopAppActivation)({ start: Effect.void }), - Layer.mock(DesktopWslBackend.DesktopWslBackend)({ reconcile: Effect.void }), - Layer.mock(DesktopBackendPool.DesktopBackendPool)({ - primary: Effect.succeed(primary), - list: Effect.succeed([]), - }), - Layer.mock(DesktopServerExposure.DesktopServerExposure)({ - configureFromSettings: () => - Effect.succeed({ - mode: "local-only", - advertisedHost: null, - endpointUrl: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - backendConfig: Effect.succeed({ - port: 3773, - bindHost: "127.0.0.1", - httpBaseUrl: new URL("http://127.0.0.1:3773"), - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - }), - Layer.mock(DesktopUpdates.DesktopUpdates)({ - configure: Effect.void, - subscribe: Effect.never, - }), - Layer.mock(DesktopTelemetryPublisher.DesktopTelemetryPublisher)({ - updateRequests: Stream.empty, - updateCancellations: Stream.empty, - updateCommits: Stream.empty, - }), - Layer.mock(DesktopShutdown.DesktopShutdown)({ - awaitRequest: Deferred.succeed(bootstrapped, undefined).pipe( - Effect.andThen(Effect.never), - ), - markComplete: Effect.void, - }), - Layer.succeed(HostProcess.Platform, "darwin"), - NodeServices.layer, - ); - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const app = yield* DesktopApp.program.pipe(Effect.forkScoped); - yield* Effect.raceFirst(Deferred.await(bootstrapped), Fiber.join(app)); - - assert.deepEqual(windowsAtBackendStart, settings.localEnvironmentEnabled ? [1] : []); - assert.equal(yield* Ref.get(scenario.createCalls), 1); - if (settings.localEnvironmentEnabled) { - assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - assert.equal(yield* Ref.get(scenario.createCalls), 2); - } - assert.deepEqual(yield* Ref.get(scenario.mainWindow), Option.some(main.window)); - assert.equal(splash.close.mock.calls.length, 0); - const readyToShow = main.windowListeners.get("ready-to-show"); - assert.isDefined(readyToShow); - readyToShow(); - assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [main.window]); - assert.equal(splash.close.mock.calls.length, settings.localEnvironmentEnabled ? 1 : 0); - readyToShow(); - assert.equal(splash.close.mock.calls.length, settings.localEnvironmentEnabled ? 1 : 0); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(scenario.layer, startupLayer, DesktopState.layer)), - ); - }), - ); - it.effect("shows native context menus for browser guests and sign-in popups", () => Effect.gen(function* () { const host = makeFakeBrowserWindow(); @@ -854,8 +660,6 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(createCount), 1); assert.equal(createdWindowOptions[0]?.width, 1100); assert.equal(createdWindowOptions[0]?.height, 780); - assert.equal(createdWindowOptions[0]?.minWidth, 600); - assert.equal(createdWindowOptions[0]?.minHeight, 620); assert.isUndefined(createdWindowOptions[0]?.x); assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); @@ -1114,7 +918,7 @@ describe("DesktopWindow", () => { createdWindowOptions, desktopSettings: { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - mainWindowBounds: { x: 120, y: 80, width: 768, height: 780 }, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, }, }); @@ -1122,8 +926,8 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - assert.equal(createdWindowOptions[0]?.width, 768); - assert.equal(createdWindowOptions[0]?.height, 780); + assert.equal(createdWindowOptions[0]?.width, 1320); + assert.equal(createdWindowOptions[0]?.height, 880); assert.equal(createdWindowOptions[0]?.x, 120); assert.equal(createdWindowOptions[0]?.y, 80); }).pipe(Effect.provide(layer)); @@ -1348,14 +1152,14 @@ describe("DesktopWindow", () => { move(); yield* TestClock.adjust(250); - fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 664, height: 780 }); + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); resize(); yield* TestClock.adjust(499); assert.deepEqual(mainWindowBoundsUpdates, []); yield* TestClock.adjust(1); yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 664, height: 780 }]); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); }).pipe(Effect.provide(layer)); }), ); @@ -1437,7 +1241,7 @@ describe("DesktopWindow", () => { it.effect("does not persist bounds that fail the domain schema", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.getBounds.mockReturnValue({ x: 100, y: 80, width: 599, height: 620 }); + fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 8a10de409834b..5d14fdfdc53f2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -176,12 +176,12 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean, text: string): string { +function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { const background = getInitialWindowBackgroundColor(shouldUseDarkColors); const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; - const html = `
${text}
`; + const html = `
Connecting to WSL…
`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } @@ -315,7 +315,11 @@ export const make = Effect.gen(function* () { } }); - // Window lookups fall back to the first window, which may be the splash. + // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", + // which during WSL-only boot is the connecting splash. The splash is never + // registered via setMain, so it must be treated as "no real main window" -- + // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never + // open (or retry) the real main. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -379,8 +383,8 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, - minWidth: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.width, - minHeight: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.height, + minWidth: 840, + minHeight: 620, show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), @@ -899,11 +903,6 @@ export const make = Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; if (yield* Ref.get(desktopState.quitting)) return; - const settings = yield* desktopSettings.get; - const text = - settings.wslOnly === true && settings.wslBackendEnabled === true - ? "Connecting to WSL…" - : "Starting T3 Code…"; const splash = yield* electronWindow.create({ width: 360, height: 220, @@ -937,7 +936,7 @@ export const make = Effect.gen(function* () { splash.show(); } }); - void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors, text)); + void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); yield* logWindowInfo("connecting splash shown"); }).pipe( Effect.catch((error) => diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index c991a9003514f..89d52c87b2827 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -321,7 +321,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { className="h-dvh! min-h-0!" data-panel-animations={routePanelAnimationsActive ? "true" : "false"} defaultOpen - mobileBreakpoint={sidebarMinimumWidth + THREAD_MAIN_CONTENT_MIN_WIDTH} style={sidebarProviderStyle} > diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 70c7d7b0c1f8c..9076b9c127ce5 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -204,6 +204,7 @@ import { import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -419,7 +420,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); - const { isMobile } = useSidebar(); + const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, threadId: thread.id, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 9f613f3866596..ee0328f602fa8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3307,16 +3307,9 @@ export function ConnectionsSettings() { Off {desktopWslState.distros.length === 0 ? ( - <> - - Default distro - - {desktopWslState.distro !== null ? ( - - {desktopWslState.distro} - - ) : null} - + + Default distro + ) : ( desktopWslState.distros.map((distro) => ( diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index c04edb1799f02..e7a61f6e9927d 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -36,7 +36,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - const { isMobile } = useSidebar(); const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const backdropVariant = resolveSidebarStageBackdropVariant( @@ -56,17 +55,15 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ )} > {backdropVariant ? : null} - {isMobile ? ( -
- -
- ) : null} +
+ +
- {!isMobile ? : null} + {pillLabel ? (
@@ -108,7 +105,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { & { defaultOpen?: boolean; - mobileBreakpoint?: number; open?: boolean; onOpenChange?: (open: boolean) => void; }) { - const isMobile = useMediaQuery( - mobileBreakpoint === undefined ? "max-md" : { max: mobileBreakpoint }, - ); + const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); - if (!isMobile && openMobile) { - setOpenMobile(false); - } const [mobileSheetMounted, setMobileSheetMounted] = React.useState(false); const setMobileSheetElement = (element: HTMLDivElement | null) => { setMobileSheetMounted(element !== null); @@ -165,7 +158,6 @@ function SidebarProvider({ SIDEBAR_TITLEBAR_CONTROL_SIZE, className, )} - data-sidebar-mobile={isMobile} data-sidebar-state={state} data-slot="sidebar-wrapper" style={ @@ -306,7 +298,7 @@ function Sidebar({
- {previewOption?.preview?.trim() ? ( -
-

- Preview · {previewOption.label} -

- -
- ) : null} diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 7f14db16cc654..fb159426b5f40 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,5 +1,4 @@ -import type { ThreadContextRecord } from "@t3tools/contracts"; -import type { ThreadUserInputQuestion as UserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; +import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; From 818f09611d64bdf514247fb1399fa3cd4630aaaf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:02:43 -0700 Subject: [PATCH 007/146] revert: "fix(web): stop composer banner glass overlap" (#8034) (#22227) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 3 - .../src/components/chat/MessagesTimeline.tsx | 105 ++++++++---------- apps/web/src/index.css | 10 -- 3 files changed, 44 insertions(+), 74 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8c12286a24197..9703212766012 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1423,7 +1423,6 @@ export default function ChatView(props: ChatViewProps) { const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerTimelineInset, setComposerTimelineInset] = useState(0); - const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const composerTimelineInsetRef = useRef(0); const [composerReservedHeight, setComposerReservedHeight] = useState(0); const composerReservedHeightRef = useRef(0); @@ -4800,7 +4799,6 @@ export default function ChatView(props: ChatViewProps) { const publishComposerOverlayHeight = (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; - setComposerOverlayHeight(isDraftHeroState ? 0 : nextHeight); const isResting = overlayComposerIsResting({ composerMounted: composerMountedRef.current, composerReportedResting: composerRestingRef.current, @@ -8284,7 +8282,6 @@ export default function ChatView(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReadyStable} onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} - composerOverlayHeight={isDraftHeroState ? 0 : composerOverlayHeight} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bb209924a1c92..1e591b5683638 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -84,7 +84,6 @@ import { type KeyboardEvent, type MouseEvent, type ReactNode, - type CSSProperties, } from "react"; import { LegendList, @@ -508,7 +507,6 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; - composerOverlayHeight?: number; onIsAtEndChange: (isAtEnd: boolean) => void; /** * Off while the user is reading history; LegendList's own maintainScrollAtEnd @@ -609,7 +607,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, - composerOverlayHeight = 0, onIsAtEndChange, onContentOverflowChange, liveFollowEnabled, @@ -1102,12 +1099,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ {footer} ); - const timelineMaskStyle = - composerOverlayHeight === 0 - ? undefined - : ({ - "--timeline-composer-overlay-height": `${composerOverlayHeight}px`, - } as CSSProperties); const measureContentOverflow = () => timelineContentOverflowsViewport(listRef.current?.getState?.(), { @@ -1522,60 +1513,52 @@ const ConversationTimeline = memo(function ConversationTimeline({ onCite={onCiteAssistantText} /> ) : null} -
+ ref={setTimelineList} + data={rows} + extraData={listExtraData} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd={ + !findActive && citationRequest === null && rememberedPosition?.atEnd !== false + } + // Legend needs a data refresh to mount new pins without a scroll event. + dataVersion={readyCitationRequest?.key ?? listIdentityKey} + {...(alwaysRender ? { alwaysRender } : {})} + onLoad={handleListLoad} + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} + maintainScrollAtEnd={ + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) || + anchoredEndSpace || + !liveFollowEnabled || + fullscreenAppRowId !== null || + disclosureToggleSettling + ? false + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={ + findActive || + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) + ? false + : maintainVisibleContentPosition + } + maintainScrollAtEndThreshold={1} + onScroll={handleScroll} + onItemSizeChanged={handleItemSizeChanged} className={cn( - "h-full min-h-0", - composerOverlayHeight > 0 && "virtualized-scroll-fade timeline-scroll-bottom-fade", + "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", + topFadeEnabled && "topbar-scroll-fade", )} - style={timelineMaskStyle} - > - - ref={setTimelineList} - data={rows} - extraData={listExtraData} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd={ - !findActive && citationRequest === null && rememberedPosition?.atEnd !== false - } - // Legend needs a data refresh to mount new pins without a scroll event. - dataVersion={readyCitationRequest?.key ?? listIdentityKey} - {...(alwaysRender ? { alwaysRender } : {})} - onLoad={handleListLoad} - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} - maintainScrollAtEnd={ - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) || - anchoredEndSpace || - !liveFollowEnabled || - fullscreenAppRowId !== null || - disclosureToggleSettling - ? false - : isWorking && !prefersReducedMotion && settlingListIdentity === null - ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={ - findActive || - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) - ? false - : maintainVisibleContentPosition - } - maintainScrollAtEndThreshold={1} - onScroll={handleScroll} - onItemSizeChanged={handleItemSizeChanged} - className={cn( - "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", - topFadeEnabled && "topbar-scroll-fade", - )} - ListHeaderComponent={listHeader} - ListFooterComponent={timelineListFooter} - /> -
+ ListHeaderComponent={listHeader} + ListFooterComponent={timelineListFooter} + /> Date: Tue, 20 Oct 2026 02:02:49 -0700 Subject: [PATCH 008/146] revert: "fix(web): keep subagent links visible in long background-work banners" (#20054) (#22228) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 3 +- .../chat/ComposerBannerStack.browser.test.tsx | 90 ------------------- .../components/chat/ComposerBannerStack.tsx | 30 ++----- 3 files changed, 10 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9703212766012..05a6bec7fa9ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5406,7 +5406,6 @@ export default function ChatView(props: ChatViewProps) { /> ), title: presentation.title, - descriptionPlacement: "below", description: presentation.items.length === 1 && presentation.items[0]?.childThreadId === undefined ? undefined @@ -5422,7 +5421,7 @@ export default function ChatView(props: ChatViewProps) { {separator} ) : ( - + { expect(hasDetails()).toBe(true); }); -it.each([320, 800])( - "keeps long background-work links reachable beside Stop in a %ipx composer", - async (width) => { - await page.viewport(width + 32, 800); - container.style.width = `${width}px`; - const openThread = vi.fn(); - const label = "Review the background task layout and child-thread navigation ".repeat(6); - await act(async () => - root.render( - ( - - {index > 0 ? " " : null} - openThread(index + 1)} - > - {label} - - {index < 11 ? "," : null} - - )), - actions: ( - - ), - }, - ]} - />, - ), - ); - await nextFrame(); - - const firstLink = page.getByRole("button", { name: "Open subagent 1", exact: true }); - const lastLink = page.getByRole("button", { name: "Open subagent 12", exact: true }); - const stop = page.getByRole("button", { name: "Stop", exact: true }); - expect(firstLink.element().getBoundingClientRect().height).toBeGreaterThan( - Number.parseFloat(getComputedStyle(firstLink.element()).lineHeight) * 2, - ); - const viewport = container.querySelector('[data-slot="scroll-area-viewport"]')!; - expect(viewport).not.toBeNull(); - expect(viewport.clientHeight).toBeGreaterThan(0); - expect(viewport.clientHeight).toBeLessThanOrEqual(window.innerHeight / 2); - expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); - expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth + 1); - - const scrollBounds = viewport.getBoundingClientRect(); - const stopBounds = stop.element().getBoundingClientRect(); - expect(stopBounds.top).toBe( - page.getByText("12 background tasks", { exact: true }).element().getBoundingClientRect().top, - ); - expect(scrollBounds.right).toBeLessThanOrEqual(stopBounds.left); - expect(stopBounds.right).toBeLessThanOrEqual(container.getBoundingClientRect().right); - expect(stopBounds.bottom).toBeLessThanOrEqual(scrollBounds.bottom); - expect(lastLink.element().getBoundingClientRect().top).toBeGreaterThan(scrollBounds.bottom); - await firstLink.click(); - expect(openThread).toHaveBeenLastCalledWith(1); - - viewport.scrollTop = viewport.scrollHeight; - await nextFrame(); - expect(viewport.scrollTop).toBeGreaterThan(0); - const lastBounds = lastLink.element().getBoundingClientRect(); - expect(lastBounds.top).toBeGreaterThanOrEqual(scrollBounds.top); - expect(lastBounds.bottom).toBeLessThanOrEqual(scrollBounds.bottom + 1); - expect(stop.element().getBoundingClientRect().top).toBe(stopBounds.top); - for (const control of [lastLink.element(), stop.element()]) { - const bounds = control.getBoundingClientRect(); - expect( - control.contains( - document.elementFromPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2), - ), - ).toBe(true); - } - await lastLink.click(); - expect(openThread).toHaveBeenLastCalledWith(12); - }, -); - it("keeps only the usage limit attached while an active goal waits in the collapsible stack", async () => { await act(async () => root.render( diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index e2e7ecc42d0be..fc309c5352a67 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -17,7 +17,6 @@ export interface ComposerBannerStackItem { readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; - readonly descriptionPlacement?: "inline" | "below"; readonly children?: ReactNode; readonly actions?: ReactNode; readonly dismissLabel?: string; @@ -348,7 +347,6 @@ function ComposerBannerStackAlert({ ); } - const descriptionBelow = item.descriptionPlacement === "below"; return ( - + {item.icon} - + {item.description ? ( - descriptionBelow ? ( - - {item.description} - - ) : ( - - {item.description} - - ) + + {item.description} + ) : null} {item.actions || item.onDismiss ? ( - + {item.actions} {item.onDismiss ? ( Date: Tue, 20 Oct 2026 02:02:59 -0700 Subject: [PATCH 009/146] revert: "fix(scheduler): label fixed-time tasks with the server timezone" (#18653) (#22230) Co-authored-by: Claude Opus 5.5 --- .../ScheduledTaskService.schedule.test.ts | 47 ------------------- .../scheduledTasks/ScheduledTaskService.ts | 5 +- apps/server/src/ws.ts | 1 - ...omationsPanel.permissions.browser.test.tsx | 43 +---------------- .../chat/ThreadAutomationsPanel.tsx | 12 ++--- .../settings/ScheduledTasksSettings.tsx | 22 ++------- packages/contracts/src/scheduledTask.ts | 2 - 7 files changed, 11 insertions(+), 121 deletions(-) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 91d58d53b684b..bcb110a5da71b 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -6,7 +6,6 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; @@ -59,52 +58,6 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); -it.effect("includes the scheduler zone in empty lists and subscription updates", () => - Effect.gen(function* () { - const layerDependencies = Layer.mergeAll( - NodeCrypto.layer, - Scheduler.layer, - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - isScratchProject: () => Effect.succeed(false), - }), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - ); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const timeZone = DateTime.zoneToString(DateTime.zoneMakeLocal()); - expect(yield* service.list()).toEqual({ tasks: [], timeZone }); - - const input = yield* decodeUpsertInput({ - title: "Morning review", - prompt: "Review the open pull requests.", - enabled: false, - schedule: { type: "fixed_time", timeOfDay: "09:00" }, - projectId: "project-schedule-zone", - workspaceStrategy: { type: "root" }, - modelSelection: { instanceId: "codex", model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - }); - const snapshots = yield* service.subscribeList().pipe( - Stream.tap((snapshot) => - snapshot.tasks.length === 0 ? service.upsert(input) : Effect.void, - ), - Stream.take(2), - Stream.runCollect, - ); - const populated = yield* service.list(); - expect(populated).toMatchObject({ - timeZone, - tasks: [{ title: "Morning review", schedule: input.schedule }], - }); - expect(snapshots).toEqual([{ tasks: [], timeZone }, populated]); - }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); - it.effect("preserves a due run when a save only pads the scheduled hour", () => Effect.gen(function* () { const dueAt = DateTime.makeZonedUnsafe( diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index d3d2ef19c6ac5..d9a9629940409 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -936,10 +936,7 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ - tasks, - timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), - })), + Effect.map((tasks) => ({ tasks })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7b88c268e7c35..8c3744ed776f6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1321,7 +1321,6 @@ export const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { - ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( 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 0d38057c65450..e780a63507564 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx @@ -1,10 +1,4 @@ -import { - EnvironmentId, - ThreadId, - ScheduledTaskId, - type ScheduledTask, - type ScheduledTaskListResult, -} from "@t3tools/contracts"; +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"; @@ -34,7 +28,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => { }); vi.mock(import("../../state/query"), async (importOriginal) => ({ ...(await importOriginal()), - useEnvironmentQuery: (() => ({ data: listResult, error: null })) as never, + useEnvironmentQuery: (() => ({ data: { tasks: [task] }, error: null })) as never, })); import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; @@ -47,15 +41,12 @@ const task = { enabled: true, schedule: { type: "interval", everyMs: 60000 }, lastRunStatus: "never", - nextRunAt: null, } as ScheduledTask; -let listResult: ScheduledTaskListResult & { timeZone?: string }; let root: Root; let container: HTMLDivElement; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); state.allowed = true; - listResult = { tasks: [task] }; state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); state.run.mockReset().mockResolvedValue({ _tag: "Success" }); container = document.createElement("div"); @@ -93,33 +84,3 @@ it("rejects a retained toggle after task permission is revoked", async () => { await act(async () => byLabel("Pause Daily review").click()); expect(state.toggle).toHaveBeenCalledOnce(); }); - -it("labels fixed times with the server zone and preserves labels from older servers", async () => { - const tasks: ScheduledTask[] = [ - { ...task, schedule: { type: "fixed_time", timeOfDay: "09:00" } }, - { ...task, id: ScheduledTaskId.make("interval"), title: "Interval review" }, - { - ...task, - id: ScheduledTaskId.make("webhook"), - title: "Webhook review", - schedule: { type: "webhook", signature: null }, - }, - ]; - const labels = () => Array.from(container.querySelectorAll("li p"), (row) => row.textContent); - - listResult = { tasks, timeZone: "Asia/Kolkata" }; - await renderPanel(); - expect(labels()).toEqual(["Daily at 09:00 Asia/Kolkata", "Every 1 min", "On webhook"]); - - listResult = { tasks, timeZone: "Etc/UTC" }; - await renderPanel(); - expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); - - listResult = { tasks, timeZone: "UTC" }; - await renderPanel(); - expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); - - listResult = { tasks }; - await renderPanel(); - expect(labels()).toEqual(["Daily at 09:00", "Every 1 min", "On webhook"]); -}); diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 670ea8db30662..28fb297b35265 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -37,10 +37,10 @@ const STATUS_DOT_CLASS: Record = { failed: "bg-destructive", }; -function taskScheduleLine(task: ScheduledTask, timeZone: string | undefined) { +function taskScheduleLine(task: ScheduledTask) { return ( <> - {scheduleLabel(task.schedule, timeZone)} + {scheduleLabel(task.schedule)} {task.enabled && task.nextRunAt !== null ? ( <> {" · next "} @@ -185,15 +185,11 @@ export function ThreadAutomationsPanel(props: { {task.title} -

- {taskScheduleLine(task, tasksQuery.data?.timeZone)} -

+

{taskScheduleLine(task)}

{task.title}

-

- {taskScheduleLine(task, tasksQuery.data?.timeZone)} -

+

{taskScheduleLine(task)}

diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 98988c3d278c6..c22bdc188dbd1 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -179,11 +179,7 @@ function splitModelKey(value: string): ModelSelection | null { }; } -function scheduleTimeZoneLabel(timeZone: string | undefined) { - return timeZone === "Etc/UTC" ? "UTC" : timeZone; -} - -export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string): string { +export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; if (schedule.type === "interval") { const minutes = schedule.everyMs / 60_000; @@ -198,8 +194,7 @@ export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) ? "Weekdays" : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - const zone = scheduleTimeZoneLabel(timeZone); - return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`; + return `${days} at ${schedule.timeOfDay}`; } /** @@ -369,7 +364,6 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, - timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -411,7 +405,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks, timeZone }) => { + {populated.map(({ environment, tasks }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return ( @@ -441,7 +435,6 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} - timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -557,7 +550,6 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} - timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -571,12 +563,10 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, - timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; - readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -621,7 +611,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule, timeZone)} ·{" "} + {scheduleLabel(task.schedule)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( @@ -1008,7 +998,6 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); - const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -1512,9 +1501,6 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, timeOfDay: event.target.value })) } /> - {runAtZone === undefined ? null : ( - {runAtZone} - )} on
Date: Tue, 20 Oct 2026 02:03:05 -0700 Subject: [PATCH 010/146] revert: "fix(web): preserve snapshot thumbnail context and detail" (#18440) (#22231) Co-authored-by: Claude Opus 5.5 --- .../src/snapShot/SnapShotTransition.ts | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 6 +- .../chat/ComposerImageThumbnail.tsx | 32 +++-------- apps/web/src/lib/imageCompression.test.ts | 55 ------------------- apps/web/src/lib/imageCompression.ts | 46 +++++----------- 5 files changed, 25 insertions(+), 118 deletions(-) diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 6a25cd5048b4a..31eae474f87cd 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -175,7 +175,7 @@ window.prepareCaptureTransition=async destination=>{ const inner={width:Math.max(1,target.width-borderWidth*2),height:Math.max(1,target.height-borderWidth*2)}; const sourceCenter={x:source.x+source.width/2,y:source.y+source.height/2},targetCenter={x:target.x+target.width/2,y:target.y+target.height/2}; const initial="translate3d("+(sourceCenter.x-targetCenter.x)+"px,"+(sourceCenter.y-targetCenter.y)+"px,0) scale("+(source.width/target.width)+","+(source.height/target.height)+")"; - const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),fit=Math.min(inner.width/imageWidth,inner.height/imageHeight); + const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),cover=Math.max(inner.width/imageWidth,inner.height/imageHeight); Object.assign(card.style,{left:target.x+"px",top:target.y+"px",width:target.width+"px",height:target.height+"px",borderRadius:destination.cornerRadius+"px",backgroundColor:destination.backgroundColor,boxShadow:"none"}); Object.assign(content.style,{inset:borderWidth+"px",borderRadius:Math.max(0,destination.cornerRadius-borderWidth)+"px"}); Object.assign(border.style,{borderWidth:borderWidth+"px",borderColor:destination.borderColor}); @@ -185,7 +185,7 @@ window.prepareCaptureTransition=async destination=>{ const animations=[ card.animate([{transform:initial},{transform:"translate3d(0,0,0) scale(1)"}],options), content.animate([{transform:"scale("+(target.width/inner.width)+","+(target.height/inner.height)+")"},{transform:"scale(1)"}],options), - snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*fit)/inner.width)+","+((imageHeight*fit)/inner.height)+")"}],options), + snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*cover)/inner.width)+","+((imageHeight*cover)/inner.height)+")"}],options), border.animate([{opacity:0},{opacity:1}],options), details.animate([{opacity:0},{opacity:1}],{delay:Math.max(0,destination.durationMs-125),duration:125,fill:"both",easing:"ease-in"}) ]; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 137c4d26adb5d..b62812f25775f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4923,9 +4923,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {image.previewUrl ? ( (null); + const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null); useEffect(() => { let active = true; - void createComposerImageThumbnail(file, mode).then((src) => { - if (active) setPreview({ file, mode, src }); + void createComposerImageThumbnail(file).then((src) => { + if (active) setPreview({ file, src }); }); return () => { active = false; }; - }, [file, mode]); - const src = preview?.file === file && preview.mode === mode ? preview.src : null; - return src ? ( - {alt} - ) : ( - fallback - ); + }, [file]); + const src = preview?.file === file ? preview.src : null; + return src ? {alt} : fallback; }); diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 3a199a06ab310..69316200b7aa4 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -178,61 +178,6 @@ describe("composer image thumbnails", () => { expect(await original.text()).toBe("original bytes"); }); - it.each([ - [3840, 2160, 796, 448], - [3840, 1600, 832, 347], - ])( - "preserves the full %ix%i snapshot within an 832x448 preview without replacing its square thumbnail", - async (sourceWidth, sourceHeight, width, height) => { - const bitmap = { width: sourceWidth, height: sourceHeight, close: vi.fn() }; - const decode = vi.fn(async () => bitmap); - const drawImage = vi.fn(); - vi.stubGlobal("createImageBitmap", decode); - vi.stubGlobal( - "OffscreenCanvas", - class { - constructor( - public width: number, - public height: number, - ) {} - getContext() { - return { drawImage }; - } - async convertToBlob() { - return new Blob([`${this.width}x${this.height}`], { type: "image/png" }); - } - }, - ); - const original = new File(["original bytes"], "window.png", { type: "image/png" }); - const square = await createComposerImageThumbnail(original); - const [snapshot, cachedSnapshot] = await Promise.all([ - createComposerImageThumbnail(original, "snapshot"), - createComposerImageThumbnail(original, "snapshot"), - ]); - - expect(snapshot).toBe( - `data:image/png;base64,${Buffer.from(`${width}x${height}`).toString("base64")}`, - ); - expect(drawImage).toHaveBeenLastCalledWith( - bitmap, - 0, - 0, - sourceWidth, - sourceHeight, - 0, - 0, - width, - height, - ); - expect(cachedSnapshot).toBe(snapshot); - expect(await createComposerImageThumbnail(original, "snapshot")).toBe(snapshot); - expect(square).toBe(`data:image/png;base64,${Buffer.from("256x256").toString("base64")}`); - expect(await createComposerImageThumbnail(original)).toBe(square); - expect(decode).toHaveBeenCalledTimes(2); - expect(await original.text()).toBe("original bytes"); - }, - ); - it("releases the decoded image when thumbnail encoding fails", async () => { const close = vi.fn(); vi.stubGlobal( diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index c87f0d487595e..5e4c40cfd890d 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -204,49 +204,31 @@ async function encodeCanvas( return blob?.type === mimeType ? blob : null; } -const COMPOSER_THUMBNAIL_SIZES = { - square: { width: 256, height: 256 }, - // The 208x112 snapshot tile at retina density and 2x app zoom. - snapshot: { width: 832, height: 448 }, -} as const; -export type ComposerImageThumbnailMode = keyof typeof COMPOSER_THUMBNAIL_SIZES; -const composerThumbnails = new WeakMap< - File, - Partial>> ->(); - -export function createComposerImageThumbnail( - file: File, - mode: ComposerImageThumbnailMode = "square", -): Promise { - const fileThumbnails = composerThumbnails.get(file); - const cached = fileThumbnails?.[mode]; +const composerThumbnails = new WeakMap>(); + +export function createComposerImageThumbnail(file: File): Promise { + const cached = composerThumbnails.get(file); if (cached) return cached; const thumbnail = (async () => { if (!canRecompress()) return null; let bitmap: ImageBitmap | undefined; try { bitmap = await createImageBitmap(file); - if (bitmap.width <= 0 || bitmap.height <= 0) return null; const side = Math.min(bitmap.width, bitmap.height); - const sourceWidth = mode === "square" ? side : bitmap.width; - const sourceHeight = mode === "square" ? side : bitmap.height; - const bounds = COMPOSER_THUMBNAIL_SIZES[mode]; - const scale = Math.min(1, bounds.width / sourceWidth, bounds.height / sourceHeight); - const width = Math.max(1, Math.round(sourceWidth * scale)); - const height = Math.max(1, Math.round(sourceHeight * scale)); - const surface = createCanvas(width, height); + if (side <= 0) return null; + const dimension = Math.min(256, side); + const surface = createCanvas(dimension, dimension); if (!surface) return null; surface.context.drawImage( bitmap, - (bitmap.width - sourceWidth) / 2, - (bitmap.height - sourceHeight) / 2, - sourceWidth, - sourceHeight, + (bitmap.width - side) / 2, + (bitmap.height - side) / 2, + side, + side, 0, 0, - width, - height, + dimension, + dimension, ); const blob = await encodeCanvas(surface.canvas, 1, "image/png"); return blob ? await readFileAsDataUrl(blob) : null; @@ -256,7 +238,7 @@ export function createComposerImageThumbnail( bitmap?.close(); } })(); - composerThumbnails.set(file, { ...fileThumbnails, [mode]: thumbnail }); + composerThumbnails.set(file, thumbnail); return thumbnail; } From 5547d7c13cf7298a7ccef05ad6325f687f9a40a3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:03:27 -0700 Subject: [PATCH 011/146] revert: "fix(web): show idle server updates on environment controls" (#21950) (#22233) Co-authored-by: Claude Opus 5.5 --- .../components/BranchToolbar.logic.test.ts | 31 ----- .../web/src/components/BranchToolbar.logic.ts | 8 +- .../BranchToolbar.panel.browser.test.tsx | 92 +++---------- apps/web/src/components/BranchToolbar.tsx | 42 ++---- .../BranchToolbarEnvironmentSelector.tsx | 125 +++--------------- apps/web/src/components/ChatView.tsx | 100 ++++++++------ .../ServerUpdateAction.browser.test.tsx | 110 +-------------- .../web/src/components/ServerUpdateAction.tsx | 48 ++----- apps/web/src/versionSkew.test.ts | 6 + apps/web/src/versionSkew.ts | 4 + 10 files changed, 139 insertions(+), 427 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 33dd09d60c066..7493ba2b795a0 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -424,37 +424,6 @@ describe("shouldShowEnvironmentIndicator", () => { }); describe("shouldShowComposerContextStrip", () => { - it("keeps a sole primary server update reachable in a non-Git draft", () => { - const environment = { - activeEnvironment: { isPrimary: true }, - canPickEnvironment: false, - serverUpdateAvailable: true, - }; - const showEnvironmentIndicator = shouldShowEnvironmentIndicator(environment); - expect(showEnvironmentIndicator).toBe(true); - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator, - hostsRestingComposerControls: false, - }), - ).toBe(true); - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: false, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator, - serverUpdateAvailable: true, - hostsRestingComposerControls: false, - }), - ).toBe(true); - }); - it.each([false, true])( "honors the active-thread preference with resting controls %s", (hostsRestingComposerControls) => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 55ac56e09965d..24db217304a5d 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -59,9 +59,8 @@ export function resolveEnvironmentOptionLabel(input: { export function shouldShowEnvironmentIndicator(input: { activeEnvironment: Pick | null; canPickEnvironment: boolean; - serverUpdateAvailable?: boolean; }): boolean { - if (input.canPickEnvironment || input.serverUpdateAvailable) return true; + if (input.canPickEnvironment) return true; return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } @@ -72,13 +71,10 @@ export function shouldShowComposerContextStrip(input: { isGitRepo: boolean; showEnvironmentIndicator: boolean; hostsRestingComposerControls: boolean; - serverUpdateAvailable?: boolean; }): boolean { return ( input.hasActiveProject && - (input.isDraftHeroState || - input.persistInActiveThreads || - input.serverUpdateAvailable === true) && + (input.isDraftHeroState || input.persistInActiveThreads) && (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls) ); } diff --git a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx index 51ce200a53e60..affead3c169c8 100644 --- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx +++ b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx @@ -19,10 +19,6 @@ vi.mock(import("../localApi"), async (original) => ({ vi.mock(import("../hooks/useCopyToClipboard"), async (original) => ({ ...(await original()), writeTextToClipboard: state.writeTextToClipboard, - useCopyToClipboard: ((options: { target: string }) => ({ - copyToClipboard: (command: string) => state.writeTextToClipboard(command, options.target), - isCopied: false, - })) as never, })); vi.mock(import("./BranchToolbarBranchSelector"), async (original) => ({ ...(await original()), @@ -35,8 +31,6 @@ vi.mock(import("../state/entities"), async (original) => ({ })); import { BranchToolbar } from "./BranchToolbar"; -import { ComposerSurface } from "./chat/ComposerSurface"; -import type { ServerUpdateTarget } from "./ServerUpdateAction"; let root: Root; let container: HTMLDivElement; @@ -60,24 +54,22 @@ afterEach(async () => { function render(props: Partial>) { return act(async () => root.render( - - - , + , ), ); } @@ -153,55 +145,3 @@ it.each([ } }, ); - -it.each([ - { layout: "wide Git", width: 900, showGitControls: true }, - { layout: "narrow Git", width: 360, showGitControls: true }, - { layout: "sole primary non-Git", width: 360, showGitControls: false }, -])( - "copies the pending update from the $layout composer chip", - async ({ width, showGitControls }) => { - await page.viewport(1000, 700); - container.style.width = `${width}px`; - container.style.setProperty("--chat-content-max-width", `${width}px`); - const serverUpdate = { - environmentId: EnvironmentId.make("local"), - serverLabel: "Build box", - serverVersion: "0.0.30", - targetVersion: "0.0.31", - selfUpdate: null, - installation: { kind: "npm-global", prefix: "/opt/node" }, - } satisfies ServerUpdateTarget & { serverVersion: string }; - await render({ - layout: "composer", - envLocked: showGitControls, - showGitControls, - availableEnvironments: [ - { - environmentId: serverUpdate.environmentId, - projectId: ProjectId.make("project"), - label: serverUpdate.serverLabel, - isPrimary: true, - machine: "server", - }, - ], - serverUpdate, - }); - - expect( - container.querySelector('[data-slot="composer-shell"]')!.getBoundingClientRect().width, - ).toBe(width); - const chip = page.getByRole("button", { name: /Build box|Copy update command/ }); - await expect.element(chip).toBeVisible(); - await userEvent.hover(chip); - await expect.element(page.getByRole("tooltip")).toMatchTextContent("0.0.30"); - await expect.element(page.getByRole("tooltip")).toMatchTextContent("0.0.31"); - await userEvent.click(chip); - await vi.waitFor(() => - expect(state.writeTextToClipboard).toHaveBeenCalledWith( - "npm install --global --prefix '/opt/node' t3@0.0.31", - "update command", - ), - ); - }, -); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 2447ae3e1ebda..d4116565c8a1c 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -47,7 +47,6 @@ import { import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { openAddProjectToMachine } from "./AddProjectToMachineDialog"; import { type AddProjectTarget, useAddProjectTargets } from "../hooks/useAddProjectTargets"; -import type { PendingServerUpdate } from "./ServerUpdateAction"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; @@ -112,7 +111,6 @@ interface BranchToolbarProps { onEnvironmentChange?: (environmentId: EnvironmentId) => void; composerControlsHostRef?: (element: HTMLDivElement | null) => void; contextStripVisible?: boolean; - serverUpdate?: PendingServerUpdate | null; } function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) { @@ -634,7 +632,6 @@ export const BranchToolbar = memo(function BranchToolbar({ onEnvironmentChange, composerControlsHostRef, contextStripVisible = true, - serverUpdate = null, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); const threadRef = scopeThreadRef(environmentId, threadId); @@ -700,7 +697,6 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, - serverUpdateAvailable: serverUpdate !== null, }); const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); @@ -767,18 +763,6 @@ export const BranchToolbar = memo(function BranchToolbar({ !contextStripVisible && "pointer-events-none invisible absolute inset-x-0 top-full", )} > - {serverUpdate && availableEnvironments ? ( - 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} - serverUpdate={serverUpdate} - /> - ) : null} {showGitControls ? (
) : null} - {showGitControls || (showEnvironmentIndicator && serverUpdate === null) ? ( + {showGitControls || showEnvironmentIndicator ? (
{showEnvironmentIndicator && availableEnvironments && ( <> - {serverUpdate === null ? ( - 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} - /> - ) : null} + 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} + /> {showGitControls ? ( void; onAddToMachine?: (() => void) | undefined; - serverUpdate?: PendingServerUpdate | null; } // Action values cannot collide with server-minted environment IDs. const AUTO_VALUE = "action:auto"; -const UPDATE_VALUE = "action:update"; const ADD_MACHINE_VALUE = "action:add-machine"; const environmentValue = (id: EnvironmentId) => `env:${id}`; -export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector( - props: BranchToolbarEnvironmentSelectorProps, -) { - return props.serverUpdate ? ( - } - /> - ) : ( - - ); -}); - -function EnvironmentSelector({ +export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ autoEnvironmentLabel, onAutoEnvironment, envLocked, @@ -63,11 +43,7 @@ function EnvironmentSelector({ availableEnvironments, onEnvironmentChange, onAddToMachine, - serverUpdate, - updateControl, -}: BranchToolbarEnvironmentSelectorProps & { - updateControl: ServerUpdateControl | null; -}) { +}: BranchToolbarEnvironmentSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const shortcutLabel = shortcutLabelForCommand( useAtomValue(primaryServerKeybindingsAtom), @@ -84,34 +60,8 @@ function EnvironmentSelector({ value: environmentValue(env.environmentId), label: env.label, })), - ...(serverUpdate && updateControl - ? [{ value: UPDATE_VALUE, label: `${updateControl.label} ${serverUpdate.serverLabel}` }] - : []), ...(onAddToMachine ? [{ value: ADD_MACHINE_VALUE, label: "Add to another machine" }] : []), ]; - const updateTooltip = serverUpdate - ? `${serverUpdate.serverLabel} ${serverUpdate.serverVersion} → ${serverUpdate.targetVersion}. ${ - serverUpdate.selfUpdate === "desktop-managed" && !serverUpdate.desktopAppUpdate - ? "Update the desktop app on that machine." - : (updateControl?.label ?? "Update") - }` - : null; - const environmentIcon = serverUpdate ? ( -
)} diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 25d56d69dff7a..8a86d2d37bb0b 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -3,12 +3,9 @@ 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, useState, type ReactNode } from "react"; +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 { page, userEvent } from "vite-plus/test/browser"; - -import "~/index.css"; type SessionResult = AsyncResult.AsyncResult; const testState = vi.hoisted(() => ({ @@ -59,7 +56,6 @@ vi.mock(import("./ui/toast"), async (original) => { }); import { - completeConfirmDialogClose, readConfirmDialogState, registerConfirmDialogHost, resetConfirmDialogForTests, @@ -73,8 +69,6 @@ import { type ServerUpdateTarget, } from "./ServerUpdateAction"; -import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; - const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState); const legacyAuth = { @@ -515,105 +509,3 @@ describe("ServerUpdateProgress", () => { ); }); }); - -describe("server updates from the environment selector", () => { - const localId = "env-test" as EnvironmentId; - const otherId = "update-server" as EnvironmentId; - const availableEnvironments = [ - { environmentId: localId, label: "Build box", isPrimary: true }, - { environmentId: otherId, label: "Other machine", isPrimary: false }, - ].map((environment) => ({ ...environment, projectId: null, machine: "server" as const })); - const serverUpdate = { - environmentId: localId, - serverLabel: "Build box", - serverVersion: "0.0.30", - targetVersion: "0.0.31", - selfUpdate: "desktop-managed", - desktopAppUpdate: true, - } satisfies ServerUpdateTarget & { serverVersion: string }; - - it("confirms a locked chip update and honors cancellation before updating", async () => { - registerConfirmDialogHost(); - testState.updateServer.mockResolvedValue(success("desktop-app")); - await render( - , - ); - const chip = page.getByRole("button", { name: /Build box|Update/ }); - await userEvent.click(chip); - expect(readConfirmDialogState()).toMatchObject({ - status: "confirming", - message: expect.stringContaining("Build box"), - }); - expect(testState.updateServer).not.toHaveBeenCalled(); - await act(async () => { - respondToConfirmDialog(false); - completeConfirmDialogClose(); - }); - expect(testState.updateServer).not.toHaveBeenCalled(); - await userEvent.click(chip); - await act(async () => respondToConfirmDialog(true)); - expect(testState.updateServer).toHaveBeenCalledExactlyOnceWith({ - environmentId: localId, - input: { targetVersion: "0.0.31" }, - }); - }); - - it("copies from the picker without switching machines and still selects an action-like environment ID", async () => { - const update = { ...serverUpdate, selfUpdate: null, installation: { kind: "npx" } } as const; - function Picker() { - const [environmentId, setEnvironmentId] = useState(localId); - return ( - - ); - } - await render(); - const picker = page.getByRole("combobox", { name: "Run on" }); - await userEvent.click(picker); - await expect.element(page.getByRole("option", { name: "Other machine" })).toBeVisible(); - expect(testState.clipboard).not.toHaveBeenCalled(); - await userEvent.click(page.getByRole("option", { name: /Copy relaunch command/ })); - expect(testState.clipboard).toHaveBeenCalledExactlyOnceWith("npx t3@0.0.31"); - expect(testState.updateServer).not.toHaveBeenCalled(); - await expect.element(picker).toHaveTextContent("Build box"); - await userEvent.click(picker); - await expect - .element(page.getByRole("option", { name: "Build box", exact: true })) - .toHaveAttribute("aria-selected", "true"); - await userEvent.click(page.getByRole("option", { name: "Other machine" })); - await expect.element(picker).toHaveTextContent("Other machine"); - expect(testState.clipboard).toHaveBeenCalledTimes(1); - expect(testState.updateServer).not.toHaveBeenCalled(); - }); - - it("explains an unsupported desktop update in the picker without offering a runnable action", async () => { - registerConfirmDialogHost(); - await render( - , - ); - await userEvent.click(page.getByRole("combobox", { name: "Run on" })); - const instruction = page.getByRole("option", { name: /Update the desktop app/ }); - await expect.element(instruction).toBeVisible(); - await expect.element(instruction).toHaveAttribute("aria-disabled", "true"); - await userEvent.click(instruction, { force: true }); - expect(readConfirmDialogState().status).toBe("idle"); - expect(testState.updateServer).not.toHaveBeenCalled(); - expect(testState.clipboard).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index dd9ff5a147cee..3d809655e28b5 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -18,7 +18,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { CircleArrowUpIcon } from "lucide-react"; -import { type ComponentProps, type ReactNode, useRef, useState } from "react"; +import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -57,16 +57,6 @@ export interface ServerUpdateTarget { readonly continueThreadsAfterServerUpdate?: boolean; } -export type PendingServerUpdate = Omit & { - readonly serverVersion: string; -}; - -export interface ServerUpdateControl { - readonly label: string; - readonly disabled: boolean; - readonly onClick: () => void; -} - type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { readonly label?: string; readonly appearance?: "button" | "icon"; @@ -225,13 +215,8 @@ export function ServerUpdateAction({ size = "xs", className, appearance = "button", - render, -}: Omit & - UpdateButtonProps & { - readonly render?: (control: ServerUpdateControl) => ReactNode; - }) { +}: Omit & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; - const unsupportedDesktopUpdate = isDesktopAppUpdate && !desktopAppUpdate; const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); const continueThreadsAfterServerUpdate = useEnvironmentSettings( @@ -263,7 +248,6 @@ export function ServerUpdateAction({ const handleUpdate = async () => { if ( - unsupportedDesktopUpdate || !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || pendingUpdateEnvironmentIds.has(environmentId) ) { @@ -290,11 +274,18 @@ export function ServerUpdateAction({ }); }; + if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { + return ( + + Update the desktop app on that machine to update this server. + + ); + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; - const actionLabel = unsupportedDesktopUpdate - ? "Update the desktop app on" - : manualCommand !== null + const actionLabel = + manualCommand !== null ? installation?.kind === "npm-global" ? "Copy update command" : "Copy relaunch command" @@ -303,17 +294,6 @@ export function ServerUpdateAction({ manualCommand !== null ? () => copyToClipboard(manualCommand, { command: manualCommand }) : () => void handleUpdate(); - const disabled = unsupportedDesktopUpdate || (manualCommand === null && !canUpdate); - - if (render) return render({ label: actionLabel, disabled, onClick }); - - if (unsupportedDesktopUpdate) { - return ( - - Update the desktop app on that machine to update this server. - - ); - } if (appearance === "icon") { return ( @@ -325,7 +305,7 @@ export function ServerUpdateAction({ variant="ghost-muted" className={className} aria-label={`${actionLabel} for ${serverLabel}`} - disabled={disabled} + disabled={manualCommand === null && !canUpdate} onClick={onClick} /> } @@ -342,7 +322,7 @@ export function ServerUpdateAction({ size={size} variant={variant} className={className} - disabled={disabled} + disabled={manualCommand === null && !canUpdate} onClick={onClick} > {actionLabel} diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index 3a3e27d0fb87e..bfed9b8e081fb 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -17,6 +17,7 @@ import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, resolveVersionMismatch, + serverUpdateGuidance, supportsDesktopAppUpdate, } from "./versionSkew"; @@ -235,4 +236,9 @@ describe("versionSkew", () => { expect(supportsDesktopAppUpdate(descriptor())).toBe(false); expect(supportsDesktopAppUpdate(null)).toBe(false); }); + + it("matches version-drift guidance to the advertised update path", () => { + expect(serverUpdateGuidance("respawn")).toBe("Update to stay in sync"); + expect(serverUpdateGuidance("desktop-managed")).toBe("Update the desktop app"); + }); }); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index ec7118a2f69d2..bcf4a27b6ccec 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -114,6 +114,10 @@ export function manualServerUpdateCommand( return `${runner} t3@${targetVersion}`; } +export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { + return capability === "desktop-managed" ? "Update the desktop app" : "Update to stay in sync"; +} + export function buildVersionMismatchDismissalKey( environmentId: EnvironmentId, mismatch: Pick, From 45ad8d9f0077e3dd6cd82a50df239bd8e18e0f7f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:08:44 -0700 Subject: [PATCH 012/146] fix(client): a frozen host no longer shows a heartbeat as slow or blames your DNS (#22212) Co-authored-by: Claude Opus 5.5 --- apps/web/src/connection/platform.ts | 4 ++-- apps/web/src/rpc/requestLatencyState.test.ts | 13 ++++++++----- apps/web/src/rpc/requestLatencyState.ts | 7 ++++++- packages/client-runtime/src/errors/network.ts | 3 +++ .../src/errors/transport.test.ts | 5 +++++ .../client-runtime/src/errors/transport.ts | 3 ++- .../client-runtime/src/rpc/client.test.ts | 6 +++--- packages/client-runtime/src/rpc/client.ts | 2 ++ .../client-runtime/src/rpc/session.test.ts | 19 ++++++++++++++++--- packages/client-runtime/src/rpc/session.ts | 19 +++++++++++-------- 10 files changed, 58 insertions(+), 23 deletions(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index e98cb37802be2..86514f88fe340 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -812,11 +812,11 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( const layerRpcRequestObserver = Layer.succeed( EnvironmentRpcRequestObserver, EnvironmentRpcRequestObserver.of({ - observe: ({ environmentId, method }) => + observe: ({ environmentId, environmentLabel, method }) => Effect.sync(() => { nextObservedRpcRequestId += 1; const requestId = `${environmentId}:${nextObservedRpcRequestId}`; - trackRpcRequestSent(requestId, method, `${method} · ${environmentId}`); + trackRpcRequestSent(requestId, method, `${method} · ${environmentLabel}`); return Effect.sync(() => { acknowledgeRpcRequest(requestId); }); diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index fb9ff5c7f1bf1..5af29d8c21e3c 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -52,12 +52,15 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); - it("ignores usage summary requests", () => { - trackRpcRequestSent("1", WS_METHODS.serverGetUsageSummary); - vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + it.each([WS_METHODS.serverGetUsageSummary, WS_METHODS.serverReportClientActivity])( + "ignores background request %s", + (method) => { + trackRpcRequestSent("1", method); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); - expect(getSlowRpcAckRequests()).toEqual([]); - }); + expect(getSlowRpcAckRequests()).toEqual([]); + }, + ); it.each(Object.values(WS_METHODS).filter((method) => method.startsWith("pullRequests.")))( "ignores pull request workspace request %s", diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 086e1dcda0dae..151079905cb40 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -30,7 +30,12 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map(); -const untrackedRpcAckMethods = new Set([WS_METHODS.serverGetUsageSummary]); +// Background reports nobody waits on. A busy host delays them first, and warning +// about a request the user never made does not say what is wrong. +const untrackedRpcAckMethods = new Set([ + WS_METHODS.serverGetUsageSummary, + WS_METHODS.serverReportClientActivity, +]); const longRunningRpcAckMethods = new Set([ WS_METHODS.serverUpdateProvider, WS_METHODS.serverRefreshProviders, diff --git a/packages/client-runtime/src/errors/network.ts b/packages/client-runtime/src/errors/network.ts index 40816bc23f149..db6173f1171d3 100644 --- a/packages/client-runtime/src/errors/network.ts +++ b/packages/client-runtime/src/errors/network.ts @@ -1,2 +1,5 @@ export const NETWORK_BLOCKING_HINT = "Your DNS or firewall may be blocking T3 Connect. Try another network, such as a different Wi-Fi or mobile data."; + +/** For a host that stopped answering on an open connection, so the network path was already working. */ +export const UNRESPONSIVE_HOST_HINT = "It may be overloaded, asleep, or off the network."; diff --git a/packages/client-runtime/src/errors/transport.test.ts b/packages/client-runtime/src/errors/transport.test.ts index 46cf32f329260..dd14d5b84a494 100644 --- a/packages/client-runtime/src/errors/transport.test.ts +++ b/packages/client-runtime/src/errors/transport.test.ts @@ -23,6 +23,11 @@ describe("isTransportConnectionErrorMessage", () => { it("recognizes connection errors emitted by the Effect RPC session", () => { expect(isTransportConnectionErrorMessage("Test environment disconnected.")).toBe(true); expect(isTransportConnectionErrorMessage("Test environment stopped responding.")).toBe(true); + expect( + isTransportConnectionErrorMessage( + "Test environment stopped responding for 15s. It may be overloaded, asleep, or off the network.", + ), + ).toBe(true); expect( isTransportConnectionErrorMessage( "Test environment could not establish a WebSocket connection.", diff --git a/packages/client-runtime/src/errors/transport.ts b/packages/client-runtime/src/errors/transport.ts index 757b889c2c600..a38f0d7b9f35f 100644 --- a/packages/client-runtime/src/errors/transport.ts +++ b/packages/client-runtime/src/errors/transport.ts @@ -1,4 +1,4 @@ -import { NETWORK_BLOCKING_HINT } from "./network.ts"; +import { NETWORK_BLOCKING_HINT, UNRESPONSIVE_HOST_HINT } from "./network.ts"; const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -13,6 +13,7 @@ const TRANSPORT_ERROR_PATTERNS = [ `\\b(?:is not connected|disconnected|stopped responding|could not establish a WebSocket connection)\\.(?: ${escapeRegExp(NETWORK_BLOCKING_HINT)})?$`, "i", ), + new RegExp(`\\bstopped responding for \\d+s\\. ${escapeRegExp(UNRESPONSIVE_HOST_HINT)}$`, "i"), /\bClientProtocolError\b/i, /\bRpcClientError\b/i, /\bping timeout\b/i, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 536d54a3f5220..43df0e874db30 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -137,9 +137,9 @@ describe("environment RPC", () => { Effect.provideService( EnvironmentRpcRequestObserver, EnvironmentRpcRequestObserver.of({ - observe: ({ environmentId, method }) => + observe: ({ environmentId, environmentLabel, method }) => Effect.sync(() => { - observations.push(`start:${environmentId}:${method}`); + observations.push(`start:${environmentId}:${environmentLabel}:${method}`); return Effect.sync(() => { observations.push(`finish:${environmentId}:${method}`); }); @@ -150,7 +150,7 @@ describe("environment RPC", () => { expect(result).toEqual({ status: "available", version: "2026.6.0" }); expect(observations).toEqual([ - `start:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, + `start:${TARGET.environmentId}:${TARGET.label}:${WS_METHODS.cloudGetRelayClientStatus}`, `finish:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, ]); }), diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 5b670fd622a5f..c364b3448d03d 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -31,6 +31,7 @@ export class EnvironmentRpcUnavailableError extends Schema.TaggedError Effect.Effect, EnvironmentRpcFailure>; const completeObservation = yield* observer.observe({ environmentId: supervisor.target.environmentId, + environmentLabel: supervisor.target.label, method: tag, }); return yield* method(input).pipe(Effect.ensuring(completeObservation)); diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 77ce244aad5cf..efc8254bafa38 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1084,10 +1084,22 @@ describe("RpcSessionFactory", () => { }), ); - it.effect("closes the session after 15 seconds without a frame from the server", () => + // Over T3 Connect too: the socket opened, so blaming DNS or a firewall misleads. + it.effect.each([ + { relay: false, label: "direct" }, + { relay: true, label: "relay" }, + ])("closes the $label session after 15 seconds without a frame from the server", ({ relay }) => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); - const session = yield* factory.connect(PREPARED); + const session = yield* factory.connect({ + ...PREPARED, + target: relay + ? new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, + }) + : TARGET, + }); const readyFiber = yield* Effect.forkChild(session.ready); const closedFiber = yield* Effect.forkChild(Effect.flip(session.closed)); const socket = yield* awaitSocket(sockets); @@ -1109,7 +1121,8 @@ describe("RpcSessionFactory", () => { expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ reason: "transport", - detail: "Test environment stopped responding.", + detail: + "Test environment stopped responding for 15s. It may be overloaded, asleep, or off the network.", }); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 2f64aa6f34a18..a3618ffd1d2ac 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -7,6 +7,7 @@ import { import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; @@ -25,7 +26,7 @@ import * as RpcSerialization from "effect/rpc/RpcSerialization"; import * as Socket from "effect/socket/Socket"; import { makeWsRpcProtocolClient, PING_TIMEOUT, type WsRpcProtocolClient } from "./protocol.ts"; -import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; +import { NETWORK_BLOCKING_HINT, UNRESPONSIVE_HOST_HINT } from "../errors/network.ts"; import type { ConnectionAttemptError, ConnectionTransientError, @@ -122,6 +123,7 @@ function serverConfigReplayEvents( } const isSocketErrorReason = Schema.is(Socket.SocketErrorReason); +const PING_TIMEOUT_SECONDS = Duration.toSeconds(PING_TIMEOUT); function mapSessionRpcError( error: InitialConfigError | ProbeError | ServerConfigSubscriptionError, @@ -189,13 +191,14 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( disconnected, new ConnectionTransientErrorClass({ reason: "transport", - detail: `${ - !wasConnected - ? `${connection.label} could not establish a WebSocket connection.` - : timedOut - ? `${connection.label} stopped responding.` - : `${connection.label} disconnected.` - }${networkHint}`, + // The network hint only fits a socket that never opened. Once it + // opened, the path worked, and a host that goes quiet is more + // often overloaded or asleep than blocked. + detail: !wasConnected + ? `${connection.label} could not establish a WebSocket connection.${networkHint}` + : timedOut + ? `${connection.label} stopped responding for ${PING_TIMEOUT_SECONDS}s. ${UNRESPONSIVE_HOST_HINT}` + : `${connection.label} disconnected.`, ...socketFailureLocation(via), }), ), From d7bc3a2f49f0f8dd9d5441f2ee4f0377367bcd91 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:09:01 -0700 Subject: [PATCH 013/146] feat(server,web,mobile): a host that drops because it froze now says why (#22213) Co-authored-by: Claude Opus 5.5 --- .../connection/ConnectionEnvironmentRow.tsx | 13 +++- .../observability/EventLoopMonitor.test.ts | 27 +++++++ .../src/observability/EventLoopMonitor.ts | 71 +++++++++++++++++-- apps/server/src/ws.ts | 4 ++ .../components/HostFreezeToastCoordinator.tsx | 33 +++++++++ apps/web/src/routes/__root.tsx | 2 + .../src/connection/hostFreeze.test.ts | 26 +++++++ .../src/connection/hostFreeze.ts | 25 +++++++ .../client-runtime/src/connection/index.ts | 1 + .../client-runtime/src/state/server.test.ts | 70 ++++++++++++++++++ packages/client-runtime/src/state/server.ts | 4 +- packages/contracts/src/server.ts | 19 +++++ 12 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/HostFreezeToastCoordinator.tsx create mode 100644 packages/client-runtime/src/connection/hostFreeze.test.ts create mode 100644 packages/client-runtime/src/connection/hostFreeze.ts diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index ba332c269a736..aa332b407b2e3 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,7 +1,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { ConnectionTraceId } from "./ConnectionTraceId"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { hasSavedBearerRoute } from "@t3tools/client-runtime/connection"; +import { hasSavedBearerRoute, hostFreezeNotice } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -67,6 +67,9 @@ export function ConnectionEnvironmentRow(props: { const statusLabel = connectionStatusLabel(props.environment); const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; const hasConnectionFailure = enabled && props.environment.connectionError !== null; + const freeze = enabled + ? hostFreezeNotice(props.environment.environmentLabel, serverConfig?.lastEventLoopStall) + : null; const isRetrying = enabled && (props.environment.connectionState === "connecting" || @@ -151,6 +154,14 @@ export function ConnectionEnvironmentRow(props: { ) : null} ) : null} + {freeze ? ( + + {`${freeze.title}. ${freeze.description}`} + + ) : null} { }), ); + it.effect("reports a long stall when a client connects, before the next timed sample", () => + Effect.gen(function* () { + const long = { ...stalled, delayMaxNs: ms(13_000), activeMs: 13_000 }; + const context = yield* monitor([quiet, stalled, long]); + const eventLoop = Context.get(context, EventLoopMonitor.EventLoopMonitor); + // The startup sample is skipped, so a connect before it reads nothing. + assert.isUndefined(yield* eventLoop.latestNoticeableStall); + + yield* TestClock.adjust("30 seconds"); + // Under 5 s: logged, but not worth telling a client about. + assert.isUndefined(yield* eventLoop.latestNoticeableStall); + assert.deepStrictEqual(yield* eventLoop.latestNoticeableStall, { + detectedAt: "1970-01-01T00:00:30.000Z", + durationMs: 12_000, + loadAverage: 219.4, + cpuCount: 18, + }); + + // Too old to explain a drop now. + yield* TestClock.adjust("301 seconds"); + assert.isUndefined(yield* eventLoop.latestNoticeableStall); + }).pipe(Effect.scoped), + ); + it.effect("exports the server's memory, CPU and event loop from each sample", () => Effect.gen(function* () { yield* monitor([quiet, stalled]); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index a2944b87b749f..3e03190a6edb3 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -1,14 +1,19 @@ -// @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram. +// @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram, and node:os the load average. +import * as NodeOS from "node:os"; import * as NodePerfHooks from "node:perf_hooks"; +import type { ServerEventLoopStall } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; import * as Metrics from "./Metrics.ts"; @@ -21,6 +26,20 @@ const RESOLUTION_MS = 1000; const STALL_THRESHOLD_MS = 2000; const SAMPLE_INTERVAL = "30 seconds"; const STALL_LOG_MAX_BYTES = 256 * 1024; +// Long enough that users felt it. Shorter stalls stay in the log only. +const NOTICEABLE_STALL_MS = 5000; +// Clients show what they get, so an old stall must not explain a new drop. +const NOTICEABLE_STALL_MAX_AGE_MS = 5 * 60_000; + +export class EventLoopMonitor extends Context.Reference<{ + /** + * The latest stall of 5 s or more, if seen in the last 5 minutes. It samples + * first, so a stall that ended just before a client reconnected is included. + */ + readonly latestNoticeableStall: Effect.Effect; +}>("t3/observability/EventLoopMonitor", { + defaultValue: () => ({ latestNoticeableStall: Effect.succeed(undefined) }), +}) {} /** Delay in ns, active time in ms, CPU in µs. */ export interface EventLoopReadings { @@ -39,6 +58,9 @@ export interface EventLoopReadings { readonly heapUsedBytes: number; /** Wall time the readings cover. */ readonly elapsedMs: number; + /** One-minute load average, undefined on Windows. */ + readonly loadAverage: number | undefined; + readonly cpuCount: number; } // Node skips the first gap after a reset, so a stall right at a sample boundary can be missed. @@ -77,6 +99,9 @@ const makeNodeSampler = Effect.gen(function* () { rssBytes: memory.rss, heapUsedBytes: memory.heapUsed, elapsedMs: nextSampledAt - sampledAt, + // Windows has no load average and reports 0, which a running process never sees elsewhere. + loadAverage: NodeOS.loadavg()[0] || undefined, + cpuCount: NodeOS.availableParallelism(), }; histogram.reset(); elu = nextElu; @@ -120,9 +145,16 @@ export const layerWith = ( makeSampler: Effect.Effect, never, Scope.Scope>, stallLog: StallLogOptions, ) => - Layer.effectDiscard( + Layer.effect( + EventLoopMonitor, Effect.gen(function* () { const sample = yield* makeSampler; + // Clients sample on connect as well as the timer, and the sampler resets shared state. + const sampling = yield* Semaphore.make(1); + const warmedUp = yield* Ref.make(false); + const latestNoticeableStall = yield* Ref.make< + { readonly stall: ServerEventLoopStall; readonly detectedAtMs: number } | undefined + >(undefined); const stallSink = yield* Effect.try( () => new RotatingFileSink({ @@ -143,7 +175,22 @@ export const layerWith = ( yield* recordGauges(readings); const delayMaxMs = stallMs(readings); if (delayMaxMs === undefined) return; - const { utilization, usage, rssBytes } = readings; + const { utilization, usage, rssBytes, loadAverage, cpuCount } = readings; + const now = yield* DateTime.now; + const detectedAt = DateTime.formatIso(now); + if (delayMaxMs >= NOTICEABLE_STALL_MS) { + yield* Ref.set(latestNoticeableStall, { + detectedAtMs: DateTime.toEpochMillis(now), + stall: { + detectedAt, + durationMs: delayMaxMs, + ...(loadAverage === undefined + ? {} + : { loadAverage: Math.round(loadAverage * 10) / 10 }), + cpuCount, + }, + }); + } const attributes = { delayMaxMs, utilization: Math.round(utilization * 100) / 100, @@ -158,8 +205,7 @@ export const layerWith = ( Effect.withSpan("server.eventLoop.stall", { root: true, level: "Warn", attributes }), ); if (stallSink !== undefined) { - const time = DateTime.formatIso(yield* DateTime.now); - yield* encodeStallLine({ time, ...attributes }).pipe( + yield* encodeStallLine({ time: detectedAt, ...attributes }).pipe( Effect.flatMap((line) => Effect.try(() => stallSink.write(`${line}\n`))), Effect.catch(() => Effect.logWarning("event loop stall log write failed", { @@ -168,17 +214,28 @@ export const layerWith = ( ), ); } - }); + }).pipe(sampling.withPermits(1)); const wait = Effect.sleep(SAMPLE_INTERVAL); // The layer builds before the rest of the server, so the first sample covers // startup work such as migrations and projection bootstrap. That can block the // loop for seconds on a large database, so skip it rather than warn at every // launch. yield* wait.pipe( - Effect.andThen(sample), + Effect.andThen(sampling.withPermits(1)(sample)), + Effect.andThen(Ref.set(warmedUp, true)), Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), Effect.forkScoped, ); + + return EventLoopMonitor.of({ + latestNoticeableStall: Effect.gen(function* () { + if (yield* Ref.get(warmedUp)) yield* tick; + const latest = yield* Ref.get(latestNoticeableStall); + if (latest === undefined) return undefined; + const ageMs = DateTime.toEpochMillis(yield* DateTime.now) - latest.detectedAtMs; + return ageMs <= NOTICEABLE_STALL_MAX_AGE_MS ? latest.stall : undefined; + }), + }); }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8c3744ed776f6..f4f5b771fecc3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -214,6 +214,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; +import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; @@ -1202,6 +1203,7 @@ export const layerWsRpc = ( const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const eventLoopMonitor = yield* EventLoopMonitor.EventLoopMonitor; const threadSearch = yield* ThreadSearch.ThreadSearch; const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; @@ -1679,6 +1681,7 @@ export const layerWsRpc = ( const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; + const lastEventLoopStall = yield* eventLoopMonitor.latestNoticeableStall; const editorConfig = yield* resolveEditorConfig( yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), @@ -1720,6 +1723,7 @@ export const layerWsRpc = ( onSome: (root) => ({ scratchWorkspaceRoot: root }), }), newProjectsRoot: managedFolders.namedProjectsRoot, + ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), }; }); diff --git a/apps/web/src/components/HostFreezeToastCoordinator.tsx b/apps/web/src/components/HostFreezeToastCoordinator.tsx new file mode 100644 index 0000000000000..d04c7344b4783 --- /dev/null +++ b/apps/web/src/components/HostFreezeToastCoordinator.tsx @@ -0,0 +1,33 @@ +import { useAtomValue } from "@effect/atom-react"; +import { hostFreezeNotice } from "@t3tools/client-runtime/connection"; +import { useEffect, useRef } from "react"; + +import { environmentSummaries } from "../state/presentation"; +import { environmentServerConfigsAtom } from "../state/server"; +import { toastManager } from "./ui/toast"; + +/** Tells the user why a host dropped, once per stall, when it reports one on reconnect. */ +export function HostFreezeToastCoordinator() { + const configs = useAtomValue(environmentServerConfigsAtom); + const identities = useAtomValue(environmentSummaries.identitiesAtom); + const shownRef = useRef(new Set()); + + useEffect(() => { + for (const [environmentId, config] of configs) { + const stall = config.lastEventLoopStall; + if (stall === undefined) continue; + const key = `${environmentId}:${stall.detectedAt}`; + if (shownRef.current.has(key)) continue; + shownRef.current.add(key); + const label = + identities.find((identity) => identity.environmentId === environmentId)?.label ?? + config.environment.label; + const notice = hostFreezeNotice(label, stall); + if (notice !== null) { + toastManager.add({ type: "warning", ...notice }); + } + } + }, [configs, identities]); + + return null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a2dc82d03c4d1..31f944ef7fd4c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -37,6 +37,7 @@ import { ThreadNotificationCoordinator } from "../components/ThreadNotificationC import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; import { PullRequestGuideRunCoordinator } from "../components/pullRequest/guide/PullRequestGuideRunCoordinator"; +import { HostFreezeToastCoordinator } from "../components/HostFreezeToastCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; import { ProviderAuthCallbackCoordinator } from "../components/settings/ProviderAuthCallbackCoordinator"; @@ -243,6 +244,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} diff --git a/packages/client-runtime/src/connection/hostFreeze.test.ts b/packages/client-runtime/src/connection/hostFreeze.test.ts new file mode 100644 index 0000000000000..970d5fffe4953 --- /dev/null +++ b/packages/client-runtime/src/connection/hostFreeze.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hostFreezeNotice } from "./hostFreeze.ts"; + +const stall = { + detectedAt: "2026-10-20T07:50:37.633Z", + durationMs: 12_422, + loadAverage: 219.4, + cpuCount: 18, +}; + +describe("hostFreezeNotice", () => { + it("blames an overloaded CPU when the load is above the core count", () => { + expect(hostFreezeNotice("leftbook", stall)).toEqual({ + title: "leftbook froze for 12s", + description: "Its CPU is overloaded (load 219 on 18 cores).", + }); + }); + + it("blames T3 Code when the CPU had room, or the host reports no load", () => { + const busy = "T3 Code on leftbook was too busy to answer."; + expect(hostFreezeNotice("leftbook", { ...stall, loadAverage: 4 })?.description).toBe(busy); + const { loadAverage: _load, ...windows } = stall; + expect(hostFreezeNotice("leftbook", windows)?.description).toBe(busy); + }); +}); diff --git a/packages/client-runtime/src/connection/hostFreeze.ts b/packages/client-runtime/src/connection/hostFreeze.ts new file mode 100644 index 0000000000000..51f228f204a1b --- /dev/null +++ b/packages/client-runtime/src/connection/hostFreeze.ts @@ -0,0 +1,25 @@ +import type { ServerEventLoopStall } from "@t3tools/contracts"; + +export interface HostFreezeNotice { + readonly title: string; + readonly description: string; +} + +/** + * Says why a host stopped answering, from the recent stall it reports on + * connect. Web shows it as a toast, mobile in the environment's connection row. + */ +export function hostFreezeNotice( + hostLabel: string, + stall: ServerEventLoopStall | undefined, +): HostFreezeNotice | null { + if (stall === undefined) return null; + const { loadAverage, cpuCount } = stall; + return { + title: `${hostLabel} froze for ${Math.round(stall.durationMs / 1000)}s`, + description: + loadAverage !== undefined && loadAverage > cpuCount + ? `Its CPU is overloaded (load ${Math.round(loadAverage)} on ${cpuCount} cores).` + : `T3 Code on ${hostLabel} was too busy to answer.`, + }; +} diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 756e68c5dd450..5686b3c3df704 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -5,6 +5,7 @@ export * as CredentialStore from "./credentialStore.ts"; export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; export * from "./errors.ts"; export * from "./githubRoutingPermissions.ts"; +export * from "./hostFreeze.ts"; export * as Connection from "./layer.ts"; export * from "./model.ts"; export * as ConnectionOnboarding from "./onboarding.ts"; diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index f788e9e1fef76..0eafd2525eed7 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -882,6 +882,76 @@ describe("server state projection", () => { }), ); + it.effect("shows a reported stall but never caches it", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const savedConfigs = yield* Queue.unbounded(); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedSome(CONFIG), + saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerConfigState({}).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + expect(Option.getOrThrow(yield* SubscriptionRef.get(state)).config).toBe(CONFIG); + + const lastEventLoopStall = { + detectedAt: "2026-10-20T07:50:37.633Z", + durationMs: 12_422, + loadAverage: 219.4, + cpuCount: 18, + }; + yield* Queue.offer(events, { + version: 1, + type: "snapshot", + config: { ...CONFIG, lastEventLoopStall }, + }); + const projected = yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => + Option.match(value, { + onNone: () => false, + onSome: (projection) => projection.source === "live", + }), + ), + Stream.runHead, + ); + expect(Option.getOrThrow(Option.getOrThrow(projected)).config.lastEventLoopStall).toEqual( + lastEventLoopStall, + ); + }), + ); + + // Cached, it would read as a fresh freeze on the next launch. + expect(yield* Queue.take(savedConfigs)).not.toHaveProperty("lastEventLoopStall"); + }), + ); + it.effect("does not rewrite cached configuration when no live update arrives", () => Effect.gen(function* () { const client = { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index d0cd59c56e968..a38c28db62961 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -386,7 +386,9 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( config: ServerConfig, ) { - return yield* cache.saveServerConfig(environmentId, withoutEnvironmentThemes(config)).pipe( + // A cached stall would read as a fresh freeze on the next launch. + const { lastEventLoopStall: _stall, ...cacheable } = withoutEnvironmentThemes(config); + return yield* cache.saveServerConfig(environmentId, cacheable).pipe( Effect.as(true), Effect.catch((error) => Effect.logWarning("Could not persist cached server configuration.").pipe( diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 06719e9e16f25..c39a29d6fe776 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -586,6 +586,19 @@ export const ServerDirectEndpoint = Schema.Struct({ }); export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; +/** + * A stretch where the server's event loop could not run, so it answered + * nothing and clients may have dropped the connection. + */ +export const ServerEventLoopStall = Schema.Struct({ + detectedAt: IsoDateTime, + durationMs: NonNegativeInt, + /** One-minute load average when the stall was seen. Absent on Windows, which has none. */ + loadAverage: Schema.optionalKey(Schema.Number), + cpuCount: PositiveInt, +}); +export type ServerEventLoopStall = typeof ServerEventLoopStall.Type; + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -616,6 +629,12 @@ export const ServerConfig = Schema.Struct({ threadFindProgressive: Schema.optionalKey(Schema.Boolean), scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), + /** + * The latest stall long enough to notice, sent only for a few minutes after + * it. A client that reconnects reads it to say why the host stopped + * answering. Clients do not cache it, since it describes the host at connect. + */ + lastEventLoopStall: Schema.optionalKey(ServerEventLoopStall), /** * Never sent in a config snapshot: the theme stream emits the current set * before any change, so a snapshot carrying it too would hand every From 774c6c1953677094662e4ff83590e787aa19dcb2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:15:33 -0700 Subject: [PATCH 014/146] revert(web): restore grouped projects in the new thread selector (#22242) --- .../components/CommandPalette.logic.test.ts | 25 ------- .../src/components/CommandPalette.logic.ts | 2 - apps/web/src/components/CommandPalette.tsx | 69 ++++++++----------- apps/web/src/components/Sidebar.tsx | 8 +-- apps/web/src/environmentGrouping.test.ts | 52 -------------- apps/web/src/hooks/useHandleNewThread.test.ts | 52 +------------- apps/web/src/hooks/useHandleNewThread.ts | 9 --- apps/web/src/routes/_chat.tsx | 23 +++---- apps/web/src/sidebarProjectGrouping.ts | 37 ---------- 9 files changed, 43 insertions(+), 234 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4abd2a4655d1c..101759fc6c42f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -348,31 +348,6 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildProjectActionItems", () => { - it("shows offline checkout rows but excludes them from keyboard selection", () => { - const local = makeProject(); - const remote = makeProject({ - id: local.id, - environmentId: EnvironmentId.make("offline"), - }); - const items = buildProjectActionItems({ - projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })), - valuePrefix: "new-thread-in", - icon: () => null, - runProject: async () => undefined, - disabled: (project) => project.environmentId === remote.environmentId, - }); - const groups = [{ value: "projects", label: "Projects", items }]; - const { rows, itemValues } = buildCommandPaletteRows(groups); - - expect(rows.filter((row) => row.kind === "item").map((row) => row.item.value)).toEqual([ - `new-thread-in:${remote.environmentId}:${remote.id}`, - `new-thread-in:${local.environmentId}:${local.id}`, - ]); - expect(itemValues).toEqual([`new-thread-in:${local.environmentId}:${local.id}`]); - expect(findHighlightedCommandPaletteItem(groups, items[0]!.value)).toBeNull(); - expect(findHighlightedCommandPaletteItem(groups, items[1]!.value)).toBe(items[1]); - }); - it("shows the grouped display name but keeps the real title for icons", () => { const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); const iconTitles: string[] = []; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 9da8c940be333..3037ef72159df 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -315,7 +315,6 @@ export function buildProjectActionItems(input: { runProject: (project: CommandPaletteProject) => Promise; searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; - disabled?: (project: CommandPaletteProject) => boolean; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -330,7 +329,6 @@ export function buildProjectActionItems(input: { title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), - ...(input.disabled ? { disabled: input.disabled(project) } : {}), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { await input.runProject(project); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 14078841086c7..dd6db0fc6ac52 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -25,7 +25,7 @@ import { type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -887,20 +887,6 @@ function OpenCommandPaletteDialog(props: { groups: projectGroups, preferredProjectRef: contextualProjectRef, }); - const isEnvironmentReachable = (environmentId: EnvironmentId) => - environments.some( - (environment) => - environment.environmentId === environmentId && environment.connection.phase === "connected", - ); - const threadPickerProjects = buildSidebarProjectPickerEntries({ - groups: projectGroups, - preferredProjectRef: contextualProjectRef, - expandCheckouts: true, - isEnvironmentReachable, - }).map(({ group, targetProject }) => ({ - ...targetProject, - displayName: group.displayName, - })); const pickerProjects = projectPickerEntries.map(({ group, targetProject }) => ({ ...targetProject, displayName: group.displayName, @@ -1177,16 +1163,18 @@ function OpenCommandPaletteDialog(props: { isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); const projectItems = enumerateCommandPaletteItems( buildProjectActionItems({ - projects: threadPickerProjects.filter((project) => !isScratch(project)), + projects: pickerProjects.filter((project) => !isScratch(project)), valuePrefix: "new-thread-in", - disabled: (project) => !isEnvironmentReachable(project.environmentId), - searchTerms: (project) => [ - projectEnvironmentLocationById.get(project.environmentId)?.label ?? "Remote", - ], + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = projectEnvironmentLocationById.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? + []), + ...(location ? [location.label] : []), + ]; + }, renderDescription: (project) => { - const environment = environments.find( - (candidate) => candidate.environmentId === project.environmentId, - ); const location = projectEnvironmentLocationById.get(project.environmentId) ?? { kind: "remote", label: "Remote", @@ -1206,29 +1194,30 @@ function OpenCommandPaletteDialog(props: {
{project.workspaceRoot} - {isEnvironmentReachable(project.environmentId) ? null : ( - <> - - - {environment ? connectionStatusTitle(environment.connection) : "Unavailable"} - - - )}
); }, icon: projectFaviconIcon, runProject: async (project) => { - await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - environmentSelection: "manual", - }); + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, + ); + await handleNewThread( + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id), + ); }, }), ); if (scratchTargetEnvironmentId === null) return projectItems; - const noProjectIndex = - threadPickerProjects[0] !== undefined && isScratch(threadPickerProjects[0]) ? 0 : 1; + const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; return [ ...projectItems.slice(0, noProjectIndex), { @@ -1631,7 +1620,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); } @@ -1978,7 +1967,7 @@ function OpenCommandPaletteDialog(props: { case "new-thread-in": pushPaletteView({ addonIcon: , - groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); return; case "change-theme": @@ -2125,9 +2114,7 @@ function OpenCommandPaletteDialog(props: { ? changeThemeItem.groups : currentView?.groups[0]?.value === "appearance" ? changeAppearanceItem.groups - : currentView?.groups[0]?.value === "new-thread-in" - ? [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }] - : (currentView?.groups ?? rootGroups); + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 22910c76d5faa..b036590edd058 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -130,7 +130,6 @@ import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { selectProjectGroupingSettings } from "../logicalProject"; import { - countSidebarProjectChoices, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, @@ -4531,9 +4530,8 @@ export default function Sidebar() { updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); - const projectChoiceCount = countSidebarProjectChoices(projectGroups); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectChoiceCount)) { + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -4549,7 +4547,7 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectChoiceCount <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -4691,7 +4689,7 @@ export default function Sidebar() { newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={projectChoiceCount > 1} + showNewThreadInProjectHint={projectGroups.length > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index eba9f1f9a9a54..5be4dc5c26122 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -12,7 +12,6 @@ import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, - countSidebarProjectChoices, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, @@ -78,10 +77,6 @@ describe("environment grouping", () => { const settingsGroups = buildSidebarProjectSnapshots(input); expect(filters).toHaveLength(1); expect(settingsGroups).toHaveLength(2); - expect([ - countSidebarProjectChoices(filters), - countSidebarProjectChoices(settingsGroups), - ]).toEqual([1, 1]); expect(settingsGroups.map((group) => group.memberProjectRefs)).toEqual([ [{ environmentId: local.environmentId, projectId: local.id }], [{ environmentId: remote.environmentId, projectId: remote.id }], @@ -570,53 +565,6 @@ describe("environment grouping", () => { }); }); - it("offers every physical checkout, with reachable choices before an offline preference", () => { - const primary = makeProject({ repositoryIdentity }); - const worktree = makeProject({ - id: ProjectId.make("local-worktree"), - workspaceRoot: "/tmp/shared-repo-feature", - repositoryIdentity, - }); - const remote = makeProject({ - id: worktree.id, - environmentId: remoteEnvironmentId, - repositoryIdentity, - }); - const groups = buildSidebarProjectSnapshots({ - projects: [remote, primary, worktree], - settings: defaultGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }); - expect(countSidebarProjectChoices(groups)).toBe(3); - const pickerInput = { - groups, - expandCheckouts: true, - isEnvironmentReachable: (environmentId: EnvironmentId) => - environmentId === primaryEnvironmentId, - }; - - const connectedPreference = buildSidebarProjectPickerEntries({ - ...pickerInput, - preferredProjectRef: { environmentId: primaryEnvironmentId, projectId: worktree.id }, - }); - expect(connectedPreference.map(({ targetProject }) => targetProject)).toMatchObject([ - worktree, - primary, - remote, - ]); - - const offlinePreference = buildSidebarProjectPickerEntries({ - ...pickerInput, - preferredProjectRef: { environmentId: remoteEnvironmentId, projectId: remote.id }, - }); - expect(offlinePreference.map(({ targetProject }) => targetProject)).toMatchObject([ - primary, - worktree, - remote, - ]); - }); - it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index b6057f3824dc5..3fa77ce4b9ce4 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -103,14 +103,12 @@ vi.mock("@effect/atom-react", () => ({ ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin } : testServerConfigs(), })); -vi.mock("@t3tools/client-runtime/environment", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@t3tools/client-runtime/environment", () => ({ scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, })); @@ -140,8 +138,7 @@ vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, })); -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("react", () => ({ useCallback: (callback: T) => callback, useMemo: (factory: () => T) => factory(), })); @@ -202,49 +199,6 @@ vi.mock("../uiStateStore", () => ({ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); import { useNewThreadHandler } from "./useHandleNewThread"; -import { useComposerDraftStore } from "../composerDraftStore"; - -it("pins a reused automatic draft to the explicitly selected checkout", async () => { - const { useComposerDraftStore: realDraftStore, DraftId } = - await vi.importActual("../composerDraftStore"); - const { EnvironmentId, ProjectId, ThreadId } = - await vi.importActual("@t3tools/contracts"); - testState.reset(null); - const draftId = DraftId.make("draft-manual-checkout"); - const projectRef = { - environmentId: EnvironmentId.make("environment-ssh"), - projectId: ProjectId.make("project-remote"), - }; - const store = realDraftStore.getState(); - store.setLogicalProjectDraftThreadId( - "remote-project", - { - environmentId: EnvironmentId.make("environment-primary"), - projectId: ProjectId.make("local"), - }, - draftId, - { - threadId: ThreadId.make("thread-manual-checkout"), - environmentSelection: "auto", - loadBalancedEnvironmentId: EnvironmentId.make("environment-primary"), - }, - ); - const getState = vi.spyOn(useComposerDraftStore, "getState").mockReturnValue(store); - try { - const opened = await useNewThreadHandler()(projectRef, { environmentSelection: "manual" }); - - expect(opened?.draftId).toBe(draftId); - expect(store.getDraftSession(draftId)).toMatchObject({ - environmentId: projectRef.environmentId, - projectId: projectRef.projectId, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } finally { - getState.mockRestore(); - realDraftStore.setState(store, true); - } -}); describe.each([ ["new", null], diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b6143e453244c..cfb98fc83bd01 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -74,7 +74,6 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; - environmentSelection?: "manual"; }, ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); @@ -168,10 +167,6 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; - const selectionOptions = - options?.environmentSelection === "manual" - ? { environmentSelection: "manual" as const, loadBalancedEnvironmentId: null } - : {}; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -272,7 +267,6 @@ export function useNewThreadHandler() { projectRef, emptyStoredDraftThread.draftId, { - ...selectionOptions, threadId: emptyStoredDraftThread.threadId, ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), @@ -315,7 +309,6 @@ export function useNewThreadHandler() { setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options)); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { - ...selectionOptions, threadId: latestActiveDraftThread.threadId, createdAt: latestActiveDraftThread.createdAt, runtimeMode: latestActiveDraftThread.runtimeMode, @@ -347,7 +340,6 @@ export function useNewThreadHandler() { readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null ) { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { - ...selectionOptions, threadId: racedDraft.threadId, createdAt: racedDraft.createdAt, runtimeMode: racedDraft.runtimeMode, @@ -362,7 +354,6 @@ export function useNewThreadHandler() { return { draftId: racedDraft.draftId, threadId: racedDraft.threadId }; } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { - ...selectionOptions, threadId, createdAt, branch: options?.branch ?? null, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index de21ef7a1e112..53c11954cec61 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -13,10 +13,7 @@ import { isPreviewAvailableFor } from "../browser/previewRuntime"; import { usePrimaryEnvironmentId } from "../state/environments"; import { useEnvironmentScope } from "../state/session"; import { selectProjectGroupingSettings } from "../logicalProject"; -import { - buildSidebarProjectSnapshots, - countSidebarProjectChoices, -} from "../sidebarProjectGrouping"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useScratchProject } from "../hooks/useScratchProject"; @@ -49,14 +46,12 @@ function ChatRouteGlobalShortcuts() { const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { scratchEnvironmentId, startScratchThread } = useScratchProject(); - const projectChoiceCount = countSidebarProjectChoices( - buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }), - ); + const projectGroupCount = buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }).length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -129,7 +124,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - if (!legacySidebarEnabled && projectChoiceCount > 1) { + if (!legacySidebarEnabled && projectGroupCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -198,7 +193,7 @@ function ChatRouteGlobalShortcuts() { defaultProjectRef, previewOpen, primaryEnvironmentId, - projectChoiceCount, + projectGroupCount, routeThreadRef, scratchEnvironmentId, selectedThreadKeysSize, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 230c2fa16a4bf..7182405bbd0f9 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -26,18 +26,6 @@ export interface SidebarProjectSnapshot extends Project { remoteEnvironmentLabels: readonly string[]; } -export function countSidebarProjectChoices(groups: ReadonlyArray): number { - let checkoutCount = 0; - let hasScratch = false; - for (const group of groups) { - for (const project of group.memberProjects) { - if (project.isScratch) hasScratch = true; - else checkoutCount += 1; - } - } - return checkoutCount + Number(hasScratch); -} - export function projectGroupsSpanEnvironments( groups: ReadonlyArray>, ): boolean { @@ -185,33 +173,8 @@ export function buildSidebarProjectSnapshots(input: { export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; - expandCheckouts?: boolean; - isEnvironmentReachable?: (environmentId: EnvironmentId) => boolean; }) { const preferredProjectRef = input.preferredProjectRef; - if (input.expandCheckouts) { - // memberProjects already deduplicates physical checkouts. Keep distinct - // worktrees on the same environment as separate choices. - return input.groups - .flatMap((group) => - group.memberProjects.map((targetProject): SidebarProjectPickerEntry => ({ - group, - targetProject, - isPreferred: - preferredProjectRef !== null && - targetProject.environmentId === preferredProjectRef.environmentId && - targetProject.id === preferredProjectRef.projectId, - })), - ) - .toSorted((left, right) => { - const reachability = input.isEnvironmentReachable; - const reachableDelta = reachability - ? Number(reachability(right.targetProject.environmentId)) - - Number(reachability(left.targetProject.environmentId)) - : 0; - return reachableDelta || Number(right.isPreferred) - Number(left.isPreferred); - }); - } const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { const isPreferred = preferredProjectRef ? group.memberProjectRefs.some( From 729c56c3cb63e7ee4b855690c27daf5c9e77131b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:48:35 -0700 Subject: [PATCH 015/146] test(ssh): wait for server exit during handover (#22252) --- packages/ssh/src/tunnel.test.ts | 50 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 048104a426886..78fa7ee70135e 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1309,26 +1309,17 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { assert.equal(launched.serverKind, "managed"); return { launched, pid }; }); - // An unreaped server runs under a parent that never waits on it, so once it - // exits it lingers as a zombie. - const startDefaultHomeServer = ( - flags: ReadonlyArray, - options?: { readonly unreaped?: boolean }, - ) => + const startDefaultHomeServer = (flags: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const args = [fakeServer, "serve", "--port", "0", "--base-dir", `${home}/.t3`, ...flags]; - const server = yield* spawner.spawn( - options?.unreaped - ? ChildProcess.make("sh", ["-c", 'node "$@" & exec sleep 600', "sh", ...args]) - : ChildProcess.make("node", args), - ); + const server = yield* spawner.spawn(ChildProcess.make("node", args)); const port = yield* server.stdout.pipe( Stream.decodeText(), Stream.splitLines, Stream.runHead, ); - return Number(Option.getOrThrow(port)); + return { port: Number(Option.getOrThrow(port)), exitCode: server.exitCode }; }); return { home, @@ -1363,7 +1354,7 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { `${launched.remotePort}`, ); - const servicePort = yield* startDefaultHomeServer([]); + const { port: servicePort } = yield* startDefaultHomeServer([]); assert.deepEqual(yield* runLaunch, { remotePort: servicePort, serverKind: "external", @@ -1516,7 +1507,7 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { const fs = yield* FileSystem.FileSystem; const { stateDir, runLaunch, startDefaultHomeServer } = yield* prepareLaunch; yield* fs.writeFileString(`${stateDir}/managed`, "managed\n"); - const servicePort = yield* startDefaultHomeServer(["--answer-once"]); + const { port: servicePort } = yield* startDefaultHomeServer(["--answer-once"]); assert.deepEqual(yield* runLaunch, { remotePort: servicePort, serverKind: "external" }); assert.isFalse(yield* fs.exists(`${stateDir}/pid`)); @@ -1528,13 +1519,36 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { "launches a replacement when the adopted server exits during the handover", () => Effect.gen(function* () { - const { readManagedPid, runLaunch, launchManaged, startDefaultHomeServer } = + const fs = yield* FileSystem.FileSystem; + const { home, launch, readManagedPid, launchManaged, startDefaultHomeServer } = yield* prepareLaunch; const { pid: previousPid } = yield* launchManaged; - yield* startDefaultHomeServer(["--exit-after-answer"], { unreaped: true }); + const server = yield* startDefaultHomeServer(["--exit-after-answer"]); + const adopted = yield* Deferred.make(); + const script = yield* fs.readFileString(launch); + const adoption = 'ADOPTED_PID="$DEFAULT_RUNTIME_PID"'; + assert.include(script, adoption); + // A flushed response can arrive before process exit. Hold the launcher at + // adoption until the fixture has exited, then test its handover check. + yield* fs.writeFileString( + launch, + script.replace(adoption, `${adoption}\n echo adopted >&2\n read -r release`), + ); - const result = yield* runLaunch; - assert.equal(result.serverKind, "managed"); + const result = yield* runScript(home, [launch, "test-state"], { + stdin: Stream.fromEffect( + Deferred.await(adopted).pipe( + Effect.andThen(server.exitCode.pipe(Effect.orDie)), + Effect.as(new TextEncoder().encode("release\n")), + ), + ), + onStderrLine: (line) => + line === "adopted" + ? Deferred.succeed(adopted, undefined).pipe(Effect.asVoid) + : Effect.void, + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(launchResult(result.stdout).serverKind, "managed"); const replacementPid = yield* readManagedPid; assert.notEqual(replacementPid, previousPid); assert.isTrue(isAlive(replacementPid)); From 54346a617c60ded22c1d37b4c14b2bb6ae83d7df Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:50:13 -0700 Subject: [PATCH 016/146] fix(web,server): adding a project to another machine takes you there, with no toasts and the right favicon (#22254) Co-authored-by: Claude Opus 5.5 --- apps/server/src/assets/AssetAccess.test.ts | 1 + apps/server/src/assets/AssetAccess.ts | 32 ++++--- .../src/project/ManagedProjectFolders.test.ts | 1 + .../project/ProjectEnrichmentService.test.ts | 9 ++ .../src/project/ProjectEnrichmentService.ts | 2 + .../project/ProjectFaviconResolver.test.ts | 16 ++++ .../src/project/ProjectFaviconResolver.ts | 15 +++- .../project/ProjectService.deletion.test.ts | 1 + .../server/src/project/ProjectService.test.ts | 4 + apps/server/src/ws.ts | 7 +- .../components/AddProjectToMachineDialog.tsx | 34 +++++++- apps/web/src/components/ChatView.tsx | 26 ++++-- .../ProjectCloneToastCoordinator.tsx | 15 +++- apps/web/src/lib/addProjectToMachine.ts | 86 +++++++++++++------ apps/web/src/state/projectClones.ts | 15 ++++ 15 files changed, 210 insertions(+), 54 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8157ef8d372bd..b837a2aeb829e 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1525,6 +1525,7 @@ describe("AssetAccess", () => { cause: platformCause, }); const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ + invalidate: () => Effect.void, resolvePath: () => Effect.fail(resolutionCause), }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 251b15f9ece41..c25dce2494f26 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -493,7 +493,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i readonly resource: AssetResource; readonly workspaceRoot?: string; readonly projectFaviconPath?: string; - readonly projectCheckoutPending?: boolean; + /** Where a cloned project's checkout stands, while the clone tracker still knows it. */ + readonly projectCheckout?: "pending" | "cloned"; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -648,19 +649,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; // A lookup in a half-cloned checkout would cache a miss that outlives the clone. - const faviconPath = input.projectCheckoutPending - ? null - : yield* faviconResolver - .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) - .pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconResolutionError({ - resource: input.resource, - cause, - }), - ), - ); + // Once it lands, a miss cached before (by enrichment) is stale too. + if (input.projectCheckout === "cloned") yield* faviconResolver.invalidate(workspaceRoot); + const faviconPath = + input.projectCheckout === "pending" + ? null + : yield* faviconResolver + .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) + .pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + ); const isExternalOverride = faviconPath !== null && input.projectFaviconPath !== undefined && diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index bb31b4910f64c..3ff77c7dc1c82 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -47,6 +47,7 @@ const layerEnrichment = ProjectEnrichmentService.layer.pipe( resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ), diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 7a0238159a747..402cb56fad0eb 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -58,6 +58,7 @@ it.effect("preserves either enrichment field when the other resolver fails", () : Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => workspaceRoot === "/favicon-fails" ? Effect.fail( @@ -109,6 +110,7 @@ it.effect("does not warn about favicons for workspace roots that no longer exist resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.fail( workspaceRoot === "/missing" @@ -158,6 +160,7 @@ it.effect("publishes repository completion while favicon enrichment is still pen resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Deferred.await(releaseFavicon).pipe(Effect.as(`${workspaceRoot}/favicon.svg`)), }), @@ -186,6 +189,7 @@ it.effect("keeps repository workers available when every favicon worker is hung" resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.gen(function* () { const started = yield* Ref.updateAndGet(faviconStarts, (count) => count + 1); @@ -237,6 +241,7 @@ it.effect("getAvailable returns immediately while repository identity is still u ), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); @@ -285,6 +290,7 @@ it.effect( }, }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ); @@ -344,6 +350,7 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); @@ -403,6 +410,7 @@ it.effect("rescans a favicon only after 15 minutes", () => resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Ref.updateAndGet(faviconScans, (count) => count + 1).pipe( Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`), @@ -451,6 +459,7 @@ it.effect( ), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)), }), ); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index efb510e37ef2b..1592204816fec 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -283,6 +283,8 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( [ Cache.invalidate(repositoryIdentityCache, workspaceRoot), Cache.invalidate(faviconCache, workspaceRoot), + // The resolver keeps its own misses, which would outlive a clone filling the folder. + faviconResolver.invalidate(workspaceRoot), ], { concurrency: "unbounded", discard: true }, ), diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 1d5bf9441d0da..10f8fa4169bf6 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -74,6 +74,22 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("finds a favicon added after a cached miss once the workspace is invalidated", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + + // A clone's project exists while its folder is still empty. + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + yield* writeTextFile(cwd, "favicon.ico", "icon"); + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* resolver.invalidate(cwd); + expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.ico")); + }), + ); + it.effect("falls back at once when a cached favicon is deleted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index aef40716ae818..e42cb5fbe9193 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -111,6 +111,8 @@ export class ProjectFaviconResolver extends Context.Service< cwd: string, faviconPath?: string, ) => Effect.Effect; + /** Forgets cached lookups for a workspace whose files changed, such as a finished clone. */ + readonly invalidate: (cwd: string) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -340,7 +342,18 @@ export const make = Effect.gen(function* () { return yield* Cache.get(faviconCache, key); }); - return ProjectFaviconResolver.of({ resolvePath }); + const invalidate: ProjectFaviconResolver["Service"]["invalidate"] = (cwd) => + Cache.keys(faviconCache).pipe( + Effect.flatMap((keys) => + Effect.forEach( + Array.from(keys).filter((key) => parseFaviconCacheKey(key).cwd === cwd), + (key) => Cache.invalidate(faviconCache, key), + { discard: true }, + ), + ), + ); + + return ProjectFaviconResolver.of({ resolvePath, invalidate }); }); export const layer = Layer.effect(ProjectFaviconResolver, make); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 0e9f01dc653c8..41804842d5205 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -58,6 +58,7 @@ const layerServices = Layer.mergeAll( resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ), diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 67e76bfeafbc8..a9fed49cff6a6 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -46,6 +46,7 @@ const layerMetadata = Layer.merge( }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); @@ -513,6 +514,7 @@ it.effect( }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Ref.updateAndGet(faviconCalls, (count) => count + 1).pipe( Effect.as(`${workspaceRoot}/favicon.svg`), @@ -597,6 +599,7 @@ it.effect("keeps project snapshots available when optional metadata enrichment f resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.fail( new ProjectFaviconResolver.ProjectFaviconResolutionError({ @@ -647,6 +650,7 @@ it.effect("invalidates workspace-derived metadata when a project moves", () => ), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Ref.get(metadataVersion).pipe( Effect.map((version) => `${workspaceRoot}/favicon-v${version}.svg`), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f4f5b771fecc3..90dc72b0d8a0d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2756,10 +2756,9 @@ export const layerWsRpc = ( ...(project.value.faviconPath ? { projectFaviconPath: project.value.faviconPath } : {}), - projectCheckoutPending: - clone !== null && - clone.phase !== "done" && - clone.destinationPath === project.value.workspaceRoot, + ...(clone !== null && clone.destinationPath === project.value.workspaceRoot + ? { projectCheckout: clone.phase === "done" ? "cloned" : "pending" } + : {}), }); } const thread = yield* threadManagement diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 0007c079ad70c..7d1748f9d0c41 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -1,3 +1,5 @@ +import { useParams } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { getAddProjectInitialQuery, getCloneDestinationPath, @@ -13,7 +15,9 @@ import { ArrowRightIcon } from "lucide-react"; import { useEffect, useId, useState } from "react"; import { create } from "zustand"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; import { useAddProjectTargets } from "../hooks/useAddProjectTargets"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { COPYABLE_FILE_MAX_BYTES, @@ -71,6 +75,8 @@ export function AddProjectToMachineDialogHost() { function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRequest }) { const id = useId(); const project = useProject(request.projectRef); + const handleNewThread = useNewThreadHandler(); + const { draftId: routeDraftId } = useParams({ strict: false }); const { environments } = useEnvironments(); const targets = useAddProjectTargets(project, useLogicalProjectEnvironments(project, false)); const [targetId, setTargetId] = useState(request.targetEnvironmentId); @@ -128,6 +134,30 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe setSelected(next); }; + // Lands the user on the new machine right away; its composer shows the clone + // and holds sends until it finishes. A draft open on the source project moves + // over with whatever was typed in it. + const openClonedProject = (targetRef: ScopedProjectRef) => { + const drafts = useComposerDraftStore.getState(); + const draftId = routeDraftId as DraftId | undefined; + const draft = draftId ? drafts.getDraftSession(draftId) : null; + if ( + draftId && + draft && + !draft.promotedTo && + draft.environmentId === request.projectRef.environmentId && + draft.projectId === request.projectRef.projectId + ) { + drafts.setDraftThreadContext(draftId, { + projectRef: targetRef, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + return; + } + void handleNewThread(targetRef); + }; + const submit = async () => { if (!project || !target || submitting) return; if (destinationPath.trim().length === 0) { @@ -136,6 +166,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe } setSubmitting(true); setError(null); + const projectId = newProjectId(); try { await addProjectToMachine({ source: { environmentId: project.environmentId, cwd: project.workspaceRoot }, @@ -144,13 +175,14 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe project.workspaceRoot, ), target: { environmentId: target.environmentId, label: target.label }, - projectId: newProjectId(), + projectId, title: project.title, remoteUrl, destinationPath: destinationPath.trim(), files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path), }); close(); + openClonedProject(scopeProjectRef(target.environmentId, projectId)); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not start the clone."); setSubmitting(false); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f7e72a6c3ea6b..1f9eb813a455f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -378,6 +378,7 @@ import { vcsEnvironment } from "../state/vcs"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useProjectClone } from "../state/projectClones"; import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; +import { dismissProjectFileCopy, useProjectFileCopy } from "../lib/addProjectToMachine"; import { type EnvironmentPresentation, useEnvironmentProviders, @@ -1895,15 +1896,26 @@ export default function ChatView(props: ChatViewProps) { } }; const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); + const activeProjectFileCopy = useProjectFileCopy(activeProjectRef); const projectCloneSendBlockReason = - activeProjectClone === null - ? null - : activeProjectClone.phase === "running" - ? "Cloning repository" - : activeProjectClone.phase === "done" - ? null - : "Repository not cloned"; + activeProjectClone?.phase === "running" + ? "Cloning repository" + : activeProjectClone !== null && activeProjectClone.phase !== "done" + ? "Repository not cloned" + : activeProjectFileCopy?.phase === "copying" + ? "Copying files" + : null; const projectCloneBannerItem = (() => { + if (activeProjectFileCopy?.phase === "failed" && activeProjectRef) { + return { + id: `project-file-copy:${activeProjectRef.projectId}`, + variant: "error", + icon: , + title: activeProjectFileCopy.title, + description: activeProjectFileCopy.failures.join("\n"), + onDismiss: () => dismissProjectFileCopy(activeProjectRef), + }; + } if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { return null; } diff --git a/apps/web/src/components/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx index 37003089c794d..974138bf2f630 100644 --- a/apps/web/src/components/ProjectCloneToastCoordinator.tsx +++ b/apps/web/src/components/ProjectCloneToastCoordinator.tsx @@ -17,7 +17,11 @@ import { useEffect, useRef } from "react"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useEnvironments } from "../state/environments"; -import { useEnvironmentProjectClones } from "../state/projectClones"; +import { + isProjectCloneToastSilenced, + silenceProjectCloneToasts, + useEnvironmentProjectClones, +} from "../state/projectClones"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; @@ -97,11 +101,20 @@ function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentI toastManager.close(current.toastId); toasts.current.delete(clone.projectId); }; + const ref = { environmentId, projectId: clone.projectId }; if (isViewingProjectDraft(clone.projectId)) { + silenceProjectCloneToasts(ref); closeToast(); continue; } if (tracked?.renderedKey === key) continue; + if ( + isProjectCloneToastSilenced(ref) && + (clone.phase === "running" || clone.phase === "done") + ) { + closeToast(); + continue; + } if (clone.phase === "running") { const options = stackedThreadToast({ diff --git a/apps/web/src/lib/addProjectToMachine.ts b/apps/web/src/lib/addProjectToMachine.ts index 53c37f3729836..367420c45e0f5 100644 --- a/apps/web/src/lib/addProjectToMachine.ts +++ b/apps/web/src/lib/addProjectToMachine.ts @@ -3,11 +3,17 @@ import { runAtomCommand, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ProjectCloneSnapshot, ProjectId } from "@t3tools/contracts"; +import { scopedProjectKey } from "@t3tools/client-runtime/environment"; +import type { + EnvironmentId, + ProjectCloneSnapshot, + ProjectId, + ScopedProjectRef, +} from "@t3tools/contracts"; +import { create } from "zustand"; -import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { environmentProjectClonesAtom } from "../state/projectClones"; +import { environmentProjectClonesAtom, silenceProjectCloneToasts } from "../state/projectClones"; import { projectEnvironment } from "../state/projects"; import { sourceControlEnvironment } from "../state/sourceControl"; @@ -79,7 +85,7 @@ function failureMessage(result: Parameters[0]): /** * Resolves when the clone finishes, or null once it is removed. A failed or - * cancelled clone keeps waiting, since its toast offers a retry. + * cancelled clone keeps waiting, since the composer offers a retry. */ function waitForClone(environmentId: EnvironmentId, projectId: ProjectId) { return new Promise((resolve) => { @@ -102,11 +108,36 @@ function waitForClone(environmentId: EnvironmentId, projectId: ProjectId) { }); } +export type ProjectFileCopy = + | { readonly phase: "copying" } + | { readonly phase: "failed"; readonly title: string; readonly failures: ReadonlyArray }; + +/** Ignored-file copies still owed to a cloned project, keyed by its scoped project key. */ +const useFileCopies = create<{ copies: Readonly> }>(() => ({ + copies: {}, +})); + +function setFileCopy(key: string, copy: ProjectFileCopy | null) { + useFileCopies.setState(({ copies }) => { + const { [key]: _previous, ...rest } = copies; + return { copies: copy === null ? rest : { ...rest, [key]: copy } }; + }); +} + +/** The composer holds sends until the copy lands, and shows a failed copy until dismissed. */ +export function useProjectFileCopy(ref: ScopedProjectRef | null): ProjectFileCopy | null { + return useFileCopies((state) => (ref ? (state.copies[scopedProjectKey(ref)] ?? null) : null)); +} + +export function dismissProjectFileCopy(ref: ScopedProjectRef) { + setFileCopy(scopedProjectKey(ref), null); +} + /** * Clones a project onto another machine, then copies the chosen ignored files * from the source. The client relays the files because the two servers never - * talk to each other. Resolves once the clone starts; the clone toast shows - * progress and a second toast reports the copied files. + * talk to each other. Resolves once the clone starts. The caller opens the new + * project, whose composer shows the clone and copy progress, so nothing toasts. */ export async function addProjectToMachine(input: AddProjectToMachineInput): Promise { // Read first, so the copy reflects the files as they were when the user asked. @@ -126,6 +157,11 @@ export async function addProjectToMachine(input: AddProjectToMachineInput): Prom }), ); + const targetRef = { environmentId: input.target.environmentId, projectId: input.projectId }; + const copyKey = scopedProjectKey(targetRef); + // Marked before the start request, since the clone list can arrive before its reply. + silenceProjectCloneToasts(targetRef); + if (reads.length > 0) setFileCopy(copyKey, { phase: "copying" }); const started = await runAtomCommand( appAtomRegistry, sourceControlEnvironment.startProjectClone, @@ -141,14 +177,19 @@ export async function addProjectToMachine(input: AddProjectToMachineInput): Prom }, { label: "Add project to machine", reportFailure: false }, ); - if (started._tag === "Failure") throw new Error(failureMessage(started)); - if (input.files.length === 0) return; + if (started._tag === "Failure") { + setFileCopy(copyKey, null); + throw new Error(failureMessage(started)); + } + if (reads.length === 0) return; void (async () => { const clone = await waitForClone(input.target.environmentId, input.projectId); - if (!clone) return; + if (!clone) { + setFileCopy(copyKey, null); + return; + } const failed: Array = []; - let copied = 0; for (const read of reads) { if (!("contents" in read)) { failed.push(`${read.relativePath}: ${read.error}`); @@ -171,23 +212,16 @@ export async function addProjectToMachine(input: AddProjectToMachineInput): Prom ); if (written._tag === "Failure") failed.push(`${read.relativePath}: ${failureMessage(written)}`); - else copied += 1; } - const noun = (count: number) => (count === 1 ? "file" : "files"); - toastManager.add( - stackedThreadToast( - failed.length === 0 - ? { - type: "success", - title: `Copied ${copied} ${noun(copied)} to ${input.target.label}`, - description: reads.map((read) => read.relativePath).join(", "), - } - : { - type: "error", - title: `Could not copy ${failed.length} ${noun(failed.length)} to ${input.target.label}`, - description: failed.join("\n"), - }, - ), + setFileCopy( + copyKey, + failed.length === 0 + ? null + : { + phase: "failed", + title: `Could not copy ${failed.length} ${failed.length === 1 ? "file" : "files"} to ${input.target.label}`, + failures: failed, + }, ); })(); } diff --git a/apps/web/src/state/projectClones.ts b/apps/web/src/state/projectClones.ts index 5fe9befd056d1..8471d1c291408 100644 --- a/apps/web/src/state/projectClones.ts +++ b/apps/web/src/state/projectClones.ts @@ -41,3 +41,18 @@ export function useEnvironmentProjectClones( ): ReadonlyArray { return useAtomValue(environmentProjectClonesAtom(environmentId)); } + +const silencedCloneToasts = new Set(); + +/** + * Keeps a clone's progress out of toasts once the user is looking at its + * project, whose composer shows the same progress. Failures still toast + * whenever the project is not on screen. + */ +export function silenceProjectCloneToasts(ref: ScopedProjectRef) { + silencedCloneToasts.add(scopedProjectKey(ref)); +} + +export function isProjectCloneToastSilenced(ref: ScopedProjectRef): boolean { + return silencedCloneToasts.has(scopedProjectKey(ref)); +} From 962428df104d1958ff58a3b205e64a07117e7781 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 02:52:00 -0700 Subject: [PATCH 017/146] feat(clients): every PR +/- shows its code, tests, and docs split, without the hover wait (#22255) Co-authored-by: Claude Opus 5.5 --- .../pull-requests/PullRequestFiles.tsx | 1 + .../pullRequestChangeBreakdown.test.ts | 2 + .../pullRequest/pullRequestChangeBreakdown.ts | 8 +- .../chat/ThreadDetailsPrRow.browser.test.tsx | 42 ++++++++- .../components/chat/ThreadDetailsPrRow.tsx | 8 ++ .../PullRequestChangeBreakdownStat.tsx | 87 +++++++++++++------ .../pullRequest/PullRequestDetailPanel.tsx | 2 + .../components/pullRequest/PullRequestRow.tsx | 16 ++-- .../pullRequest/ThreadPullRequestsPanel.tsx | 26 ++++-- packages/contracts/src/pullRequest.test.ts | 24 +++++ packages/contracts/src/pullRequest.ts | 5 ++ packages/shared/src/changedFileKind.test.ts | 6 ++ packages/shared/src/changedFileKind.ts | 7 +- 13 files changed, 195 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx index dc9c765bf506e..f9d262e0f2ddb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx @@ -29,6 +29,7 @@ const BREAKDOWN_LABELS = { tests: "Tests", mocks: "Mocks", generated: "Generated", + docs: "Docs", } satisfies Record, string>; function PullRequestChangeBreakdownLine(props: { diff --git a/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts b/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts index 68321f39eb22b..ecb347d77a867 100644 --- a/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts +++ b/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts @@ -12,6 +12,7 @@ describe("toChangeBreakdown", () => { { path: "src/b.ts", additions: 5, deletions: 1 }, { path: "src/a.test.ts", additions: 40, deletions: 0 }, { path: "pnpm-lock.yaml", additions: 300, deletions: 120 }, + { path: "README.md", additions: 7, deletions: 3 }, ], }), ).toEqual({ @@ -19,6 +20,7 @@ describe("toChangeBreakdown", () => { tests: { additions: 40, deletions: 0 }, mocks: { additions: 0, deletions: 0 }, generated: { additions: 300, deletions: 120 }, + docs: { additions: 7, deletions: 3 }, }); }); diff --git a/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts b/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts index 77d8162e8956b..eaaf50c0d868c 100644 --- a/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts +++ b/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts @@ -5,7 +5,13 @@ import type { ProviderFileStats } from "@t3tools/source-control-core/server/Pull export function toChangeBreakdown(stats: ProviderFileStats): PullRequestChangeBreakdown { if (stats.truncated) return null; const empty = () => ({ additions: 0, deletions: 0 }); - const breakdown = { code: empty(), tests: empty(), mocks: empty(), generated: empty() }; + const breakdown = { + code: empty(), + tests: empty(), + mocks: empty(), + generated: empty(), + docs: empty(), + }; for (const file of stats.files) { const bucket = breakdown[classifyChangedFile(file.path)]; bucket.additions += file.additions; diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx index 10ad05b12e3c2..48fef8ee4a8e5 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx @@ -12,6 +12,7 @@ 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 { userEvent } from "vite-plus/test/browser"; const state = vi.hoisted(() => ({ status: "success" as PullRequestCheck["status"], @@ -26,7 +27,14 @@ const state = vi.hoisted(() => ({ vi.mock(import("~/state/entities"), async (importOriginal) => ({ ...(await importOriginal()), - useServerConfigs: (() => new Map()) as never, + // Rows render a pull request only with a project, so this does not reach tests that pass none. + useServerConfigs: (() => + new Map([ + [ + EnvironmentId.make("environment"), + { environment: { capabilities: { pullRequests: true } } }, + ], + ])) as never, })); const queryData = () => ({ projectId: ProjectId.make("project"), @@ -58,6 +66,16 @@ vi.mock(import("~/state/query"), async (importOriginal) => ({ select(AsyncResult.success(queryData()))) as never, useEnvironmentQueryIsNewer: () => true, useEnvironmentQueryRefresh: () => ({ refresh: vi.fn(), isPending: () => false }), + useEnvironmentQuery: (() => ({ + data: { + code: { additions: 1, deletions: 0 }, + tests: { additions: 0, deletions: 0 }, + mocks: { additions: 0, deletions: 0 }, + generated: { additions: 0, deletions: 0 }, + docs: { additions: 7, deletions: 3 }, + }, + isPending: false, + })) as never, })); vi.mock(import("../pullRequest/usePullRequestActions"), async (importOriginal) => ({ ...(await importOriginal()), @@ -231,3 +249,25 @@ it("shows the current checkout recovery detail when fixing checks fails and is r second.detail, ]); }); + +it("splits the change by file kind in the hover card", async () => { + act(() => + root.render( + , + ), + ); + await act(() => userEvent.hover(container.querySelector('button[aria-label="Open PR"]')!)); + await expect + .poll(() => document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent) + .toMatch(/Docs\+7-3/); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 5bf544f1416c1..c419beb89d39f 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -1,3 +1,4 @@ +import { PullRequestChangeBreakdownRows } from "../pullRequest/PullRequestChangeBreakdownStat"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { selectEnvironmentQueryData } from "@t3tools/client-runtime/state/runtime"; @@ -292,6 +293,13 @@ export function ThreadDetailsPrRow({
+ {reference !== null && (detail.additions > 0 || detail.deletions > 0) ? ( + + ) : null}
diff --git a/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx b/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx index cba047e2a416a..e4d6765fd20dd 100644 --- a/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx @@ -13,12 +13,15 @@ const KIND_LABELS = { tests: "Tests", mocks: "Mocks", generated: "Generated", + docs: "Docs", } satisfies Record, string>; +type DiffTotals = { additions: number; deletions: number }; + /** * The pull request's diff stat, with a hover split into code, tests, mocks, and generated files. - * The split is read on first pointer or focus intent, so a page nobody hovers costs the host - * nothing. + * In lists the split is read on first pointer or focus intent, so rows nobody hovers cost the + * host nothing. A view of one pull request passes `eager` so the split is ready before the hover. */ export function PullRequestChangeBreakdownStat({ environmentId, @@ -26,14 +29,16 @@ export function PullRequestChangeBreakdownStat({ additions, deletions, className, + eager = false, }: { environmentId: EnvironmentId; reference: PullRequestRef; additions: number; deletions: number; className: string; + eager?: boolean; }) { - const [requested, setRequested] = useState(false); + const [requested, setRequested] = useState(eager); const query = useEnvironmentQuery( requested ? pullRequestEnvironment.changeBreakdown({ environmentId, input: reference }) : null, ); @@ -56,21 +61,7 @@ export function PullRequestChangeBreakdownStat({ /> {breakdown ? ( -
- {(Object.keys(KIND_LABELS) as Array).map((kind) => { - const { additions, deletions } = breakdown[kind]; - return additions === 0 && deletions === 0 ? null : ( - - ); - })} -
- -
+ ) : query.isPending ? ( Loading breakdown @@ -79,15 +70,61 @@ export function PullRequestChangeBreakdownStat({ ); } -function BreakdownRow({ - label, - additions, - deletions, +/** + * The split shown inline, for surfaces that already sit behind a hover, such as the thread + * details card. It reads on mount, so it costs the host a request only once the card opens. + */ +export function PullRequestChangeBreakdownRows({ + environmentId, + reference, + className, }: { - label: string; - additions: number; - deletions: number; + environmentId: EnvironmentId; + reference: PullRequestRef; + className: string; }) { + const query = useEnvironmentQuery( + pullRequestEnvironment.changeBreakdown({ environmentId, input: reference }), + ); + if (!query.data) return null; + return ( +
+ +
+ ); +} + +function ChangeBreakdownGrid({ + breakdown, + total, +}: { + breakdown: NonNullable; + total?: DiffTotals; +}) { + return ( +
+ {(Object.keys(KIND_LABELS) as Array).map((kind) => { + const { additions, deletions } = breakdown[kind]; + return additions === 0 && deletions === 0 ? null : ( + + ); + })} + {total ? ( + <> +
+ + + ) : null} +
+ ); +} + +function BreakdownRow({ label, additions, deletions }: DiffTotals & { label: string }) { return ( <> {label} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 33c87722ff509..d35c85caf30dd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -2436,6 +2436,7 @@ export function PullRequestDetailPanel({ additions={detail.additions} deletions={detail.deletions} className="shrink-0 font-mono text-2xs" + eager />
@@ -2612,6 +2613,7 @@ export function PullRequestDetailPanel({ additions={detail.additions} deletions={detail.deletions} className="shrink-0 font-mono text-xs" + eager /> } /> diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 2bdbc2886265d..853305d9d272b 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,6 +1,7 @@ import type { PullRequestAction } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { SearchIcon } from "lucide-react"; +import { PullRequestChangeBreakdownStat } from "./PullRequestChangeBreakdownStat"; import { PullRequestStackPopover } from "./PullRequestStackPopover"; import { PullRequestSpeedActions, @@ -22,11 +23,7 @@ import { PullRequestRowGlyph, PullRequestRowLines, } from "./PullRequestListRow"; -import { - PullRequestDiffStat, - PullRequestLabelChip, - PullRequestReviewDecisionGlyph, -} from "./pullRequestPresentation"; +import { PullRequestLabelChip, PullRequestReviewDecisionGlyph } from "./pullRequestPresentation"; const LABEL_SLOTS = [ { overflow: "@xl/pr-row-meta:hidden" }, @@ -186,7 +183,14 @@ function PullRequestRowImpl({ } /> ) : null} - + projectId === null ? ( + + ) : ( + + ) } meta={ <> diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index b20f78dd95711..941e998763a5c 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { PullRequestActionInput, PullRequestCapabilities, + PullRequestChangeBreakdown, PullRequestDetail, PullRequestFilesViewedResult, PullRequestListInput, @@ -18,6 +19,9 @@ import { const decodeListResult = Schema.decodeUnknownSync(Schema.toCodecJson(PullRequestListResult)); const decodeDetail = Schema.decodeUnknownSync(Schema.toCodecJson(PullRequestDetail)); +const decodeChangeBreakdown = Schema.decodeUnknownSync( + Schema.toCodecJson(PullRequestChangeBreakdown), +); const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput); const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput); const decodeAction = Schema.decodeUnknownSync(Schema.toCodecJson(PullRequestActionInput)); @@ -423,3 +427,23 @@ describe("naming the file a tick belongs to", () => { ).toThrow(); }); }); + +describe("PullRequestChangeBreakdown", () => { + it("reads a breakdown from a server that predates the docs kind", () => { + const line = { additions: 1, deletions: 0 }; + expect( + decodeChangeBreakdown({ + code: line, + tests: line, + mocks: line, + generated: line, + }), + ).toEqual({ + code: line, + tests: line, + mocks: line, + generated: line, + docs: { additions: 0, deletions: 0 }, + }); + }); +}); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1bdedef247518..5553bb7f176b5 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; import * as HttpServerRespondable from "effect/http/HttpServerRespondable"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; @@ -698,6 +699,10 @@ export const PullRequestChangeBreakdown = Schema.NullOr( tests: PullRequestLineCount, mocks: PullRequestLineCount, generated: PullRequestLineCount, + // Servers from before the docs split send no key; their docs lines sit in `code`. + docs: PullRequestLineCount.pipe( + Schema.withDecodingDefault(Effect.succeed({ additions: 0, deletions: 0 })), + ), }), ); export type PullRequestChangeBreakdown = typeof PullRequestChangeBreakdown.Type; diff --git a/packages/shared/src/changedFileKind.test.ts b/packages/shared/src/changedFileKind.test.ts index a3a8fd62faea7..9ff3cea839501 100644 --- a/packages/shared/src/changedFileKind.test.ts +++ b/packages/shared/src/changedFileKind.test.ts @@ -36,6 +36,12 @@ describe("classifyChangedFile", () => { ["src/routeTree.gen.ts", "generated"], ["src/__generated__/types.ts", "generated"], ["dist/app.min.js", "generated"], + ["README.md", "docs"], + ["docs/internals/overview.mdx", "docs"], + ["NOTES.txt", "docs"], + ["requirements-dev.txt", "code"], + ["CMakeLists.txt", "code"], + ["tests/README.md", "tests"], ] as const)("%s is %s", (path, kind) => { expect(classifyChangedFile(path)).toBe(kind); }); diff --git a/packages/shared/src/changedFileKind.ts b/packages/shared/src/changedFileKind.ts index e2d6dcbe1d750..9fd5c0c7edc79 100644 --- a/packages/shared/src/changedFileKind.ts +++ b/packages/shared/src/changedFileKind.ts @@ -58,10 +58,14 @@ const TEST_DIRS = new Set([ "tests", ]); +const DOCS_NAME = /\.(md|mdx|markdown|rst|adoc|txt)$/i; +// Plain-text files that tools read as config or input, not prose. +const NOT_DOCS_NAME = /^(requirements.*|constraints.*|CMakeLists|robots|llms)\.txt$/i; + /** * Sorts a changed file by its path alone, using common naming conventions. Lockfiles and build * output count as generated, and test support (mocks, fixtures, snapshots) is kept apart from the - * tests themselves, so a reviewer can see how much of a change is logic. + * tests themselves, and prose (Markdown, plain text) is its own kind, so a reviewer can see how much of a change is logic. */ export function classifyChangedFile(path: string): ChangedFileKind { const segments = path.replaceAll("\\", "/").split("/"); @@ -72,5 +76,6 @@ export function classifyChangedFile(path: string): ChangedFileKind { return "generated"; if (MOCK_NAME.test(name) || inDir(MOCK_DIRS)) return "mocks"; if (TEST_NAME.test(name) || CLASS_TEST_NAME.test(name) || inDir(TEST_DIRS)) return "tests"; + if (DOCS_NAME.test(name) && !NOT_DOCS_NAME.test(name)) return "docs"; return "code"; } From 30afc56076c86580d5d913897280bea660d44c79 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 03:04:55 -0700 Subject: [PATCH 018/146] test(web): isolate diff worker idle clock (#22259) --- .../components/diffs/StyledDiffCodeView.browser.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx index 042f5da2cd9a0..efd47ece4e8d7 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx @@ -184,7 +184,6 @@ describe("code-view worker lifecycle", () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); // Pins the pool at two workers whatever machine runs the suite. Object.defineProperty(navigator, "hardwareConcurrency", { configurable: true, value: 2 }); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const initialize = WorkerPoolManager.prototype.initialize; vi.spyOn(WorkerPoolManager.prototype, "initialize").mockImplementation(function ( this: WorkerPoolManager, @@ -197,6 +196,7 @@ describe("code-view worker lifecycle", () => { }); afterEach(async () => { + if (!vi.isFakeTimers()) vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); await unmountAll(); await vi.runOnlyPendingTimersAsync(); vi.useRealTimers(); @@ -228,6 +228,9 @@ 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); @@ -249,6 +252,7 @@ describe("code-view worker lifecycle", () => { expect(pool.getStats().totalWorkers).toBe(0); expect(testState.terminations).toBe(2); + vi.useRealTimers(); await view.render(renderViews(1)); const reopenedPool = [...testState.pools.keys()][1]!; await act(async () => testState.pools.get(reopenedPool)); @@ -277,6 +281,7 @@ 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"] }); testState.holdInitialization = true; const held = new Promise((resolve) => { testState.onInitializationHeld = resolve; From e9a5974379943e471aa340e683b47cc89db60015 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 03:23:14 -0700 Subject: [PATCH 019/146] 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 d0e60fdfc75df..21e9e7ba99c56 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 020/146] 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 d78ac0fd286a3..a6fe979100d64 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 3f6b5f31e6fdb..a202831a6fb00 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 021/146] 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 b036590edd058..bcd24bf8498f4 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 022/146] 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 23fe91ce6b0f8..42663d4439bc9 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 3edfa7696c806..755cef4b6fa6c 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 74b5b576823e8..a48f8865a1d6e 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 98ae97a2e8c8f..1088efee4387e 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 14ae4e40bac37..6b4884efdc9fb 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 3f9f6ccbb23ec..7d96839b6d53e 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 f1d5a7a122388..0eb2c755ca1d1 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 5904d57f54dff..224fee5d59fa0 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 333bcf57a0fd9..8864e86a99678 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 b3f00642ee0e7..480e4c592209c 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 58542edf62e96..6408f020e0156 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 547bde0896de1..2c7942a7d96c0 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 56e6419a8854c..729e9b1dc39e6 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 c036977acba36..52c9babc2cee4 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 8a86d2d37bb0b..f27263efb0a9e 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 45ec1d60d177f..6d4362f3db24b 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 a8397d0119160..c7b5fe2a58dd1 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 3f8d6d1db57f5..d18dedd9e418a 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 5c066f03628d1..2046a3fc5aa07 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 09d3c38231051..9e2aa3f0cc351 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 8883e768f6724..be661c1316d16 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 790fb481174e5..645c7f0e3a804 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 6e40307bb524b..d271bea184cde 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 048c16802fc5f..49c171f0962dc 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 2e36c714761ac..55c51472788ca 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 ecb56eab5debd..51bb2d4e6422d 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 d6cbd23d02669..8bb9d41580967 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 b0d14d5047266..f1076f246b095 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 478ac9ed0db64..bd722de8c9fb9 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 97a97174c0e9b..1f124ddfbd5f2 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 7e7cc267d7573..752ec8af46f07 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 18414f8ee595e..accfe1d7d5acb 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 5672901a7717d..c9d36b55bd4a6 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 3557bc0bb200f..d7624742d4b9b 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 b4c1af4bd9e2e..b199790da2b8a 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 275f50ca37ec5..c7fd2121238dc 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 55e8a627690ca..616b0f49f17cf 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 7af9130dc1319..7883c6eba15d1 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 810c62d32469d..7b99f41dad7cd 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 82603fc3fca0f..ff50964f00c63 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 d6632a5d2c3a4..21a39de1f767b 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 18d136620e8b9..14a68afcb4348 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 e780a63507564..3a25b7196b22a 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 e47123e957e17..804b2b6dd17be 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 48fef8ee4a8e5..f220577026ce3 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 82dda73186927..34a607b9f1151 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 16924d250f204..0a1ecbd6c4ca7 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 efe5ab0f742e0..401c9e53e8249 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 9ab6c17a067eb..1a6eb321276a7 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 8f28e93b00810..eb8165061d146 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 46523cdcd1ce0..53e6e3c6d47b3 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 c0dbf9af16cdf..69c5d77df224b 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 04aff4dbc2ff2..398299e05b0e7 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 05fbf603ac5b5..ff46f9a799eae 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 da35cf05d39d4..1ddca3d1c2ecb 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 b444497032a33..04337f86f5608 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 03225f5249beb..665391e1dc40e 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 1b4080ba998d7..3129a70af0b0c 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 f27a7df7bb71b..3d06bfe82eeba 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 550f77addd455..9d3c299329741 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 63c73d52d0564..aa5ccc87c799d 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 a6aaef3dd97a2..9f836aef1d59b 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 c0efd1c068980..7a214cc69dd14 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 cd5df4f7c0640..9447a96e5471a 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 de0f629499636..bfc697e47ed24 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 e3ab74f2d51ca..8ce9141370531 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 efd47ece4e8d7..8fbd98a358601 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 3ae2c3d998a8c..f8ac76cb2fb90 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 5f91fb96ad711..9215eb1735ba8 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 118d1cf0321ba..010b1ca4a7670 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 7d8e230b56dfd..a0c403463a57e 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 a99c64dff9aba..7bcc22a5bf39b 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 95a37ebeaefed..c30afbdf152ed 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 76f5f65ef646d..498dc8444fd9e 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 ebc2f50448007..a351b81148880 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 a15e63337c59d..badb1f2456604 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 a00a79bbf0df9..c78aab0e29448 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 010f4d2438ab4..021c91eeb2cae 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 bf6874368f1ca..09613f4d578a8 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 3855354dc81ea..02508cefc683c 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 6e589b38e2e3c..2a2fa73b87d67 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 eb49960876ee2..cfe2f475f593c 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 a397e5d04f451..b6b6cb6248ba9 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 5d96fed4e2157..323297b7309c1 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 6f6569a0b5341..e857300a7bcd4 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 1d518deec4777..4e482c81db5ca 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 b967849e9521b..c68e6ba0d6b81 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 a9eefdaa58238..16193c83cbb4f 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 51844a2823639..49f3f6a27263c 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 7814e3ce0d815..ecc870f300b3b 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 9d4995be732d4..5ff45e2557cd4 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 f4d5179da2d5d..1a11e8e0ebc25 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 3af26ae02a4cf..3fc121d19e2ec 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 0563ba0eb3ae7..7bdd693f2c3f8 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 997fcae85b4ca..1a2495f0c371c 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 abb14f61b47be..3f4c5a6329114 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 2f438dfc43975..5ef9b6358195c 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 62288a12b0070..6d0de80caa482 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 eab8eb3bd8856..b4ff861b9cb84 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 acae49cafc6bc..4a47a353dbebb 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 d0ca84fb88cf8..5c056d1cb9326 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 f3c320c9214d8..8e5e161b3e9cf 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 e6d5ae1c01f41..a02de7ed5b785 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 960214b5ca583..041799562ff62 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 594a623c438cc..44a0671385128 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 2c813f07dac66..67b144d0112a7 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 326a1d4c2f8e0..8a677af063114 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 9573f3b3fb497..dc545a12eca4c 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 278442ed468f0..212787aa1e0fc 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 f625a4471e935..2616e1f226e53 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 69998c90ab0e8..2fb3346f5f888 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 b44b0cb56bf8f..d759fffc3b441 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 8c029938b17d9..fad9e050517bf 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 cc354b1d21192..64a99622ae851 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 c81720038dd47..b463ed296d6b1 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 fe6bcbefb237a..c96e27cc61303 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 05bf3ed3ede2c..57736792e70e5 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 cb208289a46c7..a70900c189d42 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 16ce09b401a5b..1b862045e1ebd 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 928679b122a5b..fad5217e749f6 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 d31ccda34cf91..04ae1264433a0 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 669a9e1362e9d..907748c3bb8fa 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 368b3d9beb05e..1115c5accb0d9 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 803eb0f0534fa..98937bbfe075f 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 26f06c75e8d97..7501763d43e25 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 12bee62ca8e66..4c98e18079562 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 190b7b7c78f2d..7624ca6053994 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 fa9298d2ac19d..e60168f9c6fd8 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 1fed4e5a2c5a8..6f00ac274b0a8 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 c6c9b2f2ab6e4..617fedfc1c105 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 24b48a12e2419..8e08ede9d6b7e 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 a59f78b2a57b9..4f64ea19c18df 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 8d3f299e074a4..c7a0c4d2053cf 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 cbb60e849fcae..0a64ad86b0521 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 78e0ddd075ada..ea55e99a122ff 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 51bb2d4e6422d..01a1ed13fdcb0 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 752ec8af46f07..01dae940a8e7e 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 7b99f41dad7cd..665eb5e16f690 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 21a39de1f767b..95b2d99e4ce53 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 14a68afcb4348..58da8b82f2a8d 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 a25bb6eb6e627..0097c191d827b 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 d0e0f78dce524..8f5f195ae847c 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 792cdba1fcb62..60ef3068557d9 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 b38e2f7018df0..2c59011160914 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 09613f4d578a8..23ff1bb4d5b2f 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 ba6a60cdbb44d..ac19c95f4fd74 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 b6b6cb6248ba9..7952157a94856 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 1a11e8e0ebc25..7fc7520cb521e 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 3fc121d19e2ec..0dc5432dee738 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 1a2495f0c371c..fa4327da432b6 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 4a47a353dbebb..5f7137ca5b33e 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 a02de7ed5b785..d3e25b90159ca 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 67b144d0112a7..f2ae1fefe8bdf 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 b463ed296d6b1..bd2a3a54337cb 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 a70900c189d42..b5b6f03415102 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 ed672464c7c16..5201c11203cf4 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 ea09ef792aacb..9491ffeeacc46 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 e017369ff4a98..575f590747cb7 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 bc69f362c01b5..92dbb5c21bf9b 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 fe8bd4c80dec0..f27420276f8a3 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 3c63eae544ed9..b6d286e307fda 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 036/146] 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 9013a3dfcc9fb..010c902364c4b 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 0000000000000..c0786c675f247 --- /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 0ac9cf2362924..bac2682a0245e 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 b5b6f03415102..b175bbd3b0976 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 8fc4b775b850c..76be2555ff8c5 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 9c67ec46e8e06..2b9264b9dd70e 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 0767f99190c9c..472ba9b998a8a 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 bb89de6717e85..cdae02679be6e 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 c2de7d240bc7e..6e35d8446964e 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 0285917096593..2b19d282dceb1 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 c3373d08abf42..61af412b05558 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 9a24e1b8abe3a..77825113992f0 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 041/146] 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 e35f173a4729f..d980164b32d37 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 2cfe3aa7aebf7..6390256108ecf 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 042/146] 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 aadc124ba4a5d..36c58dc893d0d 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 bfe363cf06524..aa7d36156e50f 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 599fb4780e882..3ef1a2f3faf6e 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 2e74482f690a2..91187bc2a1311 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 9a0dff477368a..8dfbfe712a7de 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 cd926c899d978..5bbf3cd9a7e62 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 0000000000000..b132e7c6dd146 --- /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 f1495d1e62a4f..990d450cde6b5 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 89522077bae4d..ea681a30afe03 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 2485edf7e0901..e8a4957e84c02 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 0000000000000..21d40b25ac5ed --- /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 218281053704c..332e6778ba1b3 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 ce67fa157d334..2566349d273b6 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 5f3ef760ad8b4..063d41a7ec462 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 5f54581669085..82d2092f61fbe 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 2b9264b9dd70e..4be149ac9d718 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 472ba9b998a8a..43030010c5aec 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 cdae02679be6e..db994a7fe8de7 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 61af412b05558..4e781d3c62003 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 77825113992f0..67e2314a8b010 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 043/146] 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 08552d1cd53a1..36738a03cf9d3 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 21e9e7ba99c56..df8a719f9e791 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 c96551bc00c06..c6e151434ca34 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 fd2587116159b..0815e45e46262 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 0000000000000..72655d9f0a62d --- /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 d1455a1b206b4..d71e2422a3b63 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 b7f755edc3b4d..6e0d596dd7434 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 c56106871385d..1367c27c180d5 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 77cf9ba3a26e9..7bf7fbe173e0b 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 66f05377c323a..d2cff47263455 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 e12186e90abbf..0f226a943f5e3 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 0000000000000..9b3d1fcfcfe60 --- /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 0000000000000..8d2417f90d022 --- /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 23a3c194cecd2..29029cef9d3e3 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 dd545b9671751..7c994153469bc 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 0000000000000..2867a9389ed67 --- /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 044/146] 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 36738a03cf9d3..cbd61e0604ef4 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 045/146] 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 20f8ab3941f99..266d63079e5f6 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 57304f2d4ad82..fe624532f74b3 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 046/146] 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 01a1ed13fdcb0..757f3db20c029 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 8fbd98a358601..71dd1e89ee492 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 323297b7309c1..24e2a1955ee2e 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 047/146] 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 41d8777f6dfb6..9dba4f4a4f882 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 8240fbc74df44..698a345035cca 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 048/146] 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 2b23ad865f042..c4c0946fffd02 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 e813c48faa6b8..99901652473d2 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 2d6afae419eef..71ebec0ed9c2f 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 049/146] 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 45b3339732610..d29d970c75ff1 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 df8a719f9e791..5d387de16b836 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 050/146] 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 138528263fbac..9c681f605ae41 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 051/146] 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 0ec8b1c37b291..b4dd358c17b1a 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 c2d29e98047e8..ae06aba0355dd 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 9c681f605ae41..acdec8cf3d035 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 53c71b9eed71a..f1795704844ce 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 757f3db20c029..c6551d4fcb7d1 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 665eb5e16f690..3452282be49c8 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 71dd1e89ee492..62fd79f6706c1 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 24e2a1955ee2e..27eae0e297669 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 6d0de80caa482..39aa4a19cc17b 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 5f7137ca5b33e..8d39c065f579a 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 0ad52c0ec2288..05ce93e024b3f 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 6e550f6814679..e58708100d6b2 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 040e4ead2b59b..90c72cf491301 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 052/146] 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 dbb889a1bb8ef..17f40f695472b 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 baf23d2b4cec5..78468b0c85808 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1107,7 +1107,7 @@ function MarkdownMermaidCodeBlock({ } > - + {searching ? (