From e9d5b35d64e2d5faa87f8c1d1971ee84a74f8178 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 13 Nov 2026 05:25:55 -0800 Subject: [PATCH] test: remove low-value mobile and CLI runtime tests (#42022) * test: remove low-value mobile suites and repeated vectors * test: trim low-value CLI runtime cases and obsolete allowance * test: satisfy changed-line template lint in click case --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - mobile/src/mock-server-key-pair.test.ts | 24 -- .../onboarding/MobileOnboardingPage.test.ts | 122 ------ .../NotificationOnboardingPreview.test.ts | 116 ------ .../legacy-notification-opt-in-route.test.ts | 24 -- .../settings/settings-screen-state.test.tsx | 30 -- .../terminal-live-preedit-mirror.test.ts | 29 +- .../terminal-live-text-commit.test.ts | 22 - ...inal-text-field-submit-binding.web.test.ts | 27 +- .../terminal-webview-cell-box.test.ts | 16 - ...rminal-webview-interactive-output.test.tsx | 106 ----- ...rminal-webview-query-reply-routing.test.ts | 70 ---- .../terminal-webview-tap-routing.test.ts | 375 ------------------ .../terminal-webview-wheel-scroll.test.ts | 278 ------------- .../terminal/terminal-write-coalescer.test.ts | 64 +-- .../use-terminal-live-input-click.test.ts | 37 +- .../use-terminal-live-input-commit.test.ts | 49 +-- ...erminal-text-field-submit-binding.test.tsx | 55 +-- src/cli/runtime/envelope-schema.test.ts | 117 +----- .../profile-state-recovery-launch.test.ts | 71 +--- .../runtime/runtime-listener-probe.test.ts | 26 -- src/cli/runtime/runtime-source-fence.test.ts | 25 +- src/cli/runtime/serve-appimage-update.test.ts | 14 +- src/cli/runtime/serve-bundle-park.test.ts | 9 - .../runtime/serve-electron-preflight.test.ts | 6 - src/cli/runtime/serve-install-hold.test.ts | 62 --- .../runtime/serve-pid1-init-reaper.test.ts | 43 -- .../serve-signal-exit-diagnostic.test.ts | 80 +--- .../runtime/serve-supervised-service.test.ts | 39 +- .../serve-supervisor-relocation.test.ts | 27 -- src/cli/runtime/status.test.ts | 87 +--- 31 files changed, 74 insertions(+), 1977 deletions(-) delete mode 100644 mobile/src/onboarding/MobileOnboardingPage.test.ts delete mode 100644 mobile/src/onboarding/NotificationOnboardingPreview.test.ts delete mode 100644 mobile/src/onboarding/legacy-notification-opt-in-route.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-interactive-output.test.tsx delete mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-tap-routing.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-wheel-scroll.test.ts delete mode 100644 src/cli/runtime/serve-install-hold.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index c4db40f9a5b..85c8cc9f0c6 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -121,7 +121,6 @@ 1 test-no-polling-loop:tests/e2e/terminal-tui-wheel-reports.spec.ts 1 test-no-polling-loop:tests/e2e/workspace-sleep-in-main.spec.ts 2 test-no-polling-loop:tests/e2e/worktree-card-downward-drag.spec.ts -1 test-no-real-time-wait:mobile/src/terminal/terminal-webview-tap-routing.test.ts 1 test-no-real-time-wait:src/main/agent-hooks/installation/scripts/posix-hook-json-stdin-reader.test.ts 2 test-no-real-time-wait:src/main/agent-hooks/server/testing/http/server-transport-interference.test.ts 1 test-no-real-time-wait:src/main/agent-hooks/server/testing/main-agent/server-turn-ended-at.test.ts diff --git a/mobile/src/mock-server-key-pair.test.ts b/mobile/src/mock-server-key-pair.test.ts index 1db9757e1e2..b4dbcccd3dd 100644 --- a/mobile/src/mock-server-key-pair.test.ts +++ b/mobile/src/mock-server-key-pair.test.ts @@ -3,7 +3,6 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import nacl from 'tweetnacl' import { afterEach, describe, expect, it, vi } from 'vitest' import { loadOrCreateMockServerKeyPair } from '../scripts/mock-server-key-pair' @@ -186,20 +185,6 @@ describe('mock server key persistence', () => { } }) - it('re-keys canonical-length content with malformed base64', () => { - const keyFile = keyFilePath() - const encoded = Buffer.from(nacl.box.keyPair().secretKey).toString('base64') - const malformed = `${encoded.slice(0, 4)}!${encoded.slice(4)}` - expect(Buffer.from(malformed, 'base64')).toHaveLength(nacl.box.secretKeyLength) - writeFileSync(keyFile, malformed) - const logger = { warn: vi.fn() } - - const loaded = loadOrCreateMockServerKeyPair(keyFile, logger) - - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid base64')) - expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(loaded.secretKey).toString('base64')) - }) - it('makes concurrent creators converge on the persisted winner', async () => { const keyFile = keyFilePath() const lockFile = `${keyFile}.lock` @@ -224,13 +209,4 @@ describe('mock server key persistence', () => { await cleanupConcurrentCreators(creators, lockFile, parentOwnsLock) } }) - - it('does not overwrite an invalid key owned by another creator', () => { - const keyFile = keyFilePath() - writeFileSync(keyFile, 'invalid') - writeFileSync(`${keyFile}.lock`, '', { flag: 'wx', mode: 0o600 }) - - expect(() => loadOrCreateMockServerKeyPair(keyFile)).toThrow('remained busy') - expect(readFileSync(keyFile, 'utf-8')).toBe('invalid') - }) }) diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts deleted file mode 100644 index 48612248143..00000000000 --- a/mobile/src/onboarding/MobileOnboardingPage.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { createElement, type ReactNode } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MobileOnboardingPage } from './MobileOnboardingPage' -import { findByHostType } from '../test-support/react-test-renderer-host-nodes' - -vi.mock('react-native', async () => { - const React = await import('react') - class AnimatedValue { - interpolate() { - return 0 - } - setValue() {} - } - return { - AccessibilityInfo: { - addEventListener: vi.fn(() => ({ remove: vi.fn() })), - isReduceMotionEnabled: vi.fn(() => Promise.resolve(true)) - }, - ActivityIndicator: 'ActivityIndicator', - Animated: { - Value: AnimatedValue, - View: 'AnimatedView', - delay: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - loop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - parallel: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - sequence: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - timing: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })) - }, - Easing: { cubic: (t: number) => t, in: (e: unknown) => e, out: (e: unknown) => e }, - Image: 'Image', - Pressable: 'Pressable', - ScrollView: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('ScrollView', props, children), - StyleSheet: { create: (styles: unknown) => styles }, - Text: 'Text', - View: 'View' - } -}) - -vi.mock('lucide-react-native', () => ({ - MessageSquare: 'MessageSquare' -})) - -vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) - -describe('MobileOnboardingPage', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - vi.restoreAllMocks() - }) - - async function renderPage( - step: 'session-view' | 'notifications', - options: { active?: boolean; busyChoice?: 'chat' | 'enable' | null } = {} - ) { - const onSessionChoice = vi.fn() - const onNotificationChoice = vi.fn() - const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { - throw new Error(String(args[0])) - } - }) - await act(async () => { - renderer = create( - createElement(MobileOnboardingPage, { - step, - width: 390, - active: options.active ?? true, - busyChoice: options.busyChoice ?? null, - error: null, - onSessionChoice, - onNotificationChoice - }) - ) - }) - consoleError.mockRestore() - return { onSessionChoice, onNotificationChoice } - } - - function button(label: string) { - return renderer!.root.find( - (node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === label - ) - } - - it('renders the session choices and sends exactly one selected view', async () => { - const callbacks = await renderPage('session-view') - - act(() => button('Open sessions in Chat UI').props.onPress()) - expect(callbacks.onSessionChoice).toHaveBeenCalledExactlyOnceWith('chat') - expect(callbacks.onNotificationChoice).not.toHaveBeenCalled() - }) - - it('renders the notification choices and sends the selected option', async () => { - const callbacks = await renderPage('notifications') - act(() => button('Skip notifications for now').props.onPress()) - - expect(callbacks.onNotificationChoice).toHaveBeenCalledWith('skip') - expect(callbacks.onSessionChoice).not.toHaveBeenCalled() - }) - - it('disables both notification choices while permission is pending', async () => { - await renderPage('notifications', { busyChoice: 'enable' }) - const enable = button('Enable agent notifications') - const secondary = button('Skip notifications for now') - - expect(enable.props.disabled).toBe(true) - expect(secondary.props.disabled).toBe(true) - }) - - it('hides an off-screen page from assistive technology', async () => { - await renderPage('notifications', { active: false }) - const scrollView = findByHostType(renderer!.root, 'ScrollView') - - expect(scrollView.props.accessibilityElementsHidden).toBe(true) - expect(scrollView.props.importantForAccessibility).toBe('no-hide-descendants') - }) -}) diff --git a/mobile/src/onboarding/NotificationOnboardingPreview.test.ts b/mobile/src/onboarding/NotificationOnboardingPreview.test.ts deleted file mode 100644 index fa400485a9f..00000000000 --- a/mobile/src/onboarding/NotificationOnboardingPreview.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createElement, type ReactNode } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { NotificationOnboardingPreview } from './NotificationOnboardingPreview' -import { findAllByHostType } from '../test-support/react-test-renderer-host-nodes' - -const mocks = vi.hoisted(() => { - const anim = () => ({ start: vi.fn(), stop: vi.fn() }) - return { - reducedMotion: true, - reducedMotionResult: null as Promise | null, - timing: vi.fn(anim), - loop: vi.fn(anim) - } -}) - -vi.mock('react-native', async () => { - const React = await import('react') - class AnimatedValue { - interpolate() { - return 0 - } - setValue() {} - } - return { - AccessibilityInfo: { - addEventListener: vi.fn(() => ({ remove: vi.fn() })), - isReduceMotionEnabled: vi.fn( - () => mocks.reducedMotionResult ?? Promise.resolve(mocks.reducedMotion) - ) - }, - Animated: { - Value: AnimatedValue, - View: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('AnimatedView', props, children), - delay: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - loop: mocks.loop, - parallel: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - sequence: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), - timing: mocks.timing - }, - Easing: { cubic: (t: number) => t, in: (e: unknown) => e, out: (e: unknown) => e }, - StyleSheet: { create: (styles: unknown) => styles }, - Text: 'Text', - View: 'View' - } -}) - -vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) - -describe('NotificationOnboardingPreview', () => { - let renderer: ReactTestRenderer | null = null - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - mocks.reducedMotion = true - mocks.reducedMotionResult = null - mocks.timing.mockClear() - mocks.loop.mockClear() - vi.restoreAllMocks() - }) - - async function renderPreview(active = true) { - const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { - if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { - throw new Error(String(args[0])) - } - }) - await act(async () => { - renderer = create(createElement(NotificationOnboardingPreview, { active })) - }) - consoleError.mockRestore() - } - - it('shows sample agent-done and needs-input banners as a decorative mock', async () => { - await renderPreview() - const preview = renderer!.root.findByProps({ testID: 'notification-onboarding-preview' }) - const copy = findAllByHostType(renderer!.root, 'Text') - .flatMap((node) => node.props.children) - .join(' ') - - expect(preview.props.accessibilityElementsHidden).toBe(true) - expect(preview.props.importantForAccessibility).toBe('no-hide-descendants') - expect(copy).toContain('Codex finished') - expect(copy).toContain('Claude needs input') - }) - - it('does not animate the banners when the page is off-screen', async () => { - mocks.reducedMotion = false - await renderPreview(false) - expect(mocks.loop).not.toHaveBeenCalled() - }) - - it('loops the arriving banners while the page is active', async () => { - mocks.reducedMotion = false - await renderPreview(true) - expect(mocks.loop).toHaveBeenCalledOnce() - expect(mocks.loop.mock.results[0]?.value.start).toHaveBeenCalledOnce() - }) - - it('does not start the loop until reduced-motion is known', async () => { - let resolvePreference: (enabled: boolean) => void = () => {} - mocks.reducedMotionResult = new Promise((resolve) => { - resolvePreference = resolve - }) - - await renderPreview(true) - expect(mocks.loop).not.toHaveBeenCalled() - - await act(async () => { - resolvePreference(false) - }) - expect(mocks.loop).toHaveBeenCalledOnce() - }) -}) diff --git a/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts b/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts deleted file mode 100644 index edbb44f8e72..00000000000 --- a/mobile/src/onboarding/legacy-notification-opt-in-route.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { legacyNotificationOptInDestination } from './legacy-notification-opt-in-destination' - -describe('legacy notification opt-in destination', () => { - it('selects only notifications and preserves a scalar hostId', () => { - expect(legacyNotificationOptInDestination({ hostId: 'paired-host' })).toEqual({ - pathname: '/mobile-onboarding', - params: { steps: 'notifications', hostId: 'paired-host' } - }) - }) - - it('ignores unrelated legacy parameters', () => { - const legacyParams = { - hostId: 'paired-host', - steps: 'session-view', - returnTo: '/settings' - } - - expect(legacyNotificationOptInDestination(legacyParams)).toEqual({ - pathname: '/mobile-onboarding', - params: { steps: 'notifications', hostId: 'paired-host' } - }) - }) -}) diff --git a/mobile/src/settings/settings-screen-state.test.tsx b/mobile/src/settings/settings-screen-state.test.tsx index 0bcef5862e3..6bd21b6b58c 100644 --- a/mobile/src/settings/settings-screen-state.test.tsx +++ b/mobile/src/settings/settings-screen-state.test.tsx @@ -55,36 +55,6 @@ afterEach(() => { act(() => renderer?.unmount()) }) describe('shared settings screen state', () => { - it('shows a desktop on Auto as Auto and switches it to plain Toggle', async () => { - const operations: VoiceSettingsOperations = { - load: vi.fn().mockResolvedValue({ - enabled: true, - dictationMode: 'toggle', - dictationHoldToRecord: true, - selectedModelId: '', - models: [] - }), - configure: vi.fn(() => new Promise(() => {})), - download: vi.fn(), - delete: vi.fn() - } - await act(async () => { - renderer = create( - createElement(VoiceSettingsScreen, { operations, focused: true, onBack: vi.fn() }) - ) - }) - const modeProps = () => renderer.root.findByProps({ testIDPrefix: 'voice-mode-' }).props - expect(modeProps().selected).toBe('auto') - - await act(async () => { - modeProps().onSelect('toggle') - }) - expect(operations.configure).toHaveBeenCalledWith({ - dictationMode: 'toggle', - dictationHoldToRecord: false - }) - expect(modeProps().selected).toBe('toggle') - }) it('flips the Voice switch before the desktop replies and surfaces a rejected save', async () => { const loaded = { enabled: true, diff --git a/mobile/src/terminal/terminal-live-preedit-mirror.test.ts b/mobile/src/terminal/terminal-live-preedit-mirror.test.ts index d6a966b2348..04cba45fb51 100644 --- a/mobile/src/terminal/terminal-live-preedit-mirror.test.ts +++ b/mobile/src/terminal/terminal-live-preedit-mirror.test.ts @@ -12,10 +12,7 @@ type MirrorRun = { type MirrorFrame = { readonly text: string; readonly composing?: boolean } -function runMirrorSequence( - fieldStates: readonly (string | MirrorFrame)[], - options: { readonly commitAtEnd: boolean } = { commitAtEnd: false } -): MirrorRun { +function runMirrorSequence(fieldStates: readonly (string | MirrorFrame)[]): MirrorRun { const payloads: string[] = [] let sentText = '' let heldText = '' @@ -29,16 +26,6 @@ function runMirrorSequence( sentText = step.nextSentText heldText = step.heldText } - if (options.commitAtEnd) { - const lastField = sentText + heldText - const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true }) - const payload = buildTerminalLiveMirrorPayload(step) - if (payload.length > 0) { - payloads.push(payload) - } - sentText = step.nextSentText - heldText = step.heldText - } return { payloads, sentText, heldText } } @@ -117,19 +104,7 @@ const twoSetFields = [ describe('terminal live mirror with iOS keyboard edits', () => { it.each([ { keyboard: 'Cheonjiin', fields: cheonjiinFields }, - { keyboard: 'two-set', fields: twoSetFields }, - { - keyboard: 'autocorrect around a held Korean tail', - fields: ['teh 하', 'teh 하ᄉᆞ', 'the 하ᄉᆞ', 'the 하세 '] - }, - { - keyboard: 'replacement of an already-sent Korean prefix', - fields: ['안녕하', '안녕하ᄉᆞ', '반가ᄉᆞ', '반가워 '] - }, - { - keyboard: 'delete-all with a held Korean tail', - fields: ['안녕하', '안녕하ᄉᆞ', '', '새 입력 '] - } + { keyboard: 'two-set', fields: twoSetFields } ])('replays $keyboard edits for both receiver deletion units', ({ fields }) => { const run = runMirrorSequence(fields.map((text) => ({ text, composing: false }))) const segmenter = new Intl.Segmenter('ko', { granularity: 'grapheme' }) diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts index c363419cfa5..610b2679b6f 100644 --- a/mobile/src/terminal/terminal-live-text-commit.test.ts +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -17,26 +17,4 @@ describe('terminal live accessory local edit text', () => { ) } ) - - it.each([ - ['', ''], - ['a', ''], - ['a🙂', 'a'], - ['🙂a', '🙂'], - ['🙂🙂', '🙂'], - ['a\ud800', 'a'], - ['a\udc00', 'a'], - ['\ud800\ud800', '\ud800'], - ['\udc00\ud800', '\udc00'], - ['e\u0301', 'e'], - ['👩‍💻', '👩‍'], - ['\r\n', '\r'] - ])('preserves code-point deletion for %j', (fieldText, expected) => { - expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText })).toBe( - expected - ) - expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText })).toBe( - fieldText - ) - }) }) diff --git a/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts b/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts index a3a52b32a35..4be80b442b8 100644 --- a/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts +++ b/mobile/src/terminal/terminal-text-field-submit-binding.web.test.ts @@ -80,13 +80,8 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s' it.each([ ['c', { ctrlKey: true }, '\x03'], - ['Escape', {}, '\x1b'], - ['Tab', {}, '\t'], - ['Tab', { shiftKey: true }, '\x1b[Z'], - ['ArrowUp', {}, '\x1b[A'], ['ArrowLeft', { ctrlKey: true }, '\x1b[1;5D'], - ['Enter', { shiftKey: true }, '\x1b[13;2u'], - ['ArrowLeft', { altKey: true }, '\x1b[1;3D'] + ['Enter', { shiftKey: true }, '\x1b[13;2u'] ])('claims %s once before RNWeb can resend it', (name, options, bytes) => { const { field, onChord, key } = setup() const parentKey = vi.fn() @@ -96,28 +91,18 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s' expect(parentKey).not.toHaveBeenCalled() }) - it.each(['b', '@', '[', ']', '{', ' '])( - 'keeps Option-generated %j native on iOS and Alt chords on Android', - (name) => { - const { onChord, key } = setup() - expect(key(name, { altKey: true }).defaultPrevented).toBe(os === 'android') - expect(onChord.mock.calls).toEqual(os === 'android' ? [[{ bytes: `\x1b${name}` }]] : []) - } - ) + it.each(['b'])('keeps Option-generated %j native on iOS and Alt chords on Android', (name) => { + const { onChord, key } = setup() + expect(key(name, { altKey: true }).defaultPrevented).toBe(os === 'android') + expect(onChord.mock.calls).toEqual(os === 'android' ? [[{ bytes: `\x1b${name}` }]] : []) + }) it.each([ ['a', {}], ['Enter', {}], ['Enter', { ctrlKey: true, shiftKey: true }], - ['Enter', { altKey: true, shiftKey: true }], - ['Enter', { shiftKey: true, isComposing: true }], - ['Enter', { shiftKey: true, keyCode: 229 }], ['Backspace', {}], - ['Delete', {}], ['ArrowLeft', {}], - ['ArrowRight', {}], - ['Home', {}], - ['End', {}], ['c', { ctrlKey: true, isComposing: true }], ['c', { ctrlKey: true, keyCode: 229 }], ['c', { ctrlKey: true, altKey: true }], diff --git a/mobile/src/terminal/terminal-webview-cell-box.test.ts b/mobile/src/terminal/terminal-webview-cell-box.test.ts index 5dfcb5dc351..fdbe3b06d85 100644 --- a/mobile/src/terminal/terminal-webview-cell-box.test.ts +++ b/mobile/src/terminal/terminal-webview-cell-box.test.ts @@ -109,22 +109,6 @@ describe('the cell box xterm laid out', () => { expect(handle().fitDimensions(FRAME)).toEqual({ cols: 55, rows: 47 }) }) - it('fits in the app from the reported box, and sends the frame with every grid', () => { - const { handle, notify } = mount() - notify({ type: 'web-ready', cellBox: cellAt(scale) }) - expect(handle().fitDimensions({ width: 427.5, height: 0 })).toBeNull() - expect(handle().fitDimensions({ width: 427.5, height: 710 })).toEqual({ cols: 55, rows: 47 }) - const frame = { width: 427.5, height: 710 } - handle().init({ cols: 55, rows: 47, initialData: '', frame }) - handle().resize(55, 47, frame) - handle().reflow(55, 47, frame) - const grids = nativeWebViewMethods.postMessage.mock.calls - .map(([message]) => JSON.parse(message)) - .filter((message) => ['init', 'resize', 'reflow'].includes(message.type)) - expect(grids.map((message) => message.frame)).toEqual([frame, frame, frame]) - expect(postedTypes()).not.toContain('measure') - }) - it('sends nothing before a ready: a load start drops what was queued', () => { const { handle, notify, webView } = mount() handle().init({ cols: 55, rows: 47, initialData: 'snapshot', frame: null }) diff --git a/mobile/src/terminal/terminal-webview-interactive-output.test.tsx b/mobile/src/terminal/terminal-webview-interactive-output.test.tsx deleted file mode 100644 index a84f902af16..00000000000 --- a/mobile/src/terminal/terminal-webview-interactive-output.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { - useTerminalWebViewController, - type TerminalWebViewTransport -} from './use-terminal-webview-controller' - -vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })) - -vi.mock('./terminal-webview-engine-error-state', () => ({ - useTerminalWebViewEngineErrorState: () => ({ - clearEngineError: vi.fn(), - engineError: null, - reportEngineError: vi.fn(), - reportNativeEngineError: vi.fn() - }) -})) - -let root: ReactTestRenderer | undefined - -function mountController(interactionActive = true) { - const post = vi.fn() - const onTerminalInput = vi.fn() - const exposed: { current: ReturnType | null } = { - current: null - } - const transport = { post, pingsOnForegroundRecovery: () => false } - function Probe() { - exposed.current = useTerminalWebViewController( - { onTerminalInput, interactionActive }, - transport - ) - return null - } - act(() => { - root = create() - }) - const controller = exposed.current - if (!controller) { - throw new Error('Controller did not mount') - } - act(() => controller.receive({ type: 'web-ready' })) - const writes = () => - post.mock.calls - .map(([command]) => command) - .filter((command) => command.type === 'write') - .map((command) => command.data) - return { controller, onTerminalInput, writes } -} - -describe('terminal scroll response batching', () => { - afterEach(() => { - act(() => root?.unmount()) - root = undefined - vi.useRealTimers() - }) - - it.each(['\x1b[<64;2;2M', '\x1b[A'])('shortens pending output for gesture %j', (bytes) => { - vi.useFakeTimers() - const { controller, onTerminalInput, writes } = mountController() - controller.handle.write('first') - vi.advanceTimersByTime(5) - controller.handle.write('response') - vi.advanceTimersByTime(3) - controller.receive({ type: 'terminal-input', bytes }) - vi.advanceTimersByTime(8) - expect(writes()).toEqual(['first', 'response']) - expect(onTerminalInput).toHaveBeenCalledWith(bytes) - }) - - it.each(['', 'ordinary text', 123])('keeps ordinary batching for non-gesture %j', (bytes) => { - vi.useFakeTimers() - const { controller, writes } = mountController() - controller.handle.write('first') - controller.handle.write('pending') - controller.receive({ type: 'terminal-input', bytes }) - vi.advanceTimersByTime(16) - expect(writes()).toEqual(['first']) - vi.advanceTimersByTime(32) - expect(writes()).toEqual(['first', 'pending']) - }) - - it('rejects inactive gesture notifications without changing output batching', () => { - vi.useFakeTimers() - const { controller, onTerminalInput, writes } = mountController(false) - controller.handle.write('first') - controller.handle.write('pending') - controller.receive({ type: 'terminal-input', bytes: '\x1b[A' }) - vi.advanceTimersByTime(16) - expect(onTerminalInput).not.toHaveBeenCalled() - expect(writes()).toEqual(['first']) - vi.advanceTimersByTime(32) - expect(writes()).toEqual(['first', 'pending']) - }) - - it('drops pending gesture output before a new document snapshot', () => { - vi.useFakeTimers() - const { controller, writes } = mountController() - controller.handle.write('first') - controller.handle.write('stale') - controller.receive({ type: 'terminal-input', bytes: '\x1b[A' }) - controller.handle.init({ cols: 80, rows: 24, initialData: 'snapshot', frame: null }) - vi.advanceTimersByTime(48) - expect(writes()).toEqual(['first']) - }) -}) diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts deleted file mode 100644 index b5296d75b21..00000000000 --- a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { TerminalWebView } from './TerminalWebView' -import { findByHostType } from '../test-support/react-test-renderer-host-nodes' - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios' }, - StyleSheet: { - absoluteFill: { - bottom: 0, - left: 0, - position: 'absolute', - right: 0, - top: 0 - }, - create: (styles: unknown) => styles - }, - Text: 'Text', - View: 'View' -})) - -vi.mock('react-native-webview', async () => { - const React = await import('react') - const WebView = React.forwardRef((props: Record, _ref) => - React.createElement('WebView', props) - ) - return { WebView, default: WebView } -}) - -vi.mock('lucide-react-native', () => ({ - RefreshCw: 'RefreshCw' -})) - -let renderer: ReactTestRenderer | null = null - -function postWebViewMessage(payload: Record): void { - if (!renderer) { - throw new Error('TerminalWebView did not render') - } - const webView = findByHostType(renderer.root, 'WebView') - act(() => { - webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) - }) -} - -describe('TerminalWebView query reply routing', () => { - afterEach(() => { - if (renderer) { - act(() => renderer?.unmount()) - renderer = null - } - vi.restoreAllMocks() - }) - - it('routes only complete terminal query replies to native', () => { - const onTerminalQueryReply = vi.fn() - vi.spyOn(console, 'error').mockImplementation(() => {}) - act(() => { - renderer = create(createElement(TerminalWebView, { onTerminalQueryReply })) - }) - - postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' }) - postWebViewMessage({ type: 'terminal-data', bytes: 'a' }) - postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' }) - - expect(onTerminalQueryReply).toHaveBeenCalledTimes(1) - expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R') - }) -}) diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts deleted file mode 100644 index d8b30559234..00000000000 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -// @vitest-environment happy-dom -// URL taps with slight jitter once hit the long-press slop gate and never reached the native browser bridge. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' -import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' - -function bodyMarkup(): string { - return TERMINAL_DOCUMENT_MARKUP -} - -function makeTerminal( - lineRef: { current: string }, - mouseTrackingMode = 'none', - scrollbackRows = 0 -) { - return { - cols: 80, - rows: 24, - options: { fontSize: 13 }, - modes: { mouseTrackingMode }, - element: { scrollWidth: 800, scrollHeight: 360 }, - _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } }, - buffer: { - active: { - viewportY: 0, - baseY: scrollbackRows, - length: 1, - cursorY: 0, - type: 'normal' as const, - getLine(row: number) { - // Rows after the first are xterm soft-wrap continuations. - const text = lineRef.current.split('\n')[row] - if (text === undefined) { - return null - } - return { - length: 80, - isWrapped: row > 0, - // Honor xterm’s translateToString slice arguments so wide-cell offset conversion is realistic. - translateToString: (_trim?: boolean, start?: number, end?: number) => - text.slice(start ?? 0, end ?? text.length), - getCell: () => ({ extended: undefined }) - } - } - } - }, - write(_d: string, cb?: () => void) { - cb?.() - }, - open() {}, - resize() {}, - clear() {}, - reset() {}, - refresh() {}, - selectAll() {}, - clearSelection() {}, - select() {}, - scrollLines() {}, - scrollToBottom() {}, - getSelection: () => '', - onLineFeed: () => ({ dispose() {} }), - onScroll: () => ({ dispose() {} }), - onWriteParsed: () => ({ dispose() {} }), - dispose() {} - } -} - -type Posted = Array> - -type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string } - -function boot( - line: string, - oscLinks?: OscLinkRange[], - mouseTrackingMode = 'none', - scrollbackRows = 0 -): { posted: Posted; setLine: (line: string) => void } { - const posted: Posted = [] - const lineRef = { current: line } - const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown } - w.Terminal = function () { - return makeTerminal(lineRef, mouseTrackingMode, scrollbackRows) - } - w.ReactNativeWebView = { - postMessage(s: string) { - posted.push(JSON.parse(s)) - } - } - document.body.innerHTML = bodyMarkup() - // The bundle the WebView loads, run as the WebView runs it. - new Function(TERMINAL_DOCUMENT_SCRIPT)() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 80, rows: 24, initialData: '', oscLinks }) - }) - ) - return { - posted, - setLine: (nextLine: string) => { - lineRef.current = nextLine - } - } -} - -function fireTouch(type: string, touches: Array<{ x: number; y: number }>): void { - const surface = document.getElementById('terminal-surface') as HTMLElement - const ev = new Event(type, { bubbles: true, cancelable: true }) - Object.defineProperty(ev, 'touches', { - value: touches.map((p, i) => ({ identifier: i, clientX: p.x, clientY: p.y, target: surface })) - }) - surface.dispatchEvent(ev) -} - -const settle = (): Promise => new Promise((r) => setTimeout(r, 50)) - -describe('terminal WebView tap routing', () => { - const URL_LINE = 'visit https://example.com/foo now' - const tapX = 12 * 8 * 0.3125 - const tapY = 2 - const screenXForCol = (col: number): number => col * 8 * 0.3125 - - beforeEach(() => { - vi.spyOn(window, 'addEventListener') - vi.spyOn(document, 'addEventListener') - Object.defineProperty(window, 'innerWidth', { value: 200, configurable: true }) - Object.defineProperty(window, 'innerHeight', { value: 400, configurable: true }) - }) - - afterEach(() => { - for (const [type, listener, options] of vi.mocked(window.addEventListener).mock.calls) { - window.removeEventListener(type, listener, options) - } - for (const [type, listener, options] of vi.mocked(document.addEventListener).mock.calls) { - document.removeEventListener(type, listener, options) - } - vi.restoreAllMocks() - }) - - it('still posts open-url when the tap jitters a few pixels', async () => { - // Small finger jitter must stay a tap and open the touched URL. - const { posted } = boot(URL_LINE) - await settle() - fireTouch('touchstart', [{ x: tapX, y: tapY }]) - fireTouch('touchmove', [{ x: tapX + 11, y: tapY + 4 }]) - fireTouch('touchend', []) - expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo') - }) - - it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => { - const { posted } = boot('interactive prompt', undefined, 'drag') - await settle() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' }) - }) - ) - - fireTouch('touchstart', [{ x: 20, y: tapY }]) - fireTouch('touchend', []) - - expect( - posted - .filter((message) => message.type === 'terminal-input' || message.type === 'terminal-tap') - .map((message) => message.type) - ).toEqual(['terminal-input', 'terminal-tap']) - }) - - it('focuses native input without mouse bytes while the tracking encoding is unproven', async () => { - const { posted } = boot('interactive prompt', undefined, 'drag') - await settle() - - fireTouch('touchstart', [{ x: 20, y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined() - expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) - }) - - it('opens first-load OSC links from snapshot metadata on the exact cell range', async () => { - const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] - const { posted } = boot('issue #1234 done', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234') - }) - - it('opens first-load file OSC links through terminal file taps', async () => { - const oscLinks = [{ row: 0, startCol: 5, endCol: 15, uri: 'file:///tmp/result.json#L12C3' }] - const { posted } = boot('open artifact here', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '/tmp/result.json', line: 12, column: 3 }] - }) - }) - - it('keeps the UNC host of an OSC file link for a Windows share workspace', async () => { - const oscLinks = [ - { row: 0, startCol: 5, endCol: 15, uri: 'file://server/share/repo/app.ts#L12' } - ] - const { posted } = boot('open artifact here', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '//server/share/repo/app.ts', line: 12, column: null }] - }) - }) - - it('keeps a tap on prose beside a path an ordinary tap', async () => { - const line = 'me@host:~/proj$ git status' - const { posted } = boot(line) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('git') + 1), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toBeUndefined() - expect(posted.find((m) => m.type === 'terminal-tap')).toBeDefined() - }) - - it('opens a markdown link whose destination has spaces', async () => { - const line = 'see [PDF]()' - const { posted } = boot(line) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('with') + 1), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '/tmp/folder with spaces/report.pdf', line: null, column: null }] - }) - }) - - it('opens plain file URLs through terminal file taps', async () => { - const line = 'open file:///tmp/result.json#L12C3 now' - const { posted } = boot(line) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('result')), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({ - type: 'terminal-file-tap', - candidates: [{ pathText: '/tmp/result.json', line: 12, column: 3 }] - }) - }) - - it('opens the whole URL when the tap lands on its soft-wrapped second row', async () => { - const url = `https://example.com/${'a'.repeat(70)}/end` - const text = `see ${url} now` - const { posted } = boot(`${text.slice(0, 80)}\n${text.slice(80)}`) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(2), y: tapY + 15 * 0.3125 }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')?.url).toBe(url) - }) - - it('does not open snapshot OSC links from adjacent terminal cells', async () => { - const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] - const { posted } = boot('issue #1234 done', oscLinks) - await settle() - - fireTouch('touchstart', [{ x: screenXForCol(12), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - }) - - it('does not open stale snapshot OSC links after the row text changes', async () => { - const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }] - const { posted, setLine } = boot('issue #1234 done', oscLinks) - await settle() - setLine('issue plain done') - - fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - }) - - it('does not post open-url for a scroll gesture past the tap slop', async () => { - const { posted } = boot(URL_LINE) - await settle() - fireTouch('touchstart', [{ x: tapX, y: tapY }]) - fireTouch('touchmove', [{ x: tapX, y: tapY + 120 }]) - fireTouch('touchend', []) - expect(posted.find((m) => m.type === 'open-url')).toBeUndefined() - }) - - // Scaled rows are smaller than tap slop; a short drag can still count as a real row scroll. - it('does not tap after a short drag that scrolled scrollback', async () => { - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 88 }]) - fireTouch('touchend', []) - expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined() - }) - - it('does not tap or click after a short drag that scrolled a mouse-tracking TUI', async () => { - const { posted } = boot('interactive prompt', undefined, 'drag') - await settle() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' }) - }) - ) - - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 88 }]) - fireTouch('touchend', []) - - expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined() - const input = posted - .filter((m) => m.type === 'terminal-input') - .map((m) => String(m.bytes)) - .join('') - const reports = input.split('\u001b[M').slice(1) - expect(reports.length).toBeGreaterThan(0) - expect(reports.every((report) => report[0] === '`' || report[0] === 'a')).toBe(true) - }) - - it('still taps when the touch moves less than a row', async () => { - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 97 }]) - fireTouch('touchend', []) - expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1) - }) - - it('still taps when the touch moves a row and back before anything scrolls', async () => { - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 100 }]) - fireTouch('touchmove', [{ x: 20, y: 94 }]) - fireTouch('touchmove', [{ x: 20, y: 100 }]) - fireTouch('touchend', []) - expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1) - }) - - it('does not tap when the touch stops a coast', async () => { - let now = 1_000 - vi.spyOn(Date, 'now').mockImplementation(() => now) - const { posted } = boot('plain output', undefined, 'none', 50) - await settle() - fireTouch('touchstart', [{ x: 20, y: 300 }]) - now += 16 - fireTouch('touchmove', [{ x: 20, y: 240 }]) - now += 16 - fireTouch('touchmove', [{ x: 20, y: 180 }]) - fireTouch('touchend', []) - const beforeStoppingTouch = posted.length - - fireTouch('touchstart', [{ x: 20, y: 180 }]) - fireTouch('touchend', []) - - expect(posted.slice(beforeStoppingTouch).find((m) => m.type === 'terminal-tap')).toBeUndefined() - }) -}) diff --git a/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts b/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts deleted file mode 100644 index 33f0c635258..00000000000 --- a/mobile/src/terminal/terminal-webview-wheel-scroll.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' -import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' - -function bodyMarkup(): string { - return TERMINAL_DOCUMENT_MARKUP -} - -type BufferState = { - baseY: number - type: 'alternate' | 'normal' - viewportY: number -} - -type TerminalStub = ReturnType -type RegisteredWindowListener = { - listener: EventListenerOrEventListenerObject - options?: boolean | AddEventListenerOptions - type: string -} - -const CELL_HEIGHT = 15 -const ESC = '\u001b' -const ESC_ARROW_DOWN = `${ESC}[B` -const ESC_APP_ARROW_UP = `${ESC}OA` - -function makeTerminal(buffer: BufferState, scrollLines: (lines: number) => void) { - const terminal = { - cols: 40, - rows: 24, - options: { fontSize: 13 }, - modes: { mouseTrackingMode: 'none' as string }, - element: null as HTMLElement | null, - _core: { - _renderService: { dimensions: { css: { cell: { width: 8, height: CELL_HEIGHT } } } } - }, - buffer: { - active: { - get baseY() { - return buffer.baseY - }, - get type() { - return buffer.type - }, - get viewportY() { - return buffer.viewportY - }, - cursorY: 0, - length: 1, - getLine: () => null - } - }, - write(_data: string, callback?: () => void) { - callback?.() - }, - open(surface: HTMLElement) { - terminal.element = surface - }, - loadAddon() {}, - resize(cols: number, rows: number) { - terminal.cols = cols - terminal.rows = rows - }, - clear() {}, - reset() {}, - refresh() {}, - selectAll() {}, - clearSelection() {}, - select() {}, - scrollLines, - scrollToBottom() {}, - scrollToLine() {}, - attachCustomKeyEventHandler() {}, - getSelection: () => '', - onData: () => ({ dispose() {} }), - onLineFeed: () => ({ dispose() {} }), - onScroll: () => ({ dispose() {} }), - onWriteParsed: () => ({ dispose() {} }), - dispose() {} - } - return terminal -} - -function dispatchWheel(deltaY: number, init: WheelEventInit = {}): WheelEvent { - const surface = document.getElementById('terminal-surface') - if (!surface) { - throw new Error('terminal surface missing') - } - const event = new WheelEvent('wheel', { - bubbles: true, - cancelable: true, - clientX: 40, - clientY: 60, - deltaMode: 0, - deltaY, - ...init - }) - if (init.ctrlKey) { - // Happy DOM drops wheel modifier initialization; set flags explicitly. - Object.defineProperty(event, 'ctrlKey', { value: true }) - } - surface.dispatchEvent(event) - return event -} - -function terminalInputBytes(postMessage: ReturnType): string { - return postMessage.mock.calls - .map(([raw]) => JSON.parse(String(raw)) as { bytes?: string; type: string }) - .filter((msg) => msg.type === 'terminal-input') - .map((msg) => msg.bytes ?? '') - .join('') -} - -describe('terminal WebView external pointer wheel scrolling', () => { - let animationFrames: Array<() => void> - let buffer: BufferState - let postMessage: Mock<(data: string) => void> - let registeredWindowListeners: RegisteredWindowListener[] - let scrollLines: Mock<(lines: number) => void> - let terminals: TerminalStub[] - - function boot(): void { - document.body.innerHTML = bodyMarkup() - new Function(TERMINAL_DOCUMENT_SCRIPT)() - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' }) - }) - ) - // Wait for init’s next-frame surface commit. - while (animationFrames.length > 0) { - animationFrames.shift()?.() - } - } - - function flushFrames(): void { - for (let i = 0; i < 4 && animationFrames.length > 0; i++) { - animationFrames.shift()?.() - } - } - - beforeEach(() => { - animationFrames = [] - buffer = { baseY: 0, type: 'normal', viewportY: 0 } - registeredWindowListeners = [] - scrollLines = vi.fn<(lines: number) => void>() - terminals = [] - const addWindowEventListener = window.addEventListener.bind(window) - vi.spyOn(window, 'addEventListener').mockImplementation((( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ) => { - registeredWindowListeners.push({ type, listener, options }) - addWindowEventListener(type, listener, options) - }) as typeof window.addEventListener) - vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { - animationFrames.push(callback) - return animationFrames.length - }) - vi.stubGlobal('cancelAnimationFrame', () => {}) - Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true }) - Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true }) - postMessage = vi.fn<(data: string) => void>() - const webWindow = window as unknown as { - Terminal: new () => TerminalStub - ReactNativeWebView: { postMessage: (data: string) => void } - } - webWindow.Terminal = function () { - const terminal = makeTerminal(buffer, scrollLines) - terminals.push(terminal) - return terminal - } as unknown as new () => TerminalStub - webWindow.ReactNativeWebView = { postMessage } - }) - - afterEach(() => { - for (const { type, listener, options } of registeredWindowListeners) { - window.removeEventListener(type, listener as EventListener, options) - } - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it('turns an alternate-screen wheel scroll into cursor keys (#6863, #8818)', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - expect(terminals).toHaveLength(1) - - dispatchWheel(3 * CELL_HEIGHT) - - // Alternate-screen TUIs have no scrollback; wheels must reach the PTY as input, matching touch. - expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(3)) - }) - - it('sends application cursor keys when the TUI enabled DECCKM', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - const terminal = terminals[0] - if (!terminal) { - throw new Error('terminal missing') - } - ;(terminal.modes as Record).applicationCursorKeysMode = true - - dispatchWheel(-2 * CELL_HEIGHT) - - expect(terminalInputBytes(postMessage)).toBe(ESC_APP_ARROW_UP.repeat(2)) - }) - - it('reports the wheel as mouse tracking bytes when the TUI asked for them', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - const terminal = terminals[0] - if (!terminal) { - throw new Error('terminal missing') - } - terminal.modes.mouseTrackingMode = 'any' - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` }) - }) - ) - - dispatchWheel(CELL_HEIGHT) - - const bytes = terminalInputBytes(postMessage) - expect(bytes.slice(0, 4)).toBe(`${ESC}[Ma`) - expect(bytes).toHaveLength(6) - expect(bytes.charCodeAt(4)).toBeGreaterThanOrEqual(33) - expect(bytes.charCodeAt(5)).toBeGreaterThanOrEqual(33) - }) - - it('scrolls normal-buffer scrollback from a wheel instead of leaving it frozen', () => { - buffer = { baseY: 20, type: 'normal', viewportY: 20 } - boot() - - dispatchWheel(-3 * CELL_HEIGHT) - flushFrames() - - expect(scrollLines).toHaveBeenCalledWith(-3) - }) - - it('converts line-mode deltas that Android reports for external mouse wheels', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - - dispatchWheel(2, { deltaMode: 1 }) - - expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(2)) - }) - - it('swallows a trackpad pinch instead of firing cursor keys at the TUI', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - - const event = dispatchWheel(3 * CELL_HEIGHT, { ctrlKey: true }) - - expect(terminalInputBytes(postMessage)).toBe('') - expect(event.defaultPrevented).toBe(true) - }) - - it('clears fractional wheel state when the terminal is recreated', () => { - buffer = { baseY: 0, type: 'alternate', viewportY: 0 } - boot() - dispatchWheel(CELL_HEIGHT - 1) - - window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' }) - }) - ) - flushFrames() - dispatchWheel(1) - - expect(terminalInputBytes(postMessage)).toBe('') - }) -}) diff --git a/mobile/src/terminal/terminal-write-coalescer.test.ts b/mobile/src/terminal/terminal-write-coalescer.test.ts index 5cc20d8fef1..41e833f8990 100644 --- a/mobile/src/terminal/terminal-write-coalescer.test.ts +++ b/mobile/src/terminal/terminal-write-coalescer.test.ts @@ -1,11 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { - createTerminalWriteCoalescer, - TERMINAL_WRITE_FLUSH_WINDOW_MS, - TERMINAL_WRITE_MAX_PENDING_UNITS, - TERMINAL_INTERACTIVE_WRITE_WINDOW_MS, - TERMINAL_INTERACTIVE_WRITE_DURATION_MS -} from './terminal-write-coalescer' +import { createTerminalWriteCoalescer } from './terminal-write-coalescer' function createDeliverySink() { const delivered: string[] = [] @@ -32,7 +26,7 @@ describe('terminal write coalescer', () => { coalescer.write('c') expect(sink.delivered).toEqual(['a']) - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS) + vi.advanceTimersByTime(48) expect(sink.delivered).toEqual(['a', 'bc']) }) @@ -47,25 +41,10 @@ describe('terminal write coalescer', () => { vi.setSystemTime(100_000 - 60 * 60 * 1000) coalescer.write('b') - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS) + vi.advanceTimersByTime(48) expect(sink.delivered).toEqual(['a', 'b']) }) - it('flushes immediately when the pending buffer exceeds the size cap', () => { - vi.useFakeTimers() - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - - coalescer.write('a') - coalescer.write('x'.repeat(TERMINAL_WRITE_MAX_PENDING_UNITS + 1)) - - expect(sink.delivered).toHaveLength(2) - expect(sink.delivered[1]).toHaveLength(TERMINAL_WRITE_MAX_PENDING_UNITS + 1) - expect(vi.getTimerCount()).toBe(0) - vi.runOnlyPendingTimers() - expect(sink.delivered).toHaveLength(2) - }) - it('flushNow() delivers the joined pending buffer synchronously and cancels the timer', () => { vi.useFakeTimers() const sink = createDeliverySink() @@ -77,25 +56,10 @@ describe('terminal write coalescer', () => { coalescer.flushNow() expect(sink.delivered).toEqual(['a', 'bc']) - expect(vi.getTimerCount()).toBe(0) vi.runOnlyPendingTimers() expect(sink.delivered).toEqual(['a', 'bc']) }) - it('shortens an existing output wait when a scroll gesture arrives', () => { - vi.useFakeTimers() - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - coalescer.write('first') - vi.advanceTimersByTime(5) - coalescer.write('response') - vi.advanceTimersByTime(3) - coalescer.markInteractive() - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_WINDOW_MS - 8) - expect(sink.delivered).toEqual(['first', 'response']) - expect(vi.getTimerCount()).toBe(0) - }) - it('does not postpone output when gestures repeat faster than one frame', () => { vi.useFakeTimers() const sink = createDeliverySink() @@ -105,11 +69,9 @@ describe('terminal write coalescer', () => { for (let i = 0; i < 8; i += 1) { coalescer.write(`${i}`) coalescer.markInteractive() - expect(vi.getTimerCount()).toBe(1) vi.advanceTimersByTime(2) } expect(sink.delivered).toEqual(['first', '01234567']) - expect(vi.getTimerCount()).toBe(0) }) it('clears interactive state and pending output when a document is replaced', () => { @@ -122,25 +84,9 @@ describe('terminal write coalescer', () => { coalescer.clear() coalescer.write('new') coalescer.write('new-trailing') - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) + vi.advanceTimersByTime(16) expect(sink.delivered).toEqual(['old', 'new']) - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS - TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) + vi.advanceTimersByTime(32) expect(sink.delivered).toEqual(['old', 'new', 'new-trailing']) }) - - it('expires interactive batching on a monotonic clock after a wall-clock rollback', () => { - vi.useFakeTimers() - vi.setSystemTime(100_000) - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - coalescer.markInteractive() - vi.setSystemTime(0) - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_DURATION_MS) - coalescer.write('first') - coalescer.write('second') - vi.advanceTimersByTime(TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) - expect(sink.delivered).toEqual(['first']) - vi.advanceTimersByTime(TERMINAL_WRITE_FLUSH_WINDOW_MS - TERMINAL_INTERACTIVE_WRITE_WINDOW_MS) - expect(sink.delivered).toEqual(['first', 'second']) - }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-click.test.ts b/mobile/src/terminal/use-terminal-live-input-click.test.ts index c855decfb7d..059167df485 100644 --- a/mobile/src/terminal/use-terminal-live-input-click.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-click.test.ts @@ -17,12 +17,11 @@ type ClickHarness = { readonly captures: readonly string[] readonly handlers: Handlers readonly sent: readonly string[] - readonly changeHandle: (handle: string) => void readonly unmount: () => void } function createClickHarness(sender?: TerminalLiveInputSender): ClickHarness { - let activeHandle = 'terminal-a' + const activeHandle = 'terminal-a' const activeHandleRef: RefObject = { current: activeHandle } const activeSessionTabTypeRef: RefObject = { current: 'terminal' } const captures: string[] = [] @@ -69,11 +68,6 @@ function createClickHarness(sender?: TerminalLiveInputSender): ClickHarness { return handlers }, sent, - changeHandle: (handle) => { - activeHandle = handle - activeHandleRef.current = handle - act(() => renderer?.update(createElement(Harness))) - }, unmount: () => { act(() => renderer?.unmount()) } @@ -171,21 +165,18 @@ describe('terminal live input around a click', () => { } }) - it.each(['insertText', 'insertFromPaste', 'insertCompositionText'])( - 'preserves immediate matching DOM input %s without a key event', - async (inputType) => { - const { handlers, sent, captures, unmount } = createClickHarness() - try { - change(handlers, 'x') - await handlers.sendLiveInputClick('terminal-a', CLICK) - handlers.handleLiveInputChange({ - nativeEvent: { text: 'x', isComposing: false, inputType } - }) - await vi.waitFor(() => expect(sent.join('')).toBe('x' + CLICK + 'x')) - expect(captures.at(-1)).toBe('x') - } finally { - unmount() - } + it('preserves immediate matching DOM input without a key event', async () => { + const { handlers, sent, captures, unmount } = createClickHarness() + try { + change(handlers, 'x') + await handlers.sendLiveInputClick('terminal-a', CLICK) + handlers.handleLiveInputChange({ + nativeEvent: { text: 'x', isComposing: false, inputType: 'insertText' } + }) + await vi.waitFor(() => expect(sent.join('')).toBe(`x${CLICK}x`)) + expect(captures.at(-1)).toBe('x') + } finally { + unmount() } - ) + }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 69927053d8e..9f45a1c1abc 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import type { TextInputInstance } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLiveInputSender } from './terminal-live-input-sender' -import { TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS } from './terminal-live-preedit-mirror' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' vi.mock('../platform/live-input-composing-range', () => ({ @@ -21,25 +20,18 @@ function changeLiveInput( } type TerminalLiveInputCommitHarness = { - readonly failures: ReturnType readonly captures: readonly string[] - readonly getHandlers: () => TerminalLiveInputCommitHandlers readonly handlers: TerminalLiveInputCommitHandlers readonly sent: readonly string[] - readonly setActiveSessionTabType: (next: string | undefined) => void - readonly setConnected: (next: boolean) => void - readonly setSendResult: (next: boolean) => void readonly unmount: () => void } type TerminalLiveInputCommitHarnessOptions = { - readonly sendResult?: boolean readonly sender?: TerminalLiveInputSender readonly bracketedPaste?: boolean } function createTerminalLiveInputCommitHarness({ - sendResult = true, sender, bracketedPaste = false }: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { @@ -52,16 +44,12 @@ function createTerminalLiveInputCommitHarness({ } const liveInputRef: RefObject = { current: null } const sent: string[] = [] - const failures = vi.fn() - let currentSendResult = sendResult const sendLiveTerminalInputRef: RefObject = { current: async (handle, bytes) => { sent.push(bytes) - return sender ? sender(handle, bytes) : currentSendResult + return sender ? sender(handle, bytes) : true } } - let currentActiveSessionTabType: string | undefined = 'terminal' - let currentConnected = true let handlers: TerminalLiveInputCommitHandlers | null = null let renderer: ReactTestRenderer | null = null @@ -69,10 +57,9 @@ function createTerminalLiveInputCommitHarness({ handlers = useTerminalLiveInputCommit({ activeHandle, activeHandleRef, - activeSessionTabType: currentActiveSessionTabType, + activeSessionTabType: 'terminal', activeSessionTabTypeRef, - connected: currentConnected, - onSendFailure: failures, + connected: true, framesLivePaste: () => bracketedPaste, liveInputRef, sendLiveTerminalInputRef, @@ -89,33 +76,9 @@ function createTerminalLiveInputCommitHarness({ } return { - failures, captures, - getHandlers: () => { - if (!handlers) { - throw new Error('terminal live input hook is not mounted') - } - return handlers - }, handlers, sent, - setActiveSessionTabType: (next: string | undefined): void => { - currentActiveSessionTabType = next - // Couple tab ref and prop as production does; both become null during inventory lag. - activeSessionTabTypeRef.current = next ?? null - act(() => { - renderer?.update(createElement(Harness)) - }) - }, - setConnected: (next: boolean): void => { - currentConnected = next - act(() => { - renderer?.update(createElement(Harness)) - }) - }, - setSendResult: (next: boolean): void => { - currentSendResult = next - }, unmount: () => { act(() => renderer?.unmount()) } @@ -134,7 +97,7 @@ describe('terminal live input commit hook', () => { .fn() .mockReturnValueOnce(pending) .mockResolvedValue(true) - const { handlers, sent, captures, failures, unmount } = createTerminalLiveInputCommitHarness({ + const { handlers, sent, captures, unmount } = createTerminalLiveInputCommitHarness({ sender }) changeLiveInput(handlers, 'abc', false) @@ -145,14 +108,12 @@ describe('terminal live input commit hook', () => { await expect(submit).resolves.toBe(false) expect(sent).toEqual(['abc']) expect(captures.at(-1)).toBe('') - expect(failures).toHaveBeenCalledTimes(1) changeLiveInput(handlers, 'ab', false) changeLiveInput(handlers, 'x', false) await vi.waitFor(() => expect(sent).toEqual(['abc', 'x'])) changeLiveInput(handlers, '', false) await vi.waitFor(() => expect(sent).toEqual(['abc', 'x', '\x7f'])) - expect(failures).toHaveBeenCalledTimes(1) unmount() } ) @@ -170,7 +131,7 @@ describe('terminal live input commit hook', () => { expect(sent).toEqual([`\x1b[200~${text}\x1b[201~`, 'CLICK']) expect(captures.at(-1)).toBe('') changeLiveInput(handlers, 'x', false) - await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2) + await vi.advanceTimersByTimeAsync(600) expect(sent).toEqual([`\x1b[200~${text}\x1b[201~`, 'CLICK', 'x']) } finally { unmount() diff --git a/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx b/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx index 37688c3ff3a..e26bca9fc6b 100644 --- a/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx +++ b/mobile/src/terminal/use-terminal-text-field-submit-binding.test.tsx @@ -7,27 +7,19 @@ import { useTerminalTextFieldSubmitBinding } from './use-terminal-text-field-sub // Mock only the platform listener seam so tests can invoke the closure it actually captured. const mocks = vi.hoisted(() => { - const boundHandlers: Array<() => void> = [] return { - boundHandlers, - chordHandlers: new Array<(input: TerminalLiveAccessoryInput) => boolean>(), - unbindCount: 0, - releasedCount: 0 + chordHandlers: new Array<(input: TerminalLiveAccessoryInput) => boolean>() } }) vi.mock('./terminal-text-field-submit-binding', () => ({ bindTerminalTextFieldSubmit: ( _node: unknown, - onSubmit: () => void, + _onSubmit: () => void, onChord: (input: TerminalLiveAccessoryInput) => boolean ) => { mocks.chordHandlers.push(onChord) - mocks.boundHandlers.push(onSubmit) - mocks.unbindCount += 1 - return () => { - mocks.releasedCount += 1 - } + return () => {} } })) @@ -59,31 +51,10 @@ const node = {} as TextInputInstance function reset(): void { submitted = [] mocks.chordHandlers.length = 0 - mocks.boundHandlers.length = 0 - mocks.unbindCount = 0 - mocks.releasedCount = 0 } describe('the terminal text field submit binding', () => { - it('calls the handler the newest commit supplied, not the first', () => { - reset() - let renderer: ReactTestRenderer | null = null - - act(() => { - renderer = create(createElement(Harness, { value: 'first' })) - }) - act(() => { - renderer?.update(createElement(Harness, { value: 'second' })) - }) - act(() => { - mocks.boundHandlers.at(-1)?.() - }) - - expect(submitted).toEqual(['second']) - act(() => renderer?.unmount()) - }) - - it('refreshes a keyboard chord handler after a terminal or connection change', () => { + it('refreshes a captured keyboard chord handler after a commit', () => { reset() let renderer: ReactTestRenderer | null = null act(() => { @@ -98,22 +69,4 @@ describe('the terminal text field submit binding', () => { expect(submitted).toEqual(['first', 'second']) act(() => renderer?.unmount()) }) - - it('drops the binding when the field unmounts', () => { - reset() - let renderer: ReactTestRenderer | null = null - - act(() => { - renderer = create(createElement(Harness, { value: 'first' })) - }) - act(() => { - mocks.boundHandlers.at(-1)?.() - }) - - expect(submitted).toEqual(['first']) - expect(field.current).toBe(node) - act(() => renderer?.unmount()) - expect(field.current).toBeNull() - expect(mocks.releasedCount).toBe(mocks.unbindCount) - }) }) diff --git a/src/cli/runtime/envelope-schema.test.ts b/src/cli/runtime/envelope-schema.test.ts index 75a00185cde..365f4c5de09 100644 --- a/src/cli/runtime/envelope-schema.test.ts +++ b/src/cli/runtime/envelope-schema.test.ts @@ -1,17 +1,7 @@ import { describe, expect, it } from 'vitest' -import { RuntimeRpcEnvelopeSchema, isKeepaliveFrame } from './envelope-schema' +import { RuntimeRpcEnvelopeSchema } from './envelope-schema' describe('RuntimeRpcEnvelopeSchema', () => { - it('accepts a well-formed success envelope', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: true, - result: { anything: 1 }, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(true) - }) - it('accepts and strips additive future success fields', () => { const parsed = RuntimeRpcEnvelopeSchema.safeParse({ id: 'req-1', @@ -32,33 +22,6 @@ describe('RuntimeRpcEnvelopeSchema', () => { }) }) - it('accepts a well-formed failure envelope', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { code: 'not_found', message: 'not_found' }, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(true) - }) - - it('accepts structured failure data for agent recovery hints', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { - code: 'app_not_found', - message: 'app not found: Gmail', - data: { - nextSteps: ['Target the desktop browser app/window that contains Gmail.'] - } - }, - _meta: { runtimeId: 'runtime-1' } - }) - - expect(parsed.success).toBe(true) - }) - it('accepts and strips additive future failure fields', () => { const parsed = RuntimeRpcEnvelopeSchema.safeParse({ id: 'req-1', @@ -83,82 +46,4 @@ describe('RuntimeRpcEnvelopeSchema', () => { } }) }) - - it('accepts a failure envelope without _meta', () => { - // Failures before runtimeId resolution legitimately omit _meta. - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } - }) - expect(parsed.success).toBe(true) - }) - - it('rejects a frame missing the ok discriminator', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(false) - }) - - it('rejects a frame with a non-string id', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 123, - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - expect(parsed.success).toBe(false) - }) - - it('still rejects malformed known fields when future fields are present', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ - id: 'req-1', - ok: false, - error: { code: 500, message: 'Nope', futureErrorField: true }, - futureEnvelopeField: 'new' - }) - - expect(parsed.success).toBe(false) - }) - - it('rejects a frame with unrelated fields only', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ hello: 'world' }) - expect(parsed.success).toBe(false) - }) - - it('accepts a keepalive frame', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: true }) - expect(parsed.success).toBe(true) - }) - - it('rejects a keepalive frame with _keepalive !== true', () => { - const parsed = RuntimeRpcEnvelopeSchema.safeParse({ _keepalive: false }) - expect(parsed.success).toBe(false) - }) -}) - -describe('isKeepaliveFrame', () => { - it('returns true for a well-formed keepalive', () => { - expect(isKeepaliveFrame({ _keepalive: true })).toBe(true) - }) - - it('returns false for a success envelope', () => { - expect(isKeepaliveFrame({ id: 'x', ok: true, result: {}, _meta: { runtimeId: 'r' } })).toBe( - false - ) - }) - - it('returns false for non-object inputs', () => { - expect(isKeepaliveFrame(null)).toBe(false) - expect(isKeepaliveFrame(undefined)).toBe(false) - expect(isKeepaliveFrame('keepalive')).toBe(false) - }) - - it('returns false when _keepalive is not strictly true', () => { - expect(isKeepaliveFrame({ _keepalive: 1 })).toBe(false) - expect(isKeepaliveFrame({ _keepalive: 'true' })).toBe(false) - }) }) diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts index 67ea0019a0e..c27b5b7bdcf 100644 --- a/src/cli/runtime/profile-state-recovery-launch.test.ts +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -4,10 +4,7 @@ import { PROFILE_STATE_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_RESULT_PREFIX } from '../../shared/profile-state-recovery-command' -import { - canLaunchProfileStateRecovery, - launchProfileStateRecovery -} from './profile-state-recovery-launch' +import { launchProfileStateRecovery } from './profile-state-recovery-launch' const mocks = vi.hoisted(() => ({ run: vi.fn() })) vi.mock('@orca/process-host', () => ({ runProcess: mocks.run })) @@ -47,17 +44,6 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()) describe('profile-state recovery launch', () => { - it('requires an app executable or Electron Node mode to launch recovery', () => { - vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) - vi.stubEnv('ORCA_APP_EXECUTABLE', undefined) - expect(canLaunchProfileStateRecovery()).toBe(false) - vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') - expect(canLaunchProfileStateRecovery()).toBe(true) - vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) - vi.stubEnv('ORCA_APP_EXECUTABLE', '/explicit/Orca') - expect(canLaunchProfileStateRecovery()).toBe(true) - }) - it('uses a foreground-safe serve request and binds the canonical recovery root', async () => { vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/root') @@ -81,21 +67,6 @@ describe('profile-state recovery launch', () => { expect(mocks.run.mock.calls[0][0].env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') }) - it('round-trips current JSON selection without requiring an invented revision', async () => { - const current = { ...result, revision: null } - mocks.run.mockResolvedValue({ - code: 0, - signal: null, - timedOut: false, - stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result: current })}\n`, - stderr: '' - }) - expect( - await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'current-json' } }) - ).toEqual(current) - expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"current-json"') - }) - it('preserves a structured refusal from the lock owner', async () => { mocks.run.mockResolvedValue({ code: 1, @@ -107,13 +78,6 @@ describe('profile-state recovery launch', () => { }) }) - it('round-trips latest JSON selection and the exported SQLite revision', async () => { - expect( - await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'latest-json' } }) - ).toEqual(result) - expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"latest-json"') - }) - it.each([ { code: 1 }, { signal: 'SIGKILL' }, @@ -121,41 +85,12 @@ describe('profile-state recovery launch', () => { { outputTruncated: true }, { stdout: '' }, { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{` }, - { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` }, - { - stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}\n${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}` - } - ])('rejects incomplete or ambiguous child results %j', async (override) => { + { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` } + ])('rejects incomplete child results %j', async (override) => { const original = await mocks.run() mocks.run.mockResolvedValue({ ...original, ...override }) await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ code: 'runtime_error' }) }) - - it('propagates launch failure without retrying another recovery path', async () => { - mocks.run.mockRejectedValue(new Error('Executable unavailable')) - await expect(launchProfileStateRecovery(request)).rejects.toThrow('Executable unavailable') - expect(mocks.run).toHaveBeenCalledOnce() - }) - - it('retains bounded child diagnostics when recovery exits without a result', async () => { - mocks.run.mockResolvedValue({ - code: null, - signal: 'SIGTRAP', - timedOut: false, - stdout: '', - stderr: `${'x'.repeat(5000)}\nsandbox unavailable\n` - }) - await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ - data: { - exitCode: null, - signal: 'SIGTRAP', - timedOut: false, - outputTruncated: false, - stderr: `${'x'.repeat(5000)}\nsandbox unavailable`.slice(-4096) - } - }) - expect(mocks.run).toHaveBeenCalledOnce() - }) }) diff --git a/src/cli/runtime/runtime-listener-probe.test.ts b/src/cli/runtime/runtime-listener-probe.test.ts index e0018e059c8..05ddf3ff7d1 100644 --- a/src/cli/runtime/runtime-listener-probe.test.ts +++ b/src/cli/runtime/runtime-listener-probe.test.ts @@ -109,30 +109,4 @@ describe('probeRuntimeListener', () => { vi.useRealTimers() } }) - - it.each([ - ['a missing endpoint', undefined], - ['a blank endpoint', ' '], - ['a numeric endpoint', 42] - ])('frees a profile whose metadata carries %s instead of dialling it', async (_label, value) => { - // Invalid endpoints can throw or dial an unrelated numeric TCP port. - const metadata = metadataFor(nextEndpoint()) - const transport = { ...metadata.transports[0]! } - Object.assign(transport, { endpoint: value }) - - await expect(probeRuntimeListener({ ...metadata, transports: [transport] })).resolves.toBe( - 'not-listening' - ) - }) - - it('frees a profile whose metadata published no local transport to probe', async () => { - const metadata = metadataFor(nextEndpoint()) - - await expect( - probeRuntimeListener({ - ...metadata, - transports: metadata.transports.filter((transport) => transport.kind === 'websocket') - }) - ).resolves.toBe('not-listening') - }) }) diff --git a/src/cli/runtime/runtime-source-fence.test.ts b/src/cli/runtime/runtime-source-fence.test.ts index 95667fa8c8e..e73676313b3 100644 --- a/src/cli/runtime/runtime-source-fence.test.ts +++ b/src/cli/runtime/runtime-source-fence.test.ts @@ -11,7 +11,6 @@ import { } from '../../shared/runtime-source-env' import { RuntimeClient } from './client' import { RuntimeClientError } from './types' -import { resolveFencedRuntimeSource } from './runtime-source-fence' const SOURCE_A = '11111111-1111-4111-8111-111111111111' const SOURCE_B = '22222222-2222-4222-8222-222222222222' @@ -126,27 +125,16 @@ describe('RuntimeClient runtime-source fence', () => { expect(runtime.methods).toEqual(['status.get', 'status.get']) }) - it('accepts a restarted runtime of the same source and probes it only once', async () => { + it('accepts a restarted runtime of the same source and carries its verified identity', async () => { const runtime = await startRuntime({ runtimeId: 'runtime-a2', installationId: SOURCE_A }) launchedBy(SOURCE_A, 'runtime-a1') const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') await client.call('terminal.send', { terminal: 't1', text: 'hi' }) - await client.call('worktree.list') - expect(runtime.methods).toEqual(['status.get', 'terminal.send', 'worktree.list']) + expect(runtime.methods).toEqual(['status.get', 'terminal.send']) // The host must recheck identity if it changes after the probe. const expected = { sourceId: SOURCE_A, runtimeId: 'runtime-a2' } - expect(runtime.expectations).toEqual([undefined, expected, expected]) - }) - - it('sends without a probe when the launching runtime is still the live one', async () => { - const runtime = await startRuntime({ runtimeId: 'runtime-a', installationId: SOURCE_A }) - launchedBy(SOURCE_A, 'runtime-a') - const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') - - await client.call('worktree.list') - expect(runtime.methods).toEqual(['worktree.list']) - expect(runtime.expectations).toEqual([{ sourceId: SOURCE_A, runtimeId: 'runtime-a' }]) + expect(runtime.expectations).toEqual([undefined, expected]) }) it('refuses when the live runtime cannot say which profile it serves', async () => { @@ -177,11 +165,4 @@ describe('RuntimeClient runtime-source fence', () => { await client.call('worktree.list') expect(runtime.methods).toEqual(['worktree.list']) }) - - it('still fences a terminal whose CLI fell back to the platform default profile', () => { - const stamp = { sourceId: SOURCE_A, incarnation: 'runtime-a', profilePath: '/profiles/custom' } - expect(resolveFencedRuntimeSource(stamp, '/profiles/default', '/profiles/default')).toBe(stamp) - expect(resolveFencedRuntimeSource(stamp, '/profiles/custom', '/profiles/default')).toBe(stamp) - expect(resolveFencedRuntimeSource(stamp, '/profiles/dev', '/profiles/default')).toBeNull() - }) }) diff --git a/src/cli/runtime/serve-appimage-update.test.ts b/src/cli/runtime/serve-appimage-update.test.ts index c9535d0c42a..90b44aa2424 100644 --- a/src/cli/runtime/serve-appimage-update.test.ts +++ b/src/cli/runtime/serve-appimage-update.test.ts @@ -68,14 +68,6 @@ function restartDeps(overrides: Partial = {}): AppImageRest } describe('supervised serve and a replaced AppImage', () => { - it('finds the AppImage a stable-launcher service was unpacked from', async () => { - const { appImagePath, executable } = await makeRunningGeneration() - const watch = resolveAppImageBuildWatch(executable, {}, 'linux') - expect(watch?.appImagePath).toBe(appImagePath) - expect(inspectAppImageBuild(watch!)).toBe('current') - expect(resolveAppImageBuildWatch(executable, {}, 'darwin')).toBeNull() - }) - it('ignores a source record that names another AppImage', async () => { const { root, executable } = await makeRunningGeneration() const namespacePath = join(executable, '..', '..') @@ -159,10 +151,9 @@ describe('supervised serve and a replaced AppImage', () => { const watch = resolveAppImageBuildWatch(executable, {}, 'linux')! await swapIn(appImagePath, 'build C') const attemptPath = join(root, 'attempt.json') - const logs: string[] = [] const make = (overrides: { force?: boolean; runningKey?: string } = {}) => createSupervisedAppImageUpdate({ - ...restartDeps({ log: (line) => logs.push(line) }), + ...restartDeps(), watch: { ...watch, runningKey: overrides.runningKey ?? watch.runningKey }, attemptPath, force: overrides.force ?? false @@ -172,13 +163,10 @@ describe('supervised serve and a replaced AppImage', () => { expect(first.inspect()).toBe('superseded') await first.restart(() => false) const candidateKey = resolveAppImageCacheKey(appImagePath) - expect(JSON.parse(await readFile(attemptPath, 'utf8'))).toEqual({ candidateKey }) // The new build died early; the service manager restarted the old one. const second = make() expect(second.inspect()).toBe('current') - expect(second.inspect()).toBe('current') - expect(logs.filter((line) => line.includes('did not come up'))).toHaveLength(1) expect(make({ force: true }).inspect()).toBe('superseded') await make().restart(() => false) diff --git a/src/cli/runtime/serve-bundle-park.test.ts b/src/cli/runtime/serve-bundle-park.test.ts index 29e8aa34dc1..ac17bf1b2c8 100644 --- a/src/cli/runtime/serve-bundle-park.test.ts +++ b/src/cli/runtime/serve-bundle-park.test.ts @@ -64,13 +64,6 @@ describe('openServeBundlePark', () => { await writeFile(parkRecordPath(dir), JSON.stringify(record)) } - it('takes part only under an installed launchd label on macOS', () => { - expect(open({})).toBeNull() - expect(open({ XPC_SERVICE_NAME: 'application.com.other' })).toBeNull() - expect(open({ XPC_SERVICE_NAME: LABEL }, 'linux')).toBeNull() - expect(open()).not.toBeNull() - }) - it('registers with its child pid and removes the registration on close', async () => { const park = open()! park.heartbeat(77) @@ -157,8 +150,6 @@ describe('openServeBundlePark', () => { it('holds while committed until the bundle changes, then releases', async () => { const park = open()! await writeRecord({ phase: 'commit', commitDeadlineAt: Date.now() + 60_000 }) - const bundleBefore = JSON.parse(await readFile(parkRecordPath(dir), 'utf8')).bundleBefore - expect(bundleBefore).toBe('unused') // 'unused' differs from the real identity, which models a bundle ShipIt already replaced. expect(park.holds()).toBe(false) const identity = formatBundleIdentity( diff --git a/src/cli/runtime/serve-electron-preflight.test.ts b/src/cli/runtime/serve-electron-preflight.test.ts index 4dcd0e1cfb9..51b2d1b21b3 100644 --- a/src/cli/runtime/serve-electron-preflight.test.ts +++ b/src/cli/runtime/serve-electron-preflight.test.ts @@ -167,16 +167,11 @@ describe.skipIf(process.platform === 'win32')('serve private admission snapshots writeFileSync(join(root, 'orca-runtime.json'), JSON.stringify(metadata)) return metadata } - it('keeps the missing-M0 default path synchronous without a second read', () => { - expect(preflightElectronServe({}, root, { platform: 'linux' })).toBeNull() - expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(1) - }) it.each([2147483647, process.pid])( 'retains launch permission for absent listener even with PID %s', async (pid) => { publish(pid) await expect(preflightElectronServe({}, root, { platform: 'linux' })).resolves.toBeNull() - expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(2) } ) it.each(['disappeared', 'replacement', 'incomplete'])( @@ -209,7 +204,6 @@ describe.skipIf(process.platform === 'win32')('serve private admission snapshots expect(String(vi.mocked(process.stderr.write).mock.calls.at(-1)?.[0])).toContain( change === 'replacement' ? `pid ${process.pid}` : 'pid 2147483647' ) - expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(2) } ) }) diff --git a/src/cli/runtime/serve-install-hold.test.ts b/src/cli/runtime/serve-install-hold.test.ts deleted file mode 100644 index ad7a85848dc..00000000000 --- a/src/cli/runtime/serve-install-hold.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { readServeInstallEvidence } from './serve-install-hold' - -describe('readServeInstallEvidence', () => { - let root: string - let bundle: string - let executable: string - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-install-hold-')) - bundle = join(root, 'Orca.app') - executable = join(bundle, 'Contents', 'MacOS', 'Orca') - await mkdir(join(bundle, 'Contents', 'MacOS'), { recursive: true }) - await writeFile(executable, '') - }) - - afterEach(async () => { - await rm(root, { recursive: true, force: true }) - }) - - const shipIt = (b: string): string => - `${b}/Contents/Frameworks/Squirrel.framework/Resources/ShipIt com.stablyai.orca.ShipIt /state.plist` - - it('holds while ShipIt replaces this bundle, and when the check cannot run', async () => { - await expect( - readServeInstallEvidence(executable, { - platform: 'darwin', - listProcesses: async () => `/sbin/launchd\n${shipIt(bundle)}\n` - }) - ).resolves.toEqual({ kind: 'hold', reason: 'installer-running' }) - await expect( - readServeInstallEvidence(executable, { platform: 'darwin', listProcesses: async () => null }) - ).resolves.toEqual({ kind: 'hold', reason: 'installer-unknown' }) - }) - - it("ignores another bundle's ShipIt and an executable that merely contains the name", async () => { - await expect( - readServeInstallEvidence(executable, { - platform: 'darwin', - listProcesses: async () => `${shipIt('/Applications/Other.app')}\n${executable} --serve\n` - }) - ).resolves.toEqual({ kind: 'clear' }) - }) - - it('holds while the executable is missing, and skips the macOS check elsewhere', async () => { - await expect(readServeInstallEvidence(join(root, 'gone'))).resolves.toEqual({ - kind: 'hold', - reason: 'bundle-missing' - }) - await expect( - readServeInstallEvidence(executable, { - platform: 'linux', - listProcesses: async () => { - throw new Error('must not list processes off macOS') - } - }) - ).resolves.toEqual({ kind: 'clear' }) - }) -}) diff --git a/src/cli/runtime/serve-pid1-init-reaper.test.ts b/src/cli/runtime/serve-pid1-init-reaper.test.ts index f7d85f427d9..e453de42c3b 100644 --- a/src/cli/runtime/serve-pid1-init-reaper.test.ts +++ b/src/cli/runtime/serve-pid1-init-reaper.test.ts @@ -24,26 +24,6 @@ function deps(overrides: Partial = {}): ServeInitReaperDeps } describe('reexecServeUnderInitReaper', () => { - it('replaces a PID 1 Linux serve with bash running the same command and environment', () => { - const d = deps() - expect(reexecServeUnderInitReaper(d)).toBe('reexec') - expect(d.execve).toHaveBeenCalledWith( - '/bin/bash', - [ - 'bash', - '-c', - SERVE_INIT_REAPER_SCRIPT, - 'orca-serve-init', - '/opt/orca/orca-ide', - '/opt/orca/resources/cli/index.js', - 'serve', - '--port', - '6768' - ], - { ELECTRON_RUN_AS_NODE: '1' } - ) - }) - it('keeps the eval script of a `-e -- serve` launch', () => { const d = deps({ execArgv: ['-e', 'require(cli)'], argv: ['/opt/orca/orca-ide', 'serve'] }) expect(reexecServeUnderInitReaper(d)).toBe('reexec') @@ -87,29 +67,6 @@ describe('reexecServeUnderInitReaper', () => { expect(reexecServeUnderInitReaper(d)).toBe('unavailable') expect(d.execve).not.toHaveBeenCalled() }) - - it('leaves a serve that is not PID 1, or not on Linux, alone', () => { - for (const d of [deps({ pid: 4242 }), deps({ platform: 'darwin' })]) { - expect(reexecServeUnderInitReaper(d)).toBe('not-pid1') - expect(d.execve).not.toHaveBeenCalled() - expect(d.warn).not.toHaveBeenCalled() - } - }) - - it('warns and keeps serving when no bash is installed or the process cannot be replaced', () => { - for (const d of [ - deps({ exists: () => false }), - deps({ execve: null }), - deps({ - execve: () => { - throw new Error('ENOENT') - } - }) - ]) { - expect(reexecServeUnderInitReaper(d)).toBe('unavailable') - expect(d.warn).toHaveBeenCalledWith(expect.stringContaining('docker run --init')) - } - }) }) const bash = ['/bin/bash', '/usr/bin/bash'].find((path) => existsSync(path)) diff --git a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts index cbb0bb24eb8..6868d97c0da 100644 --- a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts +++ b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts @@ -1,15 +1,13 @@ +import { MacServeStartupAbortError, serveSignalExitError } from './serve-signal-exit-diagnostic' import { EventEmitter } from 'node:events' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { MacServeStartupAbortError, serveSignalExitError } from './serve-signal-exit-diagnostic' import { - SERVE_CHILD_FORCE_KILL_GRACE_MS, SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS, superviseForegroundServe } from './serve-update-supervisor' -import { RuntimeClientError } from './types' import { QUIT_RENDERER_ACK_TIMEOUT_MS, WILL_QUIT_TEARDOWN_DEADLINE_MS @@ -26,13 +24,6 @@ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { configurable: true, value: platform }) } -function superviseUntilExit(code: number | null, signal: NodeJS.Signals | null): Promise { - const child = new FakeChildProcess() - const supervised = superviseChild(child) - child.emit('exit', code, signal) - return supervised -} - function superviseChild(child: FakeChildProcess): Promise { return superviseForegroundServe({ spec: { program: '/Applications/Orca.app/Contents/MacOS/Orca', args: ['--serve'] }, @@ -50,43 +41,9 @@ afterEach(() => { }) describe('serveSignalExitError', () => { - it('explains the Launch Services startup abort on darwin SIGABRT', () => { + it('classifies darwin SIGABRT as a startup abort', () => { const error = serveSignalExitError('SIGABRT', 'darwin') - expect(error).toBeInstanceOf(MacServeStartupAbortError) - expect(error).toBeInstanceOf(RuntimeClientError) - expect(error.code).toBe('runtime_serve_failed') - expect(error.message).toContain('aborted with SIGABRT on macOS') - expect(error.message).toContain('_RegisterApplication') - expect(error.data).toMatchObject({ - nextSteps: [ - expect.stringContaining('desktop Orca is already running'), - expect.stringContaining('refuse for 30 minutes'), - expect.stringContaining('~/Library/Logs/DiagnosticReports/Orca-*.ips') - ] - }) - }) - - it('does not claim the macOS cause off darwin', () => { - for (const platform of ['linux', 'win32'] as const) { - const error = serveSignalExitError('SIGABRT', platform) - - expect(error.message).toBe('Orca serve exited via SIGABRT.') - expect(error.data).toBeUndefined() - } - }) - - it('does not claim the macOS cause for other darwin signals', () => { - const error = serveSignalExitError('SIGKILL', 'darwin') - - expect(error.message).toBe('Orca serve exited via SIGKILL.') - expect(error.data).toBeUndefined() - }) - - it('stays clear when neither a code nor a signal is reported', () => { - expect(serveSignalExitError(null, 'darwin').message).toBe( - 'Orca serve exited without reporting an exit code or signal.' - ) }) }) @@ -97,13 +54,6 @@ describe('superviseForegroundServe signal exits', () => { const child = new FakeChildProcess() const supervised = superviseChild(child) - expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBe( - QUIT_RENDERER_ACK_TIMEOUT_MS + - WILL_QUIT_TEARDOWN_DEADLINE_MS + - SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS - ) - expect(SERVE_CHILD_FORCE_KILL_GRACE_MS).toBeLessThanOrEqual(35_000) - process.emit('SIGTERM', 'SIGTERM') expect(child.kill).toHaveBeenCalledOnce() expect(child.kill).toHaveBeenLastCalledWith('SIGTERM') @@ -127,11 +77,9 @@ describe('superviseForegroundServe signal exits', () => { process.emit('SIGINT', 'SIGINT') expect(child.kill).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(1) child.emit('exit', 0, null) await expect(supervised).resolves.toBe(0) - expect(vi.getTimerCount()).toBe(0) }) it('forwards Linux terminal hangup and removes the listener after exit', async () => { @@ -193,29 +141,5 @@ describe('superviseForegroundServe signal exits', () => { await expect(supervised).resolves.toBe(1) expect(child.kill).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - - it('throws the macOS diagnostic when the child aborts on darwin', async () => { - setPlatform('darwin') - - await expect(superviseUntilExit(null, 'SIGABRT')).rejects.toThrow( - /aborted with SIGABRT on macOS/ - ) - }) - - it('reports the plain signal on linux', async () => { - setPlatform('linux') - - await expect(superviseUntilExit(null, 'SIGABRT')).rejects.toThrow( - 'Orca serve exited via SIGABRT.' - ) - }) - - it('returns numeric exit codes unchanged', async () => { - setPlatform('darwin') - - await expect(superviseUntilExit(0, null)).resolves.toBe(0) - await expect(superviseUntilExit(7, null)).resolves.toBe(7) }) }) diff --git a/src/cli/runtime/serve-supervised-service.test.ts b/src/cli/runtime/serve-supervised-service.test.ts index 195cb534bfe..f3b97dff825 100644 --- a/src/cli/runtime/serve-supervised-service.test.ts +++ b/src/cli/runtime/serve-supervised-service.test.ts @@ -3,14 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { superviseServeService, type SupervisedServeOptions } from './serve-supervised-service' -import { readServeSupervisorBrake, SERVE_SUPERVISOR_FAILURE_LIMIT } from './serve-supervisor-brake' +import { readServeSupervisorBrake } from './serve-supervisor-brake' /** Virtual clock: timers fire in order as soon as the loop awaits them. */ function harness(overrides: Partial & { userDataPath: string }) { let clock = 1_000_000 - const sleeps: number[] = [] - const logs: string[] = [] - let stopListener: (() => void) | null = null const options: SupervisedServeOptions = { executable: '/opt/orca/orca', force: false, @@ -20,28 +17,16 @@ function harness(overrides: Partial & { userDataPath: st now: () => clock, random: () => 0.5, setTimer: (callback, ms) => { - sleeps.push(ms) clock += ms queueMicrotask(callback) return () => undefined }, - onStopSignal: (listener) => { - stopListener = listener - return () => { - stopListener = null - } - }, - log: (line) => logs.push(line), + onStopSignal: () => () => undefined, + log: () => undefined, ...overrides } return { - options, - sleeps, - logs, - stop: () => stopListener?.(), - advance: (ms: number) => { - clock += ms - } + options } } @@ -56,18 +41,9 @@ describe('superviseServeService', () => { await rm(userDataPath, { recursive: true, force: true }) }) - it('restarts a dying child with growing delays, then gives up and exits 0 durably', async () => { - let runs = 0 - const h = harness({ - userDataPath, - runOnce: async () => { - runs++ - return 1 - } - }) + it('persists give-up after repeated child failure and refuses another launch', async () => { + const h = harness({ userDataPath }) await expect(superviseServeService(h.options)).resolves.toBe(0) - expect(runs).toBe(SERVE_SUPERVISOR_FAILURE_LIMIT) - expect(h.sleeps).toEqual([1_000, 2_000, 4_000, 8_000, 16_000]) const record = readServeSupervisorBrake(userDataPath) expect(record?.gaveUp?.reason).toContain('exited with code 1') @@ -81,8 +57,5 @@ describe('superviseServeService', () => { }) await expect(superviseServeService(again.options)).resolves.toBe(0) expect(rerun).toBe(0) - expect(again.logs.join('\n')).toContain('--force') }) - - // #32177: a lock this machine cannot judge (a shared home) is a peer, not a fault to give up on. }) diff --git a/src/cli/runtime/serve-supervisor-relocation.test.ts b/src/cli/runtime/serve-supervisor-relocation.test.ts index c1d39a1ebfd..802db8ff75b 100644 --- a/src/cli/runtime/serve-supervisor-relocation.test.ts +++ b/src/cli/runtime/serve-supervisor-relocation.test.ts @@ -92,17 +92,6 @@ describe('relocateServeSupervisor', () => { expect(relocated.execve).not.toHaveBeenCalled() }) - it('stays in place outside a packaged app', async () => { - for (const d of [ - deps({ env: {} }), - deps({ env: { ELECTRON_RUN_AS_NODE: '1', ORCA_APP_EXECUTABLE: '/dev/Orca' } }), - deps({ appRoot: join(root, 'checkout') }) - ]) { - await expect(relocateServeSupervisor(d)).resolves.toBe('in-place') - expect(d.execve).not.toHaveBeenCalled() - } - }) - // Re-exec closes the AppImage keepalive pipe and unmounts libraries needed by the new image. it('stays in place when running from an AppImage, whose files cannot change under it', async () => { const d = deps({ @@ -235,14 +224,6 @@ describe('relocateServeSupervisor', () => { } const appCli = () => join(appRoot, 'out', 'cli', 'index.js') - it('re-runs plain serve with the same arguments on the replaced executable', () => { - const argv = ['/ignored', appCli(), 'serve', '--json'] - const { execve, execPath } = restart({ ELECTRON_RUN_AS_NODE: '1' }, argv) - expect(execve).toHaveBeenCalledWith(execPath, [execPath, ...argv.slice(1)], { - ELECTRON_RUN_AS_NODE: '1' - }) - }) - it('does not restart when the executable is the one it started from, as after a restart', () => { const argv = ['/ignored', appCli(), 'serve'] const installed = statSync(deps().execPath) @@ -265,14 +246,6 @@ describe('relocateServeSupervisor', () => { expect(log).toHaveBeenCalledWith(expect.stringContaining('not ready to run')) }) - it('logs instead of throwing on a runtime that cannot replace its own process', () => { - const execve = vi.fn(() => { - throw new Error('this runtime cannot replace its own process') - }) - expect(() => restart({}, ['/ignored', appCli(), 'serve'], { execve })).not.toThrow() - expect(log).toHaveBeenCalledWith(expect.stringContaining('cannot replace')) - }) - it('re-runs a relocated supervisor from the app, which copies the new release aside', async () => { const first = deps() await relocateServeSupervisor(first) diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 86b9943e981..425a26d441b 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -4,9 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' -import type { RuntimeStatus } from '../../shared/runtime-types' import { RuntimeClient } from './client' -import { projectRemoteAppStatus } from './status' const servers = new Set>() const sockets = new Set() @@ -87,33 +85,30 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { // A persisted host PID cannot establish liveness in the caller's namespace. describe.skipIf(process.platform === 'win32')('CLI status connection authority', () => { - it.each(['EPERM', 'ESRCH'])( - 'reports an unavailable endpoint without using a PID probe (%s)', - async (code) => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-probe-')) - writeFileSync( - getRuntimeMetadataPath(userDataPath), - JSON.stringify({ - runtimeId: 'runtime-unreachable', - pid: 424242, - transport: { kind: 'unix', endpoint: join(userDataPath, 'absent.sock') }, - authToken: 'token', - startedAt: 1 - }) - ) - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error(`kill ${code}`), { code }) + it('reports an unavailable endpoint without using a PID probe', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-probe-')) + writeFileSync( + getRuntimeMetadataPath(userDataPath), + JSON.stringify({ + runtimeId: 'runtime-unreachable', + pid: 424242, + transport: { kind: 'unix', endpoint: join(userDataPath, 'absent.sock') }, + authToken: 'token', + startedAt: 1 }) - try { - await expect(new RuntimeClient(userDataPath).getCliStatus()).rejects.toMatchObject({ - code: 'runtime_unavailable' - }) - expect(killSpy).not.toHaveBeenCalled() - } finally { - killSpy.mockRestore() - } + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('unexpected PID probe') + }) + try { + await expect(new RuntimeClient(userDataPath).getCliStatus()).rejects.toMatchObject({ + code: 'runtime_unavailable' + }) + expect(killSpy).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() } - ) + }) it('reports a status timeout from a live listener without declaring its hidden PID dead', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-status-timeout-')) @@ -148,41 +143,3 @@ describe.skipIf(process.platform === 'win32')('CLI status connection authority', } }) }) - -describe('projectRemoteAppStatus', () => { - function remoteStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'remote-runtime', - graphStatus: 'ready', - authoritativeWindowId: null, - ...overrides - } as RuntimeStatus - } - - it('reports the target app as running when a renderer owns the graph there', () => { - expect(projectRemoteAppStatus(remoteStatus({ desktopWindowStatus: 'available' }))).toEqual({ - running: true, - pid: null, - desktopWindowStatus: 'available' - }) - }) - - it.each(['openable', 'initializing', 'blocked'] as const)( - 'does not claim a running desktop for window status %s', - (desktopWindowStatus) => { - expect(projectRemoteAppStatus(remoteStatus({ desktopWindowStatus })).running).toBe(false) - } - ) - - it('reports not running when the target has no desktop window status', () => { - expect(projectRemoteAppStatus(remoteStatus())).toEqual({ running: false, pid: null }) - }) - - it('honors the authoritativeWindowId fallback for older runtimes', () => { - expect(projectRemoteAppStatus(remoteStatus({ authoritativeWindowId: 3 }))).toEqual({ - running: true, - pid: null, - desktopWindowStatus: 'available' - }) - }) -})