fix: isolate e2e daemon and onboarding state (#1606)
This commit is contained in:
parent
f5be12b4a3
commit
68d42040f4
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type DaemonConnectionInfo = {
|
|||
export type DaemonPidFile = {
|
||||
pid: number
|
||||
startedAtMs: number | null
|
||||
entryPath?: string
|
||||
}
|
||||
|
||||
export type DaemonProcessHandle = {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue