Files
orca/tests/e2e/onboarding.spec.ts
T
Brennan Benson b5a99462bc fix(e2e): fabricate runtime-host health through the snapshot that owns it (#20762)
* fix(e2e): seed the runtime host with a snapshot the host cannot overwrite

Since #20003 the published snapshot owns runtime-host health, so a bare status
seed no longer survives: main's status owner publishes `checking` for this
unreachable synthetic host as soon as any runtime RPC touches it, the host
reads `connecting`, and the Add Project dialog falls back to Local — so the
host-scoped copy the test asserts never renders.

Seed a snapshot pinned at the top sequence instead. applyRuntimeHostStatusSnapshot
drops any later publication whose sequence is not higher, and setRuntimeEnvironmentStatus
no-ops a snapshot-less write once a snapshot exists, so the bare seed could not
have worked either way.

Ablated: passes with the pinned snapshot, fails without it.

* fix(e2e): fabricate paired-web host health through the snapshot that owns it

Companion to the onboarding seed fix. Since #20003 the published snapshot owns
runtime-host health, so writing `status: null` alone leaves the paired-web
client's verified/ready snapshot in place: addRuntimeHost reads
snapshot.transport 'ready' before it ever consults status, and the host still
renders Connected while the test waits for Disconnected.

Rewrite the snapshot coherently instead, pinned at the top sequence so the live
status owner cannot restore the host mid-assertion. The disconnected leg uses
transport 'unknown' rather than 'disconnected', because a dropped transport is
unverifiable and renders as Connecting; only a never-reached host renders
Disconnected. remoteControl is nulled because runtimeControlHealth answers
'available' on a ready control socket even with a null status.

Ablated with ORCA_E2E_WEB_CLIENT=1 (the whole file is test.skip'd without it,
so a run without that flag reports a passing skip): fails without the change,
passes with it.

* test(e2e): preserve paired runtime status metadata
2026-09-15 16:18:36 -07:00

690 lines
29 KiB
TypeScript

/**
* E2E tests for the first-launch Onboarding flow.
*
* The onboarding overlay is gated by `OnboardingState.closedAt === null` (see
* `shouldShowOnboarding` in `should-show-onboarding.ts`). Each test gets a fresh
* Electron instance + isolated userData dir, so persistence starts clean and
* the overlay renders on first paint without any setup.
*/
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type { Page } from '@stablyai/playwright-test'
import type { GlobalSettings } from '../../src/shared/global-settings-types'
import type { TuiAgent } from '../../src/shared/tui-agent'
import { ONBOARDING_FINAL_STEP } from '../../src/shared/constants'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../src/shared/pairing'
type OnboardingState = {
closedAt: number | null
outcome: 'completed' | 'dismissed' | null
lastCompletedStep: number
checklist: Record<string, boolean>
}
const SKIP_TO_PROJECT_SETUP_BUTTON = /^Skip to project setup$/i
const TASK_SOURCES_HEADING = /Set up GitHub tasks/i
const WINDOWS_TERMINAL_HEADING = /Set Windows terminal defaults/i
const ADD_PROJECT_DIALOG_HEADING = /Add (?:a server project|a project|another project)/i
async function getOnboardingState(page: Page): Promise<OnboardingState> {
return page.evaluate(() => window.api.onboarding.get() as Promise<OnboardingState>)
}
async function getSettings(page: Page): Promise<GlobalSettings> {
return page.evaluate(() => window.api.settings.get())
}
async function getDocumentThemeClass(page: Page): Promise<'dark' | 'light'> {
return page.evaluate(() =>
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
)
}
function onboardingFooter(page: Page) {
return page
.locator('footer')
.filter({
has: page.getByRole('button', { name: /Back|Continue|Add your first project|Set up|Skip/i })
})
.first()
}
function onboardingFooterButton(page: Page, name: RegExp) {
return onboardingFooter(page).getByRole('button', { name })
}
function onboardingNotificationSoundSelect(page: Page) {
return page.getByRole('combobox').first()
}
async function expectOnboardingNotificationSoundMenuClosed(page: Page): Promise<void> {
await expect(page.getByRole('option', { name: /Choose Custom File/i })).toHaveCount(0)
}
async function expectOnboardingSkipConfirmationClosed(page: Page): Promise<void> {
await expect(page.getByRole('dialog', { name: /Skip onboarding\?/i })).toHaveCount(0)
}
async function expectOnboardingSkipConfirmationOpen(page: Page): Promise<void> {
await expect(page.getByRole('dialog', { name: /Skip onboarding\?/i })).toBeVisible()
}
async function expectOnboardingNotificationSound(page: Page, name: RegExp): Promise<void> {
await expect(onboardingNotificationSoundSelect(page)).toContainText(name)
}
async function chooseOnboardingNotificationSound(page: Page, name: RegExp): Promise<void> {
const soundSelect = onboardingNotificationSoundSelect(page)
await soundSelect.click()
const option = page.getByRole('option', { name })
await expect(option).toBeVisible()
// Why: the select menu extends over the onboarding footer on small CI
// viewports; keyboard selection avoids pointer fall-through to Skip.
await option.press('Enter')
await expect(soundSelect).toContainText(name)
await expectOnboardingNotificationSoundMenuClosed(page)
await expectOnboardingSkipConfirmationClosed(page)
}
async function expectOnboardingCustomSoundOption(page: Page): Promise<void> {
const soundSelect = onboardingNotificationSoundSelect(page)
await soundSelect.click()
await expect(page.getByRole('option', { name: /Choose Custom File/i })).toBeVisible()
await page.getByRole('option', { selected: true }).press('Enter')
await expectOnboardingNotificationSoundMenuClosed(page)
await expectOnboardingSkipConfirmationClosed(page)
}
async function continueOnboarding(page: Page): Promise<void> {
await onboardingFooterButton(page, /^(Continue|Add your first project)\b/).click()
}
async function expectOnboardingProgress(page: Page, label: RegExp): Promise<void> {
await expect(page.getByText(label)).toBeVisible()
}
async function expectAddProjectDialog(page: Page): Promise<void> {
await expect(page.getByRole('heading', { name: ADD_PROJECT_DIALOG_HEADING })).toBeVisible()
}
async function continueFromPostNotificationsToRepo(page: Page): Promise<void> {
if (await page.getByRole('heading', { name: ADD_PROJECT_DIALOG_HEADING }).isVisible()) {
return
}
await continueThroughOptionalTaskSourcesAndWindowsTerminal(page)
await expect(page.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
await expectOnboardingProgress(page, /^[345] of [345]$/)
await expect(onboardingFooterButton(page, /^Add your first project\b/)).toBeVisible()
await continueOnboarding(page)
await expectAddProjectDialog(page)
}
async function continueThroughOptionalTaskSourcesAndWindowsTerminal(page: Page): Promise<void> {
const taskSourcesVisible = await page
.getByRole('heading', { name: TASK_SOURCES_HEADING })
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (taskSourcesVisible) {
await expectOnboardingProgress(page, /^3 of [45]$/)
await continueOnboarding(page)
}
const windowsTerminalVisible = await page
.getByRole('heading', { name: WINDOWS_TERMINAL_HEADING })
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (windowsTerminalVisible) {
await expectOnboardingProgress(page, /^[34] of [45]$/)
await continueOnboarding(page)
}
await expect(page.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
}
async function continueFromThemeToNotifications(page: Page): Promise<void> {
await continueOnboarding(page)
await continueThroughOptionalTaskSourcesAndWindowsTerminal(page)
}
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
// the overlay paints on its own once App's bootstrap effect resolves.
await waitForSessionReady(orcaPage)
})
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 expectOnboardingProgress(orcaPage, /^1 of [345]$/)
await expect(onboardingFooterButton(orcaPage, /^Continue\b/)).toBeVisible()
await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toBeVisible()
// Why: Back is not rendered on the first step (was previously rendered-but-
// disabled with `disabled:invisible`, now conditionally mounted).
await expect(orcaPage.getByRole('button', { name: 'Back', exact: true })).toHaveCount(0)
// Footer hint shows the platform-correct continue shortcut (⌘ on Mac,
// Ctrl elsewhere). Match either form so the test runs cross-platform.
// Why: scope to the footer action so background UI shortcut hints cannot
// false-positive this assertion.
await expect(
onboardingFooterButton(orcaPage, /^Continue\b/)
.locator('span')
.filter({ hasText: /⌘|Ctrl/ })
.first()
).toBeVisible()
})
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
})
// --- Step 1: agent ---
// Force a deterministic, non-default selection so the assertion below
// proves the wizard actually wrote the user's choice (not just the
// pre-selected detected agent). Codex sits in the top-6 catalog when no
// agents are detected, otherwise behind the "Show N more agents" details
// expander — open it if codex isn't visible.
const targetAgent: TuiAgent = 'codex'
const codexButton = orcaPage.getByRole('button', { name: /^Codex\s/ })
// Why: isVisible() is a one-shot probe — on slow renderer paint it would
// race the wizard mount and falsely take the "show more agents" branch.
// waitFor with a small timeout actually retries until the button paints.
const codexVisible = await codexButton
.first()
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (!codexVisible) {
await orcaPage.getByText(/Show \d+ more agents/).click()
}
await codexButton.click()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await expectOnboardingProgress(orcaPage, /^2 of [345]$/)
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
message: 'lastCompletedStep did not advance to 1 after first Continue'
})
.toBe(1)
// The agent choice must be persisted to settings (the user will see this
// pre-selected when they later open a new tab / agent picker).
await expect
.poll(async () => (await getSettings(orcaPage)).defaultTuiAgent, { timeout: 5_000 })
.toBe(targetAgent)
// --- Step 2: theme ---
// Default settings.theme is 'system', so the document class can resolve to
// either 'dark' or 'light' depending on the host. Click the opposite tile
// so we always observe a live flip — the assertion that proves the wizard
// applies the choice immediately, not just on Continue.
// Why: 'system' resolves async on mount, so wait for the class to settle
// before snapshotting — otherwise startingTheme can be stale.
await orcaPage.waitForFunction(
() =>
document.documentElement.classList.contains('dark') ||
document.documentElement.classList.contains('light')
)
const startingTheme = await getDocumentThemeClass(orcaPage)
const oppositeTheme: 'dark' | 'light' = startingTheme === 'dark' ? 'light' : 'dark'
const oppositeTileName = oppositeTheme === 'light' ? /Bright & crisp/ : /Easy on the eyes/
await orcaPage.getByRole('button', { name: oppositeTileName }).click()
await expect
.poll(async () => getDocumentThemeClass(orcaPage), { timeout: 5_000 })
.toBe(oppositeTheme)
await continueOnboarding(orcaPage)
// Why: the theme Continue persists step 2, then persists *through* any
// skipped optional steps (integrations is skipped when gh is installed,
// windows_terminal off macOS), so lastCompletedStep can land at 2, 3, or 4.
// Key off the settled "theme step committed" lower bound rather than a fixed
// window that assumed integrations always renders.
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
message: 'lastCompletedStep did not advance past the theme step after second Continue'
})
.toBeGreaterThanOrEqual(2)
await expect
.poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 })
.toBe(oppositeTheme)
await continueThroughOptionalTaskSourcesAndWindowsTerminal(orcaPage)
await expectOnboardingProgress(orcaPage, /^[345] of [345]$/)
await expect
.poll(async () => [3, 4].includes((await getOnboardingState(orcaPage)).lastCompletedStep), {
timeout: 5_000,
message: 'lastCompletedStep did not include optional setup progress'
})
.toBe(true)
// --- Step 3: notifications ---
await expectOnboardingNotificationSound(orcaPage, /System Default/i)
await expect(orcaPage.getByRole('button', { name: /Send Test Notification/i })).toBeVisible()
await expectOnboardingCustomSoundOption(orcaPage)
await continueFromPostNotificationsToRepo(orcaPage)
// Verify the source defaults land without asking users to configure each
// source in the onboarding UI.
await expect
.poll(
async () => {
const s = await getSettings(orcaPage)
return {
agentTaskComplete: s.notifications.agentTaskComplete,
terminalBell: s.notifications.terminalBell,
enabled: s.notifications.enabled,
customSoundId: s.notifications.customSoundId
}
},
{ timeout: 5_000 }
)
.toEqual({
agentTaskComplete: true,
terminalBell: true,
enabled: true,
customSoundId: 'system'
})
await expect
.poll(
async () => {
const state = await getOnboardingState(orcaPage)
return {
closedAt: state.closedAt === null ? null : 'set',
outcome: state.outcome,
addedRepo: state.checklist.addedRepo,
lastCompletedStep: state.lastCompletedStep
}
},
{ timeout: 5_000 }
)
.toEqual({
closedAt: 'set',
outcome: 'completed',
addedRepo: false,
lastCompletedStep: ONBOARDING_FINAL_STEP
})
})
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
})
// Why: the OS the renderer reports drives whether Cmd or Ctrl is the
// accelerator (OnboardingFlow.tsx checks navigator.userAgent).
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
const accelerator = isMac ? 'Meta+Enter' : 'Control+Enter'
// Why: in headless Linux CI the window-level capture-phase listener can
// miss synthetic keyboard events when no element holds focus. Click an
// 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
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(1)
})
test('Skip opens Add Project, saves the selected agent, and completes onboarding', async ({
orcaPage
}) => {
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
.first()
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (!codexVisible) {
await orcaPage.getByText(/Show \d+ more agents/).click()
}
await codexButton.click()
await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click()
await expectAddProjectDialog(orcaPage)
await expect
.poll(
async () => {
const state = await getOnboardingState(orcaPage)
return {
closedAt: state.closedAt === null ? null : 'set',
outcome: state.outcome,
dismissed: state.checklist.dismissed,
lastCompletedStep: state.lastCompletedStep
}
},
{ timeout: 5_000 }
)
.toEqual({
closedAt: 'set',
outcome: 'completed',
dismissed: false,
lastCompletedStep: ONBOARDING_FINAL_STEP
})
await expect
.poll(async () => (await getSettings(orcaPage)).defaultTuiAgent, { timeout: 5_000 })
.toBe('codex')
})
test('Skip from theme restores the entry theme choice', async ({ orcaPage }) => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
timeout: 15_000
})
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await orcaPage.waitForFunction(
() =>
document.documentElement.classList.contains('dark') ||
document.documentElement.classList.contains('light')
)
const entryTheme = (await getSettings(orcaPage)).theme
const startingTheme = await getDocumentThemeClass(orcaPage)
const oppositeTheme: 'dark' | 'light' = startingTheme === 'dark' ? 'light' : 'dark'
const oppositeTileName = oppositeTheme === 'light' ? /Bright & crisp/ : /Easy on the eyes/
await orcaPage.getByRole('button', { name: oppositeTileName }).click()
await expect
.poll(async () => getDocumentThemeClass(orcaPage), { timeout: 5_000 })
.toBe(oppositeTheme)
await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click()
await expectAddProjectDialog(orcaPage)
await expect
.poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 })
.toBe(entryTheme)
await expect
.poll(async () => getDocumentThemeClass(orcaPage), { timeout: 5_000 })
.toBe(startingTheme)
})
test('Skip preserves runtime server project setup UI', async ({ orcaPage }) => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
timeout: 15_000
})
// Why: since #10011 `settings:set` strips activeRuntimeEnvironmentId — the
// durable Active Server preference is only writable through its dedicated
// handler, which resolves the id against the main-process environment
// store. So the host has to be registered for real, not faked in the
// renderer. Pairing is offline (no live server needed).
const pairingCode = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
scope: 'runtime',
endpoint: 'wss://e2e.invalid/ws',
deviceToken: 'e2e-device-token',
publicKeyB64: 'ZTJlLXB1YmxpYy1rZXk'
})
const environmentId = await orcaPage.evaluate(async (code) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const { environment } = await window.api.runtimeEnvironments.addFromPairingCode({
name: 'E2E Server',
pairingCode: code
})
// Why: after #5071 the server-path add step gates on the registered
// runtime-environment list (store.runtimeEnvironments), not just the
// activeRuntimeEnvironmentId setting.
store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list())
// Why: a runtime host is only auto-selectable (health 'available') when it
// has a live, protocol-compatible status; without one it reads
// 'disconnected' and the Add Project dialog falls back to Local Mac.
// runtimeProtocolVersion 3 clears MIN_COMPATIBLE_RUNTIME_SERVER_VERSION.
const seededStatus = {
runtimeId: `${environment.id}-runtime`,
rendererGraphEpoch: 0,
graphStatus: 'ready' as const,
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 1
}
// Why a snapshot and not a bare status: since #20003 the published snapshot owns host health,
// and main's status owner publishes `checking` for this unreachable host as soon as any
// runtime RPC touches it — which flips the Add Project host to Local mid-test. Pinning the
// seed at the top sequence makes applyRuntimeHostStatusSnapshot's monotonic guard drop those
// publications. A snapshot-less write would also no-op once any snapshot exists.
store.getState().setRuntimeEnvironmentStatus(environment.id, {
snapshot: {
environmentId: environment.id,
pairingRevision: environment.pairingRevision ?? environment.createdAt,
sequence: Number.MAX_SAFE_INTEGER,
checkedAt: Date.now(),
status: seededStatus,
verification: 'verified',
transport: 'ready'
},
status: seededStatus,
checkedAt: Date.now()
})
// Why: the store's switchRuntimeEnvironment probes reachability, which a
// synthetic host can't satisfy — write the preference directly and push
// the returned settings in rather than refetching (fetchSettings would
// kick off a status hydrate that clobbers the seeded 'available' health).
const settings = await window.api.settings.setActiveRuntimeEnvironmentPreference({
environmentId: environment.id
})
store.setState({ settings })
return environment.id
}, pairingCode)
await expect
.poll(async () => (await getSettings(orcaPage)).activeRuntimeEnvironmentId, {
timeout: 5_000
})
.toBe(environmentId)
await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click()
await expectAddProjectDialog(orcaPage)
// The runtime env is selected as the Add Project host and the browse action
// is host-scoped, proving the server project-setup UI is preserved on skip.
await expect(orcaPage.getByText('Existing Git repository or folder on this host')).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /Browse folder/i })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /Clone from URL/i })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /Create new project/i })).toBeVisible()
await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0)
expect((await getOnboardingState(orcaPage)).closedAt).not.toBeNull()
})
test('Skip from notifications does not request permission', async ({ orcaPage }) => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
timeout: 15_000
})
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await continueFromThemeToNotifications(orcaPage)
await orcaPage.evaluate(() => {
localStorage.removeItem('orca.e2e.notificationPermissionRequested')
window.api.notifications.requestPermission = async () => {
localStorage.setItem('orca.e2e.notificationPermissionRequested', '1')
return { supported: true, platform: 'darwin', requested: true }
}
})
await expectOnboardingNotificationSound(orcaPage, /System Default/i)
await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0)
await continueOnboarding(orcaPage)
await expectAddProjectDialog(orcaPage)
await expect
.poll(
async () =>
orcaPage.evaluate(() => localStorage.getItem('orca.e2e.notificationPermissionRequested')),
{ timeout: 5_000 }
)
.toBeNull()
})
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
})
const codexButton = orcaPage.getByRole('button', { name: /^Codex\s/ })
const codexVisible = await codexButton
.first()
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (!codexVisible) {
await orcaPage.getByText(/Show \d+ more agents/).click()
}
await codexButton.click()
// Why: AgentButton now sets aria-pressed so screen readers and assistive
// tech can announce the selection. Verify the attribute reflects state.
await expect(codexButton).toHaveAttribute('aria-pressed', 'true')
})
test('notification sound choice persists on Continue', async ({ orcaPage }) => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
timeout: 15_000
})
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await continueFromThemeToNotifications(orcaPage)
await chooseOnboardingNotificationSound(orcaPage, /^Ding$/i)
await continueFromPostNotificationsToRepo(orcaPage)
await expect
.poll(
async () => {
const s = await getSettings(orcaPage)
return {
agentTaskComplete: s.notifications.agentTaskComplete,
terminalBell: s.notifications.terminalBell,
customSoundId: s.notifications.customSoundId
}
},
{ timeout: 5_000 }
)
.toEqual({ agentTaskComplete: true, terminalBell: true, customSoundId: 'ding' })
})
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
})
// Advance to the Add Project dialog.
await continueOnboarding(orcaPage)
await continueOnboarding(orcaPage)
await continueFromPostNotificationsToRepo(orcaPage)
await orcaPage.getByRole('button', { name: /Clone from URL/i }).click()
// Why: focus the clone-url input and press Cmd/Ctrl+Enter. The capture-
// phase keydown handler should bail via isEditableTarget, so the dialog
// should remain visible and the empty clone form must not submit.
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
const accelerator = isMac ? 'Meta+Enter' : 'Control+Enter'
const input = orcaPage.getByPlaceholder('https://github.com/user/repo.git')
await input.click()
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: /Clone from URL/i })).toBeVisible()
await expect(input).toBeVisible()
expect((await getOnboardingState(orcaPage)).closedAt).not.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 continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(1)
// 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 expectOnboardingProgress(orcaPage, /^1 of [345]$/)
// Why: "without losing progress" means persisted lastCompletedStep stays
// at 1 — Back rewinds the visible step but must not roll persistence back.
// Poll because persistence flushes async via IPC after the Back click.
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(1)
})
test('final notification step can be dismissed via Escape or click-off', async ({ orcaPage }) => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
timeout: 15_000
})
// Advance to the final notification step. Its primary button hands off to
// Add Project, so the footer offers no "Skip to project setup" shortcut —
// but click-off and Escape must still open the skip-confirmation dialog like
// every other step, so the modal never feels stuck.
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await continueFromThemeToNotifications(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0)
// Escape opens the confirmation; "No, keep going" returns to the step with
// onboarding still open.
await orcaPage.keyboard.press('Escape')
await expectOnboardingSkipConfirmationOpen(orcaPage)
await orcaPage.getByRole('button', { name: /No, keep going/i }).click()
await expectOnboardingSkipConfirmationClosed(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
expect((await getOnboardingState(orcaPage)).closedAt).toBeNull()
// Click-off opens the confirmation; Skip dismisses onboarding outright (no
// Add Project handoff — that is the primary button's job).
await orcaPage.locator('[data-onboarding-overlay]').click({ position: { x: 8, y: 40 } })
await expectOnboardingSkipConfirmationOpen(orcaPage)
await orcaPage.getByRole('button', { name: /^Skip$/ }).click()
await expect
.poll(
async () => {
const state = await getOnboardingState(orcaPage)
return {
closedAt: state.closedAt === null ? null : 'set',
outcome: state.outcome,
dismissed: state.checklist.dismissed
}
},
{ timeout: 5_000 }
)
.toEqual({ closedAt: 'set', outcome: 'dismissed', dismissed: true })
})
})