mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test: remove wrapper replays and test fixture inventories (#33745)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
# Prune after a fix: pnpm check:flaky-test-pattern-ratchet --prune
|
||||
1 test-no-polling-loop:config/scripts/happy-dom-mutation-observer-retention.test.ts
|
||||
1 test-no-polling-loop:config/scripts/mobile-web-app/mobile-web-app-frame-budget-sweep.test.ts
|
||||
1 test-no-polling-loop:mobile/src/transport/cellular-handshake-stall-real-socket.test.ts
|
||||
1 test-no-polling-loop:mobile/src/transport/rpc-client-live-recovery.test.ts
|
||||
1 test-no-polling-loop:src/main/browser/cdp-ws-proxy.test.ts
|
||||
1 test-no-polling-loop:src/main/browser/cookies/browser-cookie-import-concurrency.test.ts
|
||||
@@ -164,7 +163,6 @@
|
||||
1 test-no-real-time-wait:mobile/src/tasks/worktree-create-retry.test.ts
|
||||
1 test-no-real-time-wait:mobile/src/terminal/terminal-web-document-mount.test.ts
|
||||
1 test-no-real-time-wait:mobile/src/terminal/terminal-webview-tap-routing.test.ts
|
||||
1 test-no-real-time-wait:mobile/src/transport/cellular-handshake-stall-real-socket.test.ts
|
||||
1 test-no-real-time-wait:mobile/src/transport/mobile-relay-runtime-failover.test.ts
|
||||
4 test-no-real-time-wait:mobile/src/transport/rpc-client-live-recovery.test.ts
|
||||
1 test-no-real-time-wait:mobile/src/transport/rpc-operation-barrier.test.ts
|
||||
@@ -481,7 +479,6 @@
|
||||
5 test-no-real-time-wait:src/shared/fish-query-reply-child-stdin.node-pty.test.ts
|
||||
1 test-no-real-time-wait:src/shared/git-branch-line-total-soft-deadline.test.ts
|
||||
1 test-no-real-time-wait:src/shared/osc52-clipboard.test.ts
|
||||
1 test-no-real-time-wait:src/shared/priority-semaphore.test.ts
|
||||
4 test-no-real-time-wait:src/shared/pty-reply-echo-shapes.node-pty.test.ts
|
||||
1 test-no-real-time-wait:src/shared/remote-runtime-outbound-admission.test.ts
|
||||
2 test-no-real-time-wait:src/shared/remote-runtime/control/remote-runtime-shared-control-auth-refusal.test.ts
|
||||
@@ -704,7 +701,6 @@
|
||||
1 test-no-wall-clock:mobile/src/session/use-mobile-native-chat-message-send.test.ts
|
||||
1 test-no-wall-clock:mobile/src/source-control/MobileGitHistoryList.test.tsx
|
||||
1 test-no-wall-clock:mobile/src/tasks/worktree-create-launch-operation.test.ts
|
||||
2 test-no-wall-clock:mobile/src/transport/cellular-handshake-stall-real-socket.test.ts
|
||||
1 test-no-wall-clock:mobile/src/transport/host-store.test.ts
|
||||
1 test-no-wall-clock:mobile/src/transport/mobile-relay-invite-director.test.ts
|
||||
3 test-no-wall-clock:mobile/src/transport/mobile-relay-resume-director.test.ts
|
||||
@@ -1179,10 +1175,9 @@
|
||||
7 test-timeout-needs-reason:mobile/src/tasks/worktree-create-retry.test.ts
|
||||
2 test-timeout-needs-reason:mobile/src/terminal/document/native-document-bundle.test.ts
|
||||
1 test-timeout-needs-reason:mobile/src/test-support/rpc-recording/family-recordings.test.ts
|
||||
2 test-timeout-needs-reason:mobile/src/test-support/rpc-recording/mutants/family-mutants.test.ts
|
||||
1 test-timeout-needs-reason:mobile/src/test-support/rpc-recording/mutants/family-mutants.test.ts
|
||||
1 test-timeout-needs-reason:mobile/src/test-support/rpc-recording/recorded-request-params.test.ts
|
||||
1 test-timeout-needs-reason:mobile/src/test-support/rpc-recording/rpc-recording-through-bridge.test.ts
|
||||
1 test-timeout-needs-reason:mobile/src/transport/cellular-handshake-stall-real-socket.test.ts
|
||||
1 test-timeout-needs-reason:mobile/src/transport/mobile-web-bundle-host-slow-link.test.ts
|
||||
1 test-timeout-needs-reason:mobile/src/transport/rpc-client-live-recovery.test.ts
|
||||
3 test-timeout-needs-reason:mobile/src/transport/rpc-client-reconnect-wait.test.ts
|
||||
|
||||
@@ -193,11 +193,6 @@ describe('resolveMobileNativeChat', () => {
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('canShowMobileNativeChat mirrors resolution', () => {
|
||||
expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true)
|
||||
expect(canShowMobileNativeChat(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves folder readability from the serving host catalog and rejects Model-A SSH', () => {
|
||||
|
||||
@@ -80,19 +80,6 @@ describe('appendMobileNativeChatPending ordinals for repeated sends', () => {
|
||||
)
|
||||
).toEqual(['p2'])
|
||||
})
|
||||
|
||||
it('still counts a single-line repeat, which never regressed', () => {
|
||||
const baseline = [userTurn('m1', 'unrelated')]
|
||||
const both = appendMobileNativeChatPending(
|
||||
appendMobileNativeChatPending({}, KEY, 'p1', sendOrigin('ping', baseline), 'ping'),
|
||||
KEY,
|
||||
'p2',
|
||||
sendOrigin('ping', baseline),
|
||||
'ping'
|
||||
)
|
||||
|
||||
expect(both[KEY]?.map((item) => item.expectedOccurrence)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile pending echoes whose text normalizes to nothing', () => {
|
||||
@@ -129,13 +116,4 @@ describe('mobile pending echoes whose text normalizes to nothing', () => {
|
||||
|
||||
expect(both[KEY]?.map((item) => item.expectedOccurrence)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('records an image echo, whose text is empty by design', () => {
|
||||
const pending = appendMobileNativeChatPending({}, KEY, 'p1', sendOrigin('', []), '', [
|
||||
'file:///photo.jpg'
|
||||
])
|
||||
|
||||
expect(pending[KEY]).toHaveLength(1)
|
||||
expect(pending[KEY]?.[0]?.expectedOccurrence).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,16 +114,6 @@ describe('selectGluedPendingIds', () => {
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('retires three collapsed prompts in one row', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two three', 5000)]
|
||||
const pending = [
|
||||
pendingSend('p1', 'one', 'm1'),
|
||||
pendingSend('p2', 'two', 'm1'),
|
||||
pendingSend('p3', 'three', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3'])
|
||||
})
|
||||
|
||||
it('matches across tab, newline and repeated-space separators', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
@@ -136,18 +126,6 @@ describe('selectGluedPendingIds', () => {
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('matches prompts that themselves contain the separator', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'fix the bug and run the tests', 5000)
|
||||
]
|
||||
const pending = [
|
||||
pendingSend('p1', 'fix the bug', 'm1'),
|
||||
pendingSend('p2', 'and run the tests', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('matches when one pending is a strict prefix of the next', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run run the tests', 5000)]
|
||||
const pending = [pendingSend('p1', 'run', 'm1'), pendingSend('p2', 'run the tests', 'm1')]
|
||||
@@ -195,12 +173,6 @@ describe('selectGluedPendingIds', () => {
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('never lets an older turn retire sends captured after it', () => {
|
||||
const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)]
|
||||
const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a row the pending run only prefixes', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
@@ -403,11 +375,4 @@ describe('retireLandedMobileNativeChatPending on a PTY-typed send', () => {
|
||||
|
||||
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual([])
|
||||
})
|
||||
|
||||
it('still holds the echo when no row carries the text at all', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000)]
|
||||
const pending = [pendingSend('p1', COMPOSER_TEXT, 'm1')]
|
||||
|
||||
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual(pending)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { compiledOperationModule } from './compiled-operation-module'
|
||||
|
||||
describe('compiled operation code', () => {
|
||||
it('reuses identical code but separates source, exposure and diagnostic filename', () => {
|
||||
const source = 'const value: number = 1; export const read = () => value'
|
||||
const original = compiledOperationModule('original.ts', source, '')
|
||||
expect(compiledOperationModule('original.ts', source, '')).toBe(original)
|
||||
expect(compiledOperationModule('original.ts', source.replace('= 1', '= 2'), '')).not.toBe(
|
||||
original
|
||||
)
|
||||
expect(compiledOperationModule('original.ts', source, '\nexports.extra = value')).not.toBe(
|
||||
original
|
||||
)
|
||||
expect(compiledOperationModule('different.ts', source, '')).not.toBe(original)
|
||||
expect(compiledOperationModule('original.ts', source, '')).toBe(original)
|
||||
})
|
||||
|
||||
it('bounds retained code and still evaluates an evicted module correctly', () => {
|
||||
const source = 'export const value = 42'
|
||||
const original = compiledOperationModule('evicted.ts', source, '')
|
||||
for (let index = 0; index < 512; index++) {
|
||||
compiledOperationModule(`bounded-${index}.ts`, 'export {}', '')
|
||||
}
|
||||
const recompiled = compiledOperationModule('evicted.ts', source, '')
|
||||
expect(recompiled).not.toBe(original)
|
||||
const exports = {}
|
||||
recompiled(() => undefined, exports)
|
||||
expect(exports).toEqual({ value: 42 })
|
||||
})
|
||||
|
||||
it('bounds retained variants of one file, not only the number of filenames', () => {
|
||||
const source = 'export const value = 42'
|
||||
const original = compiledOperationModule('variants.ts', source, '')
|
||||
for (let index = 0; index < 512; index++) {
|
||||
compiledOperationModule('variants.ts', `${source}; export const variant = ${index}`, '')
|
||||
}
|
||||
const recompiled = compiledOperationModule('variants.ts', source, '')
|
||||
expect(recompiled).not.toBe(original)
|
||||
const exports = {}
|
||||
recompiled(() => undefined, exports)
|
||||
expect(exports).toEqual({ value: 42 })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { declaredDeviceSubstitutes } from './declared-device-state'
|
||||
import { nativeMountingSubstitutes } from './native-mounting-substitutes'
|
||||
import { pilotMountAdapters } from './pilot-mount-adapters'
|
||||
import { runRecording } from './run-recording'
|
||||
import { readScenarios } from './scenario-input'
|
||||
@@ -15,23 +13,6 @@ const manifest = readScenarios(
|
||||
resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json')
|
||||
).scenarios
|
||||
|
||||
const STORE = '@react-native-async-storage/async-storage'
|
||||
|
||||
function store(declared: Record<string, string>): {
|
||||
module: Record<string, (...args: unknown[]) => Promise<unknown>>
|
||||
effects: { name: string; value: unknown }[]
|
||||
} {
|
||||
const effects: { name: string; value: unknown }[] = []
|
||||
const device = declaredDeviceSubstitutes({ deviceStore: declared })
|
||||
device.bind((name, value) => effects.push({ name, value }))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the declared store answers every member; the test asserts what each one does.
|
||||
const module = device.substitutes.get(STORE) as Record<
|
||||
string,
|
||||
(...args: unknown[]) => Promise<unknown>
|
||||
>
|
||||
return { module, effects }
|
||||
}
|
||||
|
||||
function scenario(id: string): RecordingScenario {
|
||||
const found = manifest.find((candidate) => candidate.id === id)
|
||||
if (!found) {
|
||||
@@ -65,57 +46,6 @@ function sentParams(recording: Recording, index = 0): unknown {
|
||||
}
|
||||
|
||||
describe('a scenario that declares its device', () => {
|
||||
it('leaves the refusing store in place when nothing is declared', () => {
|
||||
const undeclared = declaredDeviceSubstitutes({})
|
||||
expect(undeclared.substitutes.size).toBe(0)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the table's default store answers every member with a throwing call.
|
||||
const fallback = nativeMountingSubstitutes().get(STORE) as { getItem: () => unknown }
|
||||
expect(() => fallback.getItem()).toThrow('Native store reached during recording')
|
||||
})
|
||||
|
||||
it('reads the declared entry, and null for everything else', async () => {
|
||||
const { module } = store({ 'orca:key': 'declared' })
|
||||
await expect(module.getItem!('orca:key')).resolves.toBe('declared')
|
||||
await expect(module.getItem!('orca:other')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('records a write as an effect without letting a read see it', async () => {
|
||||
const { module, effects } = store({})
|
||||
await module.setItem!('orca:key', 'written')
|
||||
await module.removeItem!('orca:key')
|
||||
await expect(module.getItem!('orca:key')).resolves.toBeNull()
|
||||
expect(effects).toEqual([
|
||||
{ name: 'device-store.setItem', value: { key: 'orca:key', value: 'written' } },
|
||||
{ name: 'device-store.removeItem', value: { key: 'orca:key' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a member the declaration does not back', () => {
|
||||
const { module } = store({})
|
||||
expect(() => module.multiGet!('orca:key')).toThrow(
|
||||
`Native store reached during recording: ${STORE}.multiGet`
|
||||
)
|
||||
})
|
||||
|
||||
it('answers a declared tray, and records a dismissal as an effect', async () => {
|
||||
const entry = { request: { identifier: 'tray-1', content: { data: { a: 1 } } } }
|
||||
const effects: { name: string; value: unknown }[] = []
|
||||
const device = declaredDeviceSubstitutes({ deviceState: { notificationTray: [entry] } })
|
||||
device.bind((name, value) => effects.push({ name, value }))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the declared tray lists exactly these two members.
|
||||
const module = device.substitutes.get('expo-notifications') as {
|
||||
getPresentedNotificationsAsync: () => Promise<unknown[]>
|
||||
dismissNotificationAsync: (identifier: string) => Promise<void>
|
||||
}
|
||||
const presented = await module.getPresentedNotificationsAsync()
|
||||
expect(presented).toEqual([entry])
|
||||
expect(presented[0]).not.toBe(entry)
|
||||
await module.dismissNotificationAsync('tray-1')
|
||||
expect(effects).toEqual([
|
||||
{ name: 'notification-tray.dismiss', value: { identifier: 'tray-1' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('backs a recorded read: the declared last-visited repo is the one the dialog selects', async () => {
|
||||
const recording = await record('new-workspace-repositories-fulfilled')
|
||||
expect(sentParams(recording)).toEqual({ name: 'repo.list#1', params: { $rpc: 'absent' } })
|
||||
|
||||
@@ -2,7 +2,6 @@ import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readScenarios } from './scenario-input'
|
||||
import { familyGoldens } from './derived-goldens'
|
||||
import { REPLY_MATRIX_NORMAL_RESULT_INVENTORY } from './reply-matrix-normal-result'
|
||||
import { runRecording } from './run-recording'
|
||||
import { pilotMountAdapters } from './pilot-mount-adapters'
|
||||
import { vitestRecordingScheduler } from './vitest-recording-scheduler'
|
||||
@@ -47,20 +46,6 @@ async function certify(id: string, scenarios: RecordingScenario[]) {
|
||||
|
||||
describe('family reply partitions and owned schedules', () => {
|
||||
const goldens = familyGoldens(input.scenarios)
|
||||
const families = [...new Set(input.scenarios.map((scenario) => scenario.family))]
|
||||
// Count sites that actually generate tests so skipped families cannot hide behind the matrix-site reader.
|
||||
const sites = goldens.flatMap((golden) => (golden.site ? [golden] : []))
|
||||
it('matrices every family in the manifest', () => {
|
||||
expect([...new Set(sites.map((golden) => golden.family))]).toEqual(families)
|
||||
})
|
||||
// The inventory is only consulted for a live site, so a stale entry would retire silently.
|
||||
it('lists only live matrix sites in the normal-result inventory', () => {
|
||||
const live = new Set(sites.map((golden) => `${golden.family}\0${golden.site}`))
|
||||
const stale = REPLY_MATRIX_NORMAL_RESULT_INVENTORY.filter(
|
||||
(entry) => !live.has(`${entry.family}\0${entry.request}`)
|
||||
).map((entry) => `${entry.family} ${entry.request}`)
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
for (const golden of goldens) {
|
||||
it(
|
||||
golden.title,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { familyGoldens } from './derived-goldens'
|
||||
import { hoistPreludeCheckpoints } from './prelude-checkpoints'
|
||||
import { readScenarios } from './scenario-input'
|
||||
import { bindCompletions, lifecycleSchedules } from './schedule-driver'
|
||||
|
||||
it('omits only inventory admission following a refusal to a retired owner', () => {
|
||||
const manifest = readScenarios(
|
||||
resolve(import.meta.dirname, '../../../rpc-foundation/pilot-scenarios.json')
|
||||
)
|
||||
const base = manifest.scenarios.find((scenario) => scenario.id === 'inventory-lifecycle')
|
||||
if (!base) {
|
||||
throw new Error('missing inventory base scenario')
|
||||
}
|
||||
const golden = familyGoldens(manifest.scenarios).find(
|
||||
(item) => item.id === 'lifecycle-inventory-lifecycle'
|
||||
)
|
||||
if (!golden) {
|
||||
throw new Error('missing inventory lifecycle golden')
|
||||
}
|
||||
const original = hoistPreludeCheckpoints(
|
||||
{ ...base, steps: bindCompletions(base.steps) },
|
||||
(['reset', 'unmount', 'blur'] as const).flatMap((action) => lifecycleSchedules(base, action))
|
||||
)
|
||||
const derived = golden.scenarios()
|
||||
expect(derived.map((scenario) => scenario.id)).toEqual(original.map((scenario) => scenario.id))
|
||||
expect(derived).toHaveLength(12)
|
||||
for (const [index, scenario] of original.entries()) {
|
||||
const unmounted = scenario.id === 'inventory-lifecycle.unmount-before-1'
|
||||
if (!unmounted && scenario.id !== 'inventory-lifecycle.reset-before-1') {
|
||||
expect(derived[index]).toEqual(scenario)
|
||||
expect(JSON.stringify(derived[index])).toBe(JSON.stringify(scenario))
|
||||
continue
|
||||
}
|
||||
expect(scenario.steps).toHaveLength(unmounted ? 13 : 11)
|
||||
expect(scenario.steps.slice(7, 10)).toEqual([
|
||||
{ bind: 'old-inventory', request: 'files.list#1', params: { worktree: 'id:A' } },
|
||||
{ bind: 'lifecycle-old-inventory', request: 'old-inventory', params: { worktree: 'id:A' } },
|
||||
{
|
||||
complete: 'lifecycle-old-inventory',
|
||||
params: { worktree: 'id:A' },
|
||||
reply: { ok: true, result: { files: [{ relativePath: 'old.ts' }] } }
|
||||
}
|
||||
])
|
||||
const expected = {
|
||||
...scenario,
|
||||
steps: [...scenario.steps.slice(0, 7), ...scenario.steps.slice(10)]
|
||||
}
|
||||
expect(derived[index]).toEqual(expected)
|
||||
expect(JSON.stringify(derived[index])).toBe(JSON.stringify(expected))
|
||||
expect(expected.steps.filter((step) => 'checkpoint' in step)).toEqual([
|
||||
{ checkpoint: 'lifecycle-boundary' },
|
||||
{ checkpoint: 'settled' },
|
||||
...(unmounted ? [{ checkpoint: 'remounted' }] : [])
|
||||
])
|
||||
expect(expected.steps.filter((step) => 'bind' in step)).toEqual([
|
||||
{
|
||||
bind: 'lifecycle-files.searchPaths#1',
|
||||
request: 'files.searchPaths#1',
|
||||
params: { worktree: 'id:A', query: 'old', limit: 16 }
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
@@ -1,12 +1,7 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hookScreenMount,
|
||||
projectMountedScreen,
|
||||
renderedElementProps,
|
||||
screenMount
|
||||
} from './mounted-screen-tree'
|
||||
import { hookScreenMount, projectMountedScreen, screenMount } from './mounted-screen-tree'
|
||||
import { pilotMountAdapters } from './pilot-mount-adapters'
|
||||
import { runRecording } from './run-recording'
|
||||
import { readScenarios } from './scenario-input'
|
||||
@@ -42,36 +37,6 @@ describe('a mounted screen', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects the elements, copy and labels of what rendered', () => {
|
||||
const screen = screenMount(
|
||||
() =>
|
||||
createElement(
|
||||
'View',
|
||||
null,
|
||||
createElement('Text', { accessibilityLabel: 'title' }, 'Files'),
|
||||
createElement('Text', null, 'orca-files')
|
||||
),
|
||||
effectSink().effect
|
||||
)
|
||||
screen.mount()
|
||||
expect(projectMountedScreen(screen)).toEqual({
|
||||
elements: { View: 1, Text: 2 },
|
||||
text: ['Files', 'orca-files'],
|
||||
labels: ['title'],
|
||||
crash: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the props an inert element was handed, which is all a list ever renders', () => {
|
||||
const screen = screenMount(
|
||||
() => createElement('View', null, createElement('FlatList', { data: [{ id: 'row-1' }] })),
|
||||
effectSink().effect
|
||||
)
|
||||
screen.mount()
|
||||
expect(renderedElementProps(screen.tree(), 'FlatList')).toEqual([{ data: [{ id: 'row-1' }] }])
|
||||
expect(renderedElementProps(screen.tree(), 'SectionList')).toEqual([])
|
||||
})
|
||||
|
||||
// Hook projections omit crashes; the effect sink must record them independently.
|
||||
it('reports a crash through the effect sink with no adapter cooperation', () => {
|
||||
const sink = effectSink()
|
||||
|
||||
@@ -17,20 +17,18 @@ const input = readScenarios(
|
||||
const goldens = process.env.RPC_FOUNDATION_GOLDENS ?? resolve(root, 'mobile/rpc-foundation/goldens')
|
||||
|
||||
// Compare the whole variant: malformed-container mutants may differ only at settlement and recover to identical final state.
|
||||
// Pin both a killing and a blind partition; row-shape requirements remain unit coverage because matrices vary envelopes only.
|
||||
// Row-shape requirements remain unit coverage because matrices vary envelopes only.
|
||||
const MATRIX_MUTANTS: readonly {
|
||||
mutation: Mutation
|
||||
family: string
|
||||
site: string
|
||||
killedBy: string
|
||||
blindTo: string
|
||||
}[] = [
|
||||
{
|
||||
mutation: 'linear-status-nullable',
|
||||
family: 'tasks.provider-load',
|
||||
site: 'linear.status#1',
|
||||
killedBy: 'result-null',
|
||||
blindTo: 'normal'
|
||||
killedBy: 'result-null'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -78,8 +76,5 @@ describe('reply-matrix partitions hold the container requirements', () => {
|
||||
it(`${entry.family} ${entry.site}: ${entry.killedBy} kills ${entry.mutation}`, async () => {
|
||||
expect(await verdict(entry, entry.killedBy)).toBe('killed')
|
||||
}, 30_000)
|
||||
it(`${entry.family} ${entry.site}: ${entry.blindTo} cannot see ${entry.mutation}`, async () => {
|
||||
expect(await verdict(entry, entry.blindTo)).toBe('survived')
|
||||
}, 30_000)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RECORDING_DRIVERS } from '../recording-drivers'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../../../../..')
|
||||
const recorder = join(root, 'mobile/src/test-support/rpc-recording')
|
||||
const mutants = join(recorder, 'mutants')
|
||||
/** Names the drivers for the record script and the suites that check them; no recording loads it. */
|
||||
const OFF_RECORDING_PATH = ['recording-drivers.ts']
|
||||
|
||||
function sources(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true, recursive: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
|
||||
.map((entry) => join(entry.parentPath, entry.name))
|
||||
}
|
||||
|
||||
function resolved(from: string, specifier: string): string | undefined {
|
||||
const base = resolve(dirname(from), specifier)
|
||||
return ['', '.ts', '.tsx', '/index.ts']
|
||||
.map((suffix) => base + suffix)
|
||||
.find((candidate) => existsSync(candidate) && /\.tsx?$/.test(candidate))
|
||||
}
|
||||
|
||||
function relative(file: string): string {
|
||||
return file.slice(recorder.length + 1)
|
||||
}
|
||||
|
||||
/** A suite that records nothing: excluded below, since no driver has reason to reach it. */
|
||||
function suite(file: string): boolean {
|
||||
return file.endsWith('.test.ts') && !RECORDING_DRIVERS.some((driver) => file.endsWith(driver))
|
||||
}
|
||||
|
||||
// Include type-position imports too; over-approximating reachability only strengthens the forbidden mutant boundary.
|
||||
function reachable(entries: readonly string[]): Set<string> {
|
||||
const seen = new Set<string>()
|
||||
const pending = [...entries]
|
||||
while (pending.length > 0) {
|
||||
const file = pending.pop()!
|
||||
if (seen.has(file)) {
|
||||
continue
|
||||
}
|
||||
seen.add(file)
|
||||
for (const match of readFileSync(file, 'utf8').matchAll(/(?:from|import\()\s*'(\.[^']*)'/g)) {
|
||||
const target = resolved(file, match[1]!)
|
||||
if (target) {
|
||||
pending.push(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
// Walk outward from recording drivers so no recorded byte can depend on mutant code; scan names for non-import reads too.
|
||||
describe('the mutant seam', () => {
|
||||
const outside = sources(recorder).filter((file) => !file.startsWith(`${mutants}${sep}`))
|
||||
|
||||
it('is unreachable from every recording driver', () => {
|
||||
const graph = reachable(RECORDING_DRIVERS.map((driver) => join(recorder, driver)))
|
||||
const reached = [...graph]
|
||||
.filter((file) => file.startsWith(`${mutants}${sep}`))
|
||||
.map(relative)
|
||||
.sort()
|
||||
expect(reached).toEqual([])
|
||||
// Require every recorder module to be reachable so an empty graph cannot pass.
|
||||
const missed = outside
|
||||
.filter((file) => !suite(file) && !graph.has(file))
|
||||
.filter((file) => !OFF_RECORDING_PATH.includes(relative(file)))
|
||||
.map(relative)
|
||||
.sort()
|
||||
expect(missed).toEqual([])
|
||||
expect(sources(mutants).length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
// Apply the engine boundary to recording drivers; other tests cannot alter recorded bytes.
|
||||
it('is named in no recording file', () => {
|
||||
const naming = outside
|
||||
.filter((file) => !suite(file) && readFileSync(file, 'utf8').includes('mutants'))
|
||||
.map(relative)
|
||||
expect(naming).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { pilotGoldens } from '../derived-goldens'
|
||||
import { readGolden } from '../golden-recording'
|
||||
import { pilotMountAdapters } from '../pilot-mount-adapters'
|
||||
import { runRecording, runRecordingMutant } from '../run-recording'
|
||||
import { runRecordingMutant } from '../run-recording'
|
||||
import { readScenarios } from '../scenario-input'
|
||||
import { vitestRecordingScheduler } from '../vitest-recording-scheduler'
|
||||
import { operationMutation, type Mutation } from './operation-mutations'
|
||||
@@ -42,26 +42,6 @@ const mutants: Record<string, Mutation> = {
|
||||
'worktree-catalog-snapshot-unreadable': 'worktree-catalog-unchecked-reader',
|
||||
'mobile-web-bundle-fetch-paged': 'mobile-web-bundle-chunk-placement'
|
||||
}
|
||||
// Pin archived defect states so unrelated reference refactors cannot pass merely by differing.
|
||||
const referenceStates: Record<string, RecordedValue> = {
|
||||
b1: { files: [] },
|
||||
b2: {
|
||||
error: '',
|
||||
mutating: false,
|
||||
row: {
|
||||
content: {
|
||||
assignees: [],
|
||||
labels: [{ color: '808080', name: 'recorded' }],
|
||||
number: 1,
|
||||
repository: 'owner/repo'
|
||||
},
|
||||
id: 'item-1',
|
||||
itemType: 'ISSUE'
|
||||
}
|
||||
},
|
||||
b3: { error: 'issue refused', loading: false, payload: { $rpc: 'null' } }
|
||||
}
|
||||
|
||||
function visibleState(recording: Recording): RecordedValue {
|
||||
return recording.checkpoints.at(-1)!.observation.state
|
||||
}
|
||||
@@ -71,11 +51,6 @@ const mutantPilots = pilots.flatMap((pilot) => {
|
||||
const mutation = mutants[pilot.id]
|
||||
return mutation ? [{ ...pilot, mutation }] : []
|
||||
})
|
||||
const referencePilots = pilots.flatMap((pilot) => {
|
||||
const reference = referenceStates[pilot.id]
|
||||
return reference ? [{ ...pilot, reference }] : []
|
||||
})
|
||||
|
||||
describe('RPC main recording mutants', () => {
|
||||
for (const { id, scenario, mutation } of mutantPilots) {
|
||||
it(`${id}: kills ${mutation}`, async () => {
|
||||
@@ -94,19 +69,4 @@ describe('RPC main recording mutants', () => {
|
||||
expect(result.verdict).toBe('killed')
|
||||
})
|
||||
}
|
||||
for (const { id, scenario, reference } of referencePilots) {
|
||||
it.skipIf(!process.env.RPC_FOUNDATION_REFERENCE_ROOT)(`${id}: rejects bcba08b3e4`, async () => {
|
||||
const { adapters } = pilotMountAdapters(process.env.RPC_FOUNDATION_REFERENCE_ROOT!, {
|
||||
device: scenario,
|
||||
reference: true
|
||||
})
|
||||
const result = await runRecording(
|
||||
scenario,
|
||||
adapters[scenario.operation],
|
||||
vitestRecordingScheduler()
|
||||
)
|
||||
expect(visibleState(result)).toEqual(reference)
|
||||
expect(reference).not.toEqual(visibleState(readGolden(goldens, id).recording))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ const input = readScenarios(
|
||||
)
|
||||
const goldens = process.env.RPC_FOUNDATION_GOLDENS ?? resolve(root, 'mobile/rpc-foundation/goldens')
|
||||
|
||||
// Prove each probe closes a surviving mutation and that earlier scenarios stayed blind.
|
||||
// Prove each probe closes a surviving mutation.
|
||||
// Empty session inputs exercise omission guards that always-filled fixtures could not reach.
|
||||
const HOLES: readonly { mutation: Mutation; operation: string; closedBy: readonly string[] }[] = [
|
||||
{
|
||||
@@ -96,38 +96,11 @@ async function verdict(id: string, mutation: Mutation): Promise<string> {
|
||||
}
|
||||
|
||||
describe('probe scenarios close holes the pre-probe recordings left open', () => {
|
||||
// Every completion needs a later checkpoint so a params-mismatch abort always suppresses an observation the golden actually holds.
|
||||
it('never completes a request after the last checkpoint of a scenario', () => {
|
||||
const trailing = input.scenarios
|
||||
.filter((scenario) => {
|
||||
let lastComplete = -1
|
||||
let lastCheckpoint = -1
|
||||
scenario.steps.forEach((step, index) => {
|
||||
if ('complete' in step) {
|
||||
lastComplete = index
|
||||
}
|
||||
// A step may carry both, and then the checkpoint records what the completion produced.
|
||||
if ('checkpoint' in step) {
|
||||
lastCheckpoint = index
|
||||
}
|
||||
})
|
||||
return lastComplete > lastCheckpoint
|
||||
})
|
||||
.map((scenario) => scenario.id)
|
||||
expect(trailing).toEqual([])
|
||||
})
|
||||
|
||||
for (const hole of HOLES) {
|
||||
const family = input.scenarios.filter((scenario) => scenario.operation === hole.operation)
|
||||
for (const id of hole.closedBy) {
|
||||
it(`${id} kills ${hole.mutation}`, async () => {
|
||||
expect(await verdict(id, hole.mutation)).toBe('killed')
|
||||
})
|
||||
}
|
||||
for (const scenario of family.filter(({ id }) => !hole.closedBy.includes(id))) {
|
||||
it(`${scenario.id} cannot see ${hole.mutation}`, async () => {
|
||||
expect(await verdict(scenario.id, hole.mutation)).toBe('survived')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,13 +26,6 @@ describe('nativeMountingSubstitutes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the interop marker undefined so a default import binds the module, not the trap', () => {
|
||||
for (const name of ['@react-native-async-storage/async-storage', 'expo-crypto']) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads the marker the emit reads, off a proxy with no declared shape.
|
||||
expect((substitute(name) as { __esModule?: unknown }).__esModule).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('throws on a member nobody substituted rather than recording an undefined native API', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read itself is the assertion; the proxy has no declared shape.
|
||||
expect(() => (substitute('expo-crypto') as { randomUUID?: unknown }).randomUUID).toThrow(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import type * as FsModule from 'node:fs'
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
@@ -7,11 +6,6 @@ import { nativeMountingSubstitutes } from './native-mounting-substitutes'
|
||||
import type * as NativeSubstitutesModule from './native-mounting-substitutes'
|
||||
import { operationModuleLoader } from './operation-module-loader'
|
||||
|
||||
vi.mock('node:fs', async (original) => {
|
||||
const fs = await original<typeof FsModule>()
|
||||
return { ...fs, existsSync: vi.fn(fs.existsSync) }
|
||||
})
|
||||
|
||||
vi.mock('./native-mounting-substitutes', async (original) => {
|
||||
const module = await original<typeof NativeSubstitutesModule>()
|
||||
return { ...module, nativeMountingSubstitutes: vi.fn(module.nativeMountingSubstitutes) }
|
||||
@@ -36,22 +30,6 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resolves a relative import once instead of probing disk on every exported member read', () => {
|
||||
const root = sourceTree({
|
||||
'value.ts': 'export const value = 42',
|
||||
'operation.ts': `import { value } from './value'; export const read = () => value`
|
||||
})
|
||||
const operation = operationModuleLoader(root).load<{ read: () => number }>(
|
||||
'mobile/src/operation.ts'
|
||||
)
|
||||
expect(operation.read()).toBe(42)
|
||||
vi.mocked(existsSync).mockClear()
|
||||
for (let index = 0; index < 1_000; index++) {
|
||||
expect(operation.read()).toBe(42)
|
||||
}
|
||||
expect(existsSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds device substitutes only for a used loader and keeps their state per loader', async () => {
|
||||
const root = sourceTree({
|
||||
'operation.ts': `
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { C5_PAGE_CLOSURE } from '../bridged-parity/c5-page-closure'
|
||||
import { C6_BROWSER_CLOSURE_FAMILIES } from '../bridged-parity/c6-browser-closure-families'
|
||||
import { C2_PAGE_CLOSURE } from '../bridged-parity/c2-page-closure'
|
||||
import { C1_PAGE_CLOSURE } from '../bridged-parity/c1-page-closure'
|
||||
import { C3_PAGE_CLOSURE } from '../bridged-parity/c3-page-closure'
|
||||
import {
|
||||
pageClosureDrift,
|
||||
@@ -303,16 +302,6 @@ describe.skipIf(process.env[BRIDGED_PARITY_FLAG] === BRIDGED_PARITY_OFF)(
|
||||
expect({ tally: bridgedParityTallyDrift({ identical, counts }) }).toEqual({ tally: [] })
|
||||
})
|
||||
|
||||
it('gives every golden the C1 page closure records the verdict it is pinned to', () => {
|
||||
process.stdout.write(readPageClosure('C1', C1_PAGE_CLOSURE, observed))
|
||||
// Pin every closure id; aggregate corpus totals can trade a domain regression for an unrelated improvement.
|
||||
expect({ closure: pageClosureDrift(C1_PAGE_CLOSURE, observed) }).toEqual({ closure: [] })
|
||||
// Check run totals independently because C1’s table has no separate class-count pins.
|
||||
expect(pageClosureRunTotals(C1_PAGE_CLOSURE, observed)).toEqual(
|
||||
pageClosureTotals(C1_PAGE_CLOSURE)
|
||||
)
|
||||
})
|
||||
|
||||
it('gives every golden the C5 page closure records the verdict it is pinned to', () => {
|
||||
process.stdout.write(readPageClosure('C5', C5_PAGE_CLOSURE, observed))
|
||||
// Recheck inherited C1 families alongside C5’s additions so shared regressions fail both closures.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createElement, type ElementType } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inertIconModule } from './inert-native-elements'
|
||||
import { nativeMountingSubstitutes } from './native-mounting-substitutes'
|
||||
import { reactNativeScreenMembers, screenNativeSubstitutes } from './screen-native-substitutes'
|
||||
import { reactNativeScreenMembers } from './screen-native-substitutes'
|
||||
|
||||
function member(module: string, name: string): unknown {
|
||||
const substitute = nativeMountingSubstitutes().get(module)
|
||||
@@ -68,44 +67,4 @@ describe('the inert screen substitutes', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a rendered host node carries exactly these fields.
|
||||
expect((tree as { children: unknown }).children).toBeNull()
|
||||
})
|
||||
|
||||
it('pins the device inputs a screen reads instead of measuring them', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the member is a hook by construction.
|
||||
expect((member('react-native', 'useWindowDimensions') as () => unknown)()).toEqual({
|
||||
width: 390,
|
||||
height: 844
|
||||
})
|
||||
expect(member('react-native', 'StyleSheet')).toMatchObject({ hairlineWidth: 1 })
|
||||
})
|
||||
|
||||
it('keeps a style sheet readable so a screen that reads one style sees it', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the member is the substituted StyleSheet by construction.
|
||||
const sheet = member('react-native', 'StyleSheet') as {
|
||||
create: (value: unknown) => unknown
|
||||
flatten: (value: unknown) => unknown
|
||||
}
|
||||
expect(sheet.create({ row: { flex: 1 } })).toEqual({ row: { flex: 1 } })
|
||||
expect(sheet.flatten([{ flex: 1 }, [{ gap: 2 }]])).toEqual({ flex: 1, gap: 2 })
|
||||
})
|
||||
|
||||
it('answers any icon name with its own element, because every export here is an icon', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the icon module answers every string key.
|
||||
const icons = inertIconModule() as Record<string, ElementType>
|
||||
expect(icons.ChevronLeft).toBe(icons.ChevronLeft)
|
||||
act(() => {
|
||||
create(createElement(icons.ChevronLeft!, { size: 20 }))
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on a member nobody listed, in every screen package', () => {
|
||||
for (const [module, substitute] of screenNativeSubstitutes()) {
|
||||
if (module === 'lucide-react-native') {
|
||||
continue
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read is the assertion; a substitute proxy has no declared shape.
|
||||
expect(() => (substitute as Record<string, unknown>).notASubstitutedMember).toThrow(
|
||||
`Unsubstituted native member: ${module}.notASubstitutedMember`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,13 +32,4 @@ describe('a wrapper between the operation and the scripted transport', () => {
|
||||
expect(inner).not.toBeNull()
|
||||
expect(() => inner?.('alpha.one')).toThrow(/cannot take the name \(no logical request\)/)
|
||||
})
|
||||
|
||||
it('names each physical send after the logical call that made it', async () => {
|
||||
const transport = new ScriptedRpcTransport()
|
||||
void transport.client.sendRequest('alpha.one')
|
||||
void transport.client.sendRequest('beta.two')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(transport.payloads.map((payload) => payload.name)).toEqual(['alpha.one#1', 'beta.two#1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import { RpcClientStreamRegistry } from '../../transport/rpc-client-stream-registry'
|
||||
import { ScriptedRpcTransport } from './scripted-rpc-transport'
|
||||
import { vitestRecordingScheduler } from './vitest-recording-scheduler'
|
||||
import { driveReplyMatrix, replyMatrixGoldenId, replyMatrixSites } from './reply-matrix'
|
||||
import { replyMatrixNormalResult } from './reply-matrix-normal-result'
|
||||
import { runRecording } from './run-recording'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { RecordingScenario } from './recording-scenario'
|
||||
@@ -229,56 +227,6 @@ describe('subscription recordings', () => {
|
||||
// Due-timer cleanup must match synchronous cleanup rather than appear as an uncancelled stream.
|
||||
expect(await drive('timer')).toEqual([])
|
||||
})
|
||||
|
||||
it('drives the reply matrix over frames, and matrixes a family that only subscribes', () => {
|
||||
const changed = { ok: true, streaming: true, result: { type: 'worktreesChanged' } }
|
||||
const base: RecordingScenario = {
|
||||
id: 'stream',
|
||||
operation: 'op',
|
||||
family: 'op',
|
||||
sites: [],
|
||||
schedules: [],
|
||||
steps: [
|
||||
{ action: 'mount', id: 'mount' },
|
||||
{ frame: `${CLIENT_EVENTS}#1`, params: null, reply: readyFrame('sub-1') },
|
||||
{ checkpoint: 'ready' },
|
||||
{ frame: `${CLIENT_EVENTS}#1`, params: null, reply: changed },
|
||||
{ checkpoint: 'changed' }
|
||||
]
|
||||
}
|
||||
expect(replyMatrixSites(base)).toEqual([`${CLIENT_EVENTS}#1@1`, `${CLIENT_EVENTS}#1@2`])
|
||||
expect(replyMatrixGoldenId('op', `${CLIENT_EVENTS}#1@2`)).toBe(
|
||||
'matrix-op-runtime.clientevents.subscribe-1-2'
|
||||
)
|
||||
expect(replyMatrixNormalResult('op', [base], `${CLIENT_EVENTS}#1@2`)).toEqual(changed.result)
|
||||
const variants = driveReplyMatrix(base, `${CLIENT_EVENTS}#1@1`, readyFrame('sub-1').result)
|
||||
const partitions = variants.map((variant) => variant.id.replace('stream.', ''))
|
||||
// Frames have no promise, so the two request-rejection partitions do not apply.
|
||||
expect(partitions).toEqual([
|
||||
'normal',
|
||||
'result-absent',
|
||||
'result-null',
|
||||
'inner-ok-missing',
|
||||
'inner-false-string-error',
|
||||
'inner-false-object-error',
|
||||
'outer-refused',
|
||||
'outer-refused-no-message',
|
||||
'method-not-found'
|
||||
])
|
||||
const replies = new Map(variants.map((variant) => [variant.id, variant.steps[1]]))
|
||||
// Keep streaming:true on success controls so they route to the same stream.
|
||||
expect(replies.get('stream.normal')).toEqual(base.steps[1])
|
||||
expect(replies.get('stream.result-absent')).toEqual({
|
||||
frame: `${CLIENT_EVENTS}#1`,
|
||||
params: null,
|
||||
reply: { ok: true, streaming: true }
|
||||
})
|
||||
expect(replies.get('stream.outer-refused')).toMatchObject({
|
||||
reply: { ok: false, error: { code: 'refused' } }
|
||||
})
|
||||
// Frames stay required after divergence because the registry still routes them to the opening id.
|
||||
expect(variants[0]!.steps[3]).toEqual(base.steps[3])
|
||||
})
|
||||
})
|
||||
|
||||
const CLIENT_EVENTS = 'runtime.clientEvents.subscribe'
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WebSocketServer, type WebSocket as ServerWebSocket } from 'ws'
|
||||
import { connect } from './rpc-client'
|
||||
import { classifyConnection, verdictDisplayLabel } from './connection-health'
|
||||
|
||||
// Guard fake-timer mock drift with real WebSocket upgrades and delayed E2EE replies (#10119).
|
||||
// Opt in with ORCA_MOBILE_LIVE_REPRO=1; this needs real timers and roughly 22 seconds.
|
||||
|
||||
vi.mock('./e2ee', () => ({
|
||||
generateKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }),
|
||||
deriveSharedKey: () => new Uint8Array(32),
|
||||
publicKeyFromBase64: () => new Uint8Array(32),
|
||||
publicKeyToBase64: () => 'client-public-key',
|
||||
encrypt: (plaintext: string) => plaintext,
|
||||
decrypt: (raw: string) => raw,
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
const RUN_LIVE =
|
||||
process.env.ORCA_MOBILE_LIVE_REPRO === '1' || !!process.env.ORCA_MOBILE_LIVE_REPRO_FULL
|
||||
|
||||
// Allow three handshake timeout cycles plus CI scheduling slack.
|
||||
const OBSERVE_MS = 22_000
|
||||
const SAMPLE_MS = 200
|
||||
|
||||
let server: WebSocketServer | null = null
|
||||
const serverSockets: ServerWebSocket[] = []
|
||||
|
||||
// Upgrade succeeds but e2ee_ready arrives after the handshake budget.
|
||||
async function startSlowHandshakeDesktop(replyAfterMs: number): Promise<number> {
|
||||
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 })
|
||||
server = wss
|
||||
wss.on('connection', (socket) => {
|
||||
serverSockets.push(socket)
|
||||
socket.on('message', (data) => {
|
||||
const text = data.toString()
|
||||
if (!text.includes('e2ee_hello')) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'e2ee_ready', publicKeyB64: 'server-public-key' }))
|
||||
}
|
||||
}, replyAfterMs)
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => wss.once('listening', resolve))
|
||||
const address = wss.address()
|
||||
if (typeof address === 'string' || address === null) {
|
||||
throw new Error('expected a TCP address')
|
||||
}
|
||||
return address.port
|
||||
}
|
||||
|
||||
describe.runIf(RUN_LIVE)('issue #10119 — real socket, handshake slower than the 5s budget', () => {
|
||||
beforeEach(() => {
|
||||
serverSockets.length = 0
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
for (const socket of serverSockets) {
|
||||
socket.terminate()
|
||||
}
|
||||
const wss = server
|
||||
server = null
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it('escalates and latches against a live desktop that answers 1s too late', async () => {
|
||||
const port = await startSlowHandshakeDesktop(6_000)
|
||||
const endpoint = `ws://127.0.0.1:${port}`
|
||||
const client = connect(endpoint, 'device-token', 'server-public-key')
|
||||
|
||||
const labels: string[] = []
|
||||
const states: string[] = []
|
||||
let maxAttempts = 0
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < OBSERVE_MS) {
|
||||
const state = client.getState()
|
||||
const attempts = client.getReconnectAttempt()
|
||||
maxAttempts = Math.max(maxAttempts, attempts)
|
||||
states.push(state)
|
||||
labels.push(
|
||||
verdictDisplayLabel(
|
||||
classifyConnection({
|
||||
state,
|
||||
reconnectAttempts: attempts,
|
||||
lastConnectedAt: client.getLastConnectedAt(),
|
||||
endpoint
|
||||
})
|
||||
)
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS))
|
||||
}
|
||||
client.close()
|
||||
|
||||
expect(serverSockets.length).toBeGreaterThanOrEqual(2)
|
||||
expect(states).toContain('handshaking')
|
||||
expect(states).not.toContain('connected')
|
||||
|
||||
expect(maxAttempts).toBeGreaterThanOrEqual(3)
|
||||
const firstEscalated = labels.findIndex((l) => l !== 'Connecting…' && l !== 'Reconnecting…')
|
||||
expect(firstEscalated).toBeGreaterThan(0)
|
||||
|
||||
expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,9 +1,4 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript-api'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
import {
|
||||
GenerationScopedRequestOwner,
|
||||
type LoadedRequest,
|
||||
@@ -83,23 +78,6 @@ describe('key-reset-cleanup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('blur', () => {
|
||||
it('refuses a reply that settles after a blur, without the scope having moved', async () => {
|
||||
const owner: Owner = new GenerationScopedRequestOwner()
|
||||
const scope = scopeAt('w1', 1)
|
||||
const request = pending()
|
||||
const loaded = owner.load(scope, QUERY, request.start)
|
||||
|
||||
// Blur retires the surface generation without changing host/workspace identity.
|
||||
owner.reset()
|
||||
|
||||
request.resolve(['stale.ts'])
|
||||
const lease = await settled(loaded)
|
||||
expect(owner.commit(lease.lease, lease.value)).toBe('retired-generation')
|
||||
expect(owner.read(scope, QUERY)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cutover', () => {
|
||||
it('retires the whole scope when the logical authority epoch advances', async () => {
|
||||
const owner: Owner = new GenerationScopedRequestOwner()
|
||||
@@ -340,178 +318,3 @@ describe('currency probe', () => {
|
||||
expect(owner.read(scope, QUERY)).toEqual(['sent.ts'])
|
||||
})
|
||||
})
|
||||
|
||||
const mobileRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ownerModule = join(mobileRoot, 'src', 'transport', 'generation-scoped-request-owner')
|
||||
|
||||
function importsOwner(path: string, source: string): boolean {
|
||||
const parsed = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return parsed.statements.some((statement) => {
|
||||
const specifier = ts.isImportDeclaration(statement) ? statement.moduleSpecifier : undefined
|
||||
return (
|
||||
specifier !== undefined &&
|
||||
ts.isStringLiteral(specifier) &&
|
||||
specifier.text.startsWith('.') &&
|
||||
resolve(path, '..', specifier.text) === ownerModule
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function declaredInside(callback: ts.Node): Set<string> {
|
||||
const names = new Set<string>()
|
||||
const bind = (name: ts.BindingName): void => {
|
||||
if (ts.isIdentifier(name)) {
|
||||
names.add(name.text)
|
||||
return
|
||||
}
|
||||
for (const element of name.elements) {
|
||||
if (ts.isBindingElement(element)) {
|
||||
bind(element.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
|
||||
bind(node.name)
|
||||
}
|
||||
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
||||
names.add(node.name.text)
|
||||
}
|
||||
if (ts.isCatchClause(node) && node.variableDeclaration) {
|
||||
bind(node.variableDeclaration.name)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
ts.forEachChild(callback, visit)
|
||||
return names
|
||||
}
|
||||
|
||||
function assignmentRoot(target: ts.Expression): string | null {
|
||||
let node: ts.Expression = target
|
||||
for (;;) {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return node.text
|
||||
}
|
||||
if (node.kind === ts.SyntaxKind.ThisKeyword) {
|
||||
return 'this'
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
||||
node = node.expression
|
||||
continue
|
||||
}
|
||||
if (ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) {
|
||||
node = node.expression
|
||||
continue
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function externalWrites(callback: ts.Node): string[] {
|
||||
const declared = declaredInside(callback)
|
||||
const offenders: string[] = []
|
||||
const record = (target: ts.Expression): void => {
|
||||
const root = assignmentRoot(target)
|
||||
if (root !== null && !declared.has(root)) {
|
||||
offenders.push(root)
|
||||
}
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment) {
|
||||
if (node.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
|
||||
record(node.left)
|
||||
}
|
||||
}
|
||||
if (
|
||||
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
||||
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
||||
node.operator === ts.SyntaxKind.MinusMinusToken)
|
||||
) {
|
||||
record(node.operand)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
ts.forEachChild(callback, visit)
|
||||
return offenders
|
||||
}
|
||||
|
||||
function loaders(path: string, source: string): ts.Node[] {
|
||||
const parsed = ts.createSourceFile(
|
||||
path,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
extname(path) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
)
|
||||
const found: ts.Node[] = []
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
node.expression.name.text === 'load'
|
||||
) {
|
||||
const loader = node.arguments[2]
|
||||
if (loader && (ts.isArrowFunction(loader) || ts.isFunctionExpression(loader))) {
|
||||
found.push(loader)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
ts.forEachChild(parsed, visit)
|
||||
return found
|
||||
}
|
||||
|
||||
function loaderWrites(path: string, source: string): string[] {
|
||||
return loaders(path, source).flatMap((loader) => externalWrites(loader))
|
||||
}
|
||||
|
||||
describe('loader write fence', () => {
|
||||
const holders = ['app', 'src']
|
||||
.map((directory) => join(mobileRoot, directory))
|
||||
.flatMap(censusSourceFiles)
|
||||
.filter((path) => ['.ts', '.tsx'].includes(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
.map((path) => ({ path, source: readFileSync(path, 'utf8') }))
|
||||
.filter(({ path, source }) => importsOwner(path, source))
|
||||
|
||||
it('recognizes a loader that writes outside itself and leaves an honest one alone', () => {
|
||||
const probe = join(mobileRoot, 'src', 'transport', 'probe.ts')
|
||||
expect(
|
||||
loaderWrites(probe, 'owner.load(scope, {}, async () => { cacheRef.current = await read() })')
|
||||
).toEqual(['cacheRef'])
|
||||
expect(loaderWrites(probe, 'owner.load(scope, {}, async () => { sequence++ })')).toEqual([
|
||||
'sequence'
|
||||
])
|
||||
expect(
|
||||
loaderWrites(probe, 'owner.load(scope, {}, async () => { this.paths = await read() })')
|
||||
).toEqual(['this'])
|
||||
expect(
|
||||
loaderWrites(
|
||||
probe,
|
||||
'owner.load(scope, {}, async () => { const rows = await read(); return rows })'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('has every owner holder loading without writing external state', () => {
|
||||
// Require discovered owners/loaders so an empty scan cannot pass vacuously.
|
||||
expect(
|
||||
holders.map(({ path }) => relative(mobileRoot, path).split(/[/\\]/).join('/'))
|
||||
).toContain('src/session/native-chat/composer/use-mobile-native-chat-file-search.ts')
|
||||
expect(
|
||||
holders.reduce((total, { path, source }) => total + loaders(path, source).length, 0)
|
||||
).toBeGreaterThan(0)
|
||||
const offenders = holders
|
||||
.map(({ path, source }) => ({
|
||||
file: relative(mobileRoot, path).split(/[/\\]/).join('/'),
|
||||
writes: loaderWrites(path, source)
|
||||
}))
|
||||
.filter((entry) => entry.writes.length > 0)
|
||||
.map((entry) => `${entry.file}: ${[...new Set(entry.writes)].sort().join(', ')}`)
|
||||
.sort()
|
||||
expect(
|
||||
offenders,
|
||||
'Return the value from the loader and publish it with commit(lease, value) instead.'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
import { stubRpcClient } from './rpc-client-test-stub'
|
||||
|
||||
const feed = vi.hoisted((): { hosts: readonly MobileRelayHost[] | null } => ({ hosts: null }))
|
||||
const gatesConnStates = vi.hoisted((): ConnectionState[] => [])
|
||||
|
||||
vi.mock('../worktree/relay-hosts-feed', () => ({ useRelayHosts: () => feed.hosts }))
|
||||
vi.mock('./execution-host-scoped-rpc-client', () => ({
|
||||
workspaceRouteExecutionHost: (_client: unknown, _caps: unknown, host: string) => host,
|
||||
scopeRpcClientToExecutionHost: (client: RpcClient) => client
|
||||
}))
|
||||
vi.mock('./host-status-gates', () => ({
|
||||
useHostStatusGates: (args: { connState: ConnectionState }) => {
|
||||
gatesConnStates.push(args.connState)
|
||||
return {}
|
||||
}
|
||||
}))
|
||||
|
||||
const { useWorkspaceServer } = await import('./workspace-server')
|
||||
|
||||
const HOST = 'runtime:server-1' as const
|
||||
|
||||
function server(relay: MobileRelayHost['relay']): MobileRelayHost {
|
||||
return { hostId: HOST, label: 'Server', health: 'available', relay }
|
||||
}
|
||||
|
||||
describe('useWorkspaceServer', () => {
|
||||
it("re-reads the server's status when the server reconnects under a connected desktop", async () => {
|
||||
const client = stubRpcClient()
|
||||
function Probe(): null {
|
||||
useWorkspaceServer({
|
||||
client,
|
||||
connState: 'connected',
|
||||
desktopCapabilities: [],
|
||||
executionHost: HOST
|
||||
})
|
||||
return null
|
||||
}
|
||||
feed.hosts = [server('ready')]
|
||||
let renderer: ReactTestRenderer | undefined
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe))
|
||||
})
|
||||
feed.hosts = [server('unavailable')]
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Probe))
|
||||
})
|
||||
feed.hosts = [server('ready')]
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Probe))
|
||||
})
|
||||
// The gates' status read is keyed on this state, so the restart must reach it.
|
||||
expect(gatesConnStates).toEqual(['connected', 'connecting', 'connected'])
|
||||
renderer?.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import type { AutomationRunWriter } from './automation-run-writer'
|
||||
import { UNEVALUABLE_SCHEDULE, recordUnevaluableAutomation } from './dispatch-refusal'
|
||||
|
||||
const brokenAutomation: Automation = {
|
||||
id: 'a1',
|
||||
name: 'Broken schedule',
|
||||
prompt: 'Check the repo',
|
||||
precheck: null,
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
schedulerOwner: 'local_host_service',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
baseBranch: null,
|
||||
reuseSession: false,
|
||||
timezone: 'UTC',
|
||||
rrule: '0 9 32 * *',
|
||||
dtstart: 0,
|
||||
enabled: true,
|
||||
nextRunAt: 1000,
|
||||
missedRunPolicy: 'run_once_within_grace',
|
||||
missedRunGraceMinutes: 720,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
|
||||
const makeRun = (id: string): AutomationRun => ({
|
||||
id,
|
||||
automationId: brokenAutomation.id,
|
||||
title: 'Broken schedule run',
|
||||
scheduledFor: brokenAutomation.nextRunAt,
|
||||
status: 'pending',
|
||||
trigger: 'scheduled',
|
||||
workspaceId: brokenAutomation.workspaceId,
|
||||
sessionKind: 'terminal',
|
||||
chatSessionId: null,
|
||||
terminalSessionId: null,
|
||||
terminalPaneKey: null,
|
||||
terminalPtyId: null,
|
||||
outputSnapshot: null,
|
||||
precheckResult: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
dispatchedAt: null,
|
||||
createdAt: 0
|
||||
})
|
||||
|
||||
function makeRunWriter(foldsRepeat: boolean): {
|
||||
writer: AutomationRunWriter
|
||||
created: string[]
|
||||
updated: { status: string; error?: string | null }[]
|
||||
} {
|
||||
const created: string[] = []
|
||||
const updated: { status: string; error?: string | null }[] = []
|
||||
const writer: AutomationRunWriter = {
|
||||
advanceNextRun: async () => brokenAutomation,
|
||||
repeatSkip: async () => (foldsRepeat ? makeRun('folded') : null),
|
||||
createRun: async () => {
|
||||
const run = makeRun(`run-${created.length + 1}`)
|
||||
created.push(run.id)
|
||||
return run
|
||||
},
|
||||
updateRun: async (args) => {
|
||||
updated.push({ status: args.status, error: args.error })
|
||||
return { ...makeRun(args.runId), status: args.status, error: args.error ?? null }
|
||||
}
|
||||
}
|
||||
return { writer, created, updated }
|
||||
}
|
||||
|
||||
describe('recordUnevaluableAutomation', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('writes one run and logs once when the record is newly broken', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { writer, created, updated } = makeRunWriter(false)
|
||||
|
||||
await recordUnevaluableAutomation({
|
||||
runs: writer,
|
||||
automation: brokenAutomation,
|
||||
error: new Error('Invalid cron day of month.')
|
||||
})
|
||||
|
||||
expect(created).toEqual(['run-1'])
|
||||
expect(updated).toEqual([{ status: 'skipped_unavailable', error: UNEVALUABLE_SCHEDULE }])
|
||||
expect(logged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays silent on a record it has already reported', async () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { writer, created } = makeRunWriter(true)
|
||||
|
||||
for (let tick = 0; tick < 5; tick += 1) {
|
||||
await recordUnevaluableAutomation({
|
||||
runs: writer,
|
||||
automation: brokenAutomation,
|
||||
error: new Error('Invalid cron day of month.')
|
||||
})
|
||||
}
|
||||
|
||||
expect(created).toEqual([])
|
||||
expect(logged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ExternalAutomationProbeCancelledError,
|
||||
ExternalAutomationProbeScheduler,
|
||||
isExternalAutomationProbeCancelled
|
||||
ExternalAutomationProbeScheduler
|
||||
} from './external-automation-probe-scheduler'
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
@@ -71,58 +70,6 @@ describe('ExternalAutomationProbeScheduler', () => {
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels queued and in-flight probes whose scope is no longer selected', async () => {
|
||||
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 })
|
||||
const never = deferred<string>()
|
||||
const queuedRun = vi.fn(() => Promise.resolve('queued'))
|
||||
const keptRun = vi.fn(() => Promise.resolve('local'))
|
||||
|
||||
const inFlight = scheduler.schedule({
|
||||
key: 'openclaw@ssh',
|
||||
scopeKey: 'owner:desktop:ssh:target-a:3',
|
||||
run: () => never.promise
|
||||
})
|
||||
const queued = scheduler.schedule({
|
||||
key: 'openclaw@ssh',
|
||||
scopeKey: 'owner:desktop:ssh:target-a:3',
|
||||
run: queuedRun
|
||||
})
|
||||
const kept = scheduler.schedule({
|
||||
key: 'openclaw@self',
|
||||
scopeKey: 'owner:desktop:self',
|
||||
run: keptRun
|
||||
})
|
||||
|
||||
scheduler.retainScopes(['owner:desktop:self'])
|
||||
|
||||
await expect(inFlight).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
|
||||
await expect(queued).rejects.toSatisfy(isExternalAutomationProbeCancelled)
|
||||
expect(queuedRun).not.toHaveBeenCalled()
|
||||
expect(keptRun).not.toHaveBeenCalled()
|
||||
expect(scheduler.inFlight).toBe(1)
|
||||
|
||||
never.resolve('ignored')
|
||||
await expect(kept).resolves.toBe('local')
|
||||
expect(scheduler.inFlight).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels everything on demand', async () => {
|
||||
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 1 })
|
||||
const never = deferred<string>()
|
||||
const probe = scheduler.schedule({
|
||||
key: 'openclaw@self',
|
||||
scopeKey: 'owner:desktop:self',
|
||||
run: () => never.promise
|
||||
})
|
||||
|
||||
scheduler.cancelAll()
|
||||
|
||||
await expect(probe).rejects.toBeInstanceOf(ExternalAutomationProbeCancelledError)
|
||||
expect(scheduler.inFlight).toBe(1)
|
||||
never.resolve('ignored')
|
||||
await vi.waitFor(() => expect(scheduler.inFlight).toBe(0))
|
||||
})
|
||||
|
||||
it('does not start a duplicate key while cancelled provider work is still settling', async () => {
|
||||
const scheduler = new ExternalAutomationProbeScheduler({ concurrency: 2 })
|
||||
const never = deferred<string>()
|
||||
|
||||
@@ -53,10 +53,6 @@ function desktopSsh(targetId: string, targetGeneration: number): AutomationOwner
|
||||
return { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId, targetGeneration } }
|
||||
}
|
||||
|
||||
function orcaStoreHealth() {
|
||||
return { read: vi.fn<() => void>(), markUnavailable: vi.fn<() => void>() }
|
||||
}
|
||||
|
||||
function buildEngine(targets: SshTarget[]) {
|
||||
return createScopedExternalAutomations({
|
||||
registry: { getSshTargets: () => targets },
|
||||
@@ -125,39 +121,6 @@ describe('scoped external automations', () => {
|
||||
expect([...files]).toEqual(before)
|
||||
})
|
||||
|
||||
it('settles when a local command lookup hangs', async () => {
|
||||
vi.useFakeTimers()
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: null,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: true
|
||||
})
|
||||
const promise = buildEngine([]).listManager({ owner: desktopSelf, provider: 'openclaw' })
|
||||
let settled = false
|
||||
void promise.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ manager: null })
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('probes only local managers when Local is selected', async () => {
|
||||
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
|
||||
|
||||
const entry = await engine.listManager({ owner: desktopSelf, provider: 'openclaw' })
|
||||
|
||||
expect(entry.manager?.id).toBe('openclaw:local')
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
expect(runProcessMock.mock.calls[0]?.[0]).toEqual({
|
||||
program: process.platform === 'win32' ? 'where' : 'which',
|
||||
args: ['openclaw'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an SSH host this process does not serve without probing this computer', async () => {
|
||||
const engine = buildEngine([sshTarget(), sshTarget({ id: 'target-b', generation: 1 })])
|
||||
|
||||
@@ -233,28 +196,4 @@ describe('scoped external automations', () => {
|
||||
).rejects.toBeInstanceOf(AutomationOwnerConflictError)
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('confines a manager failure to its own scope and never to Orca store health', async () => {
|
||||
const health = orcaStoreHealth()
|
||||
const engine = createScopedExternalAutomations({
|
||||
registry: {
|
||||
getSshTargets: () => {
|
||||
health.read()
|
||||
return [sshTarget()]
|
||||
}
|
||||
},
|
||||
scheduler: new ExternalAutomationProbeScheduler(),
|
||||
cache: new ExternalAutomationManagerCache({ ttlMs: 0 })
|
||||
})
|
||||
|
||||
const failed = await engine.listManager({
|
||||
owner: desktopSsh('target-a', 3),
|
||||
provider: 'openclaw'
|
||||
})
|
||||
const healthy = await engine.listManager({ owner: desktopSelf, provider: 'openclaw' })
|
||||
|
||||
expect(failed).toMatchObject({ manager: null, error: SSH_HOST_NOT_SERVED_MESSAGE })
|
||||
expect(healthy.manager?.status).toBe('available')
|
||||
expect(health.markUnavailable).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,22 +37,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('runExternalAutomationAction', () => {
|
||||
it('runs local OpenClaw lifecycle actions through the CLI', async () => {
|
||||
await runExternalAutomationAction({
|
||||
managerId: 'openclaw:local',
|
||||
provider: 'openclaw',
|
||||
target: { type: 'local' },
|
||||
jobId: 'job-1',
|
||||
action: 'run'
|
||||
})
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalledWith({
|
||||
program: 'openclaw',
|
||||
args: ['cron', 'run', 'job-1'],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects job IDs that could be parsed as CLI options', async () => {
|
||||
await expect(
|
||||
runExternalAutomationAction({
|
||||
@@ -109,22 +93,6 @@ describe('runExternalAutomationAction', () => {
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps OpenClaw lifecycle actions through its cron CLI names', async () => {
|
||||
await runExternalAutomationAction({
|
||||
managerId: 'openclaw:local',
|
||||
provider: 'openclaw',
|
||||
target: { type: 'local' },
|
||||
jobId: 'job-1',
|
||||
action: 'pause'
|
||||
})
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalledWith({
|
||||
program: 'openclaw',
|
||||
args: ['cron', 'disable', 'job-1'],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses actions on an SSH host without launching locally', async () => {
|
||||
await expect(
|
||||
runExternalAutomationAction({
|
||||
|
||||
@@ -117,45 +117,6 @@ function launch(
|
||||
}
|
||||
|
||||
describe('headless automation agent launch', () => {
|
||||
it('launches Hermes through the ordinary agent contract', async () => {
|
||||
const runtime = harness()
|
||||
|
||||
const launched = await launch(runtime, { agentId: 'hermes' })
|
||||
|
||||
expect(runtime.launchAgentTerminal).toHaveBeenCalledWith('id:wt-1', {
|
||||
agent: 'hermes',
|
||||
prompt: 'fix the flaky test',
|
||||
title: 'Nightly',
|
||||
suppressStartupUpdatePrompt: true
|
||||
})
|
||||
expect(runtime.deliverStartupFollowup).not.toHaveBeenCalled()
|
||||
expect(launched.terminalHandle).toBe('term_existing')
|
||||
})
|
||||
|
||||
it('starts an argv agent in an existing workspace with the prompt on its command', async () => {
|
||||
const runtime = harness()
|
||||
|
||||
const launched = await launch(runtime, { extraAgentArgs: '--model opus' })
|
||||
|
||||
expect(runtime.launchAgentTerminal).toHaveBeenCalledWith('id:wt-1', {
|
||||
agent: 'claude',
|
||||
prompt: 'fix the flaky test',
|
||||
title: 'Nightly',
|
||||
suppressStartupUpdatePrompt: true,
|
||||
extraAgentArgs: '--model opus'
|
||||
})
|
||||
expect(runtime.deliverStartupFollowup).not.toHaveBeenCalled()
|
||||
expect(runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled()
|
||||
expect(launched).toEqual({
|
||||
workspaceId: 'wt-1',
|
||||
workspaceDisplayName: 'repo',
|
||||
terminalHandle: 'term_existing',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'tab-1:leaf-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a post-start agent bare and delivers its prompt once, after the terminal is recorded', async () => {
|
||||
const runtime = harness()
|
||||
let finishDelivery: (delivered: boolean) => void = () => {}
|
||||
@@ -178,54 +139,6 @@ describe('headless automation agent launch', () => {
|
||||
finishDelivery(true)
|
||||
})
|
||||
|
||||
it('creates a new-per-run workspace agent-first with the argv prompt', async () => {
|
||||
const runtime = harness()
|
||||
|
||||
const launched = await launch(runtime, {
|
||||
workspaceMode: 'new_per_run',
|
||||
workspaceId: null,
|
||||
extraAgentArgs: '--model opus'
|
||||
})
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
const args = runtime.createManagedWorktree.mock.calls[0]![0]
|
||||
expect(args).toMatchObject({
|
||||
repoSelector: 'repo-1',
|
||||
activate: false,
|
||||
setupDecision: 'skip',
|
||||
startupAgent: 'claude',
|
||||
startupPrompt: 'fix the flaky test',
|
||||
startupExtraAgentArgs: '--model opus',
|
||||
createdWithAgent: 'claude',
|
||||
telemetrySource: 'unknown'
|
||||
})
|
||||
expect(args).not.toHaveProperty('awaitTerminalProvisioning')
|
||||
expect(args).not.toHaveProperty('observeSetupCompletion')
|
||||
expect(runtime.launchAgentTerminal).not.toHaveBeenCalled()
|
||||
expect(runtime.deliverStartupFollowup).not.toHaveBeenCalled()
|
||||
expect(launched).toEqual({
|
||||
workspaceId: 'repo-1::/wt/auto',
|
||||
workspaceDisplayName: 'auto-nightly',
|
||||
terminalHandle: 'term_new',
|
||||
terminalSessionId: 'tab-new',
|
||||
terminalPaneKey: 'tab-new:leaf-1',
|
||||
terminalPtyId: 'pty-new'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a new-per-run workspace for a post-start agent and delivers its prompt once', async () => {
|
||||
const runtime = harness()
|
||||
|
||||
await launch(runtime, { agentId: 'goose', workspaceMode: 'new_per_run', workspaceId: null })
|
||||
|
||||
expect(runtime.createManagedWorktree.mock.calls[0]![0]).not.toHaveProperty('startupPrompt')
|
||||
expect(runtime.deliverStartupFollowup).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.deliverStartupFollowup).toHaveBeenCalledWith('term_new', {
|
||||
agent: 'goose',
|
||||
prompt: 'fix the flaky test'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails the run with the create warning when no agent terminal started, without a second one', async () => {
|
||||
const runtime = harness({ startupTerminal: null })
|
||||
|
||||
@@ -235,18 +148,6 @@ describe('headless automation agent launch', () => {
|
||||
expect(runtime.launchAgentTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('launches bare and delivers nothing for a blank prompt', async () => {
|
||||
const runtime = harness()
|
||||
|
||||
await launch(runtime, { agentId: 'aider', prompt: ' ' })
|
||||
|
||||
expect(runtime.launchAgentTerminal).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
expect.objectContaining({ prompt: '' })
|
||||
)
|
||||
expect(runtime.deliverStartupFollowup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs a failure after the terminal was recorded instead of dropping it', async () => {
|
||||
const runtime = harness()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -203,18 +203,6 @@ describe('headless run terminal retention', () => {
|
||||
expect(h.forgotten[0]).toMatchObject({ id: 'missing', status: 'dispatch_failed' })
|
||||
})
|
||||
|
||||
it('keeps a timed-out run whose agent still runs in its shell', async () => {
|
||||
const runs = [
|
||||
makeRun('timed-out', 0, 'dispatch_failed'),
|
||||
...[1, 2, 3].map((n) => makeRun(`done${n}`, n))
|
||||
]
|
||||
const h = harness(runs)
|
||||
await h.retention.sweep()
|
||||
vi.advanceTimersByTime(RUN_TERMINAL_GRACE_MS * 10)
|
||||
await h.retention.sweep()
|
||||
expect(h.closed).toEqual([])
|
||||
})
|
||||
|
||||
it('closes a still-dispatched run whose agent exited, leaving its shell idle', async () => {
|
||||
const runs = [
|
||||
makeRun('exited', 0, 'dispatched'),
|
||||
@@ -354,17 +342,6 @@ describe('headless run terminal retention', () => {
|
||||
expect(h.forgotten.map((run) => run.id)).not.toContain('r0')
|
||||
})
|
||||
|
||||
it('closes a completed run whose workspace is shared', async () => {
|
||||
const runs = [0, 1, 2, 3].map((n) => makeRun(`r${n}`, n))
|
||||
const h = harness(runs)
|
||||
await h.retention.sweep()
|
||||
vi.advanceTimersByTime(RUN_TERMINAL_GRACE_MS)
|
||||
await h.retention.sweep()
|
||||
expect(h.slept).toEqual([])
|
||||
expect(h.closed).toEqual(['tab-r0:1'])
|
||||
expect(h.forgotten.map((run) => run.id)).toEqual(['r0'])
|
||||
})
|
||||
|
||||
it('closes a failed run in its own workspace, and never sleeps or closes a used one', async () => {
|
||||
const runs = [
|
||||
makeRun('failed', 0, 'dispatch_failed'),
|
||||
|
||||
@@ -89,29 +89,4 @@ describe('headless automation workspace create args', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to skip for legacy automations without a saved setup decision', () => {
|
||||
const args = buildHeadlessAutomationWorktreeCreateArgs({
|
||||
automation: { ...automation, setupDecision: undefined },
|
||||
run: {
|
||||
id: 'run-1',
|
||||
title: 'Nightly review run',
|
||||
scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5)
|
||||
},
|
||||
repo
|
||||
})
|
||||
|
||||
expect(args.setupDecision).toBe('skip')
|
||||
expect(Object.hasOwn(args, 'startupExtraAgentArgs')).toBe(false)
|
||||
})
|
||||
|
||||
it('carries saved extras into the startup agent launch', () => {
|
||||
const args = buildHeadlessAutomationWorktreeCreateArgs({
|
||||
automation: { ...automation, extraAgentArgs: '--model gpt-5.5' },
|
||||
run: { id: 'run-1', title: 'Nightly review run', scheduledFor: 0 },
|
||||
repo
|
||||
})
|
||||
|
||||
expect(args.startupExtraAgentArgs).toBe('--model gpt-5.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import {
|
||||
isFinalAutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun
|
||||
} from '../../shared/automations-types'
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import { AutomationService } from './service'
|
||||
import type {
|
||||
AutomationRunCompletionObservation,
|
||||
@@ -156,44 +152,6 @@ describe('reconciling retained runs against a graph that has not published yet',
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('persists the terminal status exactly once when the restored pane finishes the run', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
const retained = retainDispatchedRun(store, automation)
|
||||
const surface = createPaneSurface()
|
||||
const service = new AutomationService(store, { terminalObserver: surface.observer })
|
||||
|
||||
const transitions: string[] = []
|
||||
const persist = store.updateAutomationRun.bind(store)
|
||||
vi.spyOn(store, 'updateAutomationRun').mockImplementation((update) => {
|
||||
const before = store.listAutomationRuns(automation.id).find((r) => r.id === update.runId)
|
||||
if (before && !isFinalAutomationRunStatus(before.status)) {
|
||||
if (isFinalAutomationRunStatus(update.status)) {
|
||||
transitions.push(update.status)
|
||||
}
|
||||
}
|
||||
return persist(update)
|
||||
})
|
||||
|
||||
service.start()
|
||||
service.setRendererReady()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
surface.mountPane()
|
||||
await service.markDispatchResult({
|
||||
runId: retained.id,
|
||||
status: 'completed',
|
||||
...LAUNCH_TARGET,
|
||||
error: null
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(AFTER_SETTLE_MS)
|
||||
|
||||
expect(transitions).toEqual(['completed'])
|
||||
expect(readRun(store, automation.id, retained.id).status).toBe('completed')
|
||||
expect(readRun(store, automation.id, retained.id).error).toBeNull()
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('does not fail a serve-startup retained run whose host pane has not reattached', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
|
||||
@@ -344,23 +344,4 @@ describe('automationsChanged publication', () => {
|
||||
expect(seen.every((entry) => entry.payload.selector?.kind === 'self')).toBe(true)
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('changes nothing for a consumer that ignores the unknown event type', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
const oldClientState = { repoRefreshes: 0 }
|
||||
const service = new AutomationService(store, {
|
||||
headlessDispatcher: async () => ({ ...LAUNCH_TARGET }),
|
||||
onAutomationsChanged: (payload) => {
|
||||
if ((payload as { type?: string }).type === 'reposChanged') {
|
||||
oldClientState.repoRefreshes += 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
expect(oldClientState.repoRefreshes).toBe(0)
|
||||
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
|
||||
service.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,15 +147,6 @@ describe('resolveAutomationRunTarget projectId drift', () => {
|
||||
expect(result).toMatchObject({ ok: true, cwd: '/repo' })
|
||||
})
|
||||
|
||||
it('resolves when the snapshot is at the git: tier and the setup climbed to github:', () => {
|
||||
const store = makeStore([makeSetup()], [makeRepo()])
|
||||
const automation = makeAutomation(makeRunContext({ projectId: 'git:github.com/o/r' }))
|
||||
|
||||
const result = resolveAutomationRunTarget(store, automation)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, cwd: '/repo' })
|
||||
})
|
||||
|
||||
it('still blocks when the repoId no longer matches the setup', () => {
|
||||
const store = makeStore([makeSetup({ repoId: 'repo-2' })], [makeRepo()])
|
||||
const automation = makeAutomation(makeRunContext({ repoId: 'repo-1' }))
|
||||
|
||||
@@ -195,22 +195,4 @@ describe('headless automation service run terminal retention', () => {
|
||||
expect(runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(service.markDispatchResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a retry that finds the run PTY already stopped by a failed sleep as slept', async () => {
|
||||
const { runtime, service, store } = build(true)
|
||||
store.getWorktreeMeta.mockImplementation(firstRunOwnsWorkspace)
|
||||
store.listAutomationRuns.mockReturnValue(finishedRuns(1))
|
||||
// Simulate retrying a sleep that stopped the PTY before throwing.
|
||||
runtime.sleepTerminalsForWorktree.mockResolvedValue({ livePtyIds: [], stoppedPtyIds: [] })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked service exposes the drain hook it was given.
|
||||
const drain = (service as unknown as { releaseFinishedRunTerminals: () => Promise<number> })
|
||||
.releaseFinishedRunTerminals
|
||||
await expect(drain()).resolves.toBe(1)
|
||||
expect(runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(service.markDispatchResult).toHaveBeenCalledWith({
|
||||
runId: 'r0',
|
||||
status: 'completed',
|
||||
terminalPtyId: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AutomationRun } from '../../shared/automations-types'
|
||||
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
|
||||
import type { AutomationRunTerminalObserver } from './run-completion-watcher'
|
||||
|
||||
@@ -93,118 +92,3 @@ describe('headless automation dispatch', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('headless automation dispatch with extra agent args', () => {
|
||||
it('passes saved extras to an existing-workspace launch', async () => {
|
||||
const runtime = {
|
||||
setAutomationService: vi.fn(),
|
||||
notifyAutomationsChanged: vi.fn(),
|
||||
launchAgentTerminal: vi.fn(async () => ({
|
||||
handle: 'terminal-1',
|
||||
tabId: 'tab-1',
|
||||
paneKey: 'tab-1:pane-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt-1'
|
||||
})),
|
||||
showManagedWorktree: vi.fn(async () => ({ displayName: 'repo' })),
|
||||
getTerminalHandleForPaneKey: vi.fn(() => null),
|
||||
getAgentStatusRowsForPane: vi.fn(() => [])
|
||||
}
|
||||
createRuntimeAutomationService({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked service never reads the store; the dispatcher reads only past runs.
|
||||
store: { listAutomationRuns: () => [] } as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only the members faked above.
|
||||
runtime: runtime as never,
|
||||
headless: true
|
||||
})
|
||||
const dispatcher = capturedOption('headlessDispatcher')
|
||||
const automation = {
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt-1',
|
||||
agentId: 'claude',
|
||||
prompt: 'go',
|
||||
extraAgentArgs: '--model opus'
|
||||
}
|
||||
await dispatcher({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only these fields are read.
|
||||
automation: automation as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the title is read.
|
||||
run: { title: 'Nightly' } as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: unused for an existing workspace.
|
||||
target: {} as never
|
||||
})
|
||||
|
||||
expect(runtime.launchAgentTerminal).toHaveBeenCalledWith('id:wt-1', {
|
||||
agent: 'claude',
|
||||
prompt: 'go',
|
||||
title: 'Nightly',
|
||||
suppressStartupUpdatePrompt: true,
|
||||
extraAgentArgs: '--model opus'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('headless reuse-session dispatch', () => {
|
||||
it('sends the second run into the agent the first run started, on one PTY', async () => {
|
||||
const runs: AutomationRun[] = []
|
||||
const runtime = {
|
||||
setAutomationService: vi.fn(),
|
||||
notifyAutomationsChanged: vi.fn(),
|
||||
launchAgentTerminal: vi.fn(async () => ({
|
||||
handle: 'terminal-1',
|
||||
tabId: 'tab-1',
|
||||
paneKey: 'tab-1:pane-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt-1'
|
||||
})),
|
||||
showManagedWorktree: vi.fn(async () => ({ displayName: 'repo' })),
|
||||
getTerminalHandleForPaneKey: vi.fn(() => 'terminal-1'),
|
||||
getTerminalPtyIdForHandle: vi.fn(() => 'pty-1'),
|
||||
getAgentStatusRowsForPane: vi.fn(() => [
|
||||
{ state: 'done', agentType: 'claude', receivedAt: 1 }
|
||||
])
|
||||
}
|
||||
createRuntimeAutomationService({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked service never reads the store; the dispatcher reads only past runs.
|
||||
store: { listAutomationRuns: () => runs } as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only the members faked above.
|
||||
runtime: runtime as never,
|
||||
headless: true
|
||||
})
|
||||
const dispatcher = capturedOption('headlessDispatcher')
|
||||
const dispatch = (id: string) =>
|
||||
dispatcher({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only these fields are read.
|
||||
automation: {
|
||||
id: 'auto-1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt-1',
|
||||
agentId: 'claude',
|
||||
prompt: 'triage',
|
||||
reuseSession: true
|
||||
} as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the id and title are read.
|
||||
run: { id, title: 'Nightly' } as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: unused for an existing workspace.
|
||||
target: {} as never
|
||||
})
|
||||
|
||||
const first = await dispatch('run-1')
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: selection reads only these run fields.
|
||||
runs.push({
|
||||
...first,
|
||||
id: 'run-1',
|
||||
automationId: 'auto-1',
|
||||
status: 'completed',
|
||||
createdAt: 1
|
||||
} as AutomationRun)
|
||||
const second = await dispatch('run-2')
|
||||
|
||||
expect(runtime.launchAgentTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(deliverTerminalAgentLaunchPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ handle: 'terminal-1', freshLaunch: false, text: 'triage' })
|
||||
)
|
||||
expect(second.terminalPtyId).toBe(first.terminalPtyId)
|
||||
expect(second.terminalPaneKey).toBe('tab-1:pane-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,16 +226,6 @@ describe('observing a run with agent evidence', () => {
|
||||
expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }))
|
||||
})
|
||||
|
||||
it('keeps idle-means-done for an agent that never reports, after its start window', async () => {
|
||||
const pane = createPane({ idle: true, tail: ['summary written', '$'] })
|
||||
const run = observe(pane)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(run.settled).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(AGENT_START_GRACE_MS)
|
||||
await run.promise
|
||||
expect(run.settled).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }))
|
||||
})
|
||||
|
||||
it("never reads an agent's own tool output as its command missing", async () => {
|
||||
const tail = ['running tests', 'bash: pytest: command not found', 'fell back to unittest', '$']
|
||||
const run = observe(createPane({ idle: true, tail }))
|
||||
|
||||
@@ -118,40 +118,6 @@ describe('createRuntimeAutomationRunTerminalObserver', () => {
|
||||
await run.promise
|
||||
})
|
||||
|
||||
it('does not complete from a stale idle pane title (leaf branch shape)', async () => {
|
||||
const runtime = createFakeRuntime({ lastAgentStatus: null, paneTitle: '✳ Claude — idle' })
|
||||
const run = observe(runtime)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(run.settled).toEqual([])
|
||||
|
||||
runtime.setPane({ paneTitle: '✳ Claude — working' })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(run.settled).toEqual([])
|
||||
|
||||
runtime.setPane({ paneTitle: '✳ Claude — idle' })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(run.settled[0]?.status).toBe('completed')
|
||||
await run.promise
|
||||
})
|
||||
|
||||
it('does not complete from a ready shell prompt left over at dispatch', async () => {
|
||||
const runtime = createFakeRuntime({ preview: 'user@host repo %\n$' })
|
||||
const run = observe(runtime)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(run.settled).toEqual([])
|
||||
|
||||
runtime.setPane({ preview: 'claude is thinking…' })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(run.settled).toEqual([])
|
||||
|
||||
runtime.setPane({ lastAgentStatus: 'idle' })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(run.settled[0]?.status).toBe('completed')
|
||||
await run.promise
|
||||
})
|
||||
|
||||
it('fails the run truthfully when the pane never leaves its pre-dispatch state', async () => {
|
||||
const runtime = createFakeRuntime({ lastAgentStatus: 'idle' })
|
||||
const run = observe(runtime)
|
||||
|
||||
@@ -86,56 +86,6 @@ describe('AutomationService prechecks', () => {
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('runs scheduled prechecks in the target repo before dispatch', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ path: '/repo/path' }))
|
||||
const automation = store.createAutomation({
|
||||
name: 'Conditional check',
|
||||
prompt: 'Check the repo',
|
||||
precheck: {
|
||||
command: 'test -f ready',
|
||||
timeoutSeconds: 30
|
||||
},
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
|
||||
const precheckResult = {
|
||||
command: 'test -f ready',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
durationMs: 5,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now()
|
||||
}
|
||||
runAutomationPrecheckMock.mockResolvedValue(precheckResult)
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
|
||||
const result = await service.runPrecheck(automation.id, run.id)
|
||||
|
||||
expect(result).toEqual(precheckResult)
|
||||
expect(runAutomationPrecheckMock).toHaveBeenCalledWith({
|
||||
precheck: {
|
||||
command: 'test -f ready',
|
||||
timeoutSeconds: 30
|
||||
},
|
||||
target: {
|
||||
type: 'local',
|
||||
cwd: '/repo/path'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not run scheduled prechecks when the selected host setup is stale', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -45,23 +45,6 @@ describe('Azure DevOps API request (STA-3494)', () => {
|
||||
globalThis.fetch = OLD_FETCH
|
||||
})
|
||||
|
||||
it('retries with -preview when Azure DevOps Server rejects the api-version', async () => {
|
||||
const versions: (string | null)[] = []
|
||||
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input))
|
||||
versions.push(url.searchParams.get('api-version'))
|
||||
if (!url.searchParams.get('api-version')?.endsWith('-preview')) {
|
||||
return previewRejection()
|
||||
}
|
||||
return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } })
|
||||
}) as never
|
||||
|
||||
await expect(
|
||||
requestAzureDevOpsJsonAtBase(SERVER_BASE, '/_apis/connectionData')
|
||||
).resolves.toEqual({ authenticatedUser: { providerDisplayName: 'Server User' } })
|
||||
expect(versions).toEqual(['7.1', '7.1-preview'])
|
||||
})
|
||||
|
||||
it('remembers the -preview requirement per origin after the first rejection', async () => {
|
||||
const versions: (string | null)[] = []
|
||||
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixture
|
||||
import {
|
||||
getAzureDevOpsAuthStatus,
|
||||
getAzureDevOpsPullRequestForBranch,
|
||||
getAzureDevOpsPullRequestForBranchOrThrow,
|
||||
normalizeAzureDevOpsApiBaseUrl
|
||||
getAzureDevOpsPullRequestForBranchOrThrow
|
||||
} from './client'
|
||||
import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request'
|
||||
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
|
||||
@@ -46,23 +45,6 @@ describe('Azure DevOps client', () => {
|
||||
_resetAzureDevOpsRepoRefCache()
|
||||
})
|
||||
|
||||
it('normalizes configured API base URLs', () => {
|
||||
expect(normalizeAzureDevOpsApiBaseUrl('https://dev.azure.com/acme/Project/_apis/')).toBe(
|
||||
'https://dev.azure.com/acme/Project'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks token-only auth as configured but unverified because repository remotes supply the API base URL', async () => {
|
||||
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
|
||||
await expect(getAzureDevOpsAuthStatus()).resolves.toEqual({
|
||||
configured: true,
|
||||
authenticated: false,
|
||||
account: null,
|
||||
baseUrl: null,
|
||||
tokenConfigured: true
|
||||
})
|
||||
})
|
||||
|
||||
it('authenticates against an on-prem Server that requires -preview api-versions (STA-3494)', async () => {
|
||||
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/tfs/MyCollection'
|
||||
const versions: (string | null)[] = []
|
||||
@@ -334,48 +316,6 @@ describe('Azure DevOps client', () => {
|
||||
).resolves.toMatchObject({ number: 40, state: 'open' })
|
||||
})
|
||||
|
||||
it('keeps an active PR whose source branch is the repo default branch', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n'
|
||||
})
|
||||
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input))
|
||||
const response = (body: unknown): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
if (url.pathname === '/acme/Project/_apis/git/repositories/repo') {
|
||||
return response({ id: 'repo-guid' })
|
||||
}
|
||||
if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') {
|
||||
return response({
|
||||
value: [
|
||||
{
|
||||
pullRequestId: 31,
|
||||
title: 'main → release',
|
||||
status: 'active',
|
||||
creationDate: '2026-05-10T00:00:00Z',
|
||||
mergeStatus: 'succeeded',
|
||||
lastMergeSourceCommit: { commitId: 'main-head-oid' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
if (
|
||||
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/31/statuses'
|
||||
) {
|
||||
return response({ value: [{ state: 'succeeded' }] })
|
||||
}
|
||||
return new Response(JSON.stringify({ message: 'not found' }), { status: 404 })
|
||||
}) as never
|
||||
|
||||
await expect(
|
||||
getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/main')
|
||||
).resolves.toMatchObject({ number: 31, state: 'open' })
|
||||
})
|
||||
|
||||
it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n'
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createAzureDevOpsPullRequest,
|
||||
isAzureDevOpsReviewCreationAuthenticated
|
||||
} from './pull-request-creation'
|
||||
import { createAzureDevOpsPullRequest } from './pull-request-creation'
|
||||
import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request'
|
||||
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
||||
@@ -40,11 +37,6 @@ describe('Azure DevOps pull request creation', () => {
|
||||
_resetAzureDevOpsRepoRefCache()
|
||||
})
|
||||
|
||||
it('treats token-only auth as sufficient for repo-scoped creation', () => {
|
||||
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
|
||||
expect(isAzureDevOpsReviewCreationAuthenticated()).toBe(true)
|
||||
})
|
||||
|
||||
it('posts a pull request create body to the repository REST endpoint', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(String(input))
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deriveAzureDevOpsStatus,
|
||||
mapAzureDevOpsPullRequest,
|
||||
mapAzureDevOpsPullRequestState
|
||||
} from './pull-request-mappers'
|
||||
import { deriveAzureDevOpsStatus, mapAzureDevOpsPullRequestState } from './pull-request-mappers'
|
||||
|
||||
describe('Azure DevOps pull request mappers', () => {
|
||||
it('maps Azure DevOps PR states into the hosted review state set', () => {
|
||||
@@ -19,30 +15,4 @@ describe('Azure DevOps pull request mappers', () => {
|
||||
expect(deriveAzureDevOpsStatus([{ state: 'succeeded' }, { state: 'failed' }])).toBe('failure')
|
||||
expect(deriveAzureDevOpsStatus([])).toBe('neutral')
|
||||
})
|
||||
|
||||
it('maps a raw PR into hosted review info', () => {
|
||||
expect(
|
||||
mapAzureDevOpsPullRequest(
|
||||
{
|
||||
pullRequestId: 42,
|
||||
title: 'Add Azure support',
|
||||
status: 'active',
|
||||
creationDate: '2026-05-15T12:00:00Z',
|
||||
mergeStatus: 'succeeded',
|
||||
lastMergeSourceCommit: { commitId: 'abc123' }
|
||||
},
|
||||
'success',
|
||||
'https://dev.azure.com/acme/Project/_git/repo'
|
||||
)
|
||||
).toEqual({
|
||||
number: 42,
|
||||
title: 'Add Azure support',
|
||||
state: 'open',
|
||||
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/42',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-15T12:00:00Z',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'abc123'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -136,17 +136,4 @@ describe('parseAzureDevOpsRepoRef', () => {
|
||||
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds cached repository refs for distinct repo paths', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
for (let i = 0; i < 513; i += 1) {
|
||||
await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin')
|
||||
}
|
||||
|
||||
expect(_getAzureDevOpsRepoRefCacheSize()).toBe(512)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,44 +82,6 @@ describe('Bitbucket client', () => {
|
||||
await expect(getBitbucketPullRequestForBranch('/repo', 'refs/heads/main')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an OPEN PR whose source branch is the repo default branch', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes('/statuses/build')) {
|
||||
return Response.json({ values: [{ state: 'SUCCESSFUL' }] })
|
||||
}
|
||||
return Response.json({
|
||||
values: [
|
||||
{ ...bitbucketPr(8), source: { branch: { name: 'main' }, commit: { hash: 'abc123' } } }
|
||||
]
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
getBitbucketPullRequestForBranch('/repo', 'refs/heads/main')
|
||||
).resolves.toMatchObject({ number: 8, state: 'open' })
|
||||
})
|
||||
|
||||
it('discards a MERGED default-branch shadow and refetches the linked PR via the fallback (#9171)', async () => {
|
||||
primeGitExecWithDefaultBranch()
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes('/statuses/build')) {
|
||||
return Response.json({ values: [] })
|
||||
}
|
||||
if (url.endsWith('/pullrequests/42')) {
|
||||
return Response.json(bitbucketPr(42))
|
||||
}
|
||||
return Response.json({
|
||||
values: [{ ...bitbucketPr(7), state: 'MERGED', source: { branch: { name: 'main' } } }]
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
getBitbucketPullRequestForBranch('/repo', 'refs/heads/main', 42)
|
||||
).resolves.toMatchObject({ number: 42 })
|
||||
})
|
||||
|
||||
it('hides a MERGED PR matched only by feature-branch name so a new PR can be created', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes('/statuses/build')) {
|
||||
@@ -289,37 +251,6 @@ describe('Bitbucket client', () => {
|
||||
).rejects.toThrow(/Bitbucket request failed/)
|
||||
})
|
||||
|
||||
it('falls back to a linked PR number when branch lookup misses', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
if (url.includes('/statuses/build')) {
|
||||
return Response.json({ values: [] })
|
||||
}
|
||||
if (url.endsWith('/pullrequests/42')) {
|
||||
return Response.json(bitbucketPr(42))
|
||||
}
|
||||
return Response.json({ values: [] })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getBitbucketPullRequestForBranch('/repo', 'different', 42)).resolves.toMatchObject(
|
||||
{
|
||||
number: 42,
|
||||
status: 'neutral'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('reports env-token auth status through the Bitbucket /user endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ username: 'bitbucket-user' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(getBitbucketAuthStatus()).resolves.toEqual({
|
||||
configured: true,
|
||||
authenticated: true,
|
||||
account: 'bitbucket-user'
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels unread error-response bodies so bundled undici cannot crash on socket close', async () => {
|
||||
let cancelledBodies = 0
|
||||
const fetchMock = vi.fn(async () =>
|
||||
|
||||
@@ -150,36 +150,4 @@ describe('Bitbucket credential connection', () => {
|
||||
authMode: 'token'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an env API base URL override the stored one without env credentials', async () => {
|
||||
const conn = await loadModule()
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ username: 'ada' })
|
||||
) as unknown as typeof fetch
|
||||
await conn.connectBitbucket({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
apiToken: 'tok',
|
||||
baseUrl: 'https://stored.example.com/2.0'
|
||||
})
|
||||
|
||||
process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://env.example.com/2.0'
|
||||
const { resolveBitbucketAuthConfig } = await import('./resolve-auth')
|
||||
expect(resolveBitbucketAuthConfig().baseUrl).toBe('https://env.example.com/2.0')
|
||||
})
|
||||
|
||||
it('clears the stored connection on disconnect', async () => {
|
||||
const conn = await loadModule()
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ username: 'ada' })
|
||||
) as unknown as typeof fetch
|
||||
await conn.connectBitbucket({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
apiToken: 'tok'
|
||||
})
|
||||
|
||||
conn.disconnectBitbucket()
|
||||
expect(conn.getBitbucketConnectionStatus().source).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,31 +82,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('Bitbucket credential store', () => {
|
||||
it('persists plaintext metadata and an encrypted secret, then reads them back', async () => {
|
||||
const store = await loadStore()
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
baseUrl: null,
|
||||
account: 'ada',
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token'
|
||||
})
|
||||
|
||||
expect(store.hasStoredBitbucketCredential()).toBe(true)
|
||||
expect(store.getStoredBitbucketMetadata()).toMatchObject({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
account: 'ada'
|
||||
})
|
||||
expect(store.loadStoredBitbucketSecret()).toMatchObject({
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token',
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes both credential files 0600', async () => {
|
||||
const store = await loadStore()
|
||||
store.saveBitbucketCredential({
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createBitbucketPullRequest,
|
||||
isBitbucketReviewCreationAuthenticated
|
||||
} from './pull-request-creation'
|
||||
import { createBitbucketPullRequest } from './pull-request-creation'
|
||||
import { _resetBitbucketRepoRefCache } from './repository-ref'
|
||||
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
@@ -82,18 +79,6 @@ describe('Bitbucket pull request creation', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the stored credential when no environment variable is set', async () => {
|
||||
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
|
||||
const fetchMock = vi.fn(async () => createdPullRequestResponse())
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')
|
||||
|
||||
// This fixture has no stored credential after the environment token is removed.
|
||||
expect(result).toMatchObject({ ok: false, code: 'auth_required' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a draft request rather than dead-ending a hidden persisted default', async () => {
|
||||
const fetchMock = vi.fn(async () => createdPullRequestResponse())
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
@@ -106,12 +91,6 @@ describe('Bitbucket pull request creation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports authenticated only when a credential resolves', async () => {
|
||||
expect(isBitbucketReviewCreationAuthenticated()).toBe(true)
|
||||
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
|
||||
expect(isBitbucketReviewCreationAuthenticated()).toBe(false)
|
||||
})
|
||||
|
||||
it('maps a duplicate-branch rejection to already_exists with the existing review', async () => {
|
||||
let call = 0
|
||||
const fetchMock = vi.fn(async () => {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deriveBitbucketBuildStatus,
|
||||
mapBitbucketPullRequest,
|
||||
mapBitbucketPullRequestState
|
||||
} from './pull-request-mappers'
|
||||
import { deriveBitbucketBuildStatus, mapBitbucketPullRequestState } from './pull-request-mappers'
|
||||
|
||||
describe('Bitbucket pull request mappers', () => {
|
||||
it('normalizes Bitbucket pull request states', () => {
|
||||
@@ -19,30 +15,4 @@ describe('Bitbucket pull request mappers', () => {
|
||||
expect(deriveBitbucketBuildStatus([{ state: 'INPROGRESS' }])).toBe('pending')
|
||||
expect(deriveBitbucketBuildStatus([{ state: 'FAILED' }])).toBe('failure')
|
||||
})
|
||||
|
||||
it('maps raw pull request JSON into the shared PR-like shape', () => {
|
||||
expect(
|
||||
mapBitbucketPullRequest(
|
||||
{
|
||||
id: 42,
|
||||
title: 'Add Bitbucket',
|
||||
state: 'MERGED',
|
||||
updated_on: '2026-05-10T00:00:00.000Z',
|
||||
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } },
|
||||
source: { branch: { name: 'feature' }, commit: { hash: 'abc123' } },
|
||||
destination: { branch: { name: 'main' } }
|
||||
},
|
||||
'success'
|
||||
)
|
||||
).toEqual({
|
||||
number: 42,
|
||||
title: 'Add Bitbucket',
|
||||
state: 'merged',
|
||||
url: 'https://bitbucket.org/team/repo/pull-requests/42',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-10T00:00:00.000Z',
|
||||
mergeable: 'UNKNOWN',
|
||||
headSha: 'abc123'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -108,17 +108,4 @@ describe('Bitbucket repository refs', () => {
|
||||
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds cached repository refs for distinct repo paths', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'git@bitbucket.org:team/project.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
for (let i = 0; i < 513; i += 1) {
|
||||
await getBitbucketRepoRef(`/repo-${i}`)
|
||||
}
|
||||
|
||||
expect(_getBitbucketRepoRefCacheSize()).toBe(512)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,8 +40,3 @@ it('removes the listener on disposal', () => {
|
||||
parent.emit('disconnect')
|
||||
expect(quit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not couple a desktop launch to a sidecar owner marker', () => {
|
||||
installServeSupervisorDisconnectQuit(false, new EventEmitter())
|
||||
expect(quit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -100,23 +100,6 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(holdPaintForCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives the helper proxy a paint hold scoped to its page', async () => {
|
||||
const release = vi.fn()
|
||||
const holdPaintForCapture = vi.fn(() => release)
|
||||
const b = new AgentBrowserBridge(
|
||||
mockBrowserManager(undefined, undefined, { holdPaintForCapture })
|
||||
)
|
||||
b.setActiveTab(100)
|
||||
webContentsFromIdMock.mockReturnValue(mockWebContents(100))
|
||||
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
await b.snapshot()
|
||||
|
||||
const holdPaint = CdpWsProxyMock.mock.calls[0]?.[1]
|
||||
expect(holdPaint()).toBe(release)
|
||||
expect(holdPaintForCapture).toHaveBeenCalledWith(100)
|
||||
})
|
||||
|
||||
it('captures full-page screenshots directly through CDP using CSS layout bounds', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
wc.debugger.sendCommand.mockImplementation((method: string) => {
|
||||
|
||||
@@ -53,7 +53,6 @@ vi.mock('./cdp-bridge', () => ({
|
||||
}))
|
||||
|
||||
import { AgentBrowserBridge } from './agent-browser-bridge'
|
||||
import { AGENT_BROWSER_IDLE_TIMEOUT_MS } from './agent-browser-process-environment'
|
||||
import {
|
||||
createFakeAgentBrowserChild,
|
||||
createSucceedWith,
|
||||
@@ -94,64 +93,6 @@ describe('AgentBrowserBridge', () => {
|
||||
bridge.setActiveTab(100)
|
||||
})
|
||||
|
||||
it('uses browserPageId as session name', async () => {
|
||||
succeedWith({ snapshot: '...' })
|
||||
await bridge.snapshot()
|
||||
|
||||
const args = execFileMock.mock.calls[0][1] as string[]
|
||||
expect(args).toContain('--session')
|
||||
expect(args[args.indexOf('--session') + 1]).toBe('orca-tab-tab-1')
|
||||
})
|
||||
|
||||
it('passes --cdp on every helper command family so a restarted daemon cannot launch Chrome', async () => {
|
||||
succeedWith({ snapshot: '...' })
|
||||
await bridge.snapshot()
|
||||
|
||||
const snapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
|
||||
(c[1] as string[]).includes('snapshot')
|
||||
)
|
||||
expect(snapshotCall![1]).toContain('--cdp')
|
||||
const cdpIdx = (snapshotCall![1] as string[]).indexOf('--cdp')
|
||||
expect((snapshotCall![1] as string[])[cdpIdx + 1]).toBe('9222')
|
||||
|
||||
await bridge.click('@e1')
|
||||
await bridge.scroll('down')
|
||||
await bridge.setOffline('on')
|
||||
await bridge.consoleLog()
|
||||
await bridge.exec('get title')
|
||||
|
||||
for (const command of ['click', 'scroll', 'set', 'console', 'get']) {
|
||||
const call = execFileMock.mock.calls.find((candidate: unknown[]) =>
|
||||
(candidate[1] as string[]).includes(command)
|
||||
)
|
||||
expect(call).toBeDefined()
|
||||
const args = call![1] as string[]
|
||||
expect(args).toContain('--cdp')
|
||||
expect(args[args.indexOf('--cdp') + 1]).toBe('9222')
|
||||
}
|
||||
})
|
||||
|
||||
it('always appends --json to commands', async () => {
|
||||
succeedWith({ snapshot: '...' })
|
||||
await bridge.snapshot()
|
||||
|
||||
const snapshotCall = execFileMock.mock.calls.find((c: unknown[]) =>
|
||||
(c[1] as string[]).includes('snapshot')
|
||||
)
|
||||
expect((snapshotCall![1] as string[]).at(-1)).toBe('--json')
|
||||
})
|
||||
|
||||
it('translates success response to result', async () => {
|
||||
succeedWith({ snapshot: 'tree output' })
|
||||
const result = await bridge.snapshot()
|
||||
expect(result).toEqual({ browserPageId: 'tab-1', snapshot: 'tree output' })
|
||||
})
|
||||
|
||||
it('translates error response to BrowserError', async () => {
|
||||
failWith('Element not found')
|
||||
await expect(bridge.click('@e1')).rejects.toThrow('Element not found')
|
||||
})
|
||||
|
||||
it('keeps CDP discovery failures generic while the tab session is still live', async () => {
|
||||
failWith(CDP_DISCOVERY_FAILURE)
|
||||
await expect(bridge.snapshot()).rejects.toMatchObject({
|
||||
@@ -218,102 +159,6 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(args).toContain('@e3')
|
||||
})
|
||||
|
||||
it('serializes concurrent commands per session', async () => {
|
||||
const commandCalls: string[][] = []
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => {
|
||||
commandCalls.push(args)
|
||||
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
|
||||
}
|
||||
)
|
||||
|
||||
const [r1, r2] = await Promise.all([bridge.snapshot(), bridge.click('@e1')])
|
||||
expect(r1).toEqual({ browserPageId: 'tab-1', ok: true })
|
||||
expect(r2).toEqual({ ok: true })
|
||||
const snapshotIdx = commandCalls.findIndex((a) => a.includes('snapshot'))
|
||||
const clickIdx = commandCalls.findIndex((a) => a.includes('click'))
|
||||
expect(snapshotIdx).toBeLessThan(clickIdx)
|
||||
})
|
||||
|
||||
it('builds cookie set args with all options', async () => {
|
||||
succeedWith({ success: true })
|
||||
await bridge.cookieSet({
|
||||
name: 'sid',
|
||||
value: 'abc',
|
||||
domain: '.example.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
expires: 1700000000
|
||||
})
|
||||
|
||||
const args = execFileMock.mock.calls.at(-1)![1] as string[]
|
||||
expect(args).toContain('cookies')
|
||||
expect(args).toContain('set')
|
||||
expect(args).toContain('sid')
|
||||
expect(args).toContain('abc')
|
||||
expect(args).toContain('--domain')
|
||||
expect(args).toContain('.example.com')
|
||||
expect(args).toContain('--path')
|
||||
expect(args).toContain('/')
|
||||
expect(args).toContain('--secure')
|
||||
expect(args).toContain('--httpOnly')
|
||||
expect(args).toContain('--sameSite')
|
||||
expect(args).toContain('Lax')
|
||||
expect(args).toContain('--expires')
|
||||
expect(args).toContain('1700000000')
|
||||
})
|
||||
|
||||
it('applies viewport emulation through CDP so mobile mode is preserved', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await bridge.setViewport(375, 812, 2, true)
|
||||
|
||||
expect(wc.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', {
|
||||
width: 375,
|
||||
height: 812,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: true
|
||||
})
|
||||
expect(wc.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setVisibleSize', {
|
||||
width: 375,
|
||||
height: 812
|
||||
})
|
||||
const viewportCall = execFileMock.mock.calls.find((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('viewport')
|
||||
)
|
||||
expect(viewportCall).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes selector wait state=visible to the default supported semantics', async () => {
|
||||
succeedWith({ selector: 'h1', waited: 'selector' })
|
||||
|
||||
await bridge.wait({ selector: 'h1', state: 'visible' })
|
||||
|
||||
const args = execFileMock.mock.calls.at(-1)![1] as string[]
|
||||
expect(args).toContain('wait')
|
||||
expect(args).toContain('h1')
|
||||
expect(args).not.toContain('--state')
|
||||
})
|
||||
|
||||
it('enforces conditional wait timeouts at the bridge layer', async () => {
|
||||
succeedWith({ selector: '#ready', waited: 'selector' })
|
||||
|
||||
await bridge.wait({ selector: '#ready', timeout: 1200 })
|
||||
|
||||
const args = execFileMock.mock.calls.at(-1)![1] as string[]
|
||||
const options = execFileMock.mock.calls.at(-1)![2] as { timeout?: number; env?: unknown }
|
||||
expect(args).toContain('wait')
|
||||
expect(args).toContain('#ready')
|
||||
expect(options.timeout).toBe(2200)
|
||||
const env = options.env as NodeJS.ProcessEnv
|
||||
expect(env.PATH).toBe(process.env.PATH)
|
||||
expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe(String(AGENT_BROWSER_IDLE_TIMEOUT_MS))
|
||||
})
|
||||
|
||||
it('returns browser_timeout for timed conditional waits without recycling the session', async () => {
|
||||
const killedError = Object.assign(new Error('timeout'), { killed: true })
|
||||
execFileMock.mockImplementation(
|
||||
|
||||
@@ -218,14 +218,6 @@ describe('AgentBrowserBridge keypress input', () => {
|
||||
expect(keyEventCalls(wc)[1]).toMatchObject({ type: 'keyUp', windowsVirtualKeyCode: 229 })
|
||||
})
|
||||
|
||||
it('keeps the helper for a surrogate-pair character', async () => {
|
||||
succeedWith({ pressed: '👍' })
|
||||
|
||||
await expect(bridge.keypress('👍', undefined, 'tab-1')).resolves.toEqual({ pressed: '👍' })
|
||||
|
||||
expect(keyEventCalls(wc)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to agent-browser for a key name the table cannot express', async () => {
|
||||
succeedWith({ pressed: 'MediaPlayPause' })
|
||||
|
||||
|
||||
@@ -198,17 +198,4 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', x: 12, y: 34, modifiers: 4 })
|
||||
expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 12, y: 34, modifiers: 4 })
|
||||
})
|
||||
|
||||
it('drops empty command queues after direct CDP commands finish', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
wc.debugger.sendCommand.mockResolvedValue({})
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await bridge.mouseClick(10, 20, 'right', undefined, 'tab-1')
|
||||
|
||||
expect(
|
||||
(bridge as unknown as { commandQueues: Map<string, unknown[]> }).commandQueues.size
|
||||
).toBe(0)
|
||||
expect((bridge as unknown as { processingQueues: Set<string> }).processingQueues.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,65 +82,6 @@ describe('AgentBrowserBridge', () => {
|
||||
bridge.setActiveTab(100)
|
||||
})
|
||||
|
||||
it('clears reload fallback timer after the load event settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
succeedWith(null)
|
||||
const wc = {
|
||||
...mockWebContents(100, 'https://reloaded.example', 'Reloaded'),
|
||||
reload: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
const result = bridge.reload()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wc.on).toHaveBeenCalledWith('did-finish-load', expect.any(Function))
|
||||
})
|
||||
|
||||
const finishListener = wc.on.mock.calls.find(
|
||||
([event]) => event === 'did-finish-load'
|
||||
)?.[1] as (() => void) | undefined
|
||||
const failListener = wc.on.mock.calls.find(([event]) => event === 'did-fail-load')?.[1] as
|
||||
| (() => void)
|
||||
| undefined
|
||||
expect(finishListener).toBeDefined()
|
||||
expect(failListener).toBeDefined()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
finishListener!()
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
url: 'https://reloaded.example',
|
||||
title: 'Reloaded'
|
||||
})
|
||||
expect(wc.removeListener).toHaveBeenCalledWith('did-finish-load', finishListener)
|
||||
expect(wc.removeListener).toHaveBeenCalledWith('did-fail-load', failListener)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(wc.removeListener).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('navigates the registered webContents without spawning agent-browser', async () => {
|
||||
const wc = mockWebContents(100, 'https://example.com/start', 'Example')
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
await expect(bridge.goto('https://example.com/next')).resolves.toEqual({
|
||||
url: 'https://example.com/next',
|
||||
title: 'Example'
|
||||
})
|
||||
|
||||
expect(wc.loadURL).toHaveBeenCalledWith('https://example.com/next')
|
||||
expect(wc.isLoading).not.toHaveBeenCalled()
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves scheme-less navigation semantics at the direct WebContents boundary', async () => {
|
||||
const wc = mockWebContents(100)
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
|
||||
@@ -101,20 +101,6 @@ describe('AgentBrowserBridge coordinate pointer input', () => {
|
||||
webContentsFromIdMock.mockReturnValue(wc)
|
||||
})
|
||||
|
||||
it('dispatches move, down, up and wheel over CDP without spawning the helper', async () => {
|
||||
await expect(bridge.mouseMove(10, 20)).resolves.toEqual({ moved: true })
|
||||
await expect(bridge.mouseDown('left')).resolves.toEqual({ pressed: true })
|
||||
await expect(bridge.mouseUp('left')).resolves.toEqual({ released: true })
|
||||
await expect(bridge.mouseWheel(120, 30)).resolves.toEqual({
|
||||
scrolled: true,
|
||||
deltaX: 30,
|
||||
deltaY: 120
|
||||
})
|
||||
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
expect(dispatched()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('sends CDP payloads matching a real pointer press and release', async () => {
|
||||
await bridge.mouseMove(10, 20)
|
||||
await bridge.mouseDown('left')
|
||||
@@ -343,14 +329,6 @@ describe('AgentBrowserBridge coordinate pointer input', () => {
|
||||
expect(dispatched()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports a dispatch failure as browser_error on the call that failed', async () => {
|
||||
wc.debugger.sendCommand.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
await expect(bridge.mouseMove(10, 20)).rejects.toMatchObject({
|
||||
code: 'browser_error'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a page that dies mid-dispatch as browser_tab_not_found', async () => {
|
||||
wc.debugger.sendCommand.mockImplementation(async () => {
|
||||
webContentsFromIdMock.mockReturnValue(null)
|
||||
@@ -415,12 +393,6 @@ describe('AgentBrowserBridge coordinate pointer input', () => {
|
||||
expect(wc.debugger.detach).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses an offscreen page on press, as mouseClick does', async () => {
|
||||
await bridge.mouseDown('left')
|
||||
|
||||
expect(wc.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('guards the user’s focus instead of focusing an embedded guest on press', async () => {
|
||||
const release = vi.fn()
|
||||
const guardUserFocusForAgentInput = vi.fn(async () => release)
|
||||
|
||||
@@ -21,7 +21,6 @@ vi.mock('node:child_process', async (importOriginal) => {
|
||||
|
||||
import { AgentBrowserBridge } from './agent-browser-bridge'
|
||||
import { mockBrowserManager } from './agent-browser-bridge-test-harness'
|
||||
import { AGENT_BROWSER_EXIT_DRAIN_MS } from './agent-browser-bridge-types'
|
||||
|
||||
let bridge: AgentBrowserBridge
|
||||
|
||||
@@ -71,47 +70,3 @@ it('releases the completed helper before the fallback deadline while its bridge
|
||||
expect(child.deref()).toBeUndefined()
|
||||
expect(await run("process.stdout.write('next')")).toBe('next')
|
||||
})
|
||||
|
||||
it.each(['error', 'cancel'] as const)(
|
||||
'retires the unused drain timer after %s',
|
||||
async (outcome) => {
|
||||
const timers = vi.spyOn(globalThis, 'setTimeout')
|
||||
const clear = vi.spyOn(globalThis, 'clearTimeout')
|
||||
const spawned = vi.mocked(childProcess.execFile)
|
||||
const pending = run(
|
||||
outcome === 'error'
|
||||
? "process.stderr.write('fixture-error');process.exitCode=1"
|
||||
: 'setInterval(() => {}, 1000)'
|
||||
)
|
||||
if (outcome === 'cancel') {
|
||||
const result = spawned.mock.results[0]
|
||||
if (result?.type !== 'return') {
|
||||
throw new Error('Expected a real helper process')
|
||||
}
|
||||
bridge['cancelledProcesses'].add(result.value)
|
||||
result.value.kill()
|
||||
}
|
||||
await expect(pending).rejects.toThrow(outcome === 'error' ? 'fixture-error' : 'Tab was closed')
|
||||
const completed = spawned.mock.results[0]
|
||||
if (completed?.type !== 'return') {
|
||||
throw new Error('Expected a real helper process')
|
||||
}
|
||||
expect(bridge['cancelledProcesses'].has(completed.value)).toBe(false)
|
||||
const drainIndex = timers.mock.calls.findIndex(
|
||||
([, delay]) => delay === AGENT_BROWSER_EXIT_DRAIN_MS
|
||||
)
|
||||
expect(drainIndex).toBeGreaterThanOrEqual(0)
|
||||
const drain = timers.mock.results[drainIndex]
|
||||
if (drain?.type !== 'return') {
|
||||
throw new Error('Expected a scheduled drain timer')
|
||||
}
|
||||
expect(clear).toHaveBeenCalledWith(drain.value)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves spawn failure without scheduling an exit drain', async () => {
|
||||
Object.defineProperty(bridge, 'agentBrowserBin', { value: join(userDataPath.current, 'missing') })
|
||||
const timers = vi.spyOn(globalThis, 'setTimeout')
|
||||
await expect(run('')).rejects.toThrow('ENOENT')
|
||||
expect(timers.mock.calls.some(([, delay]) => delay === AGENT_BROWSER_EXIT_DRAIN_MS)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } =
|
||||
vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => false),
|
||||
readFileSyncMock: vi.fn(() => Buffer.from('')),
|
||||
stdinWrites: new Array<string>()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
accessSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
}))
|
||||
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false },
|
||||
webContents: { fromId: webContentsFromIdMock }
|
||||
}))
|
||||
const { CdpWsProxyMock } = vi.hoisted(() => {
|
||||
class MockCdpWsProxy {
|
||||
static instances: unknown[] = []
|
||||
start = vi.fn(async () => 'ws://127.0.0.1:9222')
|
||||
stop = vi.fn(async () => {})
|
||||
getPort = vi.fn(() => 9222)
|
||||
|
||||
constructor(_wc: unknown) {
|
||||
MockCdpWsProxy.instances.push(this)
|
||||
}
|
||||
}
|
||||
return { CdpWsProxyMock: MockCdpWsProxy }
|
||||
})
|
||||
|
||||
vi.mock('./cdp-ws-proxy', () => ({ CdpWsProxy: CdpWsProxyMock }))
|
||||
vi.mock('./cdp-bridge', () => ({
|
||||
BrowserError: class BrowserError extends Error {
|
||||
code: string
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { AgentBrowserBridge } from './agent-browser-bridge'
|
||||
import {
|
||||
createSucceedWith,
|
||||
mockBrowserManager,
|
||||
overrideBridgeWebContentsLookup,
|
||||
resetAgentBrowserBridgeMocks
|
||||
} from './agent-browser-bridge-test-harness'
|
||||
|
||||
overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock)
|
||||
|
||||
const succeedWith = createSucceedWith(execFileMock, stdinWrites)
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
||||
}
|
||||
|
||||
function screenshotArgs(): string[] {
|
||||
const screenshotCall = execFileMock.mock.calls.find(
|
||||
(call) => Array.isArray(call) && isStringArray(call[1]) && call[1].includes('screenshot')
|
||||
)
|
||||
if (!screenshotCall || !isStringArray(screenshotCall[1])) {
|
||||
throw new Error('Expected agent-browser screenshot arguments')
|
||||
}
|
||||
return screenshotCall[1]
|
||||
}
|
||||
|
||||
describe('AgentBrowserBridge viewport screenshot formats', () => {
|
||||
let bridge: AgentBrowserBridge
|
||||
|
||||
beforeEach(() => {
|
||||
resetAgentBrowserBridgeMocks({
|
||||
webContentsFromIdMock,
|
||||
existsSyncMock,
|
||||
readFileSyncMock,
|
||||
stdinWrites,
|
||||
cdpWsProxyInstances: CdpWsProxyMock.instances
|
||||
})
|
||||
bridge = new AgentBrowserBridge(mockBrowserManager())
|
||||
bridge.setActiveTab(100)
|
||||
})
|
||||
|
||||
it('forwards JPEG encoding and returns matching JPEG bytes and metadata', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0])
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(jpeg)
|
||||
succeedWith({ path: '/tmp/capture.jpeg' })
|
||||
|
||||
const result = await bridge.screenshot('jpeg')
|
||||
|
||||
expect(result).toEqual({
|
||||
data: jpeg.toString('base64'),
|
||||
format: 'jpeg'
|
||||
})
|
||||
expect(Buffer.from(result.data, 'base64')).toEqual(jpeg)
|
||||
|
||||
expect(screenshotArgs()).toEqual(
|
||||
expect.arrayContaining(['screenshot', '--screenshot-format', 'jpeg'])
|
||||
)
|
||||
expect(screenshotArgs().slice(-4)).toEqual([
|
||||
'screenshot',
|
||||
'--screenshot-format',
|
||||
'jpeg',
|
||||
'--json'
|
||||
])
|
||||
expect(screenshotArgs()).not.toContain('--screenshot-quality')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['png', 'explicit PNG'],
|
||||
[undefined, 'an omitted format'],
|
||||
['webp', 'an unsupported format']
|
||||
])('normalizes %s to PNG for %s', async (requestedFormat, _description) => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(png)
|
||||
succeedWith({ path: '/tmp/capture.png' })
|
||||
|
||||
const result = await bridge.screenshot(requestedFormat)
|
||||
|
||||
expect(result).toEqual({
|
||||
data: png.toString('base64'),
|
||||
format: 'png'
|
||||
})
|
||||
expect(Buffer.from(result.data, 'base64')).toEqual(png)
|
||||
|
||||
expect(screenshotArgs().slice(-4)).toEqual([
|
||||
'screenshot',
|
||||
'--screenshot-format',
|
||||
'png',
|
||||
'--json'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -155,44 +155,6 @@ describe('AgentBrowserBridge', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('destroys session after 3 consecutive timeouts', async () => {
|
||||
const killedError = Object.assign(new Error('timeout'), { killed: true })
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => {
|
||||
if (args.includes('close')) {
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
return
|
||||
}
|
||||
cb(killedError, '', '')
|
||||
}
|
||||
)
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(bridge.snapshot()).rejects.toThrow('timed out')
|
||||
}
|
||||
|
||||
succeedWith({ snapshot: 'fresh' })
|
||||
await bridge.snapshot()
|
||||
|
||||
const lastArgs = execFileMock.mock.calls.at(-1)![1] as string[]
|
||||
expect(lastArgs).toContain('--cdp')
|
||||
})
|
||||
|
||||
it('bounds owner cleanup independently from command execution timeouts', async () => {
|
||||
succeedWith({ snapshot: 'initial' })
|
||||
await bridge.snapshot()
|
||||
execFileMock.mockClear()
|
||||
|
||||
succeedWith(null)
|
||||
await bridge.onPageClosed('tab-1')
|
||||
|
||||
const closeCall = execFileMock.mock.calls.find((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('close')
|
||||
)
|
||||
expect(closeCall?.[2]).toMatchObject({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
it('waits for pending session destruction before recreating the same session', async () => {
|
||||
succeedWith({ snapshot: 'initial' })
|
||||
await bridge.snapshot()
|
||||
@@ -496,33 +458,6 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(routeCalls[0]).toContain('--cdp')
|
||||
})
|
||||
|
||||
it('clears pending intercept restore state when a swapped tab closes before reuse', async () => {
|
||||
const tabs = new Map([['tab-1', 100]])
|
||||
const mgr = mockBrowserManager(tabs)
|
||||
const b = new AgentBrowserBridge(mgr)
|
||||
b.setActiveTab(100)
|
||||
|
||||
succeedWith({ ok: true })
|
||||
await b.interceptEnable(['https://old.example/**'])
|
||||
|
||||
tabs.set('tab-1', 200)
|
||||
webContentsFromIdMock.mockReturnValue(mockWebContents(200))
|
||||
succeedWith(null)
|
||||
await b.onProcessSwap('tab-1', 200)
|
||||
|
||||
expect(
|
||||
(b as unknown as { pendingInterceptRestore: Map<string, string[]> }).pendingInterceptRestore
|
||||
.size
|
||||
).toBe(1)
|
||||
|
||||
await b.onTabClosed(200)
|
||||
|
||||
expect(
|
||||
(b as unknown as { pendingInterceptRestore: Map<string, string[]> }).pendingInterceptRestore
|
||||
.size
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('replays intercept routes after the daemon idles out', async () => {
|
||||
succeedWith({ ok: true })
|
||||
await bridge.interceptEnable(['https://api.example/**'])
|
||||
@@ -620,42 +555,4 @@ describe('AgentBrowserBridge', () => {
|
||||
|
||||
expect(peakRetirements).toBe(4)
|
||||
})
|
||||
|
||||
it('bounds every teardown close well inside the quit barrier', async () => {
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
await bridge.snapshot()
|
||||
|
||||
succeedWith(null)
|
||||
await bridge.destroyAllSessions()
|
||||
|
||||
const closeCall = execFileMock.mock.calls.findLast((c: unknown[]) =>
|
||||
(c[1] as string[]).includes('close')
|
||||
)
|
||||
expect((closeCall![2] as { timeout: number }).timeout).toBeLessThanOrEqual(5_000)
|
||||
})
|
||||
|
||||
it('closes a session still being created when everything is torn down', async () => {
|
||||
let releaseProxyStart: (() => void) | undefined
|
||||
CdpWsProxyMock.mockImplementationOnce(function (this: Record<string, unknown>) {
|
||||
this.start = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
releaseProxyStart = () => resolve('ws://127.0.0.1:9222')
|
||||
})
|
||||
)
|
||||
this.stop = vi.fn(async () => {})
|
||||
this.getPort = vi.fn(() => 9222)
|
||||
})
|
||||
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
const inFlight = bridge.snapshot()
|
||||
await vi.waitFor(() => expect(releaseProxyStart).toBeDefined())
|
||||
|
||||
const closesBeforeTeardown = closeCallCount()
|
||||
const teardown = bridge.destroyAllSessions()
|
||||
releaseProxyStart!()
|
||||
await Promise.allSettled([inFlight, teardown])
|
||||
|
||||
expect(closeCallCount()).toBeGreaterThan(closesBeforeTeardown)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,66 +217,6 @@ describe('AgentBrowserBridge', () => {
|
||||
await expect(b.snapshot()).rejects.toThrow('No browser tab open')
|
||||
})
|
||||
|
||||
it('clears activeWebContentsId on tab close', async () => {
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
await bridge.snapshot()
|
||||
|
||||
await bridge.onTabClosed(100)
|
||||
expect(bridge.getActiveWebContentsId()).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves repeated guest lookups without enumerating registered tabs', () => {
|
||||
const tabCount = 1_000
|
||||
const tabs = new Map<string, number>()
|
||||
const tabIdByWebContentsId = new Map<number, string>()
|
||||
for (let index = 0; index < tabCount; index += 1) {
|
||||
const tabId = `tab-${index}`
|
||||
const webContentsId = 20_000 + index
|
||||
tabs.set(tabId, webContentsId)
|
||||
tabIdByWebContentsId.set(webContentsId, tabId)
|
||||
}
|
||||
|
||||
const getRegisteredTabs = vi.fn(() => {
|
||||
throw new Error('unexpected registered-tab enumeration')
|
||||
})
|
||||
const getTabIdForWebContentsId = vi.fn(
|
||||
(webContentsId: number) => tabIdByWebContentsId.get(webContentsId) ?? null
|
||||
)
|
||||
const b = new AgentBrowserBridge(
|
||||
mockBrowserManager(tabs, new Map(), {
|
||||
getWebContentsIdByTabId: getRegisteredTabs,
|
||||
getTabIdForWebContentsId
|
||||
})
|
||||
)
|
||||
const resolveTabIdSafe = (
|
||||
b as unknown as { resolveTabIdSafe: (webContentsId: number) => string | null }
|
||||
).resolveTabIdSafe.bind(b)
|
||||
const targetWebContentsId = 20_000 + tabCount - 1
|
||||
|
||||
for (let lookup = 0; lookup < tabCount; lookup += 1) {
|
||||
expect(resolveTabIdSafe(targetWebContentsId)).toBe(`tab-${tabCount - 1}`)
|
||||
}
|
||||
|
||||
expect(getTabIdForWebContentsId).toHaveBeenCalledTimes(tabCount)
|
||||
expect(getRegisteredTabs).not.toHaveBeenCalled()
|
||||
expect(resolveTabIdSafe(99_999)).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the named agent-browser session when a tab closes', async () => {
|
||||
succeedWith({ snapshot: 'tree' })
|
||||
await bridge.snapshot()
|
||||
|
||||
execFileMock.mockClear()
|
||||
succeedWith(null)
|
||||
await bridge.onTabClosed(100)
|
||||
|
||||
const closeCall = execFileMock.mock.calls.find((call: unknown[]) =>
|
||||
(call[1] as string[]).includes('close')
|
||||
)
|
||||
expect(closeCall).toBeTruthy()
|
||||
expect(closeCall![1]).toEqual(['--session', 'orca-tab-tab-1', 'close'])
|
||||
})
|
||||
|
||||
it('repairs per-worktree active routing when the active tab closes', async () => {
|
||||
const tabs = new Map([
|
||||
['tab-a', 1],
|
||||
@@ -313,40 +253,6 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(b.tabList('wt-1').tabs).toMatchObject([{ browserPageId: 'tab-a', active: true }])
|
||||
})
|
||||
|
||||
it('switches active tab and returns switched index', async () => {
|
||||
const tabs = new Map([
|
||||
['tab-a', 1],
|
||||
['tab-b', 2]
|
||||
])
|
||||
const wc1 = mockWebContents(1)
|
||||
const wc2 = mockWebContents(2)
|
||||
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
|
||||
|
||||
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
|
||||
b.setActiveTab(1)
|
||||
|
||||
const result = await b.tabSwitch(1)
|
||||
expect(result).toEqual({ switched: 1, browserPageId: 'tab-b' })
|
||||
expect(b.getActiveWebContentsId()).toBe(2)
|
||||
})
|
||||
|
||||
it('switches tabs by explicit browser page id', async () => {
|
||||
const tabs = new Map([
|
||||
['tab-a', 1],
|
||||
['tab-b', 2]
|
||||
])
|
||||
const wc1 = mockWebContents(1)
|
||||
const wc2 = mockWebContents(2)
|
||||
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
|
||||
|
||||
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
|
||||
b.setActiveTab(1)
|
||||
|
||||
const result = await b.tabSwitch(undefined, undefined, 'tab-b')
|
||||
expect(result).toEqual({ switched: 1, browserPageId: 'tab-b' })
|
||||
expect(b.getActiveWebContentsId()).toBe(2)
|
||||
})
|
||||
|
||||
it('updates the owning worktree active tab when switching by browser page id', async () => {
|
||||
const tabs = new Map([
|
||||
['tab-a', 1],
|
||||
|
||||
@@ -318,19 +318,6 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(environment.editor.textContent).toBe(`${firstChunk}tail`)
|
||||
})
|
||||
|
||||
it('uses target-aware agent-browser fill when clearing a non-rich target', async () => {
|
||||
succeedWith({ filled: '@disabled' })
|
||||
|
||||
const result = await bridge.clear('@disabled')
|
||||
|
||||
const commandArgs = execFileMock.mock.calls.map((call: unknown[]) => call[1] as string[])
|
||||
expect(commandArgs.some((args) => args.includes('eval'))).toBe(false)
|
||||
expect(commandArgs.some((args) => args.includes('fill') && args.includes('@disabled'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(result).toEqual({ cleared: '@disabled' })
|
||||
})
|
||||
|
||||
it('routes focused spinbutton wrappers to editable descendants before filling', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
@@ -437,24 +424,6 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(wrapper.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps plain focused inputs as fill targets', async () => {
|
||||
succeedWith({ ok: true })
|
||||
|
||||
await bridge.fill('@input', '200')
|
||||
|
||||
const expressions = stdinWrites
|
||||
|
||||
const input = createFillEvalNode({ tagName: 'INPUT' })
|
||||
|
||||
runFillEvalExpressions(expressions, {
|
||||
activeElement: input.node,
|
||||
getElementById: () => null
|
||||
})
|
||||
|
||||
expect(input.value).toBe('200')
|
||||
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
|
||||
})
|
||||
|
||||
it('fills large plain fields with one stdin edit and one event pair', async () => {
|
||||
const text = `${'é\n'.repeat(512 * 1024)}tail'\\`
|
||||
succeedWith({ ok: true })
|
||||
|
||||
@@ -41,18 +41,6 @@ describe('agent-browser orphan sweep', () => {
|
||||
runProcessMock.mockReset()
|
||||
})
|
||||
|
||||
it('closes tab daemons left by a previous run', async () => {
|
||||
respond(['orca-tab-aaa', 'orca-tab-bbb'])
|
||||
|
||||
const closed = await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
|
||||
expect(closed).toEqual(['orca-tab-aaa', 'orca-tab-bbb'])
|
||||
expect(closedArgs()).toEqual([
|
||||
['--session', 'orca-tab-aaa', 'close'],
|
||||
['--session', 'orca-tab-bbb', 'close']
|
||||
])
|
||||
})
|
||||
|
||||
it('never closes a daemon outside Orca tab naming', async () => {
|
||||
respond(['default', 'agent1', 'orca-orcad-deadbeef', 'orca-tab-aaa'])
|
||||
|
||||
@@ -104,14 +92,6 @@ describe('agent-browser orphan sweep', () => {
|
||||
expect(closedArgs()).toEqual([])
|
||||
})
|
||||
|
||||
it('survives a listing that never returns', async () => {
|
||||
runProcessMock.mockRejectedValue(new Error('ENOENT'))
|
||||
|
||||
await expect(
|
||||
sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('keeps sweeping after one close fails', async () => {
|
||||
runProcessMock.mockImplementation((spec: Spec) => {
|
||||
if (spec.args?.[0] === 'session') {
|
||||
@@ -133,16 +113,6 @@ describe('agent-browser orphan sweep', () => {
|
||||
|
||||
expect(closed).toEqual(['orca-tab-bbb'])
|
||||
})
|
||||
|
||||
it('bounds every child it starts', async () => {
|
||||
respond(['orca-tab-aaa'])
|
||||
|
||||
await sweepOrphanedAgentBrowserSessions({ binaryPath: BIN, ...SCOPED })
|
||||
|
||||
for (const call of runProcessMock.mock.calls) {
|
||||
expect((call[0] as { timeoutMs?: number | null }).timeoutMs).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweep kill switch', () => {
|
||||
@@ -169,18 +139,4 @@ describe('sweep kill switch', () => {
|
||||
expect(closed).toEqual([])
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still sweeps when the flag holds any other value', async () => {
|
||||
process.env.ORCA_DISABLE_AGENT_BROWSER_SWEEP = '0'
|
||||
runProcessMock.mockClear()
|
||||
runProcessMock.mockResolvedValue({ code: 0, stdout: '{"data":{"sessions":[]}}', stderr: '' })
|
||||
|
||||
await sweepOrphanedAgentBrowserSessions({
|
||||
binaryPath: BIN,
|
||||
env: {},
|
||||
ownsSocketDirectory: true
|
||||
})
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
MOBILE_WEB_BUNDLE_MAX_ROUTES,
|
||||
MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES,
|
||||
MOBILE_WEB_BUNDLE_SCHEMA_VERSION,
|
||||
MOBILE_WEB_PAGE_VERSION,
|
||||
type MobileWebBundleAsset
|
||||
} from './manifest-contract'
|
||||
|
||||
@@ -117,10 +116,6 @@ describe('the page version a manifest carries', () => {
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('is at least 1, so a manifest without one reads as older than every page that has one', () => {
|
||||
expect(MOBILE_WEB_PAGE_VERSION).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the page routes a manifest declares', () => {
|
||||
@@ -352,31 +347,6 @@ describe('refinement short-circuit', () => {
|
||||
const withinCeiling = assetsTotalling(MOBILE_WEB_BUNDLE_MAX_ASSETS, 64)
|
||||
expect(issuePaths(manifestOf(withinCeiling, { buildId: WRONG_BUILD_ID }))).toEqual(['buildId'])
|
||||
})
|
||||
|
||||
it('does not hash an oversized asset list', () => {
|
||||
const overCeiling = [
|
||||
...assetsTotalling(MOBILE_WEB_BUNDLE_MAX_ASSETS, 64),
|
||||
asset('assets/overflow.js', 0)
|
||||
]
|
||||
const paths = issuePaths(manifestOf(overCeiling, { buildId: WRONG_BUILD_ID }))
|
||||
expect(paths).toEqual(['assets'])
|
||||
expect(paths).not.toContain('buildId')
|
||||
})
|
||||
|
||||
it('does not hash once a cheaper invariant has failed', () => {
|
||||
const twoAssets = [ENTRY, asset('assets/a.js', 10)]
|
||||
expect(issuePaths(manifestOf(twoAssets, { totalBytes: 0, buildId: WRONG_BUILD_ID }))).toEqual([
|
||||
'totalBytes'
|
||||
])
|
||||
expect(
|
||||
issuePaths(
|
||||
manifestOf(twoAssets, {
|
||||
minCompatibleRuntimeProtocolVersion: 4,
|
||||
buildId: WRONG_BUILD_ID
|
||||
})
|
||||
)
|
||||
).toEqual(['minCompatibleRuntimeProtocolVersion'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('asset paths', () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildInjectRejectionMessage,
|
||||
taskNotFoundRefusal,
|
||||
taskNotStartableRefusal
|
||||
} from './orchestration-dispatch-refusal-contract'
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import { recognizeAgentProcess } from './agent-process-recognition'
|
||||
|
||||
describe('buildInjectRejectionMessage', () => {
|
||||
@@ -15,13 +13,6 @@ describe('buildInjectRejectionMessage', () => {
|
||||
expect(message).toContain('no recognized agent detected')
|
||||
})
|
||||
|
||||
it('names every agent Orca recognizes, including agy', () => {
|
||||
expect(message).toMatch(/\bagy\b/)
|
||||
for (const config of Object.values(TUI_AGENT_CONFIG)) {
|
||||
expect(message).toContain(config.expectedProcess)
|
||||
}
|
||||
})
|
||||
|
||||
it('lists only names detection actually resolves, deduped and sorted', () => {
|
||||
const listed = (/\(([^)]+)\)/.exec(message)?.[1] ?? '').split(', ')
|
||||
|
||||
@@ -36,19 +27,6 @@ describe('buildInjectRejectionMessage', () => {
|
||||
|
||||
// Pin published receipt literals independently so refactors cannot change builders and expectations together.
|
||||
describe('dispatch refusal receipts keep their published messages', () => {
|
||||
it('leaves the message exactly as each call site supplies it', () => {
|
||||
expect(taskNotFoundRefusal('Task not found: task_1', { taskId: 'task_1' }).message).toBe(
|
||||
'Task not found: task_1'
|
||||
)
|
||||
expect(
|
||||
taskNotStartableRefusal('Task task_1 is pending; only a ready Task can start.', {
|
||||
taskId: 'task_1',
|
||||
status: 'pending',
|
||||
unmetDependencies: []
|
||||
}).message
|
||||
).toBe('Task task_1 is pending; only a ready Task can start.')
|
||||
})
|
||||
|
||||
it('tailors nextSteps to retry, dependency, occupancy, and terminal-status refusals', () => {
|
||||
const base = { taskId: 'task_1', status: 'failed', unmetDependencies: [] }
|
||||
expect(taskNotStartableRefusal('m', { ...base, retryOf: 'ctx_1' }).data.nextSteps[0]).toMatch(
|
||||
|
||||
@@ -50,28 +50,4 @@ describe('orchestration fleet attention', () => {
|
||||
expect(child.categories).toEqual([])
|
||||
expect(root).toEqual({ categories: ['root_completion'], requiresAction: false })
|
||||
})
|
||||
|
||||
it('measures a five-worker wave without choosing one alert policy', () => {
|
||||
const wave = [
|
||||
{ isRoot: true, outcome: 'succeeded' as const },
|
||||
{ isRoot: false, outcome: 'succeeded' as const },
|
||||
{ isRoot: false, outcome: 'in_progress' as const, pendingInput: true },
|
||||
{ isRoot: false, outcome: 'failed' as const },
|
||||
{ isRoot: false, outcome: 'in_progress' as const, interrupted: true }
|
||||
].map((facts) =>
|
||||
projectOrchestrationFleetAttention({
|
||||
...facts,
|
||||
liveness: { verdict: facts.outcome === 'in_progress' ? 'live' : 'exited' }
|
||||
})
|
||||
)
|
||||
const counts = wave
|
||||
.flatMap((entry) => entry.categories)
|
||||
.reduce<Record<string, number>>(
|
||||
(result, category) => ({ ...result, [category]: (result[category] ?? 0) + 1 }),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(counts).toEqual({ root_completion: 1, input: 1, failure: 1, interruption: 1 })
|
||||
expect(wave.filter((entry) => entry.requiresAction)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,14 +42,4 @@ describe('buildOrchestrationTaskDisplayMetadata', () => {
|
||||
expect(metadata.displayName).toHaveLength(ORCHESTRATION_DISPLAY_NAME_MAX_LENGTH)
|
||||
expect(metadata.displayName.endsWith('...')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not leave a dangling high surrogate when truncating', () => {
|
||||
const metadata = buildOrchestrationTaskDisplayMetadata({
|
||||
spec: `x${'😀'.repeat(ORCHESTRATION_TASK_TITLE_MAX_LENGTH)}`
|
||||
})
|
||||
const last = metadata.taskTitle.charCodeAt(metadata.taskTitle.length - 1)
|
||||
|
||||
expect(metadata.taskTitle.length).toBeLessThanOrEqual(ORCHESTRATION_TASK_TITLE_MAX_LENGTH)
|
||||
expect(last < 0xd800 || last > 0xdbff).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,49 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as ownership from './own-retained-string'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractOscTitleScanTail } from './osc-title-scan-tail'
|
||||
|
||||
const INCOMPLETE_TITLE = '\x1b]2;Working on a terminal title'
|
||||
|
||||
describe('OSC title scan tail storage', () => {
|
||||
it('routes every retained title tail through ownRetainedString', () => {
|
||||
const own = vi.spyOn(ownership, 'ownRetainedString')
|
||||
try {
|
||||
const tail = extractOscTitleScanTail('x'.repeat(32 * 1024) + INCOMPLETE_TITLE)
|
||||
expect(own).toHaveBeenCalledTimes(1)
|
||||
expect(own).toHaveBeenLastCalledWith(INCOMPLETE_TITLE)
|
||||
expect(tail).toBe(INCOMPLETE_TITLE)
|
||||
|
||||
// Ownership is unconditional: a growing title is re-owned on every chunk.
|
||||
let pending = tail
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
pending = extractOscTitleScanTail(pending + 'x'.repeat(512))
|
||||
}
|
||||
expect(own).toHaveBeenCalledTimes(9)
|
||||
expect(own).toHaveBeenLastCalledWith(pending)
|
||||
|
||||
// An unrelated incomplete OSC still yields nothing to retain.
|
||||
expect(extractOscTitleScanTail(`${'x'.repeat(32 * 1024)}\x1b]9999;incomplete`)).toBe('')
|
||||
} finally {
|
||||
own.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
[16 * 1024, 256, INCOMPLETE_TITLE],
|
||||
[64 * 1024, 64, INCOMPLETE_TITLE],
|
||||
[1024 * 1024, 16, INCOMPLETE_TITLE],
|
||||
[16 * 1024, 128, INCOMPLETE_TITLE + 'x'.repeat(16 * 1024)]
|
||||
])('keeps %i-character chunks with %i incomplete titles byte-exact', (size, count, title) => {
|
||||
const tails: string[] = []
|
||||
for (let index = 0; index < count; index++) {
|
||||
tails.push(
|
||||
extractOscTitleScanTail(String.fromCharCode(65 + (index % 26)).repeat(size) + title)
|
||||
)
|
||||
}
|
||||
const expected = title.length <= 4096 ? title : title.slice(0, 4) + title.slice(-4092)
|
||||
expect(tails).toEqual(Array.from({ length: count }, () => expected))
|
||||
})
|
||||
|
||||
it.each(['0', '1', '2'])('preserves title %s introducer and exact UTF-16 at the cap', (code) => {
|
||||
const prefix = `\x1b]${code};`
|
||||
for (const length of [4095, 4096, 4097, 16 * 1024]) {
|
||||
|
||||
@@ -368,28 +368,6 @@ describe('createOsc52OscHandler', () => {
|
||||
})
|
||||
|
||||
describe('resolveOsc52ClipboardGate', () => {
|
||||
it('allows a live write once the setting is on', () => {
|
||||
expect(resolveOsc52ClipboardGate({ settingEnabled: true, replaying: false })).toEqual({
|
||||
allowClipboardWrite: true,
|
||||
shouldSurfaceBlockedWrite: false
|
||||
})
|
||||
})
|
||||
|
||||
it('drops replayed writes so restore cannot clobber the clipboard', () => {
|
||||
// Restore replays old PTY copies through the parser and could overwrite newer clipboard content (#10588).
|
||||
expect(resolveOsc52ClipboardGate({ settingEnabled: true, replaying: true })).toEqual({
|
||||
allowClipboardWrite: false,
|
||||
shouldSurfaceBlockedWrite: false
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the blocked toast only for a real opt-out', () => {
|
||||
expect(resolveOsc52ClipboardGate({ settingEnabled: false, replaying: false })).toEqual({
|
||||
allowClipboardWrite: false,
|
||||
shouldSurfaceBlockedWrite: true
|
||||
})
|
||||
})
|
||||
|
||||
it('stays quiet when a write races settings hydration', () => {
|
||||
// Pre-hydration reads look blocked despite default-on; consuming the once-per-session toast hides real refusals.
|
||||
for (const settingEnabled of [null, undefined]) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import nacl from 'tweetnacl'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeDeviceProofMessage, verifyDeviceProof } from './device-key-proof'
|
||||
import { verifyDeviceProof } from './device-key-proof'
|
||||
import { createMobileE2EEV2Fixture, MOBILE_E2EE_V2_VECTOR } from './mobile-e2ee-v2-fixtures'
|
||||
import { openMobileE2EEV2Frame } from './mobile-e2ee-v2-framing'
|
||||
import { createPairedE2EEClientHandshake } from './paired-e2ee-client-handshake'
|
||||
@@ -99,19 +99,4 @@ describe('paired E2EE device-key auth', () => {
|
||||
expect(early.transcriptHashB64()).toBeNull()
|
||||
await expect(early.sealAuth(keyCredential)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('signs the documented proof message', async () => {
|
||||
const handshake = v2Handshake()
|
||||
const seen: Uint8Array[] = []
|
||||
await handshake.sealAuth({
|
||||
...keyCredential,
|
||||
sign: async (message) => {
|
||||
seen.push(message)
|
||||
return nacl.sign.detached(message, key.secretKey)
|
||||
}
|
||||
})
|
||||
expect(seen[0]).toEqual(
|
||||
encodeDeviceProofMessage('auth', Buffer.from(handshake.transcriptHashB64()!, 'base64'))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,15 +70,4 @@ describe('selectAutoAdvertisedPairingAddress', () => {
|
||||
])('does not treat the route-backed near-match %s as a known host-local label', (name) => {
|
||||
expect(isVirtualBridgeInterface(name, true)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['external', EXTERNAL_SWITCH, false],
|
||||
['Default Switch', DEFAULT_SWITCH, true],
|
||||
['WSL', WSL_SWITCH, true],
|
||||
['physical', PHYSICAL_LAN, false],
|
||||
['host-local', HOST_LOCAL_BRIDGE, true],
|
||||
['ambiguous', AMBIGUOUS_SWITCH, true]
|
||||
] as const)('classifies the %s fixture', (_label, fixture, expected) => {
|
||||
expect(isVirtualBridgeInterface(fixture.name, fixture.hasDefaultRoute)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { omitPairingLocalUiFields, PAIRING_LOCAL_UI_FIELDS } from './pairing-local-ui-fields'
|
||||
import { omitPairingLocalUiFields } from './pairing-local-ui-fields'
|
||||
|
||||
describe('pairing-local UI fields', () => {
|
||||
// Host RPC and web-preload seams are spread out; a census catches new fields without complete boundary wiring.
|
||||
it('census: the set is exactly the fields no pairing may exchange', () => {
|
||||
expect([...PAIRING_LOCAL_UI_FIELDS]).toEqual([
|
||||
'automationHostFilter',
|
||||
'hideWorkspacesFromOtherDevices',
|
||||
'manualRepoOrder',
|
||||
'workspaceHostOrder',
|
||||
'sidebarOpen',
|
||||
'agentsVisibleHostIds',
|
||||
'agentsFilterRepoIds',
|
||||
'agentsHideWorkspacesFromOtherDevices',
|
||||
'agentsHideAutomationGeneratedWorkspaces',
|
||||
'agentsHideCliCreatedWorkspaces',
|
||||
'agentsShowChildAgents',
|
||||
'agentsCompactMode',
|
||||
'agentsShowSearch',
|
||||
'agentsReadFilter',
|
||||
'agentsGroupBy',
|
||||
'activityClearedAtByPaneKey',
|
||||
'manuallyUnreadTurnsByPaneKey'
|
||||
])
|
||||
})
|
||||
|
||||
it('omits every member and keeps everything else', () => {
|
||||
const state = {
|
||||
hideWorkspacesFromOtherDevices: true,
|
||||
|
||||
@@ -100,12 +100,6 @@ describe('pairing offer', () => {
|
||||
expect(decodePairingOffer(encodePairingOffer(proxiedOffer))).toEqual(proxiedOffer)
|
||||
})
|
||||
|
||||
it('encoded URL uses base64url (no +, /, or = characters)', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
||||
expect(code).not.toMatch(/[+/=]/)
|
||||
})
|
||||
|
||||
it('rejects URLs with wrong scheme', () => {
|
||||
expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL')
|
||||
})
|
||||
@@ -165,11 +159,6 @@ describe('parsePairingCode', () => {
|
||||
publicKeyB64: 'pubkey-xyz'
|
||||
}
|
||||
|
||||
it('parses a full orca://pair# URL', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
expect(parsePairingCode(url)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('parses a bare base64url payload (without scheme prefix)', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
const base64url = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
||||
|
||||
@@ -37,12 +37,6 @@ describe('per-pane coverage gate', () => {
|
||||
'uncovered'
|
||||
)
|
||||
})
|
||||
|
||||
it('is computed from evidence, never from a platform or remote flag', () => {
|
||||
// No platform/isRemote input: host-proven hooks must resolve identically on every execution host.
|
||||
const identity = resolveCanonicalPaneAgentIdentity({ hookAgent: 'claude', hookIsLive: true })
|
||||
expect(identity).toMatchObject({ agent: 'claude', source: 'live-hook', coverage: 'covered' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('process rung requires a host-stamped proof', () => {
|
||||
@@ -158,21 +152,6 @@ describe('canonical ladder inside the covered lane', () => {
|
||||
expect(identity).toMatchObject({ agent: 'claude', source: 'launch', titleOnly: false })
|
||||
})
|
||||
|
||||
it('sibling evidence needs the explicit tab-scope opt-in', () => {
|
||||
const withoutOptIn = resolveCanonicalPaneAgentIdentity({
|
||||
launchAgent: 'claude',
|
||||
siblingAgent: 'codex'
|
||||
})
|
||||
expect(withoutOptIn.agent).toBe('claude')
|
||||
const optedIn = resolveCanonicalPaneAgentIdentity({
|
||||
hookAgent: 'claude',
|
||||
hookIsLive: true,
|
||||
siblingAgent: 'codex',
|
||||
allowSibling: true
|
||||
})
|
||||
expect(optedIn).toMatchObject({ agent: 'claude', source: 'live-hook' })
|
||||
})
|
||||
|
||||
it('surfaces ambiguity instead of picking by array order', () => {
|
||||
const identity = resolveCanonicalPaneAgentIdentity({
|
||||
hookAgent: 'claude',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PANE_AGENT_SOURCE_RANK } from './pane-agent-identity-adapter'
|
||||
import {
|
||||
PANE_AGENT_EVIDENCE_SOURCES,
|
||||
type PaneAgentEvidence,
|
||||
@@ -12,14 +11,6 @@ const resolve = (evidence: PaneAgentEvidence[], extra = {}) =>
|
||||
const H = 'authority-a'
|
||||
|
||||
describe('resolvePaneAgentIdentity', () => {
|
||||
it('keeps every evidence source ranked exactly once', () => {
|
||||
expect(PANE_AGENT_SOURCE_RANK).toBe(PANE_AGENT_EVIDENCE_SOURCES)
|
||||
expect(new Set(PANE_AGENT_SOURCE_RANK).size).toBe(PANE_AGENT_SOURCE_RANK.length)
|
||||
for (const source of PANE_AGENT_EVIDENCE_SOURCES) {
|
||||
expect(PANE_AGENT_SOURCE_RANK.indexOf(source)).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
describe('a display title is the last thing consulted', () => {
|
||||
it.each(PANE_AGENT_EVIDENCE_SOURCES.filter((s) => s !== 'title' && s !== 'sibling'))(
|
||||
'lets %s outrank a conflicting title',
|
||||
@@ -39,16 +30,6 @@ describe('resolvePaneAgentIdentity', () => {
|
||||
source: 'title'
|
||||
})
|
||||
})
|
||||
|
||||
it('outranks the launch record with nothing weaker than the launch record', () => {
|
||||
// The tab ladder currently puts the parsed title ABOVE activeLaunchAgent, so a string Orca
|
||||
// parsed beats a fact Orca owns. That inversion cannot be expressed here.
|
||||
const result = resolve([
|
||||
{ source: 'launch', agent: 'claude' },
|
||||
{ source: 'title', agent: 'gemini' }
|
||||
])
|
||||
expect(result.agent).toBe('claude')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run generation separates the bug from the legitimate reclaim', () => {
|
||||
@@ -78,18 +59,6 @@ describe('resolvePaneAgentIdentity', () => {
|
||||
expect(result).toMatchObject({ agent: 'codex', source: 'title' })
|
||||
expect(result.supersededSources).toEqual(['completed-hook'])
|
||||
})
|
||||
|
||||
it('produces opposite answers from identical evidence, given only the run ids', () => {
|
||||
const bug = resolvePaneAgentIdentity({
|
||||
evidence: evidenceFor(7, 7),
|
||||
currentRun: { authorityId: H, incarnation: 7 }
|
||||
})
|
||||
const reclaim = resolvePaneAgentIdentity({
|
||||
evidence: evidenceFor(7, 8),
|
||||
currentRun: { authorityId: H, incarnation: 8 }
|
||||
})
|
||||
expect(bug.agent).not.toBe(reclaim.agent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed-version peers', () => {
|
||||
@@ -288,9 +257,5 @@ describe('resolvePaneAgentIdentity', () => {
|
||||
)
|
||||
expect(result).toMatchObject({ agent: 'claude', source: 'launch' })
|
||||
})
|
||||
|
||||
it('leaves display consumers unrestricted when no floor is given', () => {
|
||||
expect(resolve([{ source: 'title', agent: 'codex' }]).agent).toBe('codex')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,14 +70,4 @@ describe('Pi/OMP native state-title markers', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Every table row must reach detection without a second parser change (#13890).
|
||||
it('routes every table marker through title detection', () => {
|
||||
for (const marker of PI_STATE_MARKERS) {
|
||||
const status = getPiStateTitleStatus(`π ${marker} my-project`)
|
||||
expect(status).not.toBeNull()
|
||||
expect(detectAgentStatusFromTitle(`π ${marker} my-project`)).toBe(status)
|
||||
expect(detectAgentStatusFromTitle(`zsh | π ${marker} my-project`)).toBe(status)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalizeCapabilitySet, type PluginCapability } from './plugin-capabilities'
|
||||
import type { PluginCapability } from './plugin-capabilities'
|
||||
import { fingerprintPluginConsent } from './plugin-consent-fingerprint'
|
||||
import {
|
||||
getPluginActivationState,
|
||||
@@ -55,15 +54,6 @@ describe('fingerprintPluginConsent', () => {
|
||||
expect(needsReconsent('orca-samples.demo', withWorker, lists)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves capability-only fingerprints for existing panel plugins', () => {
|
||||
const capabilities = [workspaceRead, storage]
|
||||
const legacy = `sha256-${createHash('sha256')
|
||||
.update(canonicalizeCapabilitySet(capabilities))
|
||||
.digest('base64')}`
|
||||
|
||||
expect(fingerprintPluginConsent({ main: undefined, capabilities })).toBe(legacy)
|
||||
})
|
||||
|
||||
it('requires re-consent when instructional content bytes change', () => {
|
||||
const subject = {
|
||||
main: undefined,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isPluginLanguagePackRegistration,
|
||||
parsePluginLanguagePackArtifact,
|
||||
PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH,
|
||||
PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES,
|
||||
validatePluginLanguagePackCatalog,
|
||||
checkPluginLanguagePackCatalog,
|
||||
pluginLanguageResourceId
|
||||
} from './plugin-language-pack-artifact'
|
||||
|
||||
@@ -120,23 +118,6 @@ describe('plugin language-pack artifacts', () => {
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
||||
})
|
||||
|
||||
it('forges no trust badge: the community→Official swap is refused', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: {
|
||||
components: {
|
||||
settings: { PluginConsentProvenance: { community: 'Official' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('auto.components.settings.PluginConsentProvenance')
|
||||
})
|
||||
})
|
||||
|
||||
it('still lets language packs translate non-plugin settings copy', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
@@ -185,29 +166,4 @@ describe('plugin language-pack artifacts', () => {
|
||||
error: expect.stringContaining(`${PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES} entries`)
|
||||
})
|
||||
})
|
||||
|
||||
it('shape-validates 16 maximum-entry registrations without returning catalog copies', () => {
|
||||
const catalog = Object.fromEntries(
|
||||
Array.from({ length: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES }, (_, index) => [
|
||||
`key${index}`,
|
||||
'value'
|
||||
])
|
||||
)
|
||||
const packs = Array.from({ length: 16 }, (_, index) => {
|
||||
const id = `plugin:maximum-${index}` as const
|
||||
return {
|
||||
id,
|
||||
resourceLanguage: pluginLanguageResourceId(id),
|
||||
pluginKey: `maximum-${index}`,
|
||||
locale: 'en',
|
||||
catalog
|
||||
}
|
||||
})
|
||||
|
||||
expect(checkPluginLanguagePackCatalog(catalog)).toEqual({
|
||||
ok: true,
|
||||
entries: PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES
|
||||
})
|
||||
expect(packs.filter(isPluginLanguagePackRegistration)).toHaveLength(16)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,13 +17,6 @@ describe('structuredCloneMessageBytes', () => {
|
||||
expect(structuredCloneMessageBytes(tinyView, 256)).toBeGreaterThanOrEqual(128)
|
||||
expect(structuredCloneMessageBytes(new Blob([new Uint8Array(80)]), 256)).toBe(80)
|
||||
})
|
||||
|
||||
it('handles cycles and stops walking once the cap is exceeded', () => {
|
||||
const cyclic: { self?: unknown; text: string } = { text: 'x'.repeat(100) }
|
||||
cyclic.self = cyclic
|
||||
|
||||
expect(structuredCloneMessageBytes(cyclic, 32)).toBe(33)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPanelMessageBudget', () => {
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPluginPanelShellHtml, PLUGIN_PANEL_CSP } from './plugin-panel-shell'
|
||||
import { buildPluginPanelShellHtml } from './plugin-panel-shell'
|
||||
|
||||
describe('buildPluginPanelShellHtml', () => {
|
||||
it('places CSP and navigation guards before plugin content', () => {
|
||||
const html = buildPluginPanelShellHtml('<main id="plugin-content">Plugin</main>')
|
||||
const pluginOffset = html.indexOf('plugin-content')
|
||||
|
||||
expect(PLUGIN_PANEL_CSP).toContain("form-action 'none'")
|
||||
expect(html.indexOf('Content-Security-Policy')).toBeLessThan(pluginOffset)
|
||||
expect(html.indexOf("window.navigation.addEventListener('navigate'")).toBeLessThan(pluginOffset)
|
||||
expect(html.indexOf("window.addEventListener('click'")).toBeLessThan(pluginOffset)
|
||||
expect(html.indexOf("window.addEventListener('submit'")).toBeLessThan(pluginOffset)
|
||||
expect(html.indexOf("Object.defineProperty(window, 'open'")).toBeLessThan(pluginOffset)
|
||||
})
|
||||
|
||||
it('cancels anchor and form default navigation in the fallback path', () => {
|
||||
const html = buildPluginPanelShellHtml('<main>Plugin</main>')
|
||||
const script = html.match(/<script>\n([\s\S]*?)<\/script>/)?.[1]
|
||||
|
||||
@@ -7,24 +7,6 @@ const check = (name: string, conclusion: string | null) =>
|
||||
({ name, conclusion }) as Pick<PRCheckDetail, 'name' | 'conclusion'>
|
||||
|
||||
describe('PR check severity order', () => {
|
||||
it('keeps passing checks above the skipped and neutral noise', () => {
|
||||
const sorted = sortChecksBySeverity([
|
||||
check('daily-cleanup', 'skipped'),
|
||||
check('Test Results', 'success'),
|
||||
check('build-admin', 'skipped'),
|
||||
check('advisory', 'neutral'),
|
||||
check('Validate Configs', 'success')
|
||||
])
|
||||
|
||||
expect(sorted.map((c) => c.name)).toEqual([
|
||||
'Test Results',
|
||||
'Validate Configs',
|
||||
'advisory',
|
||||
'daily-cleanup',
|
||||
'build-admin'
|
||||
])
|
||||
})
|
||||
|
||||
it('orders failures, then in-flight work, then successes', () => {
|
||||
const sorted = sortChecksBySeverity([
|
||||
check('success', 'success'),
|
||||
@@ -73,12 +55,6 @@ describe('PR check severity order', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('sinks unknown conclusions below every known state', () => {
|
||||
expect(getCheckSeverityRank('stale_from_a_future_github')).toBeGreaterThan(
|
||||
getCheckSeverityRank('skipped')
|
||||
)
|
||||
})
|
||||
|
||||
// Object rank tables expose prototype names as functions, producing NaN comparisons and unstable ordering.
|
||||
it('treats prototype property names as unknown conclusions', () => {
|
||||
for (const inherited of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
|
||||
|
||||
@@ -2,25 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import { PrioritySemaphore } from './priority-semaphore'
|
||||
|
||||
describe('PrioritySemaphore', () => {
|
||||
it('blocks when concurrency limit is reached', async () => {
|
||||
const sem = new PrioritySemaphore(1)
|
||||
const r1 = await sem.acquire(0)
|
||||
|
||||
let acquired = false
|
||||
const p2 = sem.acquire(0).then((r) => {
|
||||
acquired = true
|
||||
return r
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
expect(acquired).toBe(false)
|
||||
|
||||
r1()
|
||||
const r2 = await p2
|
||||
expect(acquired).toBe(true)
|
||||
r2()
|
||||
})
|
||||
|
||||
it('serves higher priority (lower number) first', async () => {
|
||||
const sem = new PrioritySemaphore(1)
|
||||
const r1 = await sem.acquire(0)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import * as processTableFormat from './process-table-snapshot'
|
||||
import {
|
||||
getProcessTableSnapshot,
|
||||
getStrictProcessTableSnapshot,
|
||||
resetProcessTableSnapshotForTests
|
||||
} from './process-table-snapshot-reader'
|
||||
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
vi.restoreAllMocks()
|
||||
resetProcessTableSnapshotForTests()
|
||||
})
|
||||
|
||||
describe('process table parsing on non-Linux hosts', () => {
|
||||
it.each(['darwin', 'win32'])('only parses the requested view on %s', async (hostPlatform) => {
|
||||
Object.defineProperty(process, 'platform', { value: hostPlatform })
|
||||
resetProcessTableSnapshotForTests()
|
||||
const lenient = vi.spyOn(processTableFormat, 'parseProcessTableRows')
|
||||
const strict = vi.spyOn(processTableFormat, 'parseStrictProcessTableRows')
|
||||
execFileMock.mockImplementation((_program, _args, _options, callback) => {
|
||||
callback(null, {
|
||||
stdout: '100 1 100 100 Ss+ ?? Fri Oct 9 12:34:56 2026 /bin/zsh\n',
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
|
||||
expect((await getStrictProcessTableSnapshot())[0]?.pid).toBe(100)
|
||||
expect(lenient).not.toHaveBeenCalled()
|
||||
expect(strict).toHaveBeenCalledTimes(1)
|
||||
await getStrictProcessTableSnapshot()
|
||||
expect(strict).toHaveBeenCalledTimes(1)
|
||||
expect((await getProcessTableSnapshot())[0]?.pid).toBe(100)
|
||||
expect(lenient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
collectDescendantsFromIndex,
|
||||
type ProcessIdentityRow
|
||||
} from './process-table-index'
|
||||
import { parseStrictProcessTableRows } from './process-table-snapshot'
|
||||
|
||||
function boundedWalk<Row extends ProcessIdentityRow>(rows: readonly Row[], rootPid: number) {
|
||||
const index = buildProcessTableIndex(rows)
|
||||
@@ -139,22 +138,4 @@ describe('collectDescendantsFromIndex', () => {
|
||||
const rows = Array.from({ length: 10_000 }, (_, index) => ({ pid: index + 2, ppid: index + 1 }))
|
||||
expect(boundedWalk(rows, 1)).toEqual(rows.map((row, index) => ({ ...row, depth: index + 1 })))
|
||||
})
|
||||
|
||||
it('preserves reverse capture order for a broad valid tree', () => {
|
||||
const rows = Array.from({ length: 10_000 }, (_, index) => ({ pid: index + 2, ppid: 1 }))
|
||||
expect(boundedWalk(rows, 1)).toEqual(rows.toReversed().map((row) => ({ ...row, depth: 1 })))
|
||||
})
|
||||
|
||||
it('retains synthetic POSIX host identity fields through a cycle', () => {
|
||||
const rows = parseStrictProcessTableRows('5 6 5 6 S pts/1 100 bash\n6 5 6 6 S+ pts/1 200 codex')
|
||||
expect(boundedWalk(rows, 5)).toEqual([{ ...rows[1], depth: 1 }])
|
||||
})
|
||||
|
||||
it('retains synthetic Windows creation markers without treating a PID as an incarnation', () => {
|
||||
const rows = [
|
||||
{ pid: 5, ppid: 6, creationTimeMs: 100, name: 'pwsh.exe' },
|
||||
{ pid: 6, ppid: 5, creationTimeMs: 200, name: 'node.exe' }
|
||||
]
|
||||
expect(boundedWalk(rows, 5)).toEqual([{ ...rows[1], depth: 1 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,11 +21,7 @@ import {
|
||||
parseStrictProcessTableRows,
|
||||
ProcessTableCaptureError
|
||||
} from './process-table-snapshot'
|
||||
import {
|
||||
buildProcessTableIndex,
|
||||
getProcessTableIndex,
|
||||
type ProcessTableIndexStats
|
||||
} from './process-table-index'
|
||||
import { buildProcessTableIndex, getProcessTableIndex } from './process-table-index'
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
@@ -215,29 +211,6 @@ describe('process-table-snapshot reader', () => {
|
||||
await expect(Promise.all([fresh, ordinary])).resolves.toEqual(['scan-1', 'scan-1'])
|
||||
expect(scans).toBe(1)
|
||||
})
|
||||
|
||||
it('shares one parsed-rows array across a burst so panes do not each re-parse', async () => {
|
||||
// Parse inside the shared scan so callers share the same rows array instead of retokenizing stdout per pane.
|
||||
let parses = 0
|
||||
const gate = deferred<ReturnType<typeof parseProcessTableRows>>()
|
||||
const reader = createProcessTableSnapshotReader<ReturnType<typeof parseProcessTableRows>>({
|
||||
runPs: () => {
|
||||
parses += 1
|
||||
return gate.promise
|
||||
},
|
||||
now: () => 0
|
||||
})
|
||||
|
||||
const a = reader.getSnapshot()
|
||||
const b = reader.getSnapshot()
|
||||
gate.resolve(parseProcessTableRows('100 1 Ss+ /bin/zsh'))
|
||||
|
||||
const rowsA = await a
|
||||
const rowsB = await b
|
||||
expect(parses).toBe(1)
|
||||
// Reference identity: the burst reuses one parse, not one-per-caller.
|
||||
expect(rowsA).toBe(rowsB)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared process-table capture', () => {
|
||||
@@ -493,24 +466,6 @@ describe('getProcessTableIndex', () => {
|
||||
expect(getProcessTableIndex(rows).byPid.get(100)).toBe(rows[0])
|
||||
expect(buildProcessTableIndex(rows).byPid.get(100)).toBe(rows[0])
|
||||
})
|
||||
|
||||
it('keeps the memo out of measured builds so a cache hit cannot satisfy a perf gate', () => {
|
||||
const rows = parseProcessTableRows('100 1 Ss bash')
|
||||
const stats: ProcessTableIndexStats = {
|
||||
indexBuilds: 0,
|
||||
rowVisits: 0,
|
||||
indexLookups: 0
|
||||
}
|
||||
|
||||
const memoized = getProcessTableIndex(rows)
|
||||
const measured = buildProcessTableIndex(rows, stats)
|
||||
|
||||
expect(memoized.stats).toBeUndefined()
|
||||
expect(measured).not.toBe(memoized)
|
||||
expect(stats).toEqual({ indexBuilds: 1, rowVisits: 1, indexLookups: 0 })
|
||||
// The measured build must not evict or replace the shared memo.
|
||||
expect(getProcessTableIndex(rows)).toBe(memoized)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process-table capture completeness', () => {
|
||||
@@ -698,9 +653,4 @@ describe('evidence-publishing capture budget', () => {
|
||||
})
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the identity budget far above its own', () => {
|
||||
// Only current-evidence consumers give up early; slow identity reads must not become absent processes.
|
||||
expect(PROCESS_TABLE_EVIDENCE_BUDGET_MS).toBeLessThan(PS_TIMEOUT_MS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeAccumulatedWorkspaceFixtureOptions } from './accumulated-workspace-profile'
|
||||
import { buildAccumulatedWorkspaceSeed } from './accumulated-workspace-state-builder'
|
||||
|
||||
describe('accumulated workspace state builder', () => {
|
||||
it('matches the captured production scale with canonical terminal identities', () => {
|
||||
const seed = buildAccumulatedWorkspaceSeed(
|
||||
normalizeAccumulatedWorkspaceFixtureOptions({}),
|
||||
1_700_000_000_000
|
||||
)
|
||||
const worktrees = Object.values(seed.worktreesByRepo).flat()
|
||||
const terminalTabs = Object.values(seed.tabsByWorktree).flat()
|
||||
const unifiedTabs = Object.values(seed.unifiedTabsByWorktree).flat()
|
||||
const unifiedTerminalTabs = unifiedTabs.filter(({ contentType }) => contentType === 'terminal')
|
||||
|
||||
expect(seed.repos).toHaveLength(27)
|
||||
expect(worktrees).toHaveLength(870)
|
||||
expect(terminalTabs).toHaveLength(1_410)
|
||||
expect(unifiedTabs).toHaveLength(2_000)
|
||||
expect(Object.keys(seed.terminalLayoutsByTabId)).toHaveLength(1_410)
|
||||
expect(Object.keys(seed.sleepingAgentSessionsByPaneKey)).toHaveLength(857)
|
||||
expect(seed.liveStatuses).toHaveLength(177)
|
||||
expect(unifiedTerminalTabs.map(({ id }) => id)).toEqual(terminalTabs.map(({ id }) => id))
|
||||
expect(unifiedTerminalTabs.every(({ id, entityId }) => id === entityId)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses shallow independent lineage pairs instead of one synthetic chain', () => {
|
||||
const seed = buildAccumulatedWorkspaceSeed(normalizeAccumulatedWorkspaceFixtureOptions({}))
|
||||
const lineages = Object.values(seed.worktreeLineageById)
|
||||
const childIds = new Set(lineages.map(({ worktreeId }) => worktreeId))
|
||||
|
||||
expect(lineages.length).toBeGreaterThan(0)
|
||||
expect(lineages.every(({ parentWorktreeId }) => !childIds.has(parentWorktreeId))).toBe(true)
|
||||
})
|
||||
|
||||
// Interval 1 must include every non-main row; ordinal % interval === 1 would silently create none.
|
||||
it('builds lineage for every non-main worktree at an interval of 1', () => {
|
||||
const options = normalizeAccumulatedWorkspaceFixtureOptions({
|
||||
worktrees: 9,
|
||||
repositories: 3,
|
||||
lineageEvery: 1
|
||||
})
|
||||
const seed = buildAccumulatedWorkspaceSeed(options)
|
||||
const lineages = Object.values(seed.worktreeLineageById)
|
||||
|
||||
// 9 worktrees over 3 repos gives ordinals 0..2 each; ordinal 0 is the main row.
|
||||
expect(lineages).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
HOLD_ALTERNATE_SCREEN_OPEN,
|
||||
alternateScreenFixtureScript
|
||||
} from './alternate-screen-fixture-script'
|
||||
import {
|
||||
type HiddenPressureOutputMode,
|
||||
pressureOutputScript
|
||||
} from './artificial-opencode-hidden-pressure-script'
|
||||
|
||||
// The escape as it appears in generated source, where it is still a JS string escape.
|
||||
const ALTERNATE_SCREEN_ENTER_SOURCE = '\\x1b[?1049h'
|
||||
|
||||
const PRESSURE_MODES: HiddenPressureOutputMode[] = ['tui', 'plain', 'title', 'latin', 'rich-model']
|
||||
|
||||
describe('alternateScreenFixtureScript', () => {
|
||||
it('holds the painting process open so the alternate screen survives the assertions', () => {
|
||||
const source = alternateScreenFixtureScript('\x1b[?1049hFRAME')
|
||||
|
||||
expect(source).toContain(HOLD_ALTERNATE_SCREEN_OPEN)
|
||||
expect(source).toContain('process.stdout.write("\\u001b[?1049hFRAME")')
|
||||
expect(source).not.toContain('setTimeout')
|
||||
})
|
||||
|
||||
it('defers the paint by the requested delay and still holds open', () => {
|
||||
const source = alternateScreenFixtureScript('FRAME', 750)
|
||||
|
||||
expect(source).toContain('setTimeout(() => process.stdout.write("FRAME"), 750)')
|
||||
expect(source).toContain(HOLD_ALTERNATE_SCREEN_OPEN)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hidden pressure fixture', () => {
|
||||
// Entering alternate screen then exiting triggers dead-TUI recovery, silently erasing fixture content.
|
||||
it.each(PRESSURE_MODES)(
|
||||
'keeps mode %s alive exactly when it enters the alternate screen',
|
||||
(mode) => {
|
||||
const source = pressureOutputScript('run-id', mode)
|
||||
|
||||
expect(source.includes(HOLD_ALTERNATE_SCREEN_OPEN)).toBe(
|
||||
source.includes(ALTERNATE_SCREEN_ENTER_SOURCE)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('holds the rich-model alternate screen open past its done marker', () => {
|
||||
const source = pressureOutputScript('run-id', 'rich-model')
|
||||
|
||||
expect(source).toContain(ALTERNATE_SCREEN_ENTER_SOURCE)
|
||||
expect(source.indexOf(HOLD_ALTERNATE_SCREEN_OPEN)).toBeGreaterThan(
|
||||
source.indexOf('OPENCODE_PRESSURE_DONE_')
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user