mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user