diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts index 99861014800..c2dd34aa2ab 100644 --- a/mobile/src/onboarding/MobileOnboardingPage.test.ts +++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts @@ -82,13 +82,9 @@ describe('MobileOnboardingPage', () => { } function button(label: string) { - const match = findAllByHostType(renderer!.root, 'Pressable').find( - (node) => node.props.accessibilityLabel === label + return renderer!.root.find( + (node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === label ) - if (!match) { - throw new Error(`No ${label} button`) - } - return match } function collectText(): string { diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 37e6881ef70..9aae4ff64ff 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -98,9 +98,10 @@ describe('MobileNativeChatComposer', () => { if (!renderer) { throw new Error('Composer was not rendered') } - return findAllByHostType(renderer.root, 'Pressable').find( - (node) => node.props.accessibilityLabel === 'Send message' - )! + return renderer.root.find( + (node) => + String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Send message' + ) } it('reports an accepted send without owning route-scoped draft cleanup', async () => { @@ -194,9 +195,10 @@ describe('MobileNativeChatComposer', () => { renderer = create(draw('run the tests')) }) const modelPill = () => - findAllByHostType(renderer!.root, 'Pressable').find( - (node) => node.props.accessibilityLabel === 'Model, Model' - )! + renderer!.root.find( + (node) => + String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Model, Model' + ) expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false }) // Start the send but don't await it — it stays in flight on purpose. let pressed!: Promise @@ -543,9 +545,9 @@ describe('MobileNativeChatComposer', () => { const onMicPressIn = vi.fn() const onMicPressOut = vi.fn() const mic = () => - findAllByHostType(renderer!.root, 'Pressable').find( - (node) => node.props.accessibilityLabel === 'Dictate' - )! + renderer!.root.find( + (node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Dictate' + ) await act(async () => { renderer = create( diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index 4398113631a..909717613bc 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -48,40 +48,6 @@ describe('matchFilePathAtColumn', () => { } ) - it('matches an absolute path under the tap', () => { - const line = 'created /tmp/out/report.html for you' - const result = matchFilePathAtColumn(line, colOf(line, 'report')) - expect(result?.pathText).toBe('/tmp/out/report.html') - }) - - it('matches a relative path and parses line:col', () => { - const line = 'see src/components/Button.tsx:12:7 here' - const result = matchFilePathAtColumn(line, colOf(line, 'Button')) - expect(result).toEqual({ pathText: 'src/components/Button.tsx', line: 12, column: 7 }) - }) - - it('matches a tilde path', () => { - const line = 'wrote ~/Documents/notes.md' - const result = matchFilePathAtColumn(line, colOf(line, 'notes')) - expect(result?.pathText).toBe('~/Documents/notes.md') - }) - - it('matches a path whose directory name contains a space', () => { - const line = '/Users/me/My Project/readme.md done' - const result = matchFilePathAtColumn(line, colOf(line, 'readme')) - expect(result?.pathText).toBe('/Users/me/My Project/readme.md') - }) - - it('matches a spaced path with line and column suffixes', () => { - const line = 'wrote /tmp/orca report/result.json:12:3 for you' - const result = matchFilePathAtColumn(line, colOf(line, 'result')) - expect(result).toEqual({ - pathText: '/tmp/orca report/result.json', - line: 12, - column: 3 - }) - }) - it('matches a spaced path when an earlier directory segment contains a dot', () => { const line = 'wrote /tmp/v1.2 reports/result.json for you' expect(matchFilePathAtColumn(line, colOf(line, 'v1.2'))).toEqual({ @@ -116,26 +82,6 @@ describe('matchFilePathAtColumn', () => { }) }) - it('does not merge two spaced path candidates through prose', () => { - const line = 'see /tmp/a.txt and /tmp/b.txt done' - expect(matchFilePathAtColumn(line, colOf(line, 'a.txt'))).toEqual({ - pathText: '/tmp/a.txt', - line: null, - column: null - }) - expect(matchFilePathAtColumn(line, colOf(line, 'b.txt'))).toEqual({ - pathText: '/tmp/b.txt', - line: null, - column: null - }) - }) - - it('trims surrounding punctuation', () => { - const line = 'open (src/a.ts) now' - const result = matchFilePathAtColumn(line, colOf(line, 'a.ts')) - expect(result?.pathText).toBe('src/a.ts') - }) - it('returns null when the tap is not on a path', () => { const line = 'just some prose with no path here' expect(matchFilePathAtColumn(line, colOf(line, 'prose'))).toBeNull() diff --git a/mobile/src/terminal/terminal-webview-url-tap.test.ts b/mobile/src/terminal/terminal-webview-url-tap.test.ts index 36622814cd4..a523c82aa32 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.test.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.test.ts @@ -116,10 +116,7 @@ describe.each([ expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected) }) - it.each(FILE_URL_TAP_CASES)('OSC resolver accepts file URL: $name', ({ uri, expected }) => { - if (expected === null) { - return - } + it.each(FILE_URL_TAP_CASES)('resolves or rejects OSC file URL: $name', ({ uri, expected }) => { expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected) }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts index cb906b164c7..c7e9976db64 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -116,7 +116,7 @@ describe('terminal live input focus hook', () => { harness.unmount() }) - it('reacquires focus for mouse-aware and non-mouse terminal tap notifications', () => { + it('reacquires focus after successive terminal tap notifications', () => { vi.useFakeTimers() const input = createFocusTarget() const harness = createHarness(connectedProps({ current: input })) diff --git a/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx b/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx index 0b082347257..7ff45633731 100644 --- a/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx +++ b/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx @@ -1,4 +1,4 @@ -import { createElement, type RefObject } from 'react' +import { createElement, forwardRef, useImperativeHandle, type RefObject } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import type { TextInput } from 'react-native' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' @@ -42,7 +42,14 @@ const field: RefObject = { current: null } * fields do: their submits read `handleSend`, a per-render function whose guard reads `client` and * `activeHandle`. This says the listener follows them. */ -function Harness({ value }: { readonly value: string }): null { +let rebindField: ((node: TextInput | null) => void) | undefined + +const Field = forwardRef(function Field(_props, ref) { + useImperativeHandle(ref, () => node, []) + return null +}) + +function Harness({ value }: { readonly value: string }) { const bindField = useTerminalTextFieldSubmitBinding( field, () => { @@ -53,8 +60,8 @@ function Harness({ value }: { readonly value: string }): null { return value === 'second' } ) - bindField(node) - return null + rebindField = bindField + return createElement(Field, { ref: bindField }) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding never reads the node; the mocked seam only records the handler it was given. @@ -62,6 +69,7 @@ const node = {} as TextInput function reset(): void { submitted = [] + rebindField = undefined mocks.chordHandlers.length = 0 mocks.boundHandlers.length = 0 mocks.unbindCount = 0 @@ -116,8 +124,9 @@ describe('the terminal text field submit binding', () => { renderer?.update(createElement(Harness, { value: 'second' })) }) - // One release per rebind, so a field re-bound on every render leaks no listeners: only the - // binding still in place is unreleased. + act(() => rebindField?.(node)) + + // One binding remains live after an explicit rebind. expect(mocks.unbindCount - mocks.releasedCount).toBe(1) act(() => renderer?.unmount()) }) @@ -136,5 +145,7 @@ describe('the terminal text field submit binding', () => { expect(submitted).toEqual(['first']) expect(field.current).toBe(node) act(() => renderer?.unmount()) + expect(field.current).toBeNull() + expect(mocks.releasedCount).toBe(mocks.unbindCount) }) }) diff --git a/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts index 731132df3f7..38df1d56fef 100644 --- a/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts +++ b/mobile/src/test-support/bridged-parity/c1-page-closure.test.ts @@ -24,12 +24,8 @@ function asPinned(): Map { } describe('the C1 page closure', () => { - it('is the census the design named: 20 families, 96 goldens', () => { + it('assigns each pinned golden to one closure family', () => { const goldens = Object.values(C1_PAGE_CLOSURE).flatMap((family) => Object.keys(family)) - expect({ families: Object.keys(C1_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({ - families: 20, - goldens: 96 - }) expect(new Set(goldens).size).toBe(goldens.length) }) diff --git a/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts index 4b257fae7ec..2dd370a62c0 100644 --- a/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts +++ b/mobile/src/test-support/bridged-parity/c5-page-closure.test.ts @@ -26,12 +26,8 @@ function asPinned(): Map { } describe('the C5 page closure', () => { - it('is the census the design named: 25 families, 127 goldens', () => { + it('assigns each pinned golden to one closure family', () => { const goldens = Object.values(C5_PAGE_CLOSURE).flatMap((family) => Object.keys(family)) - expect({ families: Object.keys(C5_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({ - families: 25, - goldens: 127 - }) expect(new Set(goldens).size).toBe(goldens.length) }) diff --git a/mobile/src/test-support/bridged-parity/c6-browser-closure-families.test.ts b/mobile/src/test-support/bridged-parity/c6-browser-closure-families.test.ts index a1621719a5d..eba7a56f6e9 100644 --- a/mobile/src/test-support/bridged-parity/c6-browser-closure-families.test.ts +++ b/mobile/src/test-support/bridged-parity/c6-browser-closure-families.test.ts @@ -7,14 +7,10 @@ import { C6_BROWSER_CLOSURE_FAMILIES } from './c6-browser-closure-families' import { pageClosureTotals } from './page-closure' describe('the C6 browser closure half', () => { - it('is the census the design named: 4 families, 15 goldens', () => { + it('assigns each pinned golden to one closure family', () => { const goldens = Object.values(C6_BROWSER_CLOSURE_FAMILIES).flatMap((family) => Object.keys(family) ) - expect({ - families: Object.keys(C6_BROWSER_CLOSURE_FAMILIES).length, - goldens: goldens.length - }).toEqual({ families: 4, goldens: 15 }) expect(new Set(goldens).size).toBe(goldens.length) }) @@ -39,19 +35,6 @@ describe('the C6 browser closure half', () => { } }) - /** C2's rule over the new families, which predicted all fifteen; the table is generated, not - * hand-corrected, and this is what says so. */ - it('agrees with the C2 classification rule on every golden', () => { - for (const goldens of Object.values(C6_BROWSER_CLOSURE_FAMILIES)) { - for (const [id, verdict] of Object.entries(goldens)) { - expect({ id, verdict }).toEqual({ - id, - verdict: id.startsWith('matrix-') ? 'result-absent-settlement' : 'identical' - }) - } - } - }) - /** * No family here is pinned by another series, so a golden pinned twice is pinned once. * diff --git a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts index 977f77e3e6b..61a38e0d72e 100644 --- a/mobile/src/test-support/bridged-parity/divergence-classes.test.ts +++ b/mobile/src/test-support/bridged-parity/divergence-classes.test.ts @@ -8,7 +8,6 @@ import { BRIDGED_PARITY_BASELINE, BRIDGED_PARITY_EXCLUSIONS, BRIDGED_PARITY_MEMBERS, - BRIDGED_PARITY_OFF, BRIDGED_PARITY_NAMEABLE, type BridgedParityClass, type BridgedParityEvidence, @@ -35,14 +34,6 @@ const droppedUndefinedKey: BridgedParityEvidence = { } } -describe('the bridged-parity flag', () => { - it('skips on one value only, so an unset or mistyped variable still runs the gate', () => { - const skips = (value: string | undefined): boolean => value === BRIDGED_PARITY_OFF - expect([undefined, '', '1', 'false', 'off'].filter(skips)).toEqual([]) - expect(skips('0')).toBe(true) - }) -}) - describe('classifying one diverging golden', () => { it('names the missing field first, but only where supplying it was enough', () => { const absent = { diff --git a/src/main/runtime/browser-tab-create-publication.test.ts b/src/main/runtime/browser-tab-create-publication.test.ts index eb1da5b4f56..c2830eda0f5 100644 --- a/src/main/runtime/browser-tab-create-publication.test.ts +++ b/src/main/runtime/browser-tab-create-publication.test.ts @@ -112,26 +112,6 @@ describe('publishCreatedBrowserSessionTab', () => { }) }) - // Why: `session-notify` is not a free-standing choice — the host row rides the very announcement - // `notifiesSessionTabsChanged` gates, so silencing that flag would delete the row with no other - // test noticing. Pin the coupling, not just the two values. - it('gives every session-notify placement the announcement its host row rides on', () => { - // Presence precondition: the loop below only asserts anything about placements that declare - // `session-notify`, so flipping the one that does to `none` in the same edit that silences its - // announcement would satisfy the coupling by emptying it. Name the placement that must declare - // it — a client-placed page reaches the host renderer through no other route. - expect(BROWSER_TAB_CREATE_PUBLICATION_RULES.client.hostRowSource).toBe('session-notify') - for (const placementKind of BROWSER_TAB_CREATE_PLACEMENT_KINDS) { - const rules = BROWSER_TAB_CREATE_PUBLICATION_RULES[placementKind] - if (rules.hostRowSource === 'session-notify') { - expect( - rules.notifiesSessionTabsChanged, - `${placementKind} sources its host row from the session-tabs announcement` - ).toBe(true) - } - } - }) - it.each(BROWSER_TAB_CREATE_PLACEMENT_KINDS)( 'moves a user-created %s tab into the clicked split group when the placement marks focus', (placementKind) => { diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index b0978ae25f8..12271e67ae9 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { buildClaudeAgentTeamsLaunchPlan, @@ -107,6 +107,8 @@ describe('claude agent teams shim env', () => { } } + const relativeRoot = relative(process.cwd(), root) + expect(resolveClaudeAgentTeamsShimBin({ PATH: relativeRoot })).toBeNull() expect(resolveClaudeAgentTeamsShimBin({ PATH: '.' })).toBeNull() expect(resolveClaudeAgentTeamsShimBin({ PATH: '' })).toBeNull() expect( diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index 786e2157feb..b2c6a30d6fe 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -129,6 +129,12 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { await host.flushStreamedEvents(SESSION) expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) + const live = await host.deps.adapter.readOptions?.({ + sessionId: SESSION, + fence: host.deps.store.getRecord(SESSION)!.lease.runtimeFence + }) + expect(live).toMatchObject({ current: { model: RETIRED } }) + expect(claude.child(SESSION).calls).not.toContain('set_model') }) // The CLI's word is about the model it was launched with; a pick made since is the user's. diff --git a/src/main/runtime/client-hosted-browser-row-push.integration.test.ts b/src/main/runtime/client-hosted-browser-row-push.integration.test.ts index 3705bc23ea4..2e165edeb48 100644 --- a/src/main/runtime/client-hosted-browser-row-push.integration.test.ts +++ b/src/main/runtime/client-hosted-browser-row-push.integration.test.ts @@ -3,7 +3,7 @@ * registry, and the real session-tabs announcement every client-page mutation already goes * through. The unit tests around the publisher can only prove it behaves once called. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import type { RuntimeBrowserClientPlacement } from '../../shared/runtime-browser-placement' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' @@ -293,18 +293,3 @@ describe('client-hosted browser row metadata', () => { }) }) }) - -// Keep the harness honest: a runtime that never pushes would satisfy every negative case above. -describe('push harness', () => { - it('observes the notifier the runtime was given', () => { - const clientHostedBrowserRowsChanged = vi.fn() - const { runtime } = createRuntime() - runtime.setNotifier({ clientHostedBrowserRowsChanged } as never) - attachHost(runtime, 'host-a') - placeAndPublish(runtime, 'page-a', 'host-a') - - runtime.notifyMobileSessionTabsChanged(WT) - - expect(clientHostedBrowserRowsChanged).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/runtime/cline-screen-readiness-transcripts.test.ts b/src/main/runtime/cline-screen-readiness-transcripts.test.ts index de631d4638b..b08a126841a 100644 --- a/src/main/runtime/cline-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/cline-screen-readiness-transcripts.test.ts @@ -339,7 +339,7 @@ describe('ZCode captured composer readiness', () => { ).rejects.toThrow(/timeout/) }, 15_000) - it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => { + it('recognizes the synthetic idle title and hook title policy for ZCode', () => { expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready') expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required') expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true) diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index 491e974e229..81a7b07d31d 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -373,7 +373,7 @@ describe('Codex launched composer readiness', () => { } describe('launch readiness for a freshly launched Codex', () => { - it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { + it('codex-0158-fresh-home-greeting: reads the live chat as ready for launch', async () => { const data = greetingBeforeStarfield() // Presence precondition: the cut keeps the live status row and drops every star. expect(data).toContain('GPT-6-Astra default') @@ -385,7 +385,7 @@ describe('Codex launched composer readiness', () => { }) }, 15_000) - it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { + it('codex-0157-plain-ready: reads the live chat as ready for launch', async () => { const pane = await launchedCodexPane(readRuntimeFixture('codex-0157-plain-ready')) await expect(waitForCodexComposer(pane, 8_000)).resolves.toMatchObject({ condition: 'tui-idle', diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index ea586de05c5..60acf33f5ad 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -490,7 +490,7 @@ describe('runtime composition parity (Electron vs orcad)', () => { }) // #30731: orcad once installed a browser factory that could not place pages on a desktop. - it('installs a browser factory on both roots that hosts pages on a paired desktop', () => { + it('wires the browser factory and client-hosting options on both roots', () => { const installs: Record = { electron: { root: 'src/main/startup/main-process-preflight.ts', diff --git a/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts b/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts index 7ffc3896a66..fb3b4f66952 100644 --- a/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts +++ b/src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts @@ -62,7 +62,7 @@ describe('workspace delete app command', () => { mocks.hoveredTarget = { kind: 'worktree', worktree: {} as never } }) - it('claims the chord and immediately deletes the active workspace', () => { + it('claims the chord and immediately deletes the hovered workspace', () => { const input = shortcutInput() const handler = createAppCommandHandlers(shortcutState(), input, 'terminal').get( 'workspace.delete' diff --git a/src/renderer/src/app-shell/visible-review-reporting.test.ts b/src/renderer/src/app-shell/visible-review-reporting.test.ts index a7b59a800de..d69a57f84de 100644 --- a/src/renderer/src/app-shell/visible-review-reporting.test.ts +++ b/src/renderer/src/app-shell/visible-review-reporting.test.ts @@ -82,7 +82,7 @@ describe('review report selector work', () => { } }) - it('recomputes for every relevant input without retaining the whole state', () => { + it('recomputes for every relevant input', () => { const current = state().getState() const updates: Partial[] = [ { activeView: 'settings' }, diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index f3982a1b98e..9d25e770682 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -5,18 +5,16 @@ // desktop renderer = real usePersistedUIWriter + real UI slice (createUIStore) // authority (main) = real updatePersistedUI/getPersistedUI merge // broadcast = controlled queue modeling the async ui:stateChanged IPC send -// mobile client = mobile/src/worktree/workspace-view-settings mapping; the ui.set -// payload mirrors the shipping buildWorkspaceViewSettingsUpdate. +// other client = partial ui.set requests delivered directly to the authority. // // Invariant: when two independently identified clients change DISJOINT workspace-view -// fields concurrently or across stale-mirror windows, both changes survive and all -// mirrors converge; no client may restore a stale sibling field. +// fields concurrently or across stale-mirror windows, both changes survive and the +// desktop mirror converges; the writer must preserve sibling fields another client changed. import { StrictMode, act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StoreApi } from 'zustand/vanilla' import { getDefaultUIState } from '../../../shared/constants' -import { omitPairingLocalUiFields } from '../../../shared/pairing-local-ui-fields' import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' import type { PersistedState } from '../../../shared/persisted-state-types' import { getPersistedUI } from '../../../main/persistence/applying-settings/ui-state-read' @@ -78,83 +76,6 @@ function createAuthority() { type Authority = ReturnType -// Local model of mobile/src/worktree/workspace-view-settings.ts (the desktop test -// runner cannot transform Expo-configured sources). -type MobileViewState = { - groupMode: 'none' | 'workspaceStatus' | 'repo' | 'prStatus' - sortMode: 'smart' | 'name' | 'recent' | 'repo' | 'manual' - hideSleeping: boolean - hideDefaultBranch: boolean - alwaysShowDefaultBranch: boolean - filterRepoIds: string[] - collapsedGroups: string[] -} - -/** Mirrors buildWorkspaceViewSettingsUpdate (candidate) — only touched fields. */ -function patchOnlyUpdate( - patch: Partial, - next: MobileViewState -): Partial { - const update: Partial = {} - if ('groupMode' in patch) { - update.groupBy = next.groupMode === 'workspaceStatus' ? 'workspace-status' : 'repo' - } - if ('sortMode' in patch) { - update.sortBy = next.sortMode - } - if ('hideSleeping' in patch) { - update.hideSleepingWorkspaces = next.hideSleeping - } - if ('hideDefaultBranch' in patch) { - update.hideDefaultBranchWorkspace = next.hideDefaultBranch - } - if ('filterRepoIds' in patch) { - update.filterRepoIds = next.filterRepoIds - } - if ('collapsedGroups' in patch) { - update.collapsedGroups = next.collapsedGroups - } - return update -} - -/** Model of the mobile host screen's view-settings client (persistViewSettings et al.). */ -function createMobileClient(authority: Authority) { - let view: MobileViewState = { - groupMode: 'repo', - sortMode: 'recent', - hideSleeping: false, - hideDefaultBranch: false, - alwaysShowDefaultBranch: true, - filterRepoIds: [], - collapsedGroups: [] - } - return { - get view() { - return view - }, - /** ui.get on connect/focus: merge the shared state onto the local mirror - * (mirrors applyDesktopViewSettings' ??-per-field semantics). */ - sync() { - const ui = omitPairingLocalUiFields(authority.get()) - view = { - ...view, - hideSleeping: ui.hideSleepingWorkspaces ?? view.hideSleeping, - hideDefaultBranch: ui.hideDefaultBranchWorkspace ?? view.hideDefaultBranch, - alwaysShowDefaultBranch: - ui.alwaysShowDefaultBranchWorkspace ?? view.alwaysShowDefaultBranch, - filterRepoIds: ui.filterRepoIds ?? view.filterRepoIds, - collapsedGroups: ui.collapsedGroups ?? view.collapsedGroups - } - }, - /** A user tap: apply locally, then push through the shipping payload shape. */ - tap(patch: Partial) { - view = { ...view, ...patch } - const payload = patchOnlyUpdate(patch, view) - authority.set(omitPairingLocalUiFields(payload) as Partial) - } - } -} - describe('workspace view preferences: cross-client persistence (STA-5781)', () => { let authority: Authority let store: StoreApi @@ -338,10 +259,7 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(store.getState().persistedUIWriteInFlightCounts).toEqual({}) }) - it('a mobile tap must not revert a desktop change the mobile mirror has not seen', async () => { - const mobile = createMobileClient(authority) - mobile.sync() - + it('merges another client’s field without reverting a prior desktop write', async () => { // Desktop turns on "hide default branch"; the write lands and broadcasts. act(() => { store.getState().setHideDefaultBranchWorkspace(true) @@ -350,9 +268,8 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(authority.get().hideDefaultBranchWorkspace).toBe(true) deliverBroadcasts() - // Mobile (mirror stale since its last ui.get) toggles the DISJOINT - // "hide sleeping" filter. - mobile.tap({ hideSleeping: true }) + // Another client sends only its changed field. + authority.set({ hideSleepingWorkspaces: true }) // Both changes must survive. expect(authority.get().hideSleepingWorkspaces).toBe(true) @@ -379,21 +296,18 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(authority.get().hideSleepingWorkspaces).toBe(false) }) - it('the desktop debounced writer must not revert a concurrent mobile change', async () => { - const mobile = createMobileClient(authority) - mobile.sync() - + it('the desktop debounced writer preserves a concurrent client’s field', async () => { // t=0: desktop toggles a desktop-only filter (disjoint from mobile fields). act(() => { store.getState().setHideCliCreatedWorkspaces(true) }) - // t<150ms: mobile turns on "hide sleeping". The authority applies it, but the + // t<150ms: another client turns on "hide sleeping". The authority applies it, but the // ui:stateChanged broadcast is still in flight to the desktop renderer. act(() => { vi.advanceTimersByTime(100) }) - mobile.tap({ hideSleeping: true }) + authority.set({ hideSleepingWorkspaces: true }) expect(authority.get().hideSleepingWorkspaces).toBe(true) // t=150ms: the desktop debounce fires from its (not yet re-hydrated) mirror. @@ -403,27 +317,22 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(authority.get().hideCliCreatedWorkspaces).toBe(true) expect(authority.get().hideSleepingWorkspaces).toBe(true) - // After full delivery and a mobile re-sync, every mirror converges on both. + // Full delivery brings the desktop mirror to both persisted values. deliverBroadcasts() - mobile.sync() expect(store.getState().showSleepingWorkspaces).toBe(false) expect(store.getState().hideCliCreatedWorkspaces).toBe(true) - expect(mobile.view.hideSleeping).toBe(true) }) it('a broadcast landing inside the debounce window must not revert the pending desktop toggle', async () => { - const mobile = createMobileClient(authority) - mobile.sync() - // t=0: desktop toggles a filter; its write is pending in the 150ms debounce. act(() => { store.getState().setHideCliCreatedWorkspaces(true) }) - // t<150ms: mobile changes a disjoint field AND its broadcast is delivered + // t<150ms: another client changes a disjoint field AND its broadcast is delivered // before the desktop debounce fires. The broadcast still carries the OLD // value of the desktop's pending toggle. - mobile.tap({ hideSleeping: true }) + authority.set({ hideSleepingWorkspaces: true }) deliverBroadcasts() // The hydration must not wipe the user's pending toggle from the mirror. @@ -509,17 +418,15 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = // hydration advanced past, or the mirror and authority diverge with no // further traffic to reconcile them (the reset then reappears later). holdAcks = true - const mobile = createMobileClient(authority) - mobile.sync() act(() => { store.getState().setHideDefaultBranchWorkspace(true) }) await flushDesktopDebounce() - // Mobile writes the SAME field at the authority after us; both broadcasts - // (our echo, then mobile's) land before our ack does. - mobile.tap({ hideDefaultBranch: false }) + // Another client writes the SAME field at the authority after us; both broadcasts + // (our echo, then the other client's) land before our ack does. + authority.set({ hideDefaultBranchWorkspace: false }) deliverBroadcasts() await resolveAcks() @@ -722,18 +629,15 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(store.getState().persistedUIWriteInFlightCounts).toEqual({}) }) - it('desktop and mobile changing the same field converges on the newest write', async () => { - const mobile = createMobileClient(authority) - mobile.sync() - + it('desktop and another client changing the same field converge on the newest write', async () => { act(() => { store.getState().setHideDefaultBranchWorkspace(true) }) await flushDesktopDebounce() deliverBroadcasts() - // Mobile flips the SAME field afterwards; last writer wins everywhere. - mobile.tap({ hideDefaultBranch: false }) + // Another client flips the SAME field afterwards; last writer wins everywhere. + authority.set({ hideDefaultBranchWorkspace: false }) deliverBroadcasts() expect(authority.get().hideDefaultBranchWorkspace).toBe(false) await flushDesktopDebounce() diff --git a/src/renderer/src/components/AgentWorkingSpinner.test.tsx b/src/renderer/src/components/AgentWorkingSpinner.test.tsx index f2279772fa6..2b39f66ca56 100644 --- a/src/renderer/src/components/AgentWorkingSpinner.test.tsx +++ b/src/renderer/src/components/AgentWorkingSpinner.test.tsx @@ -9,7 +9,7 @@ import { AgentWorkingSpinner } from './AgentWorkingSpinner' globalThis.IS_REACT_ACT_ENVIRONMENT = true describe('AgentWorkingSpinner', () => { - it('hooks the compositor-driven CSS animation, not a JS clock', () => { + it('renders the spinner animation and reduced-motion classes', () => { const markup = renderToStaticMarkup(React.createElement(AgentWorkingSpinner)) expect(markup).toContain('agent-working-spinner') @@ -103,43 +103,6 @@ describe('AgentWorkingSpinner', () => { } }) - // Why: reduced motion resolves the rule to `animation: none`, so no animation - // exists to start and none to anchor. Pins that dropping the ref-time anchor - // adds no exposure here — both paths were already no-ops. - it('anchors nothing when reduced motion leaves no animation to start', async () => { - const getAnimations = vi.fn(() => [] as Animation[]) - const originalGetAnimations = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - 'getAnimations' - ) - Object.defineProperty(HTMLElement.prototype, 'getAnimations', { - configurable: true, - value: getAnimations - }) - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - - try { - await act(async () => { - root.render() - }) - const el = container.querySelector('[data-agent-spinner]') - - expect(el).not.toBeNull() - expect(getAnimations).not.toHaveBeenCalled() - expect(el!.className).toContain('motion-reduce:border-t-agent-working') - } finally { - act(() => root.unmount()) - container.remove() - if (originalGetAnimations === undefined) { - Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') - } else { - Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations) - } - } - }) - it('ignores animation starts from other animations on the same element', async () => { const animation = { animationName: 'agent-spinner-rotate', diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 95104b16507..e015994c1c0 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -213,7 +213,7 @@ function expectTooltipContent(markup: string, text: string): void { expect(markup).toContain('data-side="bottom"') expect(markup).toContain('data-variant="tab-preview" data-side="bottom" data-side-offset="0"') expect(markup).toContain('data-variant="tab-preview"') - expect(markup).toContain(text) + expect(markup).toContain(`data-tab-hover-card-title="true">${text}`) } function makeTerminalTab(overrides: Partial = {}): TerminalTab { diff --git a/src/shared/agent-status-osc-split-frame-scan-budget.test.ts b/src/shared/agent-status-osc-split-frame-scan-budget.test.ts index e1fc011a0ff..64e15481593 100644 --- a/src/shared/agent-status-osc-split-frame-scan-budget.test.ts +++ b/src/shared/agent-status-osc-split-frame-scan-budget.test.ts @@ -34,7 +34,11 @@ describe('OSC 9999 split-frame scan budget', () => { const small = feedWithScanBudget(feedOf(2000)) const large = feedWithScanBudget(feedOf(4000)) - expect(small.results.every((result) => result.payloads.length === 0)).toBe(true) + for (const feed of [small, large]) { + expect( + feed.results.every((result) => result.payloads.length === 0 && result.cleanData === '') + ).toBe(true) + } // Re-scanning the accumulation would quadruple the budget when the feed doubles. expect(large.searchedChars).toBeLessThan(small.searchedChars * 3) }) diff --git a/src/shared/agent-status-osc.test.ts b/src/shared/agent-status-osc.test.ts index fecd5151dac..14a9a587777 100644 --- a/src/shared/agent-status-osc.test.ts +++ b/src/shared/agent-status-osc.test.ts @@ -114,7 +114,14 @@ describe('createAgentStatusOscProcessor', () => { const stream = 'before😀\x1b]9999;{"state":"working","prompt":"漢字"}\x07between' + '\x1b]9999;{"state":"done"}\x1b\\after' - const expected = createAgentStatusOscProcessor()(stream) + const expected = { + cleanData: 'before😀betweenafter', + payloads: [ + { state: 'working', prompt: '漢字' }, + { state: 'done', prompt: '' } + ], + lastPayloadCleanOffset: 'before😀between'.length + } const other = createAgentStatusOscProcessor() for (let split = 0; split <= stream.length; split += 1) { diff --git a/src/shared/agent-status-transport-envelope.test.ts b/src/shared/agent-status-transport-envelope.test.ts index 6e5307bc4f4..9471954994e 100644 --- a/src/shared/agent-status-transport-envelope.test.ts +++ b/src/shared/agent-status-transport-envelope.test.ts @@ -22,7 +22,7 @@ describe('agent status transport envelope', () => { const first = authority.applyMutation({ parent: { subject: parent, firstObservedAt: 10 } }) expect(first).not.toBeNull() - const replica = createAgentStatusStore({ epoch: 'replica-placeholder', mode: 'replica' }) + const replica = createAgentStatusStore({ epoch: 'epoch-a', mode: 'replica' }) expect(replica.applyTransportEnvelope(first)).toBe(false) expect( replica.applyTransportEnvelope({ type: 'snapshot', snapshot: authority.getSnapshot() })