mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
806 lines
28 KiB
TypeScript
806 lines
28 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
import { parsePairingCode } from '../../shared/pairing'
|
|
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
|
import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types'
|
|
import { OrcaRuntimeService } from './orca-runtime'
|
|
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
|
import {
|
|
activeTabId,
|
|
authenticate,
|
|
CLIENT_A2_WORKTREE_ID,
|
|
CLIENT_A_WORKTREE_ID,
|
|
CLIENT_B_WORKTREE_ID,
|
|
createReader,
|
|
FOLDER_REPO_ID,
|
|
HOST_WORKTREE_ID,
|
|
makeStore,
|
|
REPO_ID,
|
|
resultType,
|
|
seedSessionTabs,
|
|
send,
|
|
SESSION_WORKTREE_ID,
|
|
snapshotVersion,
|
|
worktreeId,
|
|
type PairedSession,
|
|
type ResponseReader
|
|
} from './paired-client-navigation-test-harness'
|
|
|
|
vi.mock('../git/worktree', () => {
|
|
const worktrees = ['host', 'client-a', 'client-a2', 'client-b', 'session'].map((name) => ({
|
|
path: `/tmp/${name}`,
|
|
head: name,
|
|
branch: name,
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}))
|
|
return {
|
|
listWorktrees: vi.fn().mockResolvedValue(worktrees),
|
|
listWorktreesStrict: vi.fn().mockResolvedValue(worktrees)
|
|
}
|
|
})
|
|
|
|
describe('paired runtime navigation isolation', () => {
|
|
const servers: OrcaRuntimeRpcServer[] = []
|
|
const sessions: PairedSession[] = []
|
|
const readers: ResponseReader[] = []
|
|
|
|
afterEach(async () => {
|
|
for (const reader of readers.splice(0)) {
|
|
reader.dispose()
|
|
}
|
|
for (const session of sessions.splice(0)) {
|
|
session.ws.close()
|
|
}
|
|
await Promise.all(servers.splice(0).map((server) => server.stop()))
|
|
})
|
|
|
|
async function startHarness(options: { headless?: boolean } = {}) {
|
|
const hostSelections = { worktreeId: HOST_WORKTREE_ID, tabId: 'host-tab' }
|
|
const activateWorktree = vi.fn((_repoId: string, nextWorktreeId: string) => {
|
|
hostSelections.worktreeId = nextWorktreeId
|
|
})
|
|
const focusTerminal = vi.fn((nextTabId: string) => {
|
|
hostSelections.tabId = nextTabId
|
|
})
|
|
const runtime = new OrcaRuntimeService(makeStore() as never)
|
|
if (!options.headless) {
|
|
runtime.setNotifier({
|
|
worktreesChanged: vi.fn(),
|
|
reposChanged: vi.fn(),
|
|
activateWorktree,
|
|
createTerminal: vi.fn(),
|
|
revealTerminalSession: vi.fn(),
|
|
splitTerminal: vi.fn(),
|
|
renameTerminal: vi.fn(),
|
|
focusTerminal,
|
|
closeTerminal: vi.fn(),
|
|
terminalFitOverrideChanged: vi.fn(),
|
|
terminalDriverChanged: vi.fn()
|
|
})
|
|
runtime.attachWindow(1)
|
|
runtime.markGraphReady(1)
|
|
seedSessionTabs(runtime)
|
|
}
|
|
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-navigation-isolation-'))
|
|
onTestFinished(() => rmSync(userDataPath, { recursive: true, force: true }))
|
|
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
servers.push(server)
|
|
await server.start()
|
|
|
|
const offerA = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'client-a',
|
|
scope: 'runtime'
|
|
})
|
|
if (!offerA.available) {
|
|
throw new Error('pairing_unavailable')
|
|
}
|
|
const clientA = await authenticate(offerA.pairingUrl)
|
|
sessions.push(clientA)
|
|
const offerB = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'client-b',
|
|
scope: 'runtime'
|
|
})
|
|
if (!offerB.available) {
|
|
throw new Error('pairing_unavailable')
|
|
}
|
|
const clientB = await authenticate(offerB.pairingUrl)
|
|
sessions.push(clientB)
|
|
expect(offerA.deviceId).not.toBe(offerB.deviceId)
|
|
expect(parsePairingCode(offerA.pairingUrl)?.pairedDeviceId).toBe(offerA.deviceId)
|
|
expect(parsePairingCode(offerB.pairingUrl)?.pairedDeviceId).toBe(offerB.deviceId)
|
|
expect(parsePairingCode(offerA.pairingUrl)?.deviceToken).not.toBe(
|
|
parsePairingCode(offerB.pairingUrl)?.deviceToken
|
|
)
|
|
|
|
const readerA = createReader(clientA)
|
|
const readerB = createReader(clientB)
|
|
readers.push(readerA, readerB)
|
|
return {
|
|
runtime,
|
|
hostSelections,
|
|
activateWorktree,
|
|
focusTerminal,
|
|
clientA,
|
|
clientB,
|
|
deviceIdA: offerA.deviceId,
|
|
deviceIdB: offerB.deviceId,
|
|
pairingUrlA: offerA.pairingUrl,
|
|
pairingUrlB: offerB.pairingUrl,
|
|
readerA,
|
|
readerB
|
|
}
|
|
}
|
|
|
|
it('attributes workspace creation to each authenticated device across reconnect', async () => {
|
|
const harness = await startHarness()
|
|
const createManagedWorktree = vi
|
|
.spyOn(harness.runtime, 'createManagedWorktree')
|
|
.mockImplementation(async (args) => {
|
|
const creatorProvenance = (args as unknown as Record<string, unknown>).creatorProvenance
|
|
return {
|
|
worktree: {
|
|
id: worktreeId(args.name),
|
|
creatorProvenance
|
|
}
|
|
} as never
|
|
})
|
|
|
|
send(harness.clientA, {
|
|
id: 'status-a',
|
|
method: 'status.get'
|
|
})
|
|
send(harness.clientB, {
|
|
id: 'status-b',
|
|
method: 'status.get'
|
|
})
|
|
const [statusA, statusB] = await Promise.all([
|
|
harness.readerA.next('status-a'),
|
|
harness.readerB.next('status-b')
|
|
])
|
|
expect(statusA).toMatchObject({
|
|
ok: true,
|
|
result: { pairedDeviceId: harness.deviceIdA }
|
|
})
|
|
expect(statusB).toMatchObject({
|
|
ok: true,
|
|
result: { pairedDeviceId: harness.deviceIdB }
|
|
})
|
|
|
|
send(harness.clientA, {
|
|
id: 'create-a',
|
|
method: 'worktree.create',
|
|
params: {
|
|
repo: REPO_ID,
|
|
name: 'created-a',
|
|
creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB }
|
|
}
|
|
})
|
|
send(harness.clientB, {
|
|
id: 'create-b',
|
|
method: 'worktree.create',
|
|
params: { repo: REPO_ID, name: 'created-b' }
|
|
})
|
|
await Promise.all([harness.readerA.next('create-a'), harness.readerB.next('create-b')])
|
|
expect(createManagedWorktree).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'created-a',
|
|
creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdA }
|
|
})
|
|
)
|
|
expect(createManagedWorktree).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'created-b',
|
|
creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB }
|
|
})
|
|
)
|
|
|
|
await new Promise<void>((resolve) => {
|
|
harness.clientA.ws.once('close', () => resolve())
|
|
harness.clientA.ws.close()
|
|
})
|
|
const reconnectedA = await authenticate(harness.pairingUrlA)
|
|
sessions.push(reconnectedA)
|
|
const reconnectedReaderA = createReader(reconnectedA)
|
|
readers.push(reconnectedReaderA)
|
|
send(reconnectedA, { id: 'status-a2', method: 'status.get' })
|
|
await expect(reconnectedReaderA.next('status-a2')).resolves.toMatchObject({
|
|
ok: true,
|
|
result: { pairedDeviceId: harness.deviceIdA }
|
|
})
|
|
})
|
|
|
|
it('keeps worktree navigation local to each paired runtime client by default', async () => {
|
|
const harness = await startHarness()
|
|
const clientSelections = {
|
|
a: CLIENT_A_WORKTREE_ID,
|
|
b: CLIENT_B_WORKTREE_ID
|
|
}
|
|
for (const [session, id] of [
|
|
[harness.clientA, 'events-a'],
|
|
[harness.clientB, 'events-b']
|
|
] as const) {
|
|
send(session, { id, method: 'runtime.clientEvents.subscribe' })
|
|
}
|
|
await Promise.all([
|
|
harness.readerA.next('events-a', (response) => resultType(response) === 'ready'),
|
|
harness.readerB.next('events-b', (response) => resultType(response) === 'ready')
|
|
])
|
|
|
|
clientSelections.a = CLIENT_A2_WORKTREE_ID
|
|
send(harness.clientA, {
|
|
id: 'activate-a2',
|
|
method: 'worktree.activate',
|
|
params: {
|
|
worktree: `id:${CLIENT_A2_WORKTREE_ID}`,
|
|
notifyClients: true
|
|
}
|
|
})
|
|
await expect(harness.readerA.next('activate-a2')).resolves.toMatchObject({ ok: true })
|
|
|
|
harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID)
|
|
const [nextA, nextB] = await Promise.all([
|
|
harness.readerA.next('events-a'),
|
|
harness.readerB.next('events-b')
|
|
])
|
|
expect([resultType(nextA), resultType(nextB)]).toEqual(['worktreesChanged', 'worktreesChanged'])
|
|
expect(clientSelections).toEqual({
|
|
a: CLIENT_A2_WORKTREE_ID,
|
|
b: CLIENT_B_WORKTREE_ID
|
|
})
|
|
expect(harness.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
|
|
expect(harness.activateWorktree).not.toHaveBeenCalled()
|
|
})
|
|
|
|
async function subscribeBothClientEventStreams(harness: {
|
|
clientA: PairedSession
|
|
clientB: PairedSession
|
|
readerA: ResponseReader
|
|
readerB: ResponseReader
|
|
}): Promise<void> {
|
|
for (const [session, id] of [
|
|
[harness.clientA, 'events-a'],
|
|
[harness.clientB, 'events-b']
|
|
] as const) {
|
|
send(session, { id, method: 'runtime.clientEvents.subscribe' })
|
|
}
|
|
await Promise.all([
|
|
harness.readerA.next('events-a', (response) => resultType(response) === 'ready'),
|
|
harness.readerB.next('events-b', (response) => resultType(response) === 'ready')
|
|
])
|
|
}
|
|
|
|
it('keeps a paired client workspace create-with-activate off every other client and the host', async () => {
|
|
const harness = await startHarness()
|
|
await subscribeBothClientEventStreams(harness)
|
|
|
|
for (const [index, target] of [
|
|
CLIENT_A2_WORKTREE_ID,
|
|
CLIENT_A_WORKTREE_ID,
|
|
CLIENT_A2_WORKTREE_ID
|
|
].entries()) {
|
|
send(harness.clientA, {
|
|
id: `nav-${index}`,
|
|
method: 'worktree.activate',
|
|
params: { worktree: `id:${target}` }
|
|
})
|
|
await expect(harness.readerA.next(`nav-${index}`)).resolves.toMatchObject({ ok: true })
|
|
}
|
|
|
|
send(harness.clientA, {
|
|
id: 'create-with-agent',
|
|
method: 'worktree.create',
|
|
params: {
|
|
repo: FOLDER_REPO_ID,
|
|
name: 'teammate-agent-workspace',
|
|
activate: true
|
|
}
|
|
})
|
|
await expect(harness.readerA.next('create-with-agent')).resolves.toMatchObject({ ok: true })
|
|
|
|
// Ordered shared-catalog event fences the assertion: any erroneous activation would arrive before it.
|
|
harness.runtime.notifyReposChangedForRemoteClients()
|
|
const observed: Record<'a' | 'b', string[]> = { a: [], b: [] }
|
|
for (const [key, reader, id] of [
|
|
['a', harness.readerA, 'events-a'],
|
|
['b', harness.readerB, 'events-b']
|
|
] as const) {
|
|
for (;;) {
|
|
const type = resultType(await reader.next(id))
|
|
observed[key].push(type ?? 'unknown')
|
|
if (type === 'reposChanged' || type === 'activateWorktree') {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(observed.b).not.toContain('activateWorktree')
|
|
expect(observed.b.at(-1)).toBe('reposChanged')
|
|
expect(harness.activateWorktree).not.toHaveBeenCalled()
|
|
expect(harness.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
|
|
expect(observed.a).not.toContain('activateWorktree')
|
|
expect(observed.a.at(-1)).toBe('reposChanged')
|
|
expect(observed.a).toContain('worktreesChanged')
|
|
expect(observed.b).toContain('worktreesChanged')
|
|
})
|
|
|
|
it('does not replay a missed create activation to a reconnecting observer', async () => {
|
|
const harness = await startHarness()
|
|
await subscribeBothClientEventStreams(harness)
|
|
|
|
harness.readerB.dispose()
|
|
await new Promise<void>((resolve) => {
|
|
harness.clientB.ws.once('close', () => resolve())
|
|
harness.clientB.ws.close()
|
|
})
|
|
|
|
// navigation: all proves an activation occurred while B was offline; caller-scoped creates emit none.
|
|
send(harness.clientA, {
|
|
id: 'create-while-b-offline',
|
|
method: 'worktree.create',
|
|
params: {
|
|
repo: FOLDER_REPO_ID,
|
|
name: 'offline-observer-workspace',
|
|
activate: true,
|
|
navigation: 'all'
|
|
}
|
|
})
|
|
await expect(harness.readerA.next('create-while-b-offline')).resolves.toMatchObject({
|
|
ok: true
|
|
})
|
|
expect(
|
|
resultType(
|
|
await harness.readerA.next(
|
|
'events-a',
|
|
(response) => resultType(response) === 'activateWorktree'
|
|
)
|
|
)
|
|
).toBe('activateWorktree')
|
|
|
|
const reconnectedB = await authenticate(harness.pairingUrlB)
|
|
sessions.push(reconnectedB)
|
|
const reconnectedReaderB = createReader(reconnectedB)
|
|
readers.push(reconnectedReaderB)
|
|
send(reconnectedB, { id: 'events-b2', method: 'runtime.clientEvents.subscribe' })
|
|
await reconnectedReaderB.next('events-b2', (response) => resultType(response) === 'ready')
|
|
|
|
// Ready snapshots must never retain live navigation intent and replay it on reconnect.
|
|
harness.runtime.notifyReposChangedForRemoteClients()
|
|
const observed: string[] = []
|
|
for (;;) {
|
|
const type = resultType(await reconnectedReaderB.next('events-b2'))
|
|
observed.push(type ?? 'unknown')
|
|
if (type === 'reposChanged' || type === 'activateWorktree') {
|
|
break
|
|
}
|
|
}
|
|
expect(observed).not.toContain('activateWorktree')
|
|
expect(observed.at(-1)).toBe('reposChanged')
|
|
})
|
|
|
|
it('still reveals to every client when a paired caller asks for all-surface navigation', async () => {
|
|
const harness = await startHarness()
|
|
await subscribeBothClientEventStreams(harness)
|
|
|
|
send(harness.clientA, {
|
|
id: 'cli-shaped-create',
|
|
method: 'worktree.create',
|
|
params: {
|
|
repo: FOLDER_REPO_ID,
|
|
name: 'cli-shaped-workspace',
|
|
activate: true,
|
|
navigation: 'all'
|
|
}
|
|
})
|
|
await expect(harness.readerA.next('cli-shaped-create')).resolves.toMatchObject({ ok: true })
|
|
|
|
const [eventA, eventB] = await Promise.all([
|
|
harness.readerA.next('events-a', (response) => resultType(response) === 'activateWorktree'),
|
|
harness.readerB.next('events-b', (response) => resultType(response) === 'activateWorktree')
|
|
])
|
|
expect([resultType(eventA), resultType(eventB)]).toEqual([
|
|
'activateWorktree',
|
|
'activateWorktree'
|
|
])
|
|
expect(harness.activateWorktree).toHaveBeenCalled()
|
|
expect(eventA).toMatchObject({ result: { navigation: 'all' } })
|
|
expect(eventB).toMatchObject({ result: { navigation: 'all' } })
|
|
})
|
|
|
|
it('keeps create activation caller-scoped on a headless orca serve host', async () => {
|
|
const harness = await startHarness({ headless: true })
|
|
await subscribeBothClientEventStreams(harness)
|
|
|
|
send(harness.clientA, {
|
|
id: 'headless-create',
|
|
method: 'worktree.create',
|
|
params: { repo: FOLDER_REPO_ID, name: 'headless-agent-workspace', activate: true }
|
|
})
|
|
await expect(harness.readerA.next('headless-create')).resolves.toMatchObject({ ok: true })
|
|
|
|
harness.runtime.notifyReposChangedForRemoteClients()
|
|
const observed: string[] = []
|
|
for (;;) {
|
|
const type = resultType(await harness.readerB.next('events-b'))
|
|
observed.push(type ?? 'unknown')
|
|
if (type === 'reposChanged' || type === 'activateWorktree') {
|
|
break
|
|
}
|
|
}
|
|
expect(observed).not.toContain('activateWorktree')
|
|
expect(observed).toContain('worktreesChanged')
|
|
|
|
await harness.runtime.createManagedWorktree({
|
|
repoSelector: `id:${FOLDER_REPO_ID}`,
|
|
name: 'headless-cli-workspace',
|
|
activate: true
|
|
})
|
|
harness.runtime.notifyReposChangedForRemoteClients()
|
|
const afterCliCreate: string[] = []
|
|
for (;;) {
|
|
const type = resultType(await harness.readerB.next('events-b'))
|
|
afterCliCreate.push(type ?? 'unknown')
|
|
if (type === 'reposChanged' || type === 'activateWorktree') {
|
|
break
|
|
}
|
|
}
|
|
expect(afterCliCreate).not.toContain('activateWorktree')
|
|
expect(afterCliCreate).toContain('worktreesChanged')
|
|
})
|
|
|
|
it('normalizes a paired focused terminal.create before host-renderer activation', async () => {
|
|
const harness = await startHarness()
|
|
const created = { handle: 'term-b', worktreeId: CLIENT_B_WORKTREE_ID, title: null }
|
|
const createTerminal = vi
|
|
.spyOn(harness.runtime, 'createTerminal')
|
|
.mockImplementation(async (_worktree, options) => {
|
|
if (options?.presentation === 'focused') {
|
|
harness.hostSelections.worktreeId = CLIENT_B_WORKTREE_ID
|
|
}
|
|
return created as never
|
|
})
|
|
vi.spyOn(harness.runtime, 'dedupeTerminalCreate').mockImplementation(
|
|
async (_owner, worktree, _mutationId, _reconcile, run) => run(worktree, undefined)
|
|
)
|
|
|
|
send(harness.clientB, {
|
|
id: 'terminal-create-b',
|
|
method: 'terminal.create',
|
|
params: {
|
|
worktree: `id:${CLIENT_B_WORKTREE_ID}`,
|
|
presentation: 'focused'
|
|
}
|
|
})
|
|
await expect(harness.readerB.next('terminal-create-b')).resolves.toMatchObject({
|
|
ok: true,
|
|
result: { terminal: created }
|
|
})
|
|
expect(createTerminal).toHaveBeenCalledWith(
|
|
`id:${CLIENT_B_WORKTREE_ID}`,
|
|
expect.objectContaining({ presentation: 'background', focus: false, activate: false })
|
|
)
|
|
expect(harness.hostSelections).toEqual({
|
|
worktreeId: HOST_WORKTREE_ID,
|
|
tabId: 'host-tab'
|
|
})
|
|
})
|
|
|
|
it.each([false, true])(
|
|
'keeps host create activation off paired observers (runHooks=%s)',
|
|
async (runHooks) => {
|
|
const harness = await startHarness()
|
|
await subscribeBothClientEventStreams(harness)
|
|
|
|
await harness.runtime.createManagedWorktree({
|
|
repoSelector: `id:${FOLDER_REPO_ID}`,
|
|
name: 'cli-created-workspace',
|
|
activate: !runHooks,
|
|
runHooks
|
|
})
|
|
|
|
harness.runtime.notifyReposChangedForRemoteClients()
|
|
for (const [reader, id] of [
|
|
[harness.readerA, 'events-a'],
|
|
[harness.readerB, 'events-b']
|
|
] as const) {
|
|
const observed: string[] = []
|
|
for (;;) {
|
|
const type = resultType(await reader.next(id))
|
|
observed.push(type ?? 'unknown')
|
|
if (type === 'reposChanged' || type === 'activateWorktree') {
|
|
break
|
|
}
|
|
}
|
|
expect(observed).not.toContain('activateWorktree')
|
|
expect(observed).toContain('worktreesChanged')
|
|
}
|
|
expect(harness.activateWorktree).toHaveBeenCalled()
|
|
}
|
|
)
|
|
|
|
it('keeps host worktree activation off paired observers', async () => {
|
|
const harness = await startHarness()
|
|
await subscribeBothClientEventStreams(harness)
|
|
await harness.runtime.activateManagedWorktree(`id:${CLIENT_A_WORKTREE_ID}`)
|
|
harness.runtime.notifyReposChangedForRemoteClients()
|
|
expect(resultType(await harness.readerA.next('events-a'))).toBe('reposChanged')
|
|
expect(resultType(await harness.readerB.next('events-b'))).toBe('reposChanged')
|
|
expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID)
|
|
})
|
|
|
|
it('projects session-tab activation only to the paired caller across fanout and reconnect', async () => {
|
|
const harness = await startHarness()
|
|
|
|
send(harness.clientA, {
|
|
id: 'select-a',
|
|
method: 'session.tabs.activate',
|
|
params: {
|
|
worktree: `id:${SESSION_WORKTREE_ID}`,
|
|
tabId: 'client-a-tab',
|
|
navigation: 'caller',
|
|
notifyClients: false
|
|
}
|
|
})
|
|
send(harness.clientB, {
|
|
id: 'select-b',
|
|
method: 'session.tabs.activate',
|
|
params: {
|
|
worktree: `id:${SESSION_WORKTREE_ID}`,
|
|
tabId: 'client-b-tab',
|
|
navigation: 'caller',
|
|
notifyClients: false
|
|
}
|
|
})
|
|
expect(activeTabId(await harness.readerA.next('select-a'))).toBe('client-a-tab')
|
|
expect(activeTabId(await harness.readerB.next('select-b'))).toBe('client-b-tab')
|
|
|
|
for (const [session, id] of [
|
|
[harness.clientA, 'tabs-a'],
|
|
[harness.clientB, 'tabs-b']
|
|
] as const) {
|
|
send(session, {
|
|
id,
|
|
method: 'session.tabs.subscribe',
|
|
params: { worktree: `id:${SESSION_WORKTREE_ID}` }
|
|
})
|
|
}
|
|
expect(activeTabId(await harness.readerA.next('tabs-a'))).toBe('client-a-tab')
|
|
expect(activeTabId(await harness.readerB.next('tabs-b'))).toBe('client-b-tab')
|
|
|
|
send(harness.clientA, {
|
|
id: 'select-a2',
|
|
method: 'session.tabs.activate',
|
|
params: {
|
|
worktree: `id:${SESSION_WORKTREE_ID}`,
|
|
tabId: 'client-a2-tab',
|
|
navigation: 'caller',
|
|
notifyClients: false
|
|
}
|
|
})
|
|
const selectA2 = await harness.readerA.next('select-a2')
|
|
expect(activeTabId(selectA2)).toBe('client-a2-tab')
|
|
|
|
harness.runtime.notifyMobileSessionTabsChanged(SESSION_WORKTREE_ID)
|
|
const [updateA, updateB] = await Promise.all([
|
|
harness.readerA.next(
|
|
'tabs-a',
|
|
(response) =>
|
|
resultType(response) === 'updated' &&
|
|
snapshotVersion(response) >= snapshotVersion(selectA2)
|
|
),
|
|
harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated')
|
|
])
|
|
expect(activeTabId(updateA)).toBe('client-a2-tab')
|
|
expect(activeTabId(updateB)).toBe('client-b-tab')
|
|
expect(harness.hostSelections.tabId).toBe('host-tab')
|
|
expect(harness.focusTerminal).not.toHaveBeenCalled()
|
|
expect(
|
|
(await harness.runtime.listMobileSessionTabs(`id:${SESSION_WORKTREE_ID}`)).activeTabId
|
|
).toBe('host-tab')
|
|
|
|
harness.readerA.dispose()
|
|
harness.clientA.ws.close()
|
|
const reconnectedA = await authenticate(harness.pairingUrlA)
|
|
sessions.push(reconnectedA)
|
|
const reconnectedReaderA = createReader(reconnectedA)
|
|
readers.push(reconnectedReaderA)
|
|
send(reconnectedA, {
|
|
id: 'tabs-a-reconnected',
|
|
method: 'session.tabs.list',
|
|
params: { worktree: `id:${SESSION_WORKTREE_ID}` }
|
|
})
|
|
expect(activeTabId(await reconnectedReaderA.next('tabs-a-reconnected'))).toBe('client-a2-tab')
|
|
})
|
|
|
|
it('routes explicit host and paired-client follow intent without changing the default', async () => {
|
|
const harness = await startHarness()
|
|
for (const [session, id] of [
|
|
[harness.clientA, 'events-a'],
|
|
[harness.clientB, 'events-b']
|
|
] as const) {
|
|
send(session, { id, method: 'runtime.clientEvents.subscribe' })
|
|
}
|
|
await Promise.all([
|
|
harness.readerA.next('events-a', (response) => resultType(response) === 'ready'),
|
|
harness.readerB.next('events-b', (response) => resultType(response) === 'ready')
|
|
])
|
|
|
|
send(harness.clientA, {
|
|
id: 'host-follow',
|
|
method: 'worktree.activate',
|
|
params: { worktree: `id:${CLIENT_A_WORKTREE_ID}`, navigation: 'host' }
|
|
})
|
|
await harness.readerA.next('host-follow')
|
|
harness.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID)
|
|
expect(resultType(await harness.readerA.next('events-a'))).toBe('worktreesChanged')
|
|
expect(resultType(await harness.readerB.next('events-b'))).toBe('worktreesChanged')
|
|
expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID)
|
|
|
|
send(harness.clientB, {
|
|
id: 'clients-follow',
|
|
method: 'worktree.activate',
|
|
params: { worktree: `id:${CLIENT_B_WORKTREE_ID}`, navigation: 'clients' }
|
|
})
|
|
await harness.readerB.next('clients-follow')
|
|
const [eventA, eventB] = await Promise.all([
|
|
harness.readerA.next('events-a'),
|
|
harness.readerB.next('events-b')
|
|
])
|
|
expect([resultType(eventA), resultType(eventB)]).toEqual([
|
|
'activateWorktree',
|
|
'activateWorktree'
|
|
])
|
|
expect(harness.hostSelections.worktreeId).toBe(CLIENT_A_WORKTREE_ID)
|
|
|
|
for (const [session, id] of [
|
|
[harness.clientA, 'tabs-a'],
|
|
[harness.clientB, 'tabs-b']
|
|
] as const) {
|
|
send(session, {
|
|
id,
|
|
method: 'session.tabs.subscribe',
|
|
params: { worktree: `id:${SESSION_WORKTREE_ID}` }
|
|
})
|
|
}
|
|
await Promise.all([harness.readerA.next('tabs-a'), harness.readerB.next('tabs-b')])
|
|
send(harness.clientA, {
|
|
id: 'tabs-host-follow',
|
|
method: 'session.tabs.activate',
|
|
params: {
|
|
worktree: `id:${SESSION_WORKTREE_ID}`,
|
|
tabId: 'client-a-tab',
|
|
navigation: 'host'
|
|
}
|
|
})
|
|
expect(activeTabId(await harness.readerA.next('tabs-host-follow'))).toBe('client-a-tab')
|
|
expect(
|
|
activeTabId(
|
|
await harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated')
|
|
)
|
|
).toBe('client-a-tab')
|
|
expect(harness.hostSelections.tabId).toBe('client-a-tab')
|
|
|
|
send(harness.clientB, {
|
|
id: 'tabs-clients-follow',
|
|
method: 'session.tabs.activate',
|
|
params: {
|
|
worktree: `id:${SESSION_WORKTREE_ID}`,
|
|
tabId: 'client-b-tab',
|
|
navigation: 'clients'
|
|
}
|
|
})
|
|
await harness.readerB.next('tabs-clients-follow')
|
|
const [tabsA, tabsB] = await Promise.all([
|
|
harness.readerA.next('tabs-a', (response) => resultType(response) === 'updated'),
|
|
harness.readerB.next('tabs-b', (response) => resultType(response) === 'updated')
|
|
])
|
|
expect([activeTabId(tabsA), activeTabId(tabsB)]).toEqual(['client-b-tab', 'client-b-tab'])
|
|
expect(
|
|
[tabsA, tabsB].map(
|
|
(response) =>
|
|
(response.result as RuntimeMobileSessionTabsResult | undefined)?.navigationIntent
|
|
)
|
|
).toEqual(['follow', 'follow'])
|
|
expect(harness.hostSelections.tabId).toBe('client-a-tab')
|
|
})
|
|
|
|
it('isolates crossed clients across two runtime servers', async () => {
|
|
const serverOne = await startHarness()
|
|
const serverTwo = await startHarness()
|
|
for (const [session, id] of [
|
|
[serverTwo.clientA, 'server-two-events-a'],
|
|
[serverTwo.clientB, 'server-two-events-b']
|
|
] as const) {
|
|
send(session, { id, method: 'runtime.clientEvents.subscribe' })
|
|
}
|
|
await Promise.all([
|
|
serverTwo.readerA.next('server-two-events-a', (response) => resultType(response) === 'ready'),
|
|
serverTwo.readerB.next('server-two-events-b', (response) => resultType(response) === 'ready')
|
|
])
|
|
|
|
send(serverOne.clientA, {
|
|
id: 'server-one-worktree',
|
|
method: 'worktree.activate',
|
|
params: { worktree: `id:${CLIENT_A2_WORKTREE_ID}`, notifyClients: true }
|
|
})
|
|
await serverOne.readerA.next('server-one-worktree')
|
|
serverTwo.runtime.notifyWorktreesChangedForRemoteClients(REPO_ID)
|
|
expect(resultType(await serverTwo.readerA.next('server-two-events-a'))).toBe('worktreesChanged')
|
|
expect(resultType(await serverTwo.readerB.next('server-two-events-b'))).toBe('worktreesChanged')
|
|
expect(serverOne.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
|
|
expect(serverTwo.hostSelections.worktreeId).toBe(HOST_WORKTREE_ID)
|
|
|
|
for (const [session, reader, requestId, tabId] of [
|
|
[serverOne.clientA, serverOne.readerA, 's1-a', 'client-a2-tab'],
|
|
[serverOne.clientB, serverOne.readerB, 's1-b', 'client-b-tab'],
|
|
[serverTwo.clientA, serverTwo.readerA, 's2-a', 'client-a-tab'],
|
|
[serverTwo.clientB, serverTwo.readerB, 's2-b', 'host-tab']
|
|
] as const) {
|
|
send(session, {
|
|
id: requestId,
|
|
method: 'session.tabs.activate',
|
|
params: {
|
|
worktree: `id:${SESSION_WORKTREE_ID}`,
|
|
tabId,
|
|
navigation: 'caller',
|
|
notifyClients: false
|
|
}
|
|
})
|
|
expect(activeTabId(await reader.next(requestId))).toBe(tabId)
|
|
}
|
|
|
|
expect(serverOne.hostSelections.tabId).toBe('host-tab')
|
|
expect(serverTwo.hostSelections.tabId).toBe('host-tab')
|
|
})
|
|
|
|
it('restores a device tab selection after a runtime restart', async () => {
|
|
const persisted: { state: PersistedMobileClientTabSelections } = { state: {} }
|
|
const makeStoreWithSelections = () => ({
|
|
...makeStore(),
|
|
getMobileClientTabSelections: () => persisted.state,
|
|
setMobileClientTabSelections: (next: PersistedMobileClientTabSelections) => {
|
|
persisted.state = next
|
|
}
|
|
})
|
|
|
|
const first = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
|
first.attachWindow(1)
|
|
first.markGraphReady(1)
|
|
seedSessionTabs(first)
|
|
await first.activateMobileSessionTab(`id:${SESSION_WORKTREE_ID}`, 'client-a-tab', undefined, {
|
|
notifyClients: false,
|
|
clientNavigationId: 'device-a',
|
|
navigation: 'caller'
|
|
})
|
|
expect(persisted.state['device-a']?.[SESSION_WORKTREE_ID]?.activeTabId).toBe('client-a-tab')
|
|
|
|
const restarted = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
|
restarted.attachWindow(1)
|
|
restarted.markGraphReady(1)
|
|
seedSessionTabs(restarted)
|
|
const remembered = await restarted.listMobileSessionTabs(
|
|
`id:${SESSION_WORKTREE_ID}`,
|
|
'device-a'
|
|
)
|
|
expect(remembered.activeTabId).toBe('client-a-tab')
|
|
expect(remembered.tabs.find((tab) => tab.isActive)?.id).toBe('client-a-tab')
|
|
// Unknown devices inherit the host view, never another device's restored state.
|
|
const freshDevice = await restarted.listMobileSessionTabs(
|
|
`id:${SESSION_WORKTREE_ID}`,
|
|
'device-b'
|
|
)
|
|
expect(freshDevice.activeTabId).toBe('host-tab')
|
|
})
|
|
})
|