Repository Public
1import { expect, test, type Page } from '@playwright/test';2import { apiGet, apiPost, clientSeqs, FIX, MOCK, mission, ROUTES, rowKeys, seeded, serverSeqs, watch } from './helpers';34test.beforeEach(async ({ page }) => {5 await seeded(page);6});78const timeline = (page: Page) => page.getByTestId('timeline');9const tool = (page: Page, name: string) => timeline(page).locator(`[data-testid="tool-row"][data-tool="${name}"]`);10const billingId = async (page: Page) => (await apiGet<{ id: string; name: string }[]>(page, '/projects')).find((p) => p.name === 'fixture-billing')!.id;1112test('create a mission, watch it live: streamed turn → committed steps → diff → shell block → approval → finish', async ({ page }) => {13 const w = watch(page);14 await page.goto('/home/');15 await page.getByTestId('new-mission').click();16 const form = page.getByTestId('new-mission-form');17 await expect(form).toBeVisible();18 await expect(page.getByTestId('mission-submit')).toBeDisabled(); // no objective yet19 await page.getByTestId('mission-objective').fill('Totals are off by one cent on some invoices. Fix it and prove it with a test.');20 // criteria: explained when empty; add two, remove one21 await expect(page.getByTestId('criteria-hint')).toContainText('proposes criteria first and asks you to confirm');22 await page.getByTestId('criterion-add').click();23 await page.getByTestId('criterion-input').first().fill('npm test passes with a new regression test');24 await page.getByTestId('criterion-add').click();25 await page.getByTestId('criterion-input').nth(1).fill('to be removed');26 await page.getByRole('button', { name: 'Remove criterion 2' }).click();27 await page.getByTestId('criterion-add').click();28 await page.getByTestId('criterion-input').nth(1).fill('The fix is committed');29 await expect(page.getByTestId('criteria-hint')).toHaveCount(0);30 // models: grouped by provider with prices; the unavailable one is disabled and says so31 const picker = page.getByTestId('model-picker');32 await expect(picker.locator('p').first()).toContainText('Anthropic');33 await expect(picker.locator('p').nth(1)).toContainText('OpenAI');34 await expect(page.getByTestId('model-claude-opus-5-5')).toHaveAttribute('aria-checked', 'true'); // default from settings35 await expect(page.getByTestId('model-claude-opus-5-5')).toContainText('$4 / $20');36 await expect(page.getByTestId('model-gpt-6-astra')).toBeDisabled();37 await expect(page.getByTestId('model-gpt-6-astra')).toContainText('Not available on this key');38 await page.getByTestId('model-claude-sonnet-5-5').click();39 await expect(page.getByTestId('mission-budget')).toHaveAttribute('placeholder', '15'); // default mission budget40 await expect(form).toContainText('Daily cap $60.00');41 await page.getByTestId('mission-budget').fill('4');42 await expect(page.getByTestId('network-risk')).toContainText('Recommended');43 await page.getByTestId('mission-network').click();44 await expect(page.getByTestId('network-risk')).toContainText('could send workspace content elsewhere');45 await page.getByTestId('mission-network').click();46 await page.getByTestId('mission-submit').click();4748 await expect(page).toHaveURL(/\/mission\/\?id=/);49 await expect(page.getByTestId('mission-title')).toContainText('Totals are off by one cent');50 await expect(page.getByTestId('mission-model')).toContainText('Claude Sonnet 5.5');51 // streamed text is shown as it arrives…52 await expect(timeline(page).locator('[data-testid="assistant-text"][data-streaming]')).toBeVisible();53 // …then replaced by the committed step54 await expect(tool(page, 'read_file')).toHaveAttribute('data-state', 'ok');55 // the script pauses on the approval: the wait appears inline, the timeline is pinned to it56 const wait = timeline(page).getByTestId('timeline-wait').filter({ has: page.getByTestId('wait-approve') });57 await expect(wait).toBeVisible({ timeout: 30_000 });58 await expect(page.getByTestId('status-badge').first()).toHaveAttribute('data-status', 'waiting');59 await expect(wait.getByTestId('risk-badge')).toHaveAttribute('data-risk', 'external');60 await expect(wait.getByTestId('wait-args')).toContainText('"remote": "origin"');61 // shell: terminal block with exit code, artifact downloadable62 const shell = tool(page, 'shell');63 await expect(shell).toHaveAttribute('data-state', 'ok');64 await shell.getByRole('button').first().click();65 await expect(shell.getByTestId('shell-block')).toContainText('$npm test');66 await expect(shell.getByTestId('shell-output')).toContainText('Tests 13 passed (13)');67 await expect(shell.getByTestId('exit-code')).toHaveText('exit 0');68 const href = await shell.getByTestId('artifact-link').getAttribute('href');69 expect(href).toMatch(/^\/api\/v1\/artifacts\/[0-9a-f]{64}\?name=shell-[0-9a-f]{16}\.txt$/);70 const dl = await page.request.get(href!);71 expect(dl.headers()['content-disposition']).toContain('attachment');72 // back to the start of the run (the list is virtualised: rows far from the viewport are not in the DOM)73 await page.getByTestId('timeline-scroll').evaluate((el) => { el.scrollTop = 0; });74 await expect(page.getByTestId('jump-latest')).toBeVisible();75 await expect(timeline(page).locator('[data-streaming]')).toHaveCount(0);76 await expect(timeline(page).getByTestId('assistant-text').filter({ hasText: 'I will start by reading the total computation' })).toHaveCount(1);77 // reasoning summary is collapsible78 const reasoning = timeline(page).getByTestId('reasoning').first();79 await reasoning.getByRole('button').click();80 await expect(reasoning).toContainText('read it before deciding anything');81 // edit_file is a real diff (small diffs open by default)82 const edit = tool(page, 'edit_file').first();83 await expect(edit.getByTestId('diff-view')).toBeVisible();84 await expect(edit.locator('[data-line="del"]')).toHaveCount(3);85 await expect(edit.locator('[data-line="add"]')).toHaveCount(4);86 await expect(edit.locator('[data-line="add"]').first()).toContainText('sum in integer cents');87 // a call the model issued that the engine refused (protected path) has no journal step: shown as "not executed"88 const refused = timeline(page).getByTestId('refused-call');89 await expect(refused).toHaveCount(1);90 await expect(refused).toHaveAttribute('data-tool', 'write_file');91 await expect(refused).toContainText('.git/hooks/pre-commit');92 await refused.getByRole('button').click();93 await expect(refused.getByTestId('refused-reason')).toContainText('Denied by policy: protected path');94 await refused.getByRole('button').first().click();95 // minimal Markdown: code block escaped, bold rendered, nothing clickable96 const said = timeline(page).getByTestId('assistant-text').filter({ hasText: 'The bug is in' });97 await expect(said.locator('pre')).toContainText('cents += Math.round');98 await expect(said.locator('strong')).toHaveText('binary floats');99 await expect(timeline(page).locator('a:not([data-testid="artifact-link"]), img')).toHaveCount(0);100 await page.getByTestId('jump-latest').click();101 await expect(wait).toBeVisible();102 // steer the agent while it waits103 await page.getByTestId('composer-input').fill('Also mention the fix in the changelog.');104 await page.getByTestId('composer-send').click();105 await expect(timeline(page).locator('[data-testid="owner-message"][data-pending]')).toContainText('Also mention the fix in the changelog.');106 await wait.getByTestId('wait-approve').click();107 // resolution → push → finish108 await expect(tool(page, 'git_push')).toHaveAttribute('data-state', 'ok');109 await expect(timeline(page).getByTestId('timeline-wait').filter({ hasText: 'Approved' })).toBeVisible();110 await expect(page.getByTestId('run-result')).toContainText('integer cents');111 await expect(page.getByTestId('mission-page')).toHaveAttribute('data-status', 'done');112 // the steering message was read at the next turn: journaled once, no longer pending113 await expect(timeline(page).getByTestId('owner-message')).toHaveCount(1);114 await expect(timeline(page).locator('[data-testid="owner-message"][data-pending]')).toHaveCount(0);115 await expect(timeline(page).getByTestId('assistant-text').filter({ hasText: 'Noted — “Also mention the fix in the changelog.”' })).toHaveCount(1);116 // criteria flipped with evidence; spend is real and under budget117 const side = page.getByTestId('mission-side');118 await expect(side.locator('[data-testid="criterion"][data-passes="true"]')).toHaveCount(2);119 await expect(side.getByTestId('criterion-evidence').first()).toContainText('13 passed');120 await expect(page.getByTestId('composer-ended')).toBeVisible();121 const id = new URL(page.url()).searchParams.get('id')!;122 const m = await apiGet<{ spentUsd: number; rootRunId: string }>(page, `/missions/${id}`);123 expect(m.spentUsd).toBeGreaterThan(0);124 // nothing duplicated, nothing missing125 expect(await clientSeqs(page, m.rootRunId)).toEqual(await serverSeqs(page, m.rootRunId));126 const keys = await rowKeys(page);127 expect(new Set(keys).size).toBe(keys.length);128 w.expectClean();129});130131test('without criteria the agent asks first: answer the question, criteria appear', async ({ page }) => {132 const w = watch(page);133 const m = await apiPost<{ id: string }>(page, '/missions', { projectId: await billingId(page), objective: 'Fix the totals.', model: 'claude-haiku-5-5' });134 await page.goto(`/mission/?id=${m.id}`);135 await expect(page.getByTestId('criteria-empty')).toContainText('the agent proposes them first');136 const q = timeline(page).getByTestId('timeline-wait').filter({ has: page.getByTestId('wait-option') });137 await expect(q).toContainText('The agent proposes these done criteria. Do they describe the outcome you want?');138 await expect(q).toContainText('3. The branch is pushed to origin');139 await expect(q.getByTestId('wait-option')).toHaveText(['Yes — use these criteria']);140 // a reply that is not an acceptance sends the agent back to revise: it proposes again141 await q.getByTestId('wait-answer').fill('Drop the push: keep the branch local.');142 await q.getByTestId('wait-answer-send').click();143 await expect(timeline(page).getByTestId('timeline-wait').filter({ hasText: 'Answered' })).toContainText('You answered: Drop the push: keep the branch local.');144 await expect(q).not.toContainText('The branch is pushed to origin', { timeout: 20_000 });145 await expect(page.getByTestId('criteria-empty')).toBeVisible(); // nothing is confirmed yet146 await q.getByTestId('wait-option').click();147 // criteria appear as soon as the engine announces them (system.notice {code:'criteria'})148 await expect(page.getByTestId('mission-side').getByTestId('criterion')).toHaveCount(2);149 await expect(page.getByTestId('mission-page')).toHaveAttribute('data-status', 'done', { timeout: 30_000 });150 await expect(tool(page, 'git_push')).toHaveCount(0); // the answer was honoured151 w.expectClean();152});153154test('events socket drops mid-run: resume from the cursor without duplicated or missing items; gap notice refetches', async ({ page }) => {155 const w = watch(page);156 const m = await apiPost<{ id: string; rootRunId: string }>(page, '/missions', { projectId: await billingId(page), objective: 'Fix the totals.', doneCriteria: ['tests pass', 'committed', 'pushed'] });157 await page.goto(`/mission/?id=${m.id}`);158 await expect(tool(page, 'read_file')).toBeVisible();159 for (let i = 0; i < 3; i++) {160 const before = await page.evaluate(() => window.__aos.cursor());161 await page.evaluate(() => window.__aos.dropEvents());162 await expect(page.getByTestId('conn-indicator')).toHaveAttribute('data-tone', 'success'); // reconnected163 await expect.poll(() => page.evaluate(() => window.__aos.cursor())).toBeGreaterThanOrEqual(before ?? 0);164 await page.waitForTimeout(350);165 }166 const wait = timeline(page).getByTestId('timeline-wait').filter({ has: page.getByTestId('wait-approve') });167 await expect(wait).toBeVisible({ timeout: 30_000 });168 await expect.poll(async () => JSON.stringify(await clientSeqs(page, m.rootRunId))).toBe(JSON.stringify(await serverSeqs(page, m.rootRunId)));169 const seqs = (await clientSeqs(page, m.rootRunId))!;170 expect(seqs).toEqual(Array.from({ length: seqs.length }, (_, i) => i)); // contiguous from 0: nothing missing171 let keys = await rowKeys(page);172 expect(new Set(keys).size).toBe(keys.length); // nothing rendered twice173 await expect(timeline(page)).toHaveAttribute('data-steps', String(seqs.length));174 // the cursor caught up with the server's last persisted event175 const info = (await (await page.request.get(`${MOCK}/__mock/info`)).json()) as { eventSeq: number };176 await expect.poll(() => page.evaluate(() => window.__aos.cursor())).toBe(info.eventSeq);177 // a "gap" notice makes the console refetch the journal from the start178 const refetch = page.waitForRequest((r) => r.url().includes(`/runs/${m.rootRunId}/steps`) && !r.url().includes('after='));179 await page.request.post(`${MOCK}/__mock/gap`);180 await refetch;181 await expect(wait).toBeVisible();182 keys = await rowKeys(page);183 expect(new Set(keys).size).toBe(keys.length);184 w.expectClean();185});186187test('long timeline: scrolled up shows "jump to latest" and keeps the reading position', async ({ page }) => {188 const m = await mission(page, FIX.done);189 await page.setViewportSize({ width: 1440, height: 560 });190 await page.goto(`/mission/?id=${m.id}`);191 await expect(page.getByTestId('run-result')).toBeVisible(); // pinned to the latest item on load192 const scroller = page.getByTestId('timeline-scroll');193 await scroller.evaluate((el) => { el.scrollTop = 0; });194 await expect(page.getByTestId('jump-latest')).toBeVisible();195 await expect(page.getByTestId('turn-header').first()).toBeVisible();196 await page.getByTestId('jump-latest').click();197 await expect(page.getByTestId('run-result')).toBeVisible();198 await expect(page.getByTestId('jump-latest')).toHaveCount(0);199});200201test('cancel a running mission with confirmation', async ({ page }) => {202 const w = watch(page);203 const m = await mission(page, FIX.running);204 await page.goto(`/mission/?id=${m.id}`);205 await expect(tool(page, 'shell')).toHaveAttribute('data-state', 'running');206 await page.getByTestId('mission-cancel').click();207 const dialog = page.getByRole('dialog', { name: 'Cancel this mission?' });208 await expect(dialog).toContainText('Spent so far');209 await dialog.getByRole('button', { name: 'Cancel mission' }).click();210 await expect(page.getByTestId('mission-page')).toHaveAttribute('data-status', 'cancelled');211 await expect(page.getByTestId('mission-cancel')).toHaveCount(0);212 await expect(page.getByTestId('composer-ended')).toContainText('cancelled');213 // the command had no result when the run ended: shown as interrupted, never as still running214 await expect(tool(page, 'shell')).toHaveAttribute('data-state', 'interrupted');215 await expect(timeline(page).getByTestId('thinking')).toHaveCount(0);216 w.expectClean();217});218219test('approvals inbox: exact call, diff, countdown; deny with a note; approve-and-remember explains and creates a rule', async ({ page }) => {220 const w = watch(page);221 await page.goto('/approvals/');222 await expect(page.getByTestId('nav-approvals').getByTestId('pending-badge')).toHaveText('2');223 const approval = page.locator('[data-testid="wait-card"][data-kind="approval"]');224 await expect(approval).toContainText('Push aos/');225 await expect(approval).toContainText(FIX.approval);226 await expect(approval.getByTestId('risk-badge')).toHaveText('External effect');227 await expect(approval.getByTestId('wait-args')).toContainText('"branch": "aos/');228 await expect(approval.getByTestId('wait-diff-toggle')).toContainText('2 files +8 −3');229 await expect(approval.getByTestId('diff-view').locator('[data-line="add"]').first()).toBeVisible(); // open by default where there is room230 await approval.getByTestId('wait-diff-toggle').click();231 await expect(approval.getByTestId('diff-view')).toHaveCount(0);232 await expect(approval.getByTestId('wait-expiry')).toContainText(/expires in 2 d 23 h|expires in 3 d/);233 // deny needs a second, deliberate step and takes an optional note; "Back" returns234 await approval.getByTestId('wait-deny').click();235 await approval.getByTestId('wait-deny-note').fill('not yet');236 await approval.getByRole('button', { name: 'Back' }).click();237 // approve and remember: explained, confirmed, then visible as a rule238 await approval.getByTestId('wait-remember').click();239 const explain = page.getByTestId('remember-explain');240 await expect(explain).toContainText('allow rule will match the tool git_push');241 await expect(explain).toContainText('without asking you again');242 await expect(approval).toBeVisible(); // nothing happened yet243 await page.getByTestId('wait-remember-confirm').click();244 await expect(approval).toHaveCount(0);245 await expect(page.getByTestId('nav-approvals').getByTestId('pending-badge')).toHaveText('1');246 await page.getByTestId('nav-policy').click();247 const rule = page.getByTestId('rule-row').filter({ hasText: 'git_push' });248 await expect(rule).toHaveAttribute('data-decision', 'allow');249 await expect(rule).toContainText('Project: fixture-billing');250 // the question is answered with free text251 await page.getByTestId('nav-approvals').click();252 const question = page.locator('[data-testid="wait-card"][data-kind="question"]');253 await question.getByTestId('wait-answer').fill('Yes, and add an examples section.');254 await question.getByTestId('wait-answer-send').click();255 await expect(page.getByTestId('approvals-empty')).toContainText('Inbox zero');256 await expect(page.getByTestId('nav-approvals').getByTestId('pending-badge')).toHaveCount(0);257 w.expectClean();258});259260test('deny from the inbox sends the note to the agent', async ({ page }) => {261 await page.goto('/approvals/');262 const approval = page.locator('[data-testid="wait-card"][data-kind="approval"]');263 await approval.getByTestId('wait-deny').click();264 await approval.getByTestId('wait-deny-note').fill('Open a pull request instead.');265 await approval.getByTestId('wait-deny-confirm').click();266 await expect(approval).toHaveCount(0);267 const m = await mission(page, FIX.approval);268 await page.goto(`/mission/?id=${m.id}`);269 await expect(page.getByTestId('mission-page')).toHaveAttribute('data-status', 'partial', { timeout: 20_000 });270 await expect(tool(page, 'git_push')).toHaveCount(0); // a denied call is never executed: no tool row271 await expect(timeline(page).getByTestId('timeline-wait').filter({ hasText: 'Denied' })).toContainText('Your note: Open a pull request instead.');272 await expect(page.getByTestId('mission-page')).toHaveAttribute('data-status', 'partial', { timeout: 20_000 });273});274275test('spend page shows the real numbers of the API', async ({ page }) => {276 const w = watch(page);277 const s = await apiGet<{ todayUsd: number; dailyBudgetUsd: number; last7Days: { usd: number }[]; byModel: { model: string; usd: number }[] }>(page, '/spend');278 await page.goto('/spend/');279 const usd = (n: number, d = n === 0 || n >= 1 ? 2 : n >= 0.01 ? 3 : 4) => `$${n.toLocaleString('en-CA', { minimumFractionDigits: d, maximumFractionDigits: d })}`;280 await expect(page.getByTestId('spend-today')).toHaveText(usd(s.todayUsd));281 await expect(page.getByTestId('spend-cap')).toHaveText(usd(s.dailyBudgetUsd, 2));282 await expect(page.getByTestId('spend-week')).toHaveText(usd(s.last7Days.reduce((n, d) => n + d.usd, 0), 2));283 await expect(page.getByTestId('week-bar')).toHaveCount(7);284 await expect(page.getByRole('table', { name: /Spend per day/ }).locator('tbody tr')).toHaveCount(7); // accessible equivalent of the chart285 const rows = page.getByTestId('by-model').locator('tbody tr');286 await expect(rows).toHaveCount(s.byModel.length);287 const top = [...s.byModel].sort((a, b) => b.usd - a.usd)[0]!;288 await expect(rows.first()).toContainText(top.model);289 await expect(rows.first()).toContainText(usd(top.usd, 2));290 // the daily cap comes from settings: change it there, it changes here and on Home291 await page.goto('/settings/');292 await page.getByTestId('set-daily-budget').fill('25');293 await page.getByTestId('set-daily-budget').press('Enter');294 await expect.poll(async () => (await apiGet<{ dailyBudgetUsd: number }>(page, '/settings')).dailyBudgetUsd).toBe(25);295 await page.getByTestId('nav-home').click();296 await expect(page.getByTestId('spend-today')).toContainText('of $25.00 daily cap');297 w.expectClean();298});299300test('policy rules: create, list, delete', async ({ page }) => {301 const w = watch(page);302 await page.goto('/policy/');303 await expect(page.getByTestId('rule-row')).toHaveCount(2);304 await page.getByTestId('rule-new').click();305 await page.getByTestId('rule-decision-deny').click();306 await page.getByTestId('rule-tool').fill('shell');307 await page.getByRole('combobox', { name: 'Matcher' }).click();308 await page.getByRole('option', { name: 'Command starts with' }).click();309 await page.getByTestId('rule-match-value').fill('curl');310 await page.getByRole('combobox', { name: 'Scope' }).click();311 await page.getByRole('option', { name: 'Project: fixture-docs' }).click();312 await page.getByTestId('rule-note').fill('no downloads in docs');313 await page.getByTestId('rule-submit').click();314 const row = page.getByTestId('rule-row').filter({ hasText: '“curl”' });315 await expect(row).toHaveAttribute('data-decision', 'deny');316 await expect(row).toContainText('command starts with “curl”');317 await expect(row).toContainText('Project: fixture-docs');318 await expect(page.getByTestId('rule-row').first()).toHaveAttribute('data-decision', 'deny'); // deny rules first319 const rules = await apiGet<{ tool: string; match: Record<string, string>; decision: string }[]>(page, '/policy/rules');320 expect(rules.some((r) => r.decision === 'deny' && r.tool === 'shell' && r.match.commandPrefix === 'curl')).toBe(true);321 await row.getByTestId('rule-delete').click();322 await page.getByRole('dialog').getByRole('button', { name: 'Delete rule' }).click();323 await expect(page.getByTestId('rule-row')).toHaveCount(2);324 w.expectClean();325});326327test('missions list filters; projects; settings defaults persist', async ({ page }) => {328 const w = watch(page);329 await page.goto('/missions/');330 await expect(page.getByTestId('mission-row')).toHaveCount(6);331 await page.getByRole('radio', { name: /Needs you/ }).click();332 await expect(page.getByTestId('mission-row')).toHaveCount(2);333 await page.getByRole('radio', { name: /Stopped/ }).click();334 await expect(page.getByTestId('mission-row')).toHaveCount(2);335 await page.getByRole('radio', { name: /^All/ }).click();336 await page.getByTestId('missions-search').fill('rounding');337 await expect(page.getByTestId('mission-row')).toHaveCount(1);338 await expect(page.getByTestId('mission-row')).toContainText('Claude Opus 5.5');339 await page.getByTestId('nav-projects').click();340 await expect(page.getByTestId('project-card')).toHaveCount(2);341 await page.getByTestId('project-new').click();342 await page.getByTestId('project-name').fill('scratch');343 await page.getByTestId('project-create').click();344 await expect(page.getByTestId('project-card')).toHaveCount(3);345 await page.getByTestId('nav-settings').click();346 await page.getByRole('combobox', { name: 'Default effort' }).click();347 await page.getByRole('option', { name: 'Medium' }).click();348 await page.getByTestId('set-mission-budget').fill('9.5');349 await page.getByTestId('set-mission-budget').press('Enter');350 await expect.poll(async () => { const s = await apiGet<{ defaultEffort: string; missionBudgetUsd: number }>(page, '/settings'); return `${s.defaultEffort} ${s.missionBudgetUsd}`; }).toBe('medium 9.5');351 await expect(page.getByText('Devices and sessions')).toBeVisible();352 w.expectClean();353});354355test('theme toggle: applied at once, stored locally and on the server, theme-color follows', async ({ page }) => {356 await page.goto('/home/');357 await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); // light by default358 await page.getByTestId('theme-toggle').click();359 await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');360 await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#080B10');361 expect(await page.evaluate(() => localStorage.getItem('aos.theme'))).toBe('dark');362 await expect.poll(async () => (await apiGet<{ theme: string }>(page, '/settings')).theme).toBe('dark');363 await page.reload();364 await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); // before paint, from /theme-init.js365 await page.getByTestId('theme-toggle').click();366 await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');367});368369test('every route is free of console errors and CSP violations under the production CSP', async ({ page }) => {370 const w = watch(page);371 const m = await mission(page, FIX.done);372 for (const route of [...ROUTES, `/mission/?id=${m.id}`]) {373 const res = await page.goto(route);374 const csp = res?.headers()['content-security-policy'] ?? '';375 expect(csp, route).toContain("default-src 'self'");376 expect(csp, route).not.toContain("'unsafe-eval'");377 expect(csp.match(/script-src[^;]*/)?.[0] ?? '', route).not.toContain("'unsafe-inline'");378 await expect(page.getByTestId('page-title').or(page.getByTestId('mission-title')).first()).toBeVisible();379 await page.waitForLoadState('networkidle');380 await page.waitForTimeout(250);381 }382 // overlays too: command palette and the new-mission dialog383 await page.goto('/home/');384 await page.keyboard.press('ControlOrMeta+k');385 await page.getByTestId('palette-input').fill('rounding');386 await page.keyboard.press('Enter');387 await expect(page.getByTestId('mission-title')).toHaveText(FIX.approval);388 expect(await page.evaluate(() => typeof window.__aos)).toBe('object'); // this is the E2E build389 w.expectClean();390});391