diff --git a/src/main/claude/claude-structured-real-cli-options.test.ts b/src/main/claude/claude-structured-real-cli-options.test.ts new file mode 100644 index 000000000000..decec04d8849 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli-options.test.ts @@ -0,0 +1,245 @@ +// The SDK requests behind the model, effort and permission pickers and the context meter, against +// the live CLI: CI meets them only through stand-ins, which pin the shape we read, not the one the +// CLI sends. Runs only where a signed-in Claude CLI exists, like the rest of the real-CLI suite. + +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + claudeProviderHandle, + claudeProviderHandleLeafUuid +} from '../../shared/agent-session-provider-handle-encoding' +import { + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeStructuredPermissionOptions +} from './claude-structured-launch-resolution' +import { listedModels } from './claude-structured-model-catalog' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + realClaudeAuthenticated, + realClaudeAvailable, + realClaudeCommand, + realClaudeLaunchHome +} from './claude-real-cli-availability-test-support' +import { claudeStartupSettled } from './claude-structured-session-test-support' +import { journal } from './claude-context-usage-test-support' + +const SESSION_ID = 'real-cli-options' +// The SDK's effort scale, low to high; the CLI's list order is not a contract. +const EFFORT_SCALE = ['low', 'medium', 'high', 'xhigh', 'max'] + +function identity(providerSessionId: string, leafUuid: string | null): AgentSessionJournalIdentity { + return { + sessionId: SESSION_ID, + workspaceId: 'real-cli-options-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: claudeProviderHandle(providerSessionId, leafUuid) + } +} + +async function until(check: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (check()) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return check() +} + +// Inits name a 1M-window model with `[1m]`; context reports and responses drop it. +const baseModelId = (model: string): string => + model + .replace(/\[[^\]]*\]$/u, '') + .trim() + .toLowerCase() + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI options', () => { + it.skipIf(!realClaudeAuthenticated)( + 'applies a message model and effort, reports context usage, and relaunches into a new permission mode', + async () => { + const providerSessionId = randomUUID() + const { claudeConfigDir, env } = realClaudeLaunchHome() + const cwd = await mkdtemp(join(tmpdir(), 'orca-real-options-')) + const events: ClaudeStructuredSessionEvent[] = [] + const record = journal() + const persisted: { leafUuid: string | null } = { leafUuid: null } + let launches = 0 + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async ({ identity: launchIdentity, permissionMode }) => { + const permission = claudeStructuredPermissionOptions(permissionMode ?? 'ask') + // A relaunch resumes the transcript the first launch wrote, as the host's resolver does. + const resumes = launches++ > 0 + return { + pathToClaudeCodeExecutable: realClaudeCommand, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + ...permission, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs }, + ...(resumes ? { resume: providerSessionId } : { sessionId: providerSessionId }) + }, + cwd, + env, + claudeConfigDir, + providerSessionId, + resumeLeafUuid: + resumes && launchIdentity.providerHandle + ? claudeProviderHandleLeafUuid(launchIdentity.providerHandle) + : null, + resumesTranscript: resumes, + continuesChain: resumes + } + }, + onEvent: (event) => events.push(event), + persistHandle: async (handle) => { + persisted.leafUuid = handle.leafUuid + }, + readProcessStartTime: async () => 1 + }) + const frames = (from: number) => + events.slice(from).flatMap((event) => (event.type === 'message' ? [event.message] : [])) + const inits = (from: number) => + frames(from).filter((message) => message.type === 'system' && message.subtype === 'init') + const results = (from: number) => frames(from).filter((message) => message.type === 'result') + const send = ( + clientMessageId: string, + text: string, + fence: number, + executionOptions?: AgentSessionExecutionOptions + ) => + adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + requestedAt: Date.now(), + fence, + ...(executionOptions ? { executionOptions } : {}) + }) + const contextReport = () => + record + .items() + .flatMap((row) => { + const used = readAgentJournalTurn(row.body)?.contextUsage?.used + return used?.kind === 'report' ? [used] : [] + }) + .at(-1) + + try { + await adapter.acquire({ + identity: identity(providerSessionId, null), + fence: 1, + spawnToken: 'real-cli-options-1', + permissionMode: 'ask', + events: record.sink + }) + await claudeStartupSettled(adapter, SESSION_ID) + + // A row without `value` or `resolvedModel` silently turns off the admit check, the effort + // guard and model confirmation. + const connection = adapter['sessions'].get(SESSION_ID)?.connection + if (!connection) { + throw new Error('no live Claude connection') + } + const listed = listedModels({ models: await connection.supportedModels() }) + const rows = ['haiku', 'sonnet'].map((alias) => listed.find(({ id }) => id === alias)) + for (const row of rows) { + expect(row?.resolvedModel).toEqual(expect.stringMatching(/\S/u)) + } + const opened = await adapter.readOptions({ sessionId: SESSION_ID, fence: 1 }) + expect(opened.catalogListing?.models.map(({ id }) => id)).toContain('haiku') + + const pick = rows.find((row) => row !== undefined && row.efforts.length > 0) + const level = pick?.efforts + .map(({ value }) => value) + .filter((value) => EFFORT_SCALE.includes(value)) + .sort((left, right) => EFFORT_SCALE.indexOf(left) - EFFORT_SCALE.indexOf(right))[0] + if (!pick?.resolvedModel || !level) { + throw new Error('neither haiku nor sonnet lists an effort level') + } + + // The production send path: list_models, set_model, apply_flag_settings and get_settings. + // `ranOptions` carries only what the CLI reported; it names the resolved id, so an alias + // pick reports no model and the recorder falls back to the message's own pick. + const firstTurn = events.length + const outcome = await send( + 'real-cli-options-1', + 'Reply with exactly: OK. Do not use any tools.', + 1, + { model: pick.id, effort: level } + ) + expect(outcome).toMatchObject({ state: 'admitted', ranOptions: { effort: level } }) + expect([undefined, pick.id]).toContain( + 'ranOptions' in outcome ? outcome.ranOptions?.model : undefined + ) + expect(await until(() => results(firstTurn).length > 0, 90_000)).toBe(true) + expect(results(firstTurn)[0]).toMatchObject({ subtype: 'success', is_error: false }) + expect(inits(firstTurn)[0]?.model).toBe(pick.resolvedModel) + // Effort is read only through get_settings; no frame carries it, init included. + expect(frames(0).filter((frame) => 'effortLevel' in frame)).toEqual([]) + await expect( + adapter.readOptions({ sessionId: SESSION_ID, fence: 1 }) + ).resolves.toMatchObject({ + current: { + model: pick.id, + effort: level, + confirmed: expect.arrayContaining(['model', 'effort']) + } + }) + + // The turn end asks get_context_usage; an answer Orca cannot read blanks the meter silently. + expect(await until(() => contextReport() !== undefined, 10_000)).toBe(true) + const report = contextReport() + if (!report) { + throw new Error('no context report on any turn row') + } + expect(report.usedTokens).toBeGreaterThan(0) + expect(report.windowTokens).toBeGreaterThanOrEqual(report.usedTokens) + expect(report.model).toMatch(/\S/u) + expect(baseModelId(report.model)).toBe(baseModelId(pick.resolvedModel)) + + // Orca never sends set_permission_mode: it retires the child and resumes it under the new + // flag with the saved picks, so the CLI must read its mode at launch. + await adapter.closeSession(SESSION_ID) + const resumedTurn = events.length + await adapter.acquire({ + identity: identity(providerSessionId, persisted.leafUuid), + fence: 2, + spawnToken: 'real-cli-options-2', + permissionMode: 'accept-edits', + options: { model: pick.id, effort: level }, + events: record.sink + }) + await claudeStartupSettled(adapter, SESSION_ID) + await expect( + send('real-cli-options-2', 'Reply with exactly: OK.', 2) + ).resolves.toMatchObject({ + state: 'admitted' + }) + expect(await until(() => results(resumedTurn).length > 0, 90_000)).toBe(true) + expect(results(resumedTurn)[0]).toMatchObject({ subtype: 'success', is_error: false }) + expect(inits(resumedTurn)[0]).toMatchObject({ + session_id: providerSessionId, + permissionMode: 'acceptEdits', + model: pick.resolvedModel + }) + await expect( + adapter.readOptions({ sessionId: SESSION_ID, fence: 2 }) + ).resolves.toMatchObject({ current: { effort: level } }) + } finally { + await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) + } + }, + 180_000 + ) +}) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 3d7e4ca89a34..fe10a9163393 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -78,6 +78,23 @@ function realAdapter( return adapter } +/** A temp project with its own SessionStart hook, so the hook frame never depends on the machine's real settings. */ +async function projectWithStartHook( + prefix: string, + settings: Record = {} +): Promise { + const cwd = await mkdtemp(join(tmpdir(), prefix)) + await mkdir(join(cwd, '.claude'), { recursive: true }) + await writeFile( + join(cwd, '.claude', 'settings.json'), + JSON.stringify({ + ...settings, + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'true' }] }] } + }) + ) + return cwd +} + function identity(providerSessionId: string): AgentSessionJournalIdentity { return { sessionId: 'real-cli-handshake', @@ -158,7 +175,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') const events: ClaudeStructuredSessionEvent[] = [] - const cwd = await mkdtemp(join(tmpdir(), 'orca-command-init-')) + const cwd = await projectWithStartHook('orca-command-init-') await mkdir(join(cwd, '.claude', 'commands'), { recursive: true }) await writeFile( join(cwd, '.claude', 'commands', 'orca-init-catalog-proof.md'), @@ -211,7 +228,8 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') const events: ClaudeStructuredSessionEvent[] = [] - const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + const cwd = await projectWithStartHook('orca-effort-readback-', { effortLevel: 'low' }) + const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd) try { await adapter.acquire({ @@ -233,9 +251,10 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { expect(published.filter((frame) => 'effortLevel' in frame)).toEqual([]) // Goes red if `effective.effortLevel` is renamed or dropped, which no // fixture-backed test can see. - expect(options.current.effort).toEqual(expect.any(String)) + expect(options.current.effort).toBe('low') } finally { await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) } }, 15_000 @@ -347,7 +366,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { expect(frames[0]?.model).toMatch(/^claude-haiku-/) await expect( adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 }) - ).resolves.toMatchObject({ current: { model: 'haiku' } }) + ).resolves.toMatchObject({ + current: { model: 'haiku', confirmed: expect.arrayContaining(['model']) } + }) } finally { await adapter.closeAll() } @@ -579,14 +600,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { async () => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') - const cwd = await mkdtemp(join(tmpdir(), 'orca-queued-stop-')) - await mkdir(join(cwd, '.claude'), { recursive: true }) - await writeFile( - join(cwd, '.claude', 'settings.json'), - JSON.stringify({ - hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'true' }] }] } - }) - ) + const cwd = await projectWithStartHook('orca-queued-stop-') const events: ClaudeStructuredSessionEvent[] = [] const settlements: unknown[] = [] const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd, (settlement) => diff --git a/src/main/claude/claude-supervised-stop-real-cli.test.ts b/src/main/claude/claude-supervised-stop-real-cli.test.ts index 0f21a943caea..661624b69c67 100644 --- a/src/main/claude/claude-supervised-stop-real-cli.test.ts +++ b/src/main/claude/claude-supervised-stop-real-cli.test.ts @@ -1,11 +1,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { SUPERVISED_GRACEFUL_EXIT_MS } from './claude-child-exit-proof-ladder' +import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' +import { realClaudeLaunchHome } from './claude-real-cli-availability-test-support' import { openClaudeStreamJsonConnection, type ClaudeStreamJsonConnection @@ -15,14 +17,16 @@ import { claudeStructuredPermissionOptions } from './claude-structured-launch-resolution' -// Opt-in only: spends a real (haiku) turn per case. Run it in an isolated HOME with -// ORCA_REAL_CLAUDE_BIN set, and ORCA_REAL_CLAUDE_SETTINGS when auth lives in a settings file. +// Opt-in only: spends a real (haiku) turn per case. Needs ORCA_REAL_CLAUDE_BIN; set +// ORCA_REAL_CLAUDE_SETTINGS only when auth lives in a settings file outside the Claude home. const CLAUDE_BIN = process.env.ORCA_REAL_CLAUDE_BIN ?? '' const enabled = process.env.ORCA_REAL_CLAUDE_SUPERVISED_STOP === '1' && process.platform !== 'win32' && CLAUDE_BIN.length > 0 const FRAMES_OUT = process.env.ORCA_REAL_CLAUDE_FRAMES_OUT +// Launch and transcript lookup share one home, as production acquisition does. +const home = realClaudeLaunchHome() const TOOL_MARKER = 'orca_supervised_stop_probe' const TOOL_PROMPT = `Use the Bash tool to run exactly this command, with no timeout argument, and nothing else: python3 -c "import time; time.sleep(120) # ${TOOL_MARKER}"` @@ -94,7 +98,8 @@ async function open(sessionId: string, cwd: string, resume: boolean, frames: Fra }, ...(resume ? { resume: sessionId } : { sessionId }) }, - cwd + cwd, + env: { ...home.env, ...claudeConfigDirEnvPatch(home.claudeConfigDir, { env: home.env }) } }, { onMessage: (message) => frames.push({ at: Date.now(), message }) } ) @@ -113,11 +118,10 @@ function userMessage(text: string): Record { } async function transcriptLines(sessionId: string): Promise { - const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') const path = await until( () => resolveSessionFilePath('claude', sessionId, { - claudeProjectsDir: join(configDir, 'projects') + claudeProjectsDir: join(home.claudeConfigDir, 'projects') }), 'the transcript', 15_000 diff --git a/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts index 4ba50df69598..8ee723cab674 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts @@ -188,120 +188,121 @@ export function registerPtyConnectionSplitSpawnTests(): void { 'disposed', 'hide-wait', 'disposed-hide-wait' - ])( - 'preserves first-fit startup and bounded fallback: %s', - async (scenario) => { - const visibility = scenario === 'hidden' ? 'hidden' : 'visible' - const narrow = scenario.startsWith('narrow-') - const pendingCwd = scenario.includes('cwd') ? createDeferred() : null - const hideWait = scenario.endsWith('hide-wait') - let measurable = !pendingCwd && scenario !== 'disposed' && !hideWait - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const doc = Object.assign(new EventTarget(), { - visibilityState: visibility, - addEventListener: vi.fn(EventTarget.prototype.addEventListener), - removeEventListener: vi.fn(EventTarget.prototype.removeEventListener) - }) - vi.stubGlobal('document', doc) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + ])('preserves first-fit startup and bounded fallback: %s', async (scenario) => { + const visibility = scenario === 'hidden' ? 'hidden' : 'visible' + const narrow = scenario.startsWith('narrow-') + const pendingCwd = scenario.includes('cwd') ? createDeferred() : null + const hideWait = scenario.endsWith('hide-wait') + let measurable = !pendingCwd && scenario !== 'disposed' && !hideWait + const frameCallbacks: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const doc = Object.assign(new EventTarget(), { + visibilityState: visibility, + addEventListener: vi.fn(EventTarget.prototype.addEventListener), + removeEventListener: vi.fn(EventTarget.prototype.removeEventListener) + }) + vi.stubGlobal('document', doc) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const { connectPanePty } = await import('./pty-connection') - const { applyOrDeferPaneMetricOptions } = - await import('@/lib/pane-manager/pane-metric-options-deferral') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const pane = createPane(1) - Object.assign(pane.terminal, { cols: 80, rows: 24 }) - const terminalOptions = Object.assign(pane.terminal.options, { fontSize: 12 }) - pane.container = createMeasuredElement({ rect: () => createRect(1200, 800) }) - const dimensions = () => { - if (!measurable) { - return { cols: 0, rows: 0 } - } - return narrow || terminalOptions.fontSize === 24 - ? { cols: 5, rows: 20 } - : { cols: 120, rows: 40 } + const { connectPanePty } = await import('./pty-connection') + const { applyOrDeferPaneMetricOptions } = + await import('@/lib/pane-manager/pane-metric-options-deferral') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + Object.assign(pane.terminal, { cols: 80, rows: 24 }) + const terminalOptions = Object.assign(pane.terminal.options, { fontSize: 12 }) + pane.container = createMeasuredElement({ rect: () => createRect(1200, 800) }) + const dimensions = () => { + if (!measurable) { + return { cols: 0, rows: 0 } } - pane.fitAddon.proposeDimensions = vi.fn(dimensions) - pane.fitAddon.fit = vi.fn(() => { - Object.assign(pane.terminal, dimensions()) - }) - const startup = scenario === 'narrow-plain' ? null : { command: 'claude' } - const deps = createDeps({ - startup, - ...(pendingCwd ? { cwdPromise: pendingCwd.promise } : {}) - }) - if (scenario === 'narrow-remote') { - mockStoreState.repos[0].executionHostId = 'runtime:env-1' - mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' + return narrow || terminalOptions.fontSize === 24 + ? { cols: 5, rows: 20 } + : { cols: 120, rows: 40 } + } + pane.fitAddon.proposeDimensions = vi.fn(dimensions) + pane.fitAddon.fit = vi.fn(() => { + Object.assign(pane.terminal, dimensions()) + }) + const startup = scenario === 'narrow-plain' ? null : { command: 'claude' } + const deps = createDeps({ + startup, + ...(pendingCwd ? { cwdPromise: pendingCwd.promise } : {}) + }) + if (scenario === 'narrow-remote') { + mockStoreState.repos[0].executionHostId = 'runtime:env-1' + mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane, manager and deps doubles implement every member connectPanePty reads on this startup path. + const args = [pane, createManager(1), deps] as unknown as Parameters + if (scenario === 'metrics') { + applyOrDeferPaneMetricOptions(args[0], { fontSize: 24 }, false) + } + const binding = connectPanePty(...args) + + if (visibility === 'visible' && scenario !== 'starved') { + frameCallbacks.shift()?.(0) + const hideListener = doc.addEventListener.mock.calls.at(-1)?.[1] + if ( + pendingCwd || + hideWait || + ['metrics', 'disposed', 'narrow-command'].includes(scenario) + ) { + expect(transport.connect).not.toHaveBeenCalled() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane, manager and deps doubles implement every member connectPanePty reads on this startup path. - const args = [pane, createManager(1), deps] as unknown as Parameters - if (scenario === 'metrics') { - applyOrDeferPaneMetricOptions(args[0], { fontSize: 24 }, false) + if (scenario.startsWith('disposed')) { + binding.dispose() } - const binding = connectPanePty(...args) - - if (visibility === 'visible' && scenario !== 'starved') { - frameCallbacks.shift()?.(0) - const hideListener = doc.addEventListener.mock.calls.at(-1)?.[1] - if (pendingCwd || hideWait || ['metrics', 'disposed', 'narrow-command'].includes(scenario)) { - expect(transport.connect).not.toHaveBeenCalled() - } - if (scenario.startsWith('disposed')) { - binding.dispose() - } - if (hideWait) { - doc.visibilityState = 'hidden' - doc.dispatchEvent(new Event('visibilitychange')) - expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) - expect(doc.removeEventListener).toHaveBeenCalledWith('visibilitychange', hideListener) - if (typeof hideListener === 'function') { - hideListener(new Event('visibilitychange')) - } - } - if (scenario === 'late-cwd') { - vi.advanceTimersByTime(250) - expect(transport.connect).not.toHaveBeenCalled() - } - pendingCwd?.resolve('/workspace') - await flushAsyncTicks() - if (scenario === 'cwd') { - expect(transport.connect).not.toHaveBeenCalled() - } - measurable = true - terminalOptions.fontSize = 12 - if (scenario === 'narrow-command') { - for (let retry = 0; retry < 11; retry += 1) { - frameCallbacks.shift()?.(0) - } - expect(transport.connect).not.toHaveBeenCalled() - } - frameCallbacks.shift()?.(0) + if (hideWait) { + doc.visibilityState = 'hidden' + doc.dispatchEvent(new Event('visibilitychange')) expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) - } else { + expect(doc.removeEventListener).toHaveBeenCalledWith('visibilitychange', hideListener) + if (typeof hideListener === 'function') { + hideListener(new Event('visibilitychange')) + } + } + if (scenario === 'late-cwd') { vi.advanceTimersByTime(250) + expect(transport.connect).not.toHaveBeenCalled() } - - vi.advanceTimersByTime(250) - expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) - if (scenario.startsWith('disposed')) { - return + pendingCwd?.resolve('/workspace') + await flushAsyncTicks() + if (scenario === 'cwd') { + expect(transport.connect).not.toHaveBeenCalled() } - expect(createdTransportOptions[0]?.command).toBe(startup?.command) - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining( - narrow || hideWait || scenario === 'late-cwd' - ? { cols: 80, rows: 24 } - : { cols: 120, rows: 40 } - ) - ) + measurable = true + terminalOptions.fontSize = 12 + if (scenario === 'narrow-command') { + for (let retry = 0; retry < 11; retry += 1) { + frameCallbacks.shift()?.(0) + } + expect(transport.connect).not.toHaveBeenCalled() + } + frameCallbacks.shift()?.(0) + expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) + } else { + vi.advanceTimersByTime(250) } - ) + + vi.advanceTimersByTime(250) + expect(transport.connect).toHaveBeenCalledTimes(scenario.startsWith('disposed') ? 0 : 1) + if (scenario.startsWith('disposed')) { + return + } + expect(createdTransportOptions[0]?.command).toBe(startup?.command) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining( + narrow || hideWait || scenario === 'late-cwd' + ? { cols: 80, rows: 24 } + : { cols: 120, rows: 40 } + ) + ) + }) it('spawns the delayed primary pane of an issue-command split when its sibling owns the tab PTY fallback', async () => { const { connectPanePty } = await import('./pty-connection')