fix: isolate e2e daemon and onboarding state (#1606)

This commit is contained in:
Jinjing 2026-05-08 17:45:02 -07:00 committed by GitHub
parent f5be12b4a3
commit 68d42040f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 316 additions and 85 deletions

View File

@ -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', () => {

View File

@ -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<boolean> {
@ -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,

View File

@ -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<void> }>
getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch')
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => 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<void> }>
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()
})
})

View File

@ -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<boolean> {
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 }
)

View File

@ -10,6 +10,7 @@ export type DaemonConnectionInfo = {
export type DaemonPidFile = {
pid: number
startedAtMs: number | null
entryPath?: string
}
export type DaemonProcessHandle = {

View File

@ -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<OrcaTestFixtures, OrcaWorkerFixtures>({
],
// 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<OrcaTestFixtures, OrcaWorkerFixtures>({
// 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<OrcaTestFixtures, OrcaWorkerFixtures>({
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) => {

View File

@ -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 13 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