mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test: remove redundant checks and strengthen runtime regression coverage (#33385)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<void>
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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<TextInput | null> = { 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<TextInput>(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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,12 +24,8 @@ function asPinned(): Map<string, PageClosureObservation> {
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -26,12 +26,8 @@ function asPinned(): Map<string, PageClosureObservation> {
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Root, { root: string; call: string; file: string; option: RegExp }> = {
|
||||
electron: {
|
||||
root: 'src/main/startup/main-process-preflight.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'
|
||||
|
||||
@@ -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<AppState>[] = [
|
||||
{ activeView: 'settings' },
|
||||
|
||||
@@ -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<typeof createAuthority>
|
||||
|
||||
// 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<MobileViewState>,
|
||||
next: MobileViewState
|
||||
): Partial<PersistedUIState> {
|
||||
const update: Partial<PersistedUIState> = {}
|
||||
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<MobileViewState>) {
|
||||
view = { ...view, ...patch }
|
||||
const payload = patchOnlyUpdate(patch, view)
|
||||
authority.set(omitPairingLocalUiFields(payload) as Partial<PersistedUIState>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace view preferences: cross-client persistence (STA-5781)', () => {
|
||||
let authority: Authority
|
||||
let store: StoreApi<AppState>
|
||||
@@ -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()
|
||||
|
||||
@@ -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(<AgentWorkingSpinner />)
|
||||
})
|
||||
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',
|
||||
|
||||
@@ -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}</div>`)
|
||||
}
|
||||
|
||||
function makeTerminalTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() })
|
||||
|
||||
Reference in New Issue
Block a user