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>
This commit is contained in:
OrcaWin
2026-11-13 05:25:55 -08:00
committed by GitHub
co-authored by OrcaWin
parent 8dd3b71090
commit e9d5b35d64
31 changed files with 74 additions and 1977 deletions
-1
View File
@@ -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
-24
View File
@@ -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')
})
})
@@ -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')
})
})
@@ -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<boolean> | 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()
})
})
@@ -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' }
})
})
})
@@ -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<VoiceSettingsOperations['load']>().mockResolvedValue({
enabled: true,
dictationMode: 'toggle',
dictationHoldToRecord: true,
selectedModelId: '',
models: []
}),
configure: vi.fn<VoiceSettingsOperations['configure']>(() => 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,
@@ -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' })
@@ -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
)
})
})
@@ -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 }],
@@ -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 })
@@ -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<TerminalWebViewTransport['post']>()
const onTerminalInput = vi.fn()
const exposed: { current: ReturnType<typeof useTerminalWebViewController> | null } = {
current: null
}
const transport = { post, pingsOnForegroundRecovery: () => false }
function Probe() {
exposed.current = useTerminalWebViewController(
{ onTerminalInput, interactionActive },
transport
)
return null
}
act(() => {
root = create(<Probe />)
})
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'])
})
})
@@ -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<string, unknown>, _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<string, unknown>): 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')
})
})
@@ -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<Record<string, unknown>>
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<void> => 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](</tmp/folder with spaces/report.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()
})
})
@@ -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<typeof makeTerminal>
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<typeof vi.fn>): 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<string, unknown>).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('')
})
})
@@ -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'])
})
})
@@ -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<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { 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()
}
)
})
})
@@ -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<typeof vi.fn>
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<TextInputInstance | null> = { current: null }
const sent: string[] = []
const failures = vi.fn()
let currentSendResult = sendResult
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
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<TerminalLiveInputSender>()
.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()
@@ -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)
})
})
+1 -116
View File
@@ -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)
})
})
@@ -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()
})
})
@@ -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')
})
})
+3 -22
View File
@@ -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()
})
})
+1 -13
View File
@@ -68,14 +68,6 @@ function restartDeps(overrides: Partial<AppImageRestartDeps> = {}): 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)
@@ -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(
@@ -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)
}
)
})
@@ -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' })
})
})
@@ -24,26 +24,6 @@ function deps(overrides: Partial<ServeInitReaperDeps> = {}): 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 <cli> -- 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))
@@ -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<number> {
const child = new FakeChildProcess()
const supervised = superviseChild(child)
child.emit('exit', code, signal)
return supervised
}
function superviseChild(child: FakeChildProcess): Promise<number> {
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)
})
})
@@ -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<SupervisedServeOptions> & { 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<SupervisedServeOptions> & { 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.
})
@@ -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)
+22 -65
View File
@@ -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<ReturnType<typeof createServer>>()
const sockets = new Set<Socket>()
@@ -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> = {}): 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'
})
})
})