diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index a4c9ef206..3140cc659 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -120,22 +120,48 @@ describe('parseDaemonPidFile', () => { const serialized = serializeDaemonPidFile({ pid: 12345, startedAtMs: 1_700_000_000_000 }) expect(parseDaemonPidFile(serialized)).toEqual({ pid: 12345, - startedAtMs: 1_700_000_000_000 + startedAtMs: 1_700_000_000_000, + entryPath: null + }) + }) + + it('parses JSON pid files with entryPath', () => { + const serialized = serializeDaemonPidFile({ + pid: 12345, + startedAtMs: 1_700_000_000_000, + entryPath: '/repo/out/main/daemon-entry.js' + }) + expect(parseDaemonPidFile(serialized)).toEqual({ + pid: 12345, + startedAtMs: 1_700_000_000_000, + entryPath: '/repo/out/main/daemon-entry.js' }) }) it('accepts JSON with startedAtMs missing and returns null for it', () => { // Why: forward-compatible with hypothetical future daemons that might write // pid without startedAtMs (platform where getProcessStartedAtMs returns null). - expect(parseDaemonPidFile('{"pid":9999}')).toEqual({ pid: 9999, startedAtMs: null }) + expect(parseDaemonPidFile('{"pid":9999}')).toEqual({ + pid: 9999, + startedAtMs: null, + entryPath: null + }) }) it('falls back to bare-integer parsing for legacy pid files', () => { // Why: pre-Phase-0 daemons wrote the pid file as a bare integer. // parseDaemonPidFile must still accept those to avoid leaking a stale // daemon across a single upgrade boundary. - expect(parseDaemonPidFile('12345')).toEqual({ pid: 12345, startedAtMs: null }) - expect(parseDaemonPidFile(' 12345\n')).toEqual({ pid: 12345, startedAtMs: null }) + expect(parseDaemonPidFile('12345')).toEqual({ + pid: 12345, + startedAtMs: null, + entryPath: null + }) + expect(parseDaemonPidFile(' 12345\n')).toEqual({ + pid: 12345, + startedAtMs: null, + entryPath: null + }) }) it('returns null for malformed input', () => { diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 7d6126db9..f6d3670c8 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -15,6 +15,7 @@ const START_TIME_TOLERANCE_MS = 1_500 type ParsedDaemonPid = { pid: number startedAtMs: number | null + entryPath: string | null } function canConnectSocket(socketPath: string): Promise { @@ -139,14 +140,19 @@ function commandLineMatchesDaemon( export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { const trimmed = contents.trim() try { - const parsed = JSON.parse(trimmed) as { pid?: unknown; startedAtMs?: unknown } + const parsed = JSON.parse(trimmed) as { + pid?: unknown + startedAtMs?: unknown + entryPath?: unknown + } if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) { return { pid: parsed.pid, startedAtMs: typeof parsed.startedAtMs === 'number' && Number.isFinite(parsed.startedAtMs) ? parsed.startedAtMs - : null + : null, + entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null } } } catch { @@ -154,7 +160,7 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { } const pid = Number(trimmed) - return Number.isFinite(pid) ? { pid, startedAtMs: null } : null + return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null } : null } function getLinuxProcessStartedAtMs(pid: number): number | null { @@ -278,6 +284,78 @@ function isDaemonProcess( } } +function getDaemonCommandLine(pid: number): string | null { + if (process.platform === 'win32') { + try { + return execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine` + ], + { + encoding: 'utf8', + timeout: 3_000 + } + ) + } catch { + return null + } + } + + try { + return readFileSync(`/proc/${pid}/cmdline`, 'utf8') + } catch { + try { + return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + timeout: 2_000 + }) + } catch { + return null + } + } +} + +export type DaemonLaunchIdentity = 'match' | 'mismatch' | 'unknown' + +export function getDaemonLaunchIdentity( + runtimeDir: string, + socketPath: string, + tokenPath: string, + expectedEntryPath: string, + protocolVersion = PROTOCOL_VERSION +): DaemonLaunchIdentity { + let parsedPid: ParsedDaemonPid | null + try { + parsedPid = parseDaemonPidFile( + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + ) + } catch { + return 'unknown' + } + + if (!parsedPid || !isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) { + return 'unknown' + } + + if (parsedPid.entryPath) { + return parsedPid.entryPath === expectedEntryPath ? 'match' : 'mismatch' + } + + // Why: older pid files did not persist entryPath. The command line still + // carries daemon-entry.js, so use it to stop dev worktrees from reusing a + // daemon forked from a deleted sibling checkout. If command-line probing is + // unavailable, fail open so we don't kill live sessions unnecessarily. + const commandLine = getDaemonCommandLine(parsedPid.pid) + if (!commandLine) { + return 'unknown' + } + return commandLine.includes(expectedEntryPath) ? 'match' : 'mismatch' +} + export async function killStaleDaemon( runtimeDir: string, socketPath: string, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 5b4cdfafc..82914e487 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -14,9 +14,12 @@ import { PROTOCOL_VERSION } from './types' // non-daemon-init dependency is replaced by a minimal stub that records calls. const { getPathMock, + isPackagedMock, probeSocketExistsMock, netConnectMock, + forkMock, healthCheckDaemonMock, + getDaemonLaunchIdentityMock, killStaleDaemonMock, getProcessStartedAtMsMock, daemonClientMock, @@ -27,8 +30,10 @@ const { rebindLocalProviderListenersMock } = vi.hoisted(() => { const getPathMock = vi.fn(() => '/fake/userData') + const isPackagedMock = vi.fn(() => false) const probeSocketExistsMock = vi.fn(() => false) + const forkMock = vi.fn() const netConnectMock = vi.fn(() => { // Why: the real probeSocket() in daemon-init connects to the socket and // resolves true on 'connect', false on 'error'. Our launcher never runs @@ -51,6 +56,7 @@ const { }) const healthCheckDaemonMock = vi.fn(async () => true) + const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const killStaleDaemonMock = vi.fn(async () => true) const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) @@ -73,9 +79,12 @@ const { return { getPathMock, + isPackagedMock, probeSocketExistsMock, netConnectMock, + forkMock, healthCheckDaemonMock, + getDaemonLaunchIdentityMock, killStaleDaemonMock, getProcessStartedAtMsMock, daemonClientMock, @@ -120,7 +129,13 @@ type MockAdapter = { } vi.mock('electron', () => ({ - app: { getPath: getPathMock, getAppPath: () => '/fake/app' } + app: { + get isPackaged() { + return isPackagedMock() + }, + getPath: getPathMock, + getAppPath: () => '/fake/app' + } })) vi.mock('fs', () => ({ @@ -130,11 +145,12 @@ vi.mock('fs', () => ({ writeFileSync: vi.fn() })) -vi.mock('child_process', () => ({ fork: vi.fn() })) +vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ + getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, healthCheckDaemon: healthCheckDaemonMock, killStaleDaemon: killStaleDaemonMock, getProcessStartedAtMs: getProcessStartedAtMsMock @@ -224,7 +240,11 @@ async function importFresh() { unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() healthCheckDaemonMock.mockClear() + getDaemonLaunchIdentityMock.mockClear() killStaleDaemonMock.mockClear() + forkMock.mockReset() + isPackagedMock.mockReset() + isPackagedMock.mockReturnValue(false) daemonClientMock.mockClear() probeSocketExistsMock.mockClear() // Why: importing daemon-init *after* resetModules means the module-level @@ -600,4 +620,74 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { 'restartDaemon called before initDaemonPtyProvider' ) }) + + it('respawns instead of reusing a healthy daemon launched from another app path', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') + forkMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token', + '/fake/app/out/main/daemon-entry.js' + ) + expect(killStaleDaemonMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalledWith( + '/fake/app/out/main/daemon-entry.js', + ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.objectContaining({ detached: true }) + ) + }) + + it('keeps packaged healthy-daemon reuse independent of dev app-path identity', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockClear() + killStaleDaemonMock.mockClear() + forkMock.mockClear() + isPackagedMock.mockReturnValue(true) + getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') + + await launcher('/fake/socket', '/fake/token') + + expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d1545a81d..715701b60 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -27,7 +27,12 @@ import { PROTOCOL_VERSION, type ListSessionsResult } from './types' -import { getProcessStartedAtMs, healthCheckDaemon, killStaleDaemon } from './daemon-health' +import { + getDaemonLaunchIdentity, + getProcessStartedAtMs, + healthCheckDaemon, + killStaleDaemon +} from './daemon-health' import { setLocalPtyProvider, unbindLocalProviderListeners, @@ -91,13 +96,26 @@ function probeSocket(socketPath: string): Promise { function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { return async (socketPath, tokenPath) => { + const entryPath = getDaemonEntryPath() const healthy = await healthCheckDaemon(socketPath, tokenPath) if (healthy) { - // Why: daemon is already running from a previous app session and - // responded to a protocol-level ping. Safe to reuse. - return { - shutdown: async () => { - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + // Why: dev worktrees share the same orca-dev userData, so a daemon from + // a deleted sibling checkout can pass protocol health checks while still + // pointing at missing native modules. Packaged app paths are stable and + // should preserve existing warm daemon reuse semantics. + const identity = app.isPackaged + ? 'match' + : getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) + if (identity === 'mismatch') { + console.warn('[daemon] Replacing daemon launched from a different app path') + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } else { + // Why: daemon is already running from a previous app session and + // responded to a protocol-level ping. Safe to reuse. + return { + shutdown: async () => { + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } } } } @@ -106,7 +124,6 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // before respawn so the new daemon does not race the stale process. await killStaleDaemon(runtimeDir, socketPath, tokenPath) - const entryPath = getDaemonEntryPath() const userDataPath = app.getPath('userData') const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { // Why: detached + unref lets the daemon outlive the Electron process. @@ -156,7 +173,8 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { getDaemonPidPath(runtimeDir), serializeDaemonPidFile({ pid: child.pid, - startedAtMs: getProcessStartedAtMs(child.pid) + startedAtMs: getProcessStartedAtMs(child.pid), + entryPath }), { mode: 0o600 } ) diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index d57b79e41..d40c0d39f 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -10,6 +10,7 @@ export type DaemonConnectionInfo = { export type DaemonPidFile = { pid: number startedAtMs: number | null + entryPath?: string } export type DaemonProcessHandle = { diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index ce1da22c2..4a626d459 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -30,6 +30,11 @@ type OrcaTestFixtures = { electronApp: ElectronApplication sharedPage: Page orcaPage: Page + // Why: every fresh userData dir paints the first-launch onboarding overlay + // (closedAt=null), which is `fixed inset-0 z-[100]` and intercepts pointer + // events for every other test. Dismiss it by default; onboarding.spec.ts + // opts out via `test.use({ dismissOnboarding: false })`. + dismissOnboarding: boolean } type OrcaWorkerFixtures = { @@ -140,10 +145,38 @@ export const test = base.extend({ ], // Test-scoped: one Electron app per test - // oxlint-disable-next-line no-empty-pattern -- Playwright fixture callbacks require object destructuring here. - electronApp: async ({}, provideFixture, testInfo) => { + electronApp: async ({ dismissOnboarding }, provideFixture, testInfo) => { const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) + + if (dismissOnboarding) { + // Why: onboarding renders a fullscreen `fixed inset-0 z-[100]` overlay + // when persisted `closedAt` is null, which intercepts pointer events for + // every other test. Seed explicit fresh-user state: an empty file would + // make persistence treat the profile as an existing-user upgrade cohort + // and mount the telemetry notice overlay instead. + writeFileSync( + path.join(userDataDir, 'orca-data.json'), + `${JSON.stringify( + { + settings: { + telemetry: { + optedIn: true, + installId: '00000000-0000-4000-8000-000000000000', + existedBeforeTelemetryRelease: false + } + }, + onboarding: { + closedAt: 1, + outcome: 'completed', + lastCompletedStep: 4 + } + }, + null, + 2 + )}\n` + ) + } const headful = shouldLaunchHeadful(testInfo) // Why: strip ELECTRON_RUN_AS_NODE before spawning. Some host shells (e.g. // Orca's own agent runtime) set it so Electron behaves as a plain Node @@ -161,9 +194,7 @@ export const test = base.extend({ // Why: testInfo.outputDir is created lazily by Playwright; on Windows the // dir may not exist when the fixture initializes, and Electron silently // drops the recording. mkdir up-front so the recorder always has a home. - const recordVideoDir = process.env.ORCA_E2E_RECORD_VIDEO === '1' - ? testInfo.outputDir - : null + const recordVideoDir = process.env.ORCA_E2E_RECORD_VIDEO === '1' ? testInfo.outputDir : null if (recordVideoDir) { mkdirSync(recordVideoDir, { recursive: true }) } @@ -212,6 +243,9 @@ export const test = base.extend({ rmSync(userDataDir, { recursive: true, force: true }) }, + // Default: dismiss the onboarding overlay so it doesn't intercept clicks. + dismissOnboarding: [true, { option: true }], + // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. sharedPage: async ({ electronApp, testRepoPath }, provideFixture) => { diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 7a12e8ca6..f3780f009 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -34,6 +34,11 @@ async function getDocumentThemeClass(page: Page): Promise<'dark' | 'light'> { } test.describe('Onboarding flow', () => { + // Why: the shared fixture pre-seeds onboarding as closed so non-onboarding + // tests don't get blocked by the fullscreen overlay. Opt out here so this + // spec actually exercises the first-launch flow. + test.use({ dismissOnboarding: false }) + test.beforeEach(async ({ orcaPage }) => { // Per-test userData is freshly minted by the orcaPage fixture, so persisted // onboarding state defaults to `closedAt: null, lastCompletedStep: -1` and @@ -42,9 +47,9 @@ test.describe('Onboarding flow', () => { }) test('renders on first launch with the agent step active', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) await expect(orcaPage.getByText('1 of 4')).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Continue' })).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Skip' })).toBeVisible() @@ -61,9 +66,9 @@ test.describe('Onboarding flow', () => { test('Continue advances steps, persists progress, and applies user-visible settings', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) // --- Step 1: agent --- // Force a deterministic, non-default selection so the assertion below @@ -87,9 +92,7 @@ test.describe('Onboarding flow', () => { await codexButton.click() await orcaPage.getByRole('button', { name: 'Continue' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Make it feel like home/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() await expect(orcaPage.getByText('2 of 4')).toBeVisible() await expect .poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, { @@ -134,9 +137,9 @@ test.describe('Onboarding flow', () => { message: 'lastCompletedStep did not advance to 2 after second Continue' }) .toBe(2) - await expect.poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 }).toBe( - oppositeTheme - ) + await expect + .poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 }) + .toBe(oppositeTheme) // --- Step 3: notifications --- // Why: the wizard force-defaults every toggle ON (use-onboarding-flow.ts), @@ -145,9 +148,7 @@ test.describe('Onboarding flow', () => { // the post-Continue assertion proves the wizard wrote its opt-in defaults // through the IPC boundary, including the inverted suppressWhenFocused. await orcaPage.getByRole('button', { name: 'Continue' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Point Orca at some code/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible() await expect(orcaPage.getByText('4 of 4')).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Continue' })).toHaveCount(0) await expect(orcaPage.getByRole('button', { name: /I'll add one later/ })).toBeVisible() @@ -182,9 +183,9 @@ test.describe('Onboarding flow', () => { }) test('Cmd/Ctrl+Enter advances steps like Continue', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) // Why: the OS the renderer reports drives whether Cmd or Ctrl is the // accelerator (OnboardingFlow.tsx checks navigator.userAgent). @@ -196,9 +197,7 @@ test.describe('Onboarding flow', () => { // inert area inside the overlay first to anchor focus, then press. await orcaPage.locator('footer').click({ position: { x: 1, y: 1 } }) await orcaPage.keyboard.press(accelerator) - await expect( - orcaPage.getByRole('heading', { name: /Make it feel like home/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() await expect .poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, { timeout: 5_000 @@ -207,9 +206,9 @@ test.describe('Onboarding flow', () => { }) test('selected agent button reports aria-pressed=true', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) const codexButton = orcaPage.getByRole('button', { name: /^Codex\s/ }) const codexVisible = await codexButton @@ -227,13 +226,11 @@ test.describe('Onboarding flow', () => { }) test('notification toggles flip independently and persist on Continue', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) await orcaPage.getByRole('button', { name: 'Skip' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Make it feel like home/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() await orcaPage.getByRole('button', { name: 'Skip' }).click() await expect( orcaPage.getByRole('heading', { name: /Know when an agent needs you/i }) @@ -248,9 +245,7 @@ test.describe('Onboarding flow', () => { await expect(bellSwitch).toHaveAttribute('aria-checked', 'false') await orcaPage.getByRole('button', { name: 'Continue' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Point Orca at some code/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible() await expect .poll( async () => { @@ -268,16 +263,14 @@ test.describe('Onboarding flow', () => { test('typing in the clone-url input does not hijack Enter as a global shortcut', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) // Skip to the repo step. await orcaPage.getByRole('button', { name: 'Skip' }).click() await orcaPage.getByRole('button', { name: 'Skip' }).click() await orcaPage.getByRole('button', { name: 'Skip' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Point Orca at some code/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible() // Why: focus the clone-url input and press Cmd/Ctrl+Enter. The capture- // phase keydown handler should bail via isEditableTarget, so the folder @@ -291,22 +284,18 @@ test.describe('Onboarding flow', () => { await input.press(accelerator) // Brief wait so any (incorrect) handler firing would have already happened. await orcaPage.waitForTimeout(250) - await expect( - orcaPage.getByRole('heading', { name: /Point Orca at some code/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible() // Onboarding must still be open (closedAt remains null). expect((await getOnboardingState(orcaPage)).closedAt).toBeNull() }) test('Back returns to the previous step without losing progress', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) await orcaPage.getByRole('button', { name: 'Continue' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Make it feel like home/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() await expect .poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, { timeout: 5_000 @@ -316,9 +305,7 @@ test.describe('Onboarding flow', () => { // Why: exact match — the app sidebar also exposes a "Go back" button that // would otherwise match this regex. await orcaPage.getByRole('button', { name: 'Back', exact: true }).click() - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible() await expect(orcaPage.getByText('1 of 4')).toBeVisible() // Why: "without losing progress" means persisted lastCompletedStep stays @@ -332,32 +319,29 @@ test.describe('Onboarding flow', () => { }) test('"I\'ll add one later" on the repo step dismisses onboarding', async ({ orcaPage }) => { - await expect( - orcaPage.getByRole('heading', { name: /Pick your default agent/i }) - ).toBeVisible({ timeout: 15_000 }) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) // Skip through the first three steps. On steps 1–3 the affordance is // labelled "Skip"; on the repo step it is "I'll add one later". await orcaPage.getByRole('button', { name: 'Skip' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Make it feel like home/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() await orcaPage.getByRole('button', { name: 'Skip' }).click() await expect( orcaPage.getByRole('heading', { name: /Know when an agent needs you/i }) ).toBeVisible() await orcaPage.getByRole('button', { name: 'Skip' }).click() - await expect( - orcaPage.getByRole('heading', { name: /Point Orca at some code/i }) - ).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible() await orcaPage.getByRole('button', { name: /I'll add one later/ }).click() // The overlay is unmounted once `closedAt` is set, so the heading must // disappear from the DOM, not merely become invisible. - await expect( - orcaPage.getByRole('heading', { name: /Point Orca at some code/i }) - ).toHaveCount(0, { timeout: 10_000 }) + await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toHaveCount( + 0, + { timeout: 10_000 } + ) // Why: DOM unmount fires when closedAt flips in the renderer, but the // main-process write can lag by an IPC tick. Poll until the persisted