mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.
Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.
Area: main — core runtime, ipc, daemon, pty, providers. 73 files changed, 3206 insertions(+), 10475 deletions(-).
Co-authored-by: Orca <help@stably.ai>
1018 lines
42 KiB
TypeScript
1018 lines
42 KiB
TypeScript
/**
|
||
* Integration tests for the server-authoritative mobile subscribe lifecycle.
|
||
*/
|
||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||
import type * as GitUsernameModule from '../git/git-username'
|
||
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||
import { OrcaRuntimeService } from './orca-runtime'
|
||
|
||
vi.mock('../git/worktree', () => ({
|
||
listWorktrees: vi.fn().mockResolvedValue([
|
||
{
|
||
path: '/tmp/worktree-a',
|
||
head: 'abc',
|
||
branch: 'feature/foo',
|
||
isBare: false,
|
||
isMainWorktree: false
|
||
}
|
||
]),
|
||
listWorktreesStrict: vi.fn().mockResolvedValue([])
|
||
}))
|
||
|
||
vi.mock('../hooks', () => ({
|
||
createSetupRunnerScript: vi.fn(),
|
||
getEffectiveHooks: vi.fn().mockReturnValue(null),
|
||
runHook: vi.fn().mockResolvedValue({ success: true, output: '' })
|
||
}))
|
||
|
||
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||
const actual = (await importOriginal()) as Record<string, unknown>
|
||
return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() }
|
||
})
|
||
|
||
vi.mock('../ipc/filesystem-auth', () => ({
|
||
invalidateAuthorizedRootsCache: vi.fn()
|
||
}))
|
||
|
||
vi.mock('../git/repo', async (importOriginal) => {
|
||
const actual = (await importOriginal()) as Record<string, unknown>
|
||
return {
|
||
...actual,
|
||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||
getBranchConflictKind: vi.fn().mockResolvedValue(null)
|
||
}
|
||
})
|
||
|
||
vi.mock('../git/git-username', async () => {
|
||
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
|
||
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
|
||
})
|
||
|
||
// Why: default the mock store to the clamp floor (5_000ms) so legacy "restore fires after delay" assertions hold; the real default is indefinite/null.
|
||
const LEGACY_RESTORE_MS = 5_000
|
||
const settingsState = {
|
||
mobileAutoRestoreFitMs: LEGACY_RESTORE_MS as number | null
|
||
}
|
||
|
||
const store = {
|
||
getRepo: () => ({
|
||
id: 'repo-1',
|
||
path: '/tmp/repo',
|
||
displayName: 'repo',
|
||
badgeColor: 'blue',
|
||
addedAt: 1
|
||
}),
|
||
getRepos: () => [store.getRepo()],
|
||
addRepo: () => {},
|
||
updateRepo: () => undefined as never,
|
||
getAllWorktreeMeta: () => ({}),
|
||
getWorktreeMeta: () => undefined,
|
||
getGitHubCache: () => ({ pr: {}, issue: {} }),
|
||
setWorktreeMeta: () => undefined as never,
|
||
removeWorktreeMeta: () => {},
|
||
getSettings: () => ({
|
||
workspaceDir: '/tmp/workspaces',
|
||
nestWorkspaces: false,
|
||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||
branchPrefix: 'none',
|
||
branchPrefixCustom: '',
|
||
mobileAutoRestoreFitMs: settingsState.mobileAutoRestoreFitMs
|
||
}),
|
||
updateSettings: (updates: { mobileAutoRestoreFitMs?: number | null }) => {
|
||
if ('mobileAutoRestoreFitMs' in updates) {
|
||
settingsState.mobileAutoRestoreFitMs = updates.mobileAutoRestoreFitMs ?? null
|
||
}
|
||
}
|
||
}
|
||
|
||
function createRuntime() {
|
||
const runtime = new OrcaRuntimeService(store)
|
||
const ptySizes = new Map<string, { cols: number; rows: number }>()
|
||
ptySizes.set('pty-1', { cols: 150, rows: 40 })
|
||
ptySizes.set('pty-2', { cols: 120, rows: 35 })
|
||
ptySizes.set('pty-3', { cols: 100, rows: 30 })
|
||
|
||
const resizes: { ptyId: string; cols: number; rows: number }[] = []
|
||
const notifications: { ptyId: string; mode: string; cols: number; rows: number }[] = []
|
||
|
||
runtime.setPtyController({
|
||
write: () => true,
|
||
kill: () => true,
|
||
getForegroundProcess: async () => null,
|
||
resize: (ptyId, cols, rows) => {
|
||
ptySizes.set(ptyId, { cols, rows })
|
||
resizes.push({ ptyId, cols, rows })
|
||
return true
|
||
},
|
||
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
|
||
})
|
||
runtime.setNotifier({
|
||
worktreesChanged: vi.fn(),
|
||
reposChanged: vi.fn(),
|
||
activateWorktree: vi.fn(),
|
||
createTerminal: vi.fn(),
|
||
splitTerminal: vi.fn(),
|
||
renameTerminal: vi.fn(),
|
||
focusTerminal: vi.fn(),
|
||
closeTerminal: vi.fn(),
|
||
sleepWorktree: vi.fn(),
|
||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) => {
|
||
notifications.push({ ptyId, mode, cols, rows })
|
||
},
|
||
terminalDriverChanged: vi.fn()
|
||
})
|
||
|
||
return { runtime, ptySizes, resizes, notifications }
|
||
}
|
||
|
||
describe('mobile subscribe integration', () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers()
|
||
settingsState.mobileAutoRestoreFitMs = LEGACY_RESTORE_MS
|
||
})
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
it('serializeMainTerminalBuffer reports the seq included in the headless snapshot', async () => {
|
||
vi.useRealTimers()
|
||
const { runtime } = createRuntime()
|
||
const first = 'one\r\n'
|
||
const second = 'two\r\n'
|
||
const third = 'three\r\n'
|
||
|
||
runtime.onPtyData('pty-1', first, Date.now())
|
||
const initialSnapshot = await runtime.serializeMainTerminalBuffer('pty-1')
|
||
expect(initialSnapshot?.seq).toBe(first.length)
|
||
expect(initialSnapshot?.source).toBe('headless')
|
||
|
||
type HeadlessStateForTest = {
|
||
emulator: { write: (data: string) => Promise<void> | void }
|
||
}
|
||
const headless = (
|
||
runtime as unknown as { headlessTerminals: Map<string, HeadlessStateForTest> }
|
||
).headlessTerminals.get('pty-1')
|
||
expect(headless).toBeDefined()
|
||
const originalWrite = headless!.emulator.write.bind(headless!.emulator)
|
||
const secondWriteGate: { release: (() => void) | null } = { release: null }
|
||
const secondWriteStarted = new Promise<void>((resolve) => {
|
||
headless!.emulator.write = async (data: string): Promise<void> => {
|
||
if (data === second) {
|
||
resolve()
|
||
await new Promise<void>((release) => {
|
||
secondWriteGate.release = release
|
||
})
|
||
}
|
||
await originalWrite(data)
|
||
}
|
||
})
|
||
|
||
try {
|
||
runtime.onPtyData('pty-1', second, Date.now())
|
||
await secondWriteStarted
|
||
const racedSnapshot = runtime.serializeMainTerminalBuffer('pty-1')
|
||
runtime.onPtyData('pty-1', third, Date.now())
|
||
if (!secondWriteGate.release) {
|
||
throw new Error('second write did not block')
|
||
}
|
||
secondWriteGate.release()
|
||
|
||
const snapshot = await racedSnapshot
|
||
expect(snapshot?.seq).toBe(first.length + second.length)
|
||
expect(snapshot?.source).toBe('headless')
|
||
expect(runtime.getPtyOutputSequence('pty-1')).toBe(
|
||
first.length + second.length + third.length
|
||
)
|
||
|
||
const finalSnapshot = await runtime.serializeMainTerminalBuffer('pty-1')
|
||
expect(finalSnapshot?.seq).toBe(first.length + second.length + third.length)
|
||
expect(finalSnapshot?.source).toBe('headless')
|
||
} finally {
|
||
headless!.emulator.write = originalWrite
|
||
secondWriteGate.release?.()
|
||
}
|
||
})
|
||
|
||
it('serializeMainTerminalBuffer returns an empty snapshot for an empty headless buffer', async () => {
|
||
const { runtime } = createRuntime()
|
||
type HeadlessStateForTest = {
|
||
emulator: {
|
||
isAlternateScreen: boolean
|
||
getSnapshot: (opts: { scrollbackRows?: number }) => {
|
||
rehydrateSequences: string
|
||
snapshotAnsi: string
|
||
cols: number
|
||
rows: number
|
||
}
|
||
}
|
||
outputSequence: number
|
||
writeChain: Promise<void>
|
||
}
|
||
const runtimePrivate = runtime as unknown as {
|
||
headlessTerminals: Map<string, HeadlessStateForTest>
|
||
}
|
||
runtimePrivate.headlessTerminals.set('pty-empty', {
|
||
emulator: {
|
||
isAlternateScreen: false,
|
||
getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 })
|
||
},
|
||
outputSequence: 17,
|
||
writeChain: Promise.resolve()
|
||
})
|
||
|
||
await expect(runtime.serializeMainTerminalBuffer('pty-empty')).resolves.toEqual({
|
||
data: '',
|
||
cols: 90,
|
||
rows: 30,
|
||
seq: 17,
|
||
source: 'headless',
|
||
// Non-alt-screen reports alternateScreen=false so the renderer keeps its destructive scrollback clear on restore.
|
||
alternateScreen: false
|
||
})
|
||
await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull()
|
||
})
|
||
|
||
it('handleMobileSubscribe resizes PTY to phone dims', async () => {
|
||
const { runtime, ptySizes, resizes, notifications } = createRuntime()
|
||
|
||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
|
||
cols: 45,
|
||
rows: 20
|
||
})
|
||
|
||
expect(result).toBe(true)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
expect(resizes).toEqual([{ ptyId: 'pty-1', cols: 45, rows: 20 }])
|
||
expect(notifications).toEqual([{ ptyId: 'pty-1', mode: 'mobile-fit', cols: 45, rows: 20 }])
|
||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true)
|
||
})
|
||
|
||
it('handleMobileSubscribe skips resize when mode is desktop', async () => {
|
||
const { runtime, ptySizes, resizes } = createRuntime()
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
|
||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
|
||
cols: 45,
|
||
rows: 20
|
||
})
|
||
|
||
expect(result).toBe(false)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(resizes).toEqual([])
|
||
})
|
||
|
||
it('handleMobileSubscribe skips resize when no viewport provided', async () => {
|
||
const { runtime, ptySizes, resizes } = createRuntime()
|
||
|
||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a')
|
||
|
||
expect(result).toBe(false)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(resizes).toEqual([])
|
||
})
|
||
|
||
it('handleMobileUnsubscribe restores PTY after debounce in auto mode', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
// Not yet restored
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
// Why: 'phone' sticky-fit mode was removed; auto-mode now always restores on last unsubscribe.
|
||
it('handleMobileUnsubscribe restores after auto-mode last unsubscribe', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
// mode defaults to 'auto'
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||
// Restored to desktop dims — no sticky-phone retention.
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
// TODO: inline restore on re-subscribe not yet implemented
|
||
it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
// Re-subscribe to a different terminal before the timer fires
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Advancing past the 300ms debounce should not cause a second restore
|
||
await vi.advanceTimersByTimeAsync(300)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
// TODO: inline restore on re-subscribe not yet implemented
|
||
it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
// Subscribe to A
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// pty-1 should be restored inline (not waiting for timer)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore
|
||
runtime.handleMobileUnsubscribe('pty-2', 'client-a')
|
||
await runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Verify final state
|
||
await vi.advanceTimersByTimeAsync(1000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
|
||
})
|
||
|
||
it('preserves previousDims across re-subscribes to same terminal', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
// First subscribe at desktop 150x40
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Re-subscribe (e.g. after reconnect): PTY is already 45x20 but previousDims must stay 150x40.
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Unsubscribe and let restore fire
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||
|
||
// Should restore to original desktop dims, not 45x20
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
it('preserves held-override baseline across resubscribe-after-indefinite-hold', async () => {
|
||
// Why: a fresh resubscribe under indefinite hold must inherit the override's retained desktop baseline, or a later desktop Restore becomes a no-op.
|
||
settingsState.mobileAutoRestoreFitMs = null // indefinite hold
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
// Initial subscribe at desktop 150x40 → fit to 45x20.
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Phone leaves; indefinite hold keeps PTY phone-fit while the override baseline still carries 150x40.
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
await vi.advanceTimersByTimeAsync(60_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Phone reopens — fresh resubscribe (inner map is empty).
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Desktop user clicks Restore.
|
||
await runtime.reclaimTerminalForDesktop('pty-1')
|
||
|
||
// Must restore to the original 150x40, not the phone-fit 45x20.
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
it('clamps viewport to valid range', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 })
|
||
// Should clamp to minimum 20x8
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 })
|
||
})
|
||
|
||
describe('display mode', () => {
|
||
it('defaults to auto', () => {
|
||
const { runtime } = createRuntime()
|
||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||
})
|
||
|
||
it('set/get round-trip', () => {
|
||
const { runtime } = createRuntime()
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop')
|
||
|
||
// Setting to 'auto' deletes the entry (same as default)
|
||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||
})
|
||
})
|
||
|
||
describe('applyMobileDisplayMode', () => {
|
||
it('desktop mode restores PTY when currently phone-fitted', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
const resizeEvents: unknown[] = []
|
||
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
|
||
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(resizeEvents).toHaveLength(1)
|
||
expect(resizeEvents[0]).toMatchObject({
|
||
cols: 150,
|
||
rows: 40,
|
||
displayMode: 'desktop',
|
||
reason: 'apply-layout'
|
||
})
|
||
})
|
||
|
||
it('auto mode re-fits PTY when subscriber exists and not phone-fitted', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Switch to desktop (restores to 150x40)
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
|
||
const resizeEvents: unknown[] = []
|
||
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
|
||
|
||
// Switch back to auto (should re-fit to phone dims)
|
||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
expect(resizeEvents).toHaveLength(1)
|
||
expect(resizeEvents[0]).toMatchObject({
|
||
displayMode: 'phone',
|
||
reason: 'apply-layout'
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('cleanup paths', () => {
|
||
it('onClientDisconnected restores all PTYs immediately (no debounce)', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
runtime.onClientDisconnected('client-a')
|
||
// onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers.
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
|
||
// Both PTYs restored immediately
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false)
|
||
expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false)
|
||
})
|
||
|
||
it('onClientDisconnected cancels pending restore timers', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
// Timer is pending
|
||
|
||
runtime.onClientDisconnected('client-a')
|
||
// Timer should be cancelled, PTY already restored by disconnect handler
|
||
|
||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
it('onPtyExit cleans up mobileSubscribers and pending timers', async () => {
|
||
const { runtime } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
// Timer pending for pty-1
|
||
|
||
runtime.onPtyExit('pty-1', 0)
|
||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false)
|
||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||
|
||
// Timer should have been cancelled — no crash from resizing a dead PTY
|
||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||
})
|
||
|
||
it('onPtyExit does not cancel timers for other PTYs', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
// pty-2 exits — should not affect pty-1's pending restore
|
||
runtime.onPtyExit('pty-2', 0)
|
||
|
||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
})
|
||
|
||
describe('resize listener system', () => {
|
||
it('subscribe/unsubscribe lifecycle', async () => {
|
||
const { runtime } = createRuntime()
|
||
const events: unknown[] = []
|
||
const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e))
|
||
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
|
||
expect(events.length).toBeGreaterThan(0)
|
||
|
||
const countBefore = events.length
|
||
unsubscribe()
|
||
|
||
// After unsubscribe, no more events
|
||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(events.length).toBe(countBefore)
|
||
})
|
||
})
|
||
|
||
describe('onExternalPtyResize', () => {
|
||
it('updates previousCols when desktop renderer resizes PTY after desktop restore', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Toggle to desktop — restores to previousCols (150x40)
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
|
||
// Wait out the 500ms cascade-suppress armed by phone→desktop before the renderer's fit can update lastRendererSizes. See docs/mobile-terminal-layout-state-machine.md.
|
||
await vi.advanceTimersByTimeAsync(500)
|
||
|
||
// Simulate desktop renderer's safeFit correcting to split-pane width
|
||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||
|
||
// Toggle back to auto — should capture previousCols=105 (not 150)
|
||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Toggle to desktop again — should restore to 105 (the corrected value)
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||
})
|
||
|
||
it('uses lastRendererSize for previousCols on first subscribe', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
// Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105
|
||
ptySizes.set('pty-1', { cols: 214, rows: 72 })
|
||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||
|
||
// First mobile subscribe — should use rendererSize (105) not ptySizes (214)
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Toggle to desktop — should restore to 105, not 214
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||
})
|
||
|
||
it('ignores reports while a mobile-fit override is in place', async () => {
|
||
// Why: while an override holds phone dims the renderer echoes them back; treating that echo as external geometry would poison the restore baseline. See docs/mobile-fit-hold.md.
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Renderer reports phone dims back (echo of override). Must be ignored.
|
||
runtime.onExternalPtyResize('pty-1', 45, 20)
|
||
|
||
// Toggle to desktop — restore lands on the original baseline, not the phone-dim echo.
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
it('still updates baseline once the override is cleared (real desktop resize)', async () => {
|
||
// Counterpart: once the override is cleared, pty:resize reports are legitimate and must update the baseline.
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Take back — clears override and arms the 500ms cascade-suppress.
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
|
||
// Wait past the renderer-cascade suppress window.
|
||
await vi.advanceTimersByTimeAsync(500)
|
||
|
||
// User resizes the desktop window. Renderer fires pty:resize.
|
||
runtime.onExternalPtyResize('pty-1', 130, 35)
|
||
|
||
// Toggle to phone then take back — should restore the updated geometry, not the original 150x40.
|
||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 130, rows: 35 })
|
||
})
|
||
|
||
it('updates baseline mid-fit when desktop reports dims that differ from the override', async () => {
|
||
// Why: a desktop tab becoming visible mid-fit fires pty:resize with real dims (not the override's); that legit report must refresh the restore baseline.
|
||
const { runtime, ptySizes } = createRuntime()
|
||
// Seed a stale baseline (spawn default 80×24) before subscribing.
|
||
ptySizes.set('pty-1', { cols: 80, rows: 24 })
|
||
runtime.onExternalPtyResize('pty-1', 80, 24)
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Desktop tab activated mid-fit reports real dims differing from the override (45×20), so they pass through.
|
||
runtime.onExternalPtyResize('pty-1', 200, 60)
|
||
|
||
// Take back — should land on 200×60, not 80×24.
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 200, rows: 60 })
|
||
})
|
||
})
|
||
|
||
describe('recordRendererGeometry (pty:reportGeometry IPC)', () => {
|
||
it('refreshes subscriber baseline while a mobile-fit override is active', async () => {
|
||
// Why: for a never-desktop-active terminal the baseline is the spawn default; the measurement-only report must update it so Take Back restores real dims.
|
||
const { runtime, ptySizes } = createRuntime()
|
||
ptySizes.set('pty-1', { cols: 80, rows: 24 })
|
||
runtime.onExternalPtyResize('pty-1', 80, 24)
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// Override in place; desktop pane becomes visible and reports real geometry via the measurement-only channel.
|
||
runtime.recordRendererGeometry('pty-1', 214, 72)
|
||
|
||
// Take back — should restore to the reported dims, not 80×24.
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 214, rows: 72 })
|
||
})
|
||
|
||
it('updates lastRendererSizes for a never-subscribed PTY', () => {
|
||
// Why: a geometry report before first subscribe must populate lastRendererSizes so the baseline captures real dims, not the spawn default.
|
||
const { runtime } = createRuntime()
|
||
runtime.recordRendererGeometry('pty-99', 180, 50)
|
||
|
||
const renderer = runtime.getLastRendererSize('pty-99')
|
||
expect(renderer).toEqual({ cols: 180, rows: 50 })
|
||
})
|
||
|
||
it('ignores non-positive dims', () => {
|
||
const { runtime } = createRuntime()
|
||
runtime.recordRendererGeometry('pty-1', 0, 0)
|
||
runtime.recordRendererGeometry('pty-1', -5, 10)
|
||
runtime.recordRendererGeometry('pty-1', 10, -5)
|
||
expect(runtime.getLastRendererSize('pty-1')).toBeNull()
|
||
})
|
||
|
||
it('bypasses the cascade-suppress window (it is measurement-only)', async () => {
|
||
// Why: the measurement-only channel must bypass the 500ms cascade-suppress that gates pty:resize, since it can carry a fresh measurement right after a flip.
|
||
const { runtime } = createRuntime()
|
||
// Arm cascade-suppress.
|
||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||
await runtime.applyMobileDisplayMode('pty-1')
|
||
// Within the 500ms window:
|
||
runtime.recordRendererGeometry('pty-1', 130, 40)
|
||
expect(runtime.getLastRendererSize('pty-1')).toEqual({ cols: 130, rows: 40 })
|
||
})
|
||
})
|
||
|
||
describe('backward compatibility', () => {
|
||
it('old resizeForClient still works alongside new system', async () => {
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
// Old flow: explicit resizeForClient
|
||
const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
expect(fitResult.mode).toBe('mobile-fit')
|
||
|
||
// Old flow: restore
|
||
const restoreResult = await runtime.resizeForClient('pty-1', 'restore', 'client-old')
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
expect(restoreResult.mode).toBe('desktop-fit')
|
||
})
|
||
})
|
||
|
||
// See docs/mobile-fit-hold.md.
|
||
describe('mobileAutoRestoreFitMs (fit hold)', () => {
|
||
it('null (indefinite) keeps PTY at phone dims after last unsubscribe', async () => {
|
||
settingsState.mobileAutoRestoreFitMs = null
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
// Wait long past the legacy 300ms debounce — PTY must remain held.
|
||
await vi.advanceTimersByTimeAsync(60_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
})
|
||
|
||
it('finite ms restores after the configured delay', async () => {
|
||
settingsState.mobileAutoRestoreFitMs = 60_000
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
await vi.advanceTimersByTimeAsync(30_000)
|
||
// Not yet — still mid-window.
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
await vi.advanceTimersByTimeAsync(30_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
it('re-subscribe before timer fires cancels pending restore', async () => {
|
||
settingsState.mobileAutoRestoreFitMs = 60_000
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
await vi.advanceTimersByTimeAsync(30_000)
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
// The new subscribe should have cancelled the pending restore timer.
|
||
await vi.advanceTimersByTimeAsync(120_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
})
|
||
|
||
it('reclaimTerminalForDesktop with active subscriber resets mode so next subscribe re-fits', async () => {
|
||
// Why: reclaim must reset mode to 'auto', else the next subscribe stays in passive watch and never re-fits to phone dims. See docs/mobile-fit-hold.md.
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Desktop reclaim while phone is still subscribed.
|
||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||
expect(ok).toBe(true)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
// Mode reset so a re-subscribe takes the auto path.
|
||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||
|
||
// Mobile re-subscribes (e.g. tab switch). Must re-fit to phone dims.
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
})
|
||
|
||
it('reclaim with WS disconnect between cycles — held branch restores correctly each time', async () => {
|
||
// Why: regression — under indefinite hold the held-branch reclaim must restore real desktop dims every cycle, not the current (phone) PTY size.
|
||
settingsState.mobileAutoRestoreFitMs = null
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
// Phone subscribes, fits to phone dims.
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1'), `iter ${i}: phone-fit`).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// WS disconnect under indefinite hold: PTY stays phone-fit, override persists, subscriber removed.
|
||
runtime.onClientDisconnected('client-a')
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
await vi.advanceTimersByTimeAsync(60_000)
|
||
expect(ptySizes.get('pty-1'), `iter ${i}: held after disconnect`).toEqual({
|
||
cols: 45,
|
||
rows: 20
|
||
})
|
||
expect(runtime.isMobileSubscriberActive('pty-1'), `iter ${i}: no subscribers`).toBe(false)
|
||
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override held`).not.toBeNull()
|
||
|
||
// Desktop clicks Restore — held-override branch.
|
||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||
expect(ok, `iter ${i}: reclaim ok`).toBe(true)
|
||
expect(ptySizes.get('pty-1'), `iter ${i}: PTY restored to desktop`).toEqual({
|
||
cols: 150,
|
||
rows: 40
|
||
})
|
||
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull()
|
||
}
|
||
})
|
||
|
||
it('reclaim → re-subscribe → reclaim cycle works repeatedly', async () => {
|
||
// Why: regression — the reclaim/re-subscribe ping-pong must work N times, not just once.
|
||
const { runtime, ptySizes } = createRuntime()
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
// Phone takes the floor (or first subscribe).
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
expect(ptySizes.get('pty-1'), `iter ${i}: phone-fit`).toEqual({ cols: 45, rows: 20 })
|
||
|
||
// Desktop reclaims.
|
||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||
expect(ok, `iter ${i}: reclaim ok`).toBe(true)
|
||
expect(ptySizes.get('pty-1'), `iter ${i}: reclaimed to desktop`).toEqual({
|
||
cols: 150,
|
||
rows: 40
|
||
})
|
||
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull()
|
||
}
|
||
})
|
||
|
||
it('null (indefinite) keeps PTY at phone dims when WS connection closes (onClientDisconnected)', async () => {
|
||
// Why: WS close routes through onClientDisconnected (not handleMobileUnsubscribe), which must honor indefinite-hold rather than unconditionally restore.
|
||
settingsState.mobileAutoRestoreFitMs = null
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
|
||
runtime.onClientDisconnected('client-a')
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
// Flush soft-leave grace too — that path also has its own desktop-restore branch.
|
||
await vi.advanceTimersByTimeAsync(60_000)
|
||
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
|
||
})
|
||
|
||
it('reclaimTerminalForDesktop returns held PTY to desktop dims (no subscriber)', async () => {
|
||
settingsState.mobileAutoRestoreFitMs = null
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
// No subscribers, indefinite hold — PTY stays at phone dims.
|
||
await vi.advanceTimersByTimeAsync(60_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false)
|
||
|
||
// Manual reclaim: PTY restored to desktop dims via the held branch.
|
||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||
expect(ok).toBe(true)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||
})
|
||
|
||
it('reclaimTerminalForDesktop prefers fresh desktop geometry for a held PTY', async () => {
|
||
// Why: manual restore must honor fresh desktop geometry measured while the PTY was phone-held, not the stale baseline.
|
||
settingsState.mobileAutoRestoreFitMs = null
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
await vi.advanceTimersByTimeAsync(60_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
|
||
runtime.recordRendererGeometry('pty-1', 180, 50)
|
||
|
||
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
|
||
expect(ok).toBe(true)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 180, rows: 50 })
|
||
})
|
||
|
||
it('setMobileAutoRestoreFitMs(null) clears all pending timers', async () => {
|
||
settingsState.mobileAutoRestoreFitMs = 60_000
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
// Pending timer exists. Switch preference → indefinite.
|
||
runtime.setMobileAutoRestoreFitMs(null)
|
||
// Now wait far longer than the original 60s window. No restore fires.
|
||
await vi.advanceTimersByTimeAsync(120_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
})
|
||
|
||
it('setMobileAutoRestoreFitMs to a finite value does not retroactively schedule', async () => {
|
||
settingsState.mobileAutoRestoreFitMs = null
|
||
const { runtime, ptySizes } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
|
||
// A finite value applies to the next unsubscribe only; the already-held PTY is not retroactively restored.
|
||
runtime.setMobileAutoRestoreFitMs(60_000)
|
||
await vi.advanceTimersByTimeAsync(120_000)
|
||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||
})
|
||
|
||
it('setMobileAutoRestoreFitMs clamps below MIN to MIN', () => {
|
||
const { runtime } = createRuntime()
|
||
const result = runtime.setMobileAutoRestoreFitMs(100)
|
||
expect(result).toBe(5_000)
|
||
})
|
||
|
||
it('setMobileAutoRestoreFitMs clamps above MAX to MAX', () => {
|
||
const { runtime } = createRuntime()
|
||
const result = runtime.setMobileAutoRestoreFitMs(99 * 60 * 60 * 1000)
|
||
expect(result).toBe(60 * 60 * 1000)
|
||
})
|
||
|
||
it('reclaimTerminalForDesktop on already-restored PTY returns false', async () => {
|
||
const { runtime } = createRuntime()
|
||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||
await vi.advanceTimersByTimeAsync(LEGACY_RESTORE_MS)
|
||
// Now restored to desktop — no subscriber, no override.
|
||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('session.tabs title/status churn coalescing', () => {
|
||
type SessionTabsPrivate = {
|
||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||
touchMobileSessionSnapshotsForPty: (ptyId: string) => void
|
||
notifyMobileSessionTabsChanged: (worktreeId?: string) => void
|
||
}
|
||
|
||
function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void {
|
||
const priv = runtime as unknown as SessionTabsPrivate
|
||
priv.mobileSessionTabsByWorktree.set('worktree-a', {
|
||
worktree: 'worktree-a',
|
||
publicationEpoch: 'epoch-1',
|
||
snapshotVersion: 1,
|
||
activeGroupId: 'group-1',
|
||
activeTabId: 'tab-1',
|
||
activeTabType: 'terminal',
|
||
tabs: [
|
||
{
|
||
type: 'terminal',
|
||
id: 'tab-1',
|
||
title: 'agent',
|
||
parentTabId: 'group-1',
|
||
leafId: 'leaf-1',
|
||
ptyId,
|
||
isActive: true
|
||
}
|
||
]
|
||
})
|
||
}
|
||
|
||
it('collapses a rapid title-flip burst into a single emit (repro)', () => {
|
||
const { runtime } = createRuntime()
|
||
seedPtyBackedSnapshot(runtime, 'pty-1')
|
||
const priv = runtime as unknown as SessionTabsPrivate
|
||
|
||
const emits: number[] = []
|
||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
|
||
emits.push(snapshot.snapshotVersion)
|
||
})
|
||
|
||
// A spinner-in-title agent flips the title ~20 times within a second.
|
||
const FLIPS = 20
|
||
for (let i = 0; i < FLIPS; i++) {
|
||
priv.touchMobileSessionSnapshotsForPty('pty-1')
|
||
vi.advanceTimersByTime(10)
|
||
}
|
||
// Nothing has fired yet — all flips landed inside the trailing window.
|
||
expect(emits).toHaveLength(0)
|
||
|
||
vi.advanceTimersByTime(50)
|
||
|
||
// Pre-fix this fanned out one emit per flip; now it is a single emit.
|
||
expect(emits).toHaveLength(1)
|
||
// The emitted version is the freshest (monotonic, never a stale one).
|
||
const stored = priv.mobileSessionTabsByWorktree.get('worktree-a')
|
||
expect(emits[0]).toBe(stored?.snapshotVersion)
|
||
expect(emits[0]).toBe(1 + FLIPS)
|
||
|
||
unsubscribe()
|
||
})
|
||
|
||
it('lets a structural change emit immediately and supersede the coalesced notify', () => {
|
||
const { runtime } = createRuntime()
|
||
seedPtyBackedSnapshot(runtime, 'pty-1')
|
||
const priv = runtime as unknown as SessionTabsPrivate
|
||
|
||
const emits: number[] = []
|
||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
|
||
emits.push(snapshot.snapshotVersion)
|
||
})
|
||
|
||
// Title churn schedules a coalesced notify...
|
||
priv.touchMobileSessionSnapshotsForPty('pty-1')
|
||
// ...then a structural change (e.g. tab activated) demands a prompt emit.
|
||
priv.notifyMobileSessionTabsChanged('worktree-a')
|
||
expect(emits).toHaveLength(1)
|
||
|
||
// The immediate emit cancelled the pending coalesced notify — no duplicate trailing emit.
|
||
vi.advanceTimersByTime(50)
|
||
expect(emits).toHaveLength(1)
|
||
|
||
unsubscribe()
|
||
})
|
||
|
||
it('flushes a pending notify when the last subscriber closes', () => {
|
||
const { runtime } = createRuntime()
|
||
seedPtyBackedSnapshot(runtime, 'pty-1')
|
||
const priv = runtime as unknown as SessionTabsPrivate
|
||
|
||
const emits: number[] = []
|
||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
|
||
emits.push(snapshot.snapshotVersion)
|
||
})
|
||
|
||
priv.touchMobileSessionSnapshotsForPty('pty-1')
|
||
expect(emits).toHaveLength(0)
|
||
|
||
// Closing the subscription flushes the pending window so the final state reaches the listener.
|
||
unsubscribe()
|
||
expect(emits).toHaveLength(1)
|
||
})
|
||
})
|
||
})
|