From 5b6a731ed4f87caaf4fe69232baa0f94ce1d8b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwa=C5=9Bniewski?= Date: Mon, 5 Oct 2026 10:40:02 +0200 Subject: [PATCH 01/46] test(e2e): integration suites on the fake engine, cut to what only integration proves (#821) * test(e2e): secret tests inspect model input and inflated trace entries * test(mcp): project tool results and errors pass the ledger; poll instead of sleeping * test(e2e): runProject without an app url runs on the fake engine * test(e2e): secret tests fail their deliberate tap in 500 ms, not the 30 s action timeout * test(e2e): trim agent-act, agent-act-stress, agent-policy duplicates; serial artifact checks move to artifact-store-serial * test(run): runner lifecycle on the fake engine, interrupts on the worker path, cut duplicates * test(web): trim web-platform to unique cases and fold the class and URL-pattern suites into its config-file run * test(e2e): drop integration suites unit and testbed tests already cover * test(e2e): drop a margin tap, wait for the full pipe instead of 2 s, no console noise * test(run): parallel workers on the fake engine with event-driven waits, cover a worker-path timeout * test(e2e): expect.poll scope and expect.soft run on the fake engine * test(run): run events off the browser where no page is read, drop the sink duplicate * test(e2e): trim agent, explore, ai-trace, model-usage duplicates; semantic fallback joins agent-fallback-handoff * test(web): fewer reads against the node replaced every frame * test(e2e): reporters on the fake engine, fold the boot precondition, trim skip-after-failure duplicates * test(e2e): drop trace-cache-speed wall-clock gate; its replay check is in trace-cache-replay * test(e2e): agent-act-verbs pages to row 24, records on the main run, drops duplicate verb and upload checks * test(e2e): trim agent-trace-cache to integration-only proofs, 500 ms on failing expects * test(e2e): fold the stale --strict-cache run into the changed-screen suite * test(e2e): extended fixtures on the fake engine with shorter setup sleeps * test(e2e): trace-modes proves --trace in one run * test(mcp): fold mcp-custom into mcp-server on one server, drop duplicate recording and prose checks * test(e2e): hold four negations instead of seventeen in scripted matchers * test(config): loader-only cases move to config-load, cli-init runs the CLI once per loader shape, trim managed-process duplicates * test(e2e): engine contract stalls one observe, drops cases unit tests own * test(e2e): engine targets keep the hook cases engine-contract lacks, assert the capability code * test(run): Ctrl-C releases what prepare acquired; exit 3, --shard, and CI defaults through the CLI * test(e2e): scripted suites keep what unit tests lack, drop the scene nodes only cut cases used * test(e2e): drop the agent-name and CJK-clip runs unit tests cover * test(e2e): scene root id is internal to the scripted scene * test(e2e): keep the no-model hand-off eviction check, a clean start without a session, and slack on mcp session polls * test(e2e): keep fractional usage, executor model in explore, and --trace off; bound the backpressure wait; never leak APP_URL into fixture runs --- .../tests/helpers/fixture-pages/gestures.ts | 6 +- packages/e2e/tests/helpers/run-project.ts | 65 +- packages/e2e/tests/helpers/scripted-engine.ts | 14 - packages/e2e/tests/helpers/scripted-scene.ts | 23 +- .../integration/agent-act-pixel-tools.test.ts | 20 +- .../integration/agent-act-stress.test.ts | 116 +--- .../tests/integration/agent-act-verbs.test.ts | 134 ++-- .../e2e/tests/integration/agent-act.test.ts | 74 +-- .../tests/integration/agent-ai-trace.test.ts | 33 - .../agent-fallback-handoff.test.ts | 97 ++- .../tests/integration/agent-policy.test.ts | 89 +-- .../integration/agent-scroll-settle.test.ts | 1 - .../agent-semantic-fallback.test.ts | 103 --- .../integration/agent-trace-cache.test.ts | 412 ++---------- packages/e2e/tests/integration/agent.test.ts | 45 -- .../integration/artifact-store-serial.test.ts | 11 +- .../e2e/tests/integration/cli-boot.test.ts | 5 +- .../e2e/tests/integration/cli-init.test.ts | 66 +- .../e2e/tests/integration/cli-run.test.ts | 147 ++++ .../e2e/tests/integration/config-load.test.ts | 46 +- .../tests/integration/config-names.test.ts | 18 +- .../tests/integration/engine-contract.test.ts | 132 +--- .../tests/integration/engine-target.test.ts | 209 +----- .../integration/expect-poll-scope.test.ts | 75 ++- .../e2e/tests/integration/expect-soft.test.ts | 42 +- .../e2e/tests/integration/explore.test.ts | 69 +- .../integration/extended-fixtures.test.ts | 42 +- .../integration/interrupt-signals.test.ts | 8 +- .../tests/integration/list-reporter.test.ts | 45 -- .../tests/integration/managed-process.test.ts | 47 +- .../tests/integration/matcher-options.test.ts | 124 ---- .../e2e/tests/integration/mcp-custom.test.ts | 250 ------- .../e2e/tests/integration/mcp-server.test.ts | 195 ++++-- .../integration/mcp-session-host.test.ts | 65 +- .../mcp-stdout-backpressure.test.ts | 5 +- .../e2e/tests/integration/model-usage.test.ts | 36 +- .../integration/parallel-workers.test.ts | 103 ++- .../integration/press-sequentially.test.ts | 78 --- .../tests/integration/report-markdown.test.ts | 45 -- .../e2e/tests/integration/reporters.test.ts | 38 +- .../e2e/tests/integration/run-events.test.ts | 89 +-- .../integration/runner-lifecycle.test.ts | 541 +++------------ .../integration/scripted-actions.test.ts | 60 +- .../integration/scripted-capabilities.test.ts | 72 -- .../integration/scripted-matchers.test.ts | 28 +- .../integration/scripted-polling.test.ts | 101 +-- .../tests/integration/scripted-reads.test.ts | 90 +-- .../integration/selected-secrets.test.ts | 2 +- .../integration/skip-after-failure.test.ts | 17 +- .../integration/trace-cache-replay.test.ts | 86 +-- .../integration/trace-cache-speed.test.ts | 94 --- .../e2e/tests/integration/trace-modes.test.ts | 38 +- .../integration/transformed-secrets.test.ts | 33 +- .../integration/truncated-secrets.test.ts | 5 +- .../integration/web-class-assertions.test.ts | 106 --- .../tests/integration/web-platform.test.ts | 625 ++++++------------ .../integration/web-replaced-nodes.test.ts | 6 +- .../integration/web-url-patterns.test.ts | 107 --- packages/e2e/tests/unit/config.test.ts | 2 + 59 files changed, 1269 insertions(+), 3966 deletions(-) delete mode 100644 packages/e2e/tests/integration/agent-semantic-fallback.test.ts create mode 100644 packages/e2e/tests/integration/cli-run.test.ts delete mode 100644 packages/e2e/tests/integration/matcher-options.test.ts delete mode 100644 packages/e2e/tests/integration/mcp-custom.test.ts delete mode 100644 packages/e2e/tests/integration/press-sequentially.test.ts delete mode 100644 packages/e2e/tests/integration/report-markdown.test.ts delete mode 100644 packages/e2e/tests/integration/scripted-capabilities.test.ts delete mode 100644 packages/e2e/tests/integration/trace-cache-speed.test.ts delete mode 100644 packages/e2e/tests/integration/web-class-assertions.test.ts delete mode 100644 packages/e2e/tests/integration/web-url-patterns.test.ts diff --git a/packages/e2e/tests/helpers/fixture-pages/gestures.ts b/packages/e2e/tests/helpers/fixture-pages/gestures.ts index e3f95a9ce..e3ba1e89d 100644 --- a/packages/e2e/tests/helpers/fixture-pages/gestures.ts +++ b/packages/e2e/tests/helpers/fixture-pages/gestures.ts @@ -41,8 +41,8 @@ export const GESTURE_PAGES: Record = {
Delivery
-

Page the ledger down to Row 333 and stop there.

- Jump to Row 333 +

Page the ledger down to Row 24 and stop there.

+ Jump to Row 24
golden out of view
@@ -80,7 +80,7 @@ export const GESTURE_PAGES: Record = { // A windowed list: 400 rows of 40 px exist as data, and only the rows // inside the container's scrolled window are in the DOM, so the golden // row is nowhere in the tree until the list is paged down to it. - const ROWS = 400, ROW_PX = 40, GOLDEN = 333; + const ROWS = 400, ROW_PX = 40, GOLDEN = 24; const ledger = document.getElementById('ledger'); document.getElementById('ledger-spacer').style.height = ROWS * ROW_PX + 'px'; const ledgerState = document.querySelector('output[aria-label="Ledger state"]'); diff --git a/packages/e2e/tests/helpers/run-project.ts b/packages/e2e/tests/helpers/run-project.ts index 05165a16c..3f1393fcb 100644 --- a/packages/e2e/tests/helpers/run-project.ts +++ b/packages/e2e/tests/helpers/run-project.ts @@ -8,6 +8,7 @@ import { web } from '@e2e-dev/web'; import type { ListOptions, ListedPair, RunOptions, RunOutcome } from '../../src/run/runner.ts'; import type { E2EConfig } from '../../src/index.ts'; import { inflateEntry, readZip } from '../../src/internal/zip.ts'; +import { createFakeEngine, FAKE_APP } from './fake-engine.ts'; export type { RunOptions, RunOutcome }; @@ -32,11 +33,23 @@ export function webTarget(name: string, url: string): Target { return { name, engine: web() as unknown as NonNullable, app: { url } }; } -/** The web target integration suites run against unless their config names its own. */ -function defaultTargets(appUrl: string): NonNullable { +/** + * The target a suite runs against unless its config names its own: the web + * target on the fixture app at `appUrl`, or, for a suite that opens no page + * and so passes none, a fake engine that starts no browser. + */ +function defaultTargets(appUrl: string | undefined): NonNullable { + if (appUrl === undefined) return [{ name: 'fake', platform: 'fake', engine: createFakeEngine({ state: true }).engine, app: FAKE_APP }]; return [webTarget('web', appUrl)]; } +/** The run environment: the caller's, outside CI, with `APP_URL` the fixture app's URL or unset, never the caller's. */ +function fixtureEnv(appUrl: string | undefined): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, CI: '' }; + delete env['APP_URL']; + return appUrl === undefined ? env : { ...env, APP_URL: appUrl }; +} + export interface FixtureProject { readonly dir: string; cleanup(): void; @@ -81,8 +94,9 @@ export function contentsUnder(dir: string): [string, string][] { } export interface RunProjectOptions { - appUrl: string; - /** Overrides of the fixture config; `targets` defaults to the web target on `appUrl`. */ + /** The fixture app; without one, the default target is a fake engine and no browser starts. */ + appUrl?: string; + /** Overrides of the fixture config; `targets` defaults to the web target on `appUrl`, or the fake engine without one. */ config?: Partial; runOptions?: Partial; } @@ -109,11 +123,7 @@ export async function runExisting( // Core knows no engine: a web target is served by the playwright engine // the test explicitly passes, exactly as a project config would. rawConfig: { targets: defaultTargets(options.appUrl), ...options.config }, - env: { - ...process.env, - APP_URL: options.appUrl, - CI: '', - }, + env: fixtureEnv(options.appUrl), quiet: true, ...options.runOptions, }); @@ -128,13 +138,13 @@ export async function listProject( const { pairs } = await list({ cwd: project.dir, rawConfig: { targets: defaultTargets(options.appUrl), ...options.config }, - env: { ...process.env, APP_URL: options.appUrl, CI: '' }, + env: fixtureEnv(options.appUrl), ...options.listOptions, }); return { pairs, project }; } -/** Default file-backed config used by worker-path integration tests. */ +/** Default file-backed config used by worker-path integration tests that open the fixture app. */ export function workerConfigSource(workers: number, extra = ''): string { return `import type { E2EConfig } from 'e2e'; import { web } from '@e2e-dev/web'; @@ -146,6 +156,21 @@ export default { `; } +/** + * A file-backed worker-path config on the fake engine, for suites that open + * no page: each worker imports the helper and builds its own instance. + */ +export function workerFakeConfigSource(workers: number, extra = ''): string { + return `import type { E2EConfig } from 'e2e'; +import { createFakeEngine, FAKE_APP } from '../../helpers/fake-engine.ts'; + +export default { + targets: [{ name: 'fake', platform: 'fake', engine: createFakeEngine({ state: true }).engine, app: FAKE_APP }], + workers: ${workers},${extra} +} satisfies E2EConfig; +`; +} + /** * Runs a fixture project through the parallel worker path: the config is a * real file, so the runner schedules units across worker processes. @@ -155,16 +180,26 @@ export async function runProjectWithConfigFile( options: RunProjectOptions & { configSource: string }, ): Promise<{ outcome: RunOutcome; project: FixtureProject }> { const project = createProject({ ...files, 'e2e.config.ts': options.configSource }); + return { outcome: await runExistingWithConfigFile(project, options), project }; +} + +/** + * Runs an existing project through its own `e2e.config.ts` on the worker + * path. Repeated runs share on-disk state, as with `runExisting`. + */ +export async function runExistingWithConfigFile( + project: FixtureProject, + options: Omit, +): Promise { const previousAppUrl = process.env['APP_URL']; - process.env['APP_URL'] = options.appUrl; + if (options.appUrl !== undefined) process.env['APP_URL'] = options.appUrl; try { - const outcome = await run({ + return await run({ cwd: project.dir, - env: { ...process.env, APP_URL: options.appUrl, CI: '' }, + env: fixtureEnv(options.appUrl), quiet: true, ...options.runOptions, }); - return { outcome, project }; } finally { if (previousAppUrl === undefined) delete process.env['APP_URL']; else process.env['APP_URL'] = previousAppUrl; diff --git a/packages/e2e/tests/helpers/scripted-engine.ts b/packages/e2e/tests/helpers/scripted-engine.ts index c9946bd52..0e7c48069 100644 --- a/packages/e2e/tests/helpers/scripted-engine.ts +++ b/packages/e2e/tests/helpers/scripted-engine.ts @@ -207,19 +207,5 @@ export function scene(stage: Stage): ScriptedNode[] { progress, save, growing, - { id: 'below', role: 'button', name: 'Below the fold', appearsAfterSwipes: 2 }, - { id: 'stale', role: 'button', name: 'Stale', staleOnce: true }, - { - id: 'flaky', - role: 'button', - name: 'Flaky', - fail: { tap: { code: 'NOT_ACTIONABLE', message: 'covered by an overlay' } }, - }, - { - id: 'commit', - role: 'button', - name: 'Commit', - fail: { tap: { code: 'ACTION_MAY_HAVE_COMMITTED', message: 'the tap may have landed' } }, - }, ]; } diff --git a/packages/e2e/tests/helpers/scripted-scene.ts b/packages/e2e/tests/helpers/scripted-scene.ts index 36ba7a858..ba295d66e 100644 --- a/packages/e2e/tests/helpers/scripted-scene.ts +++ b/packages/e2e/tests/helpers/scripted-scene.ts @@ -8,21 +8,15 @@ * owns one `Scene` per attempt and forwards its calls. */ -import type { LocatorAction, LocatorActionKind, LocatorExpression, SemanticNode } from '../../src/engine/index.ts'; +import type { LocatorAction, LocatorExpression, SemanticNode } from '../../src/engine/index.ts'; import { engineFailure, obj, parseKey, resolveExpression } from './engine-runtime.ts'; /** The stable id of the observation root; `perform(root, swipe)` is the viewport swipe. */ -export const SCENE_ROOT_ID = 'root'; +const SCENE_ROOT_ID = 'root'; type Mutable = { -readonly [K in keyof T]: T[K] }; type StateKey = keyof NonNullable; -/** How one action kind on one node fails instead of performing. */ -export interface ScriptedFailure { - readonly code: Parameters[0]; - readonly message?: string; -} - /** One option a `selectOption` picks from. */ export interface ScriptedOption { readonly label: string; @@ -45,10 +39,6 @@ export interface ScriptedNode extends Mutable void; - /** Fails the named action kinds instead of performing them. */ - fail?: Partial>; - /** The next perform on the node throws a retryable NODE_STALE and gives the node a fresh id. */ - staleOnce?: boolean; } /** What a scene can schedule while the attempt runs. */ @@ -240,15 +230,6 @@ export function createScene(build: (stage: Stage) => ScriptedNode[], initialLoca } const node = findById(id); if (node === undefined) throw engineFailure('NODE_STALE', `node ${id} is no longer in the tree`, true); - if (node.staleOnce === true) { - node.staleOnce = false; - node.id = `${node.id}~relocated`; - throw engineFailure('NODE_STALE', `node ${id} went stale`, true); - } - const scripted = node.fail?.[action.kind]; - if (scripted !== undefined) { - throw engineFailure(scripted.code, scripted.message ?? `${action.kind} on ${node.id} failed with ${scripted.code}`); - } apply(node, action); node.on?.(action, node); }, diff --git a/packages/e2e/tests/integration/agent-act-pixel-tools.test.ts b/packages/e2e/tests/integration/agent-act-pixel-tools.test.ts index 10dedb952..811433fd9 100644 --- a/packages/e2e/tests/integration/agent-act-pixel-tools.test.ts +++ b/packages/e2e/tests/integration/agent-act-pixel-tools.test.ts @@ -94,7 +94,7 @@ test('stops a streak of failing point verbs', async ({ app, agent }) => { test('reads a repaint the tree cannot list off the screenshot', async ({ app, agent }) => { await app.open('/canvas'); - await agent.act('tap the map twice, then the margin twice'); + await agent.act('tap the map twice, then the margin once'); }); `; @@ -171,14 +171,14 @@ function actModel(call: LoopCall) { if (calls <= 5) return [{ toolName: 'select_at', input: { x: 380 + calls * 20, y: 180 + calls * 10, value: 'x' } }]; return conclude('failed'); } - if (call.prompt.includes('tap the map twice, then the margin twice')) { + if (call.prompt.includes('tap the map twice, then the margin once')) { // After the red pin, the same miss point at CSS (100, 100) twice: the status text repeats, so the - // listing stands still while the map paints a new tap count. Then the blank margin at CSS (700, 500) - // twice: nothing there repaints. + // listing stands still while the map paints a new tap count. Then the blank margin at CSS (700, 500): + // nothing there repaints. if (calls === 0) return [{ toolName: 'screenshot', input: {} }]; if (calls === 1) return [{ toolName: 'tap_at', input: { x: 180, y: 36 } }]; if (calls <= 3) return [{ toolName: 'tap_at', input: { x: 60, y: 60 } }]; - if (calls <= 5) return [{ toolName: 'tap_at', input: { x: 420, y: 300 } }]; + if (calls === 4) return [{ toolName: 'tap_at', input: { x: 420, y: 300 } }]; return conclude('passed'); } return conclude('passed'); @@ -391,16 +391,16 @@ describe('agent.act pixel verbs', () => { it('reports a moved screenshot under an unchanged listing, and blames the control only when the image stood still too', () => { expect(resultByTitle(outcome, 'reads a repaint the tree cannot list off the screenshot').status).toBe('passed'); - const turns = turnsOf('tap the map twice, then the margin twice'); + const turns = turnsOf('tap the map twice, then the margin once'); // The first miss changed the listed status text; the second repeated it, and only the drawn tap count moved. expect(turns[3]!.lastToolResult).toMatch(/changed #\S+ status \\"Hit\\" text=\\"miss at 100,100\\"/); expect(turns[4]!.lastToolResult).toContain('listed nodes unchanged; the screenshot changed'); expect(turns[4]!.lastToolResult).not.toContain('had no visible effect'); expect(turns[4]!.lastToolResult).toContain('"type":"file"'); - // The second margin tap changed neither the listing nor a pixel: the same PNG, so the tap is blamed as before. - expect(turns[6]!.lastToolResult).toContain('had no visible effect'); - expect(turns[6]!.lastToolResult).not.toContain('the screenshot changed'); - expect(turns[6]!.lastToolResult).toContain('"type":"file"'); + // The margin tap changed neither the listing nor a pixel: the same PNG, so the tap is blamed as before. + expect(turns[5]!.lastToolResult).toContain('had no visible effect'); + expect(turns[5]!.lastToolResult).not.toContain('the screenshot changed'); + expect(turns[5]!.lastToolResult).toContain('"type":"file"'); }); }); diff --git a/packages/e2e/tests/integration/agent-act-stress.test.ts b/packages/e2e/tests/integration/agent-act-stress.test.ts index 60933272e..16bda1546 100644 --- a/packages/e2e/tests/integration/agent-act-stress.test.ts +++ b/packages/e2e/tests/integration/agent-act-stress.test.ts @@ -11,8 +11,6 @@ import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { installFakeLoopModel, loopCalls, nodeIdFor } from '../helpers/fake-loop-model.ts'; import { resultByTitle, runProject, runProjectWithConfigFile } from '../helpers/run-project.ts'; import type { StepExecutor, StepExecutorContext } from '../../src/agent/executor.ts'; -import { BLOCKABLE_CODES } from '../../src/agent/executor.ts'; -import { AGENT_CODE_TABLE } from '../../src/agent/error.ts'; const CREDS = { admin: { username: 'admin', password: 'admin-pass' } }; @@ -34,7 +32,7 @@ test('editor secret probe', async ({ app, agent }) => { }); `; -describe('secret fill policy under a hostile executor', () => { +describe('secret fill policy under a hostile model', () => { let app: FixtureApp; beforeAll(async () => { @@ -45,37 +43,6 @@ describe('secret fill policy under a hostile executor', () => { await app?.close(); }); - const run = async (executor: StepExecutor) => - runProject( - { 'tests/secret.e2e.ts': SECRET_SUITE }, - { - appUrl: app.url, - config: { tests: 'tests/**/*.e2e.ts', agents: { default: { executor } }, credentials: CREDS }, - }, - ); - - it('refuses to fill a secret into a non-secure field', async () => { - const executor: StepExecutor = { - name: 'sink-attacker', - async runStep(context: StepExecutorContext) { - const observation = await context.observe(); - const email = nodeIdFor(observation.text, /textbox "Email"/); - await context.actions.typeSecret({ id: email }, 'admin.password'); - return { status: 'passed' as const, summary: 'should never get here' }; - }, - }; - const { outcome, project } = await run(executor); - try { - const result = resultByTitle(outcome, 'secret probe'); - expect(result.status).toBe('failed'); - expect(result.attempts.at(-1)!.error?.code).toBe('POLICY_DENIED'); - expect(result.attempts.at(-1)!.error?.message).toContain('purpose'); - } finally { - project.cleanup(); - } - }, 120_000); - - it('fills a generic secret into a contenteditable host and refuses a password there', async () => { const model = installFakeLoopModel((call) => { const notes = () => nodeIdFor(call.prompt, /textbox "Notes"/); @@ -220,7 +187,6 @@ test('defiant model', async ({ app, agent }) => { await agent.act('never conclude', { maxModelCalls: 6, timeout: 30_000 }); }); `; - const startedMs = Date.now(); const { outcome, project } = await runProject( { 'tests/defiant.e2e.ts': suite }, { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', agents: { default: { model } } } }, @@ -235,13 +201,12 @@ test('defiant model', async ({ app, agent }) => { 'MODEL_OUTPUT_INVALID', 'STEP_BUDGET_EXHAUSTED', ]).toContain(result.attempts.at(-1)!.error?.code); - expect(Date.now() - startedMs).toBeLessThan(30_000); } finally { project.cleanup(); } }, 120_000); - it('runs ten sequential act steps with fresh budgets each', async () => { + it('runs sequential act steps with fresh budgets each', async () => { const executor: StepExecutor = { name: 'counter-executor', async runStep(context: StepExecutorContext) { @@ -251,24 +216,24 @@ test('defiant model', async ({ app, agent }) => { return { status: 'passed' as const, summary: 'tapped once' }; }, }; - const calls = Array.from({ length: 10 }, () => " await agent.act('tap once');").join('\n'); + const calls = Array.from({ length: 3 }, () => " await agent.act('tap once');").join('\n'); const suite = `import { test, expect } from 'e2e'; -test('ten steps', async ({ app, agent, screen }) => { +test('three steps', async ({ app, agent, screen }) => { await app.open(); ${calls} - await expect(screen.getByRole('status')).toHaveText('10'); + await expect(screen.getByRole('status')).toHaveText('3'); }); `; const { outcome, project } = await runProject( - { 'tests/ten.e2e.ts': suite }, + { 'tests/three.e2e.ts': suite }, { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', agents: { default: { executor } } } }, ); try { - const result = resultByTitle(outcome, 'ten steps'); + const result = resultByTitle(outcome, 'three steps'); expect(result.status).toBe('passed'); const steps = result.attempts.at(-1)!.steps.filter((s) => s.api === 'agent.act'); - expect(steps).toHaveLength(10); + expect(steps).toHaveLength(3); for (const step of steps) expect(step.metrics!.actionSteps).toBe(1); } finally { project.cleanup(); @@ -276,71 +241,6 @@ ${calls} }, 120_000); }); -describe('taxonomy and run derivation under mixed outcomes', () => { - let app: FixtureApp; - - beforeAll(async () => { - app = await startFixtureApp(); - }); - - afterAll(async () => { - await app?.close(); - }); - - it('every blockable code names a category; others name none', () => { - for (const code of BLOCKABLE_CODES) { - expect(AGENT_CODE_TABLE[code].blockedCategory).toBeDefined(); - } - expect(AGENT_CODE_TABLE.CANCELLED.blockedCategory).toBeUndefined(); - expect(AGENT_CODE_TABLE.ACTION_FAILED.blockedCategory).toBeUndefined(); - expect(AGENT_CODE_TABLE.SEED_DATA_MISSING.blockedCategory).toBe('seed_data'); - expect(AGENT_CODE_TABLE.AUTH_CREDENTIAL_INVALID.blockedCategory).toBe('credentials'); - }); - - it('blocks the run on a new code, but one real failure keeps it failed', async () => { - const suite = `import { test } from 'e2e'; - -test('blocked by seed data', async ({ app, agent }) => { - await app.open(); - await agent.act('needs seed data'); -}); - -test('genuinely broken', async ({ app, agent }) => { - await app.open(); - await agent.act('the app is broken'); -}); -`; - const executor: StepExecutor = { - name: 'mixed-executor', - async runStep(context: StepExecutorContext) { - if (context.step.instruction.includes('seed')) { - return { - status: 'blocked' as const, - summary: 'no fixtures exist in this environment', - errorCode: 'SEED_DATA_MISSING' as const, - }; - } - return { status: 'failed' as const, summary: 'the button does nothing' }; - }, - }; - const { outcome, project } = await runProject( - { 'tests/mixed.e2e.ts': suite }, - { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', agents: { default: { executor } } } }, - ); - try { - const blocked = resultByTitle(outcome, 'blocked by seed data'); - expect(blocked.attempts.at(-1)!.error?.code).toBe('SEED_DATA_MISSING'); - expect( - blocked.attempts.at(-1)!.steps.find((s) => s.api === 'agent.act')?.status, - ).toBe('blocked'); - // One genuine product failure outvotes the blocked result. - expect(outcome.report.run.status).not.toBe('blocked'); - } finally { - project.cleanup(); - } - }, 120_000); -}); - describe('config-file executor across worker processes', () => { let app: FixtureApp; diff --git a/packages/e2e/tests/integration/agent-act-verbs.test.ts b/packages/e2e/tests/integration/agent-act-verbs.test.ts index a87661096..1214f2e19 100644 --- a/packages/e2e/tests/integration/agent-act-verbs.test.ts +++ b/packages/e2e/tests/integration/agent-act-verbs.test.ts @@ -12,7 +12,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { imagePointFor, installFakeLoopModel, loopCalls, nodeIdFor, type LoopCall, type LoopToolCall } from '../helpers/fake-loop-model.ts'; -import { createProject, resultByTitle, runExisting, runProject, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; +import { createProject, resultByTitle, runExisting, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; /** One agentic flow: what the test asks, how the scripted model answers, and the locator check that decides it. */ interface Flow { @@ -20,7 +20,7 @@ interface Flow { readonly instruction: string; /** The test body's check after the step, as source; empty for a flow whose step is expected to fail. */ readonly check: string; - /** Whether the replay pass records and replays this flow too. */ + /** Whether the second, cached pass runs this flow and replays it. */ readonly replays?: true; /** The tool calls for the turn with `calls` results so far; undefined concludes the step. */ readonly script: (calls: number, call: LoopCall) => readonly LoopToolCall[] | undefined; @@ -105,14 +105,6 @@ const FLOWS: readonly Flow[] = [ verdict: 'failed', script: (calls, call) => (calls === 0 ? [{ toolName: 'upload', input: { target: nodeIdFor(call.prompt, /"Attachment"/), files: ['.env'] } }] : undefined), }, - { - title: 'refuses a file outside the project', - instruction: 'attach a file from outside the project', - check: '', - verdict: 'failed', - script: (calls, call) => - calls === 0 ? [{ toolName: 'upload', input: { target: nodeIdFor(call.prompt, /"Attachment"/), files: ['../outside.txt'] } }] : undefined, - }, { title: 'scrolls a listed node into view', instruction: 'scroll to the footnote', @@ -126,7 +118,7 @@ const FLOWS: readonly Flow[] = [ check: `await expect(screen.getByLabel('Ledger state')).toHaveText('golden in view');`, replays: true, script: (calls, call) => - calls === 0 ? [{ toolName: 'scroll_to', input: { text: 'Row 333', target: nodeIdFor(call.prompt, /list "Ledger"/) } }] : undefined, + calls === 0 ? [{ toolName: 'scroll_to', input: { text: 'Row 24', target: nodeIdFor(call.prompt, /list "Ledger"/) } }] : undefined, }, { title: 'selects one word with a repeated press and sees the selection', @@ -205,7 +197,10 @@ function actModel(call: LoopCall) { return [{ toolName: 'complete_step', input: { status: flow?.verdict ?? 'passed', summary: call.lastToolResult.slice(0, 1_500) || 'done' } }]; } -const VERB_TOOLS = ['hover', 'hover_at', 'double_tap', 'long_press', 'right_click', 'drag', 'check', 'upload', 'scroll_to', 'back']; +/** A `grep` pattern that selects exactly the test with this title. */ +function exactTitle(title: string): RegExp { + return new RegExp(`^${title.replace(/[.*+?^$()|[\]\\{}]/g, String.raw`\$&`)}$`); +} /** The `agent.act` step of one test in a run. */ function actStepOf(outcome: RunOutcome, title: string) { @@ -214,36 +209,31 @@ function actStepOf(outcome: RunOutcome, title: string) { return step; } -describe('agent.act grammar verbs', () => { - let app: FixtureApp; - let outcome: RunOutcome; - let project: FixtureProject; +let app: FixtureApp; +let project: FixtureProject; +let outcome: RunOutcome; - beforeAll(async () => { - app = await startFixtureApp(); - const model = installFakeLoopModel(actModel); - const run = await runProject( - { 'tests/verbs.e2e.ts': suiteOf(FLOWS), ...PROJECT_FILES }, - { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', agents: { default: { model } } } }, - ); - outcome = run.outcome; - project = run.project; - }, 240_000); +/** The run options for one pass over the project, recording to and replaying from the trace cache. */ +function cachedRun(model: ReturnType) { + return { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', agents: { default: { model } }, cache: 'read-write' as const } }; +} - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); +beforeAll(async () => { + app = await startFixtureApp(); + project = createProject({ 'tests/verbs.e2e.ts': suiteOf(FLOWS), ...PROJECT_FILES }); + outcome = await runExisting(project, cachedRun(installFakeLoopModel(actModel))); +}, 240_000); +afterAll(async () => { + project?.cleanup(); + await app?.close(); +}); + +describe('agent.act grammar verbs', () => { const stepOf = (title: string) => actStepOf(outcome, title); const engineEvents = (title: string) => stepOf(title).events.filter((event) => event.kind === 'engine'); const turnsOf = (instruction: string) => loopCalls.filter((call) => call.prompt.includes(instruction)); - it('offers every verb the browser engine declares', () => { - const [first] = turnsOf('hover the account menu'); - for (const tool of VERB_TOOLS) expect(first!.toolNames).toContain(tool); - }); - it('hovers by id, reports the control the hover revealed, and records the hover', () => { expect(resultByTitle(outcome, 'hovers the menu trigger and taps what it reveals').status).toBe('passed'); const actions = engineEvents('hovers the menu trigger and taps what it reveals'); @@ -307,23 +297,16 @@ describe('agent.act grammar verbs', () => { expect(turnsOf('attach the fixture file')[1]!.lastToolResult).toMatch(/^Uploaded "fixtures\/attachment\.txt" to #\S+\./); }); - it('refuses a hidden file and a path outside the project before any engine action, as POLICY_DENIED', () => { - for (const [title, instruction, path] of [ - ['refuses a hidden file', 'attach the env file', '.env'], - ['refuses a file outside the project', 'attach a file from outside', '../outside.txt'], - ] as const) { - expect(resultByTitle(outcome, title).status).toBe('failed'); - const step = stepOf(title); - expect(step.events.filter((event) => event.kind === 'engine')).toHaveLength(0); - expect(step.metrics!.actionSteps).toBe(0); - expect(step.events.filter((event) => event.kind === 'policy')).toEqual([ - expect.objectContaining({ name: 'upload.path', decision: 'denied', code: 'POLICY_DENIED' }), - ]); - // The refusal reaches the model as the action's failure, led by the attempt and the code, naming the path it refused. - expect(turnsOf(instruction)[1]!.lastToolResult).toMatch( - new RegExp(`^upload ${JSON.stringify(path).replaceAll('.', '\\.')} to #\\S+ failed: POLICY_DENIED: ${JSON.stringify(path).replaceAll('.', '\\.')} is`), - ); - } + it('refuses a hidden file before any engine action, as POLICY_DENIED', () => { + expect(resultByTitle(outcome, 'refuses a hidden file').status).toBe('failed'); + const step = stepOf('refuses a hidden file'); + expect(step.events.filter((event) => event.kind === 'engine')).toHaveLength(0); + expect(step.metrics!.actionSteps).toBe(0); + expect(step.events.filter((event) => event.kind === 'policy')).toEqual([ + expect.objectContaining({ name: 'upload.path', decision: 'denied', code: 'POLICY_DENIED' }), + ]); + // The refusal reaches the model as the action's failure, led by the attempt and the code, naming the path it refused. + expect(turnsOf('attach the env file')[1]!.lastToolResult).toMatch(/^upload "\.env" to #\S+ failed: POLICY_DENIED: "\.env" is/); }); it('scrolls a listed node into view as one action', () => { @@ -339,11 +322,11 @@ describe('agent.act grammar verbs', () => { const step = stepOf('pages a windowed list to a row it has not rendered'); const actions = step.events.filter((event) => event.kind === 'engine'); expect(actions.map((event) => event.name)).toEqual(['scrollUntil']); - expect(actions[0]!.detail).toMatch(/^scroll down on list "Ledger" until "Row 333" shows \(\d+ screens\)$/); - // Forty-odd pages of the list, one action of the budget. + expect(actions[0]!.detail).toMatch(/^scroll down on list "Ledger" until "Row 24" shows \(\d+ screens\)$/); + // Several pages of the list, one action of the budget. expect(step.metrics!.actionSteps).toBe(1); - expect(turnsOf('scroll the ledger')[1]!.lastToolResult).toMatch(/^Scrolled down until "Row 333" was in view\./); - expect(turnsOf('scroll the ledger')[1]!.lastToolResult).toMatch(/Row 333 · Golden/); + expect(turnsOf('scroll the ledger')[1]!.lastToolResult).toMatch(/^Scrolled down until "Row 24" was in view\./); + expect(turnsOf('scroll the ledger')[1]!.lastToolResult).toMatch(/Row 24 · Golden/); }); it('repeats a key in one call, one engine action per press, and shows the selection it made', () => { @@ -394,45 +377,32 @@ describe('agent.act grammar verbs', () => { describe('agent.act grammar verbs: record then zero-turn replay', () => { const flows = FLOWS.filter((flow) => flow.replays === true); - let app: FixtureApp; - let project: FixtureProject; - let firstRun: RunOutcome; let secondRun: RunOutcome; let secondRunModelCalls = 0; beforeAll(async () => { - app = await startFixtureApp(); - project = createProject({ 'tests/replay.e2e.ts': suiteOf(flows), ...PROJECT_FILES }); - const options = (model: ReturnType) => ({ - appUrl: app.url, - config: { tests: 'tests/**/*.e2e.ts', agents: { default: { model } }, cache: 'read-write' as const }, - }); - firstRun = await runExisting(project, options(installFakeLoopModel(actModel))); - secondRun = await runExisting( - project, - options( - installFakeLoopModel((call) => { - secondRunModelCalls += 1; - return actModel(call); - }), - ), + const options = cachedRun( + installFakeLoopModel((call) => { + secondRunModelCalls += 1; + return actModel(call); + }), ); - }, 300_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); + secondRun = await runExisting(project, { + ...options, + runOptions: { grep: flows.map((flow) => exactTitle(flow.title)) }, + }); + }, 240_000); it('records every verb on the first run', () => { - expect(firstRun.exitCode).toBe(0); for (const flow of flows) { - expect(actStepOf(firstRun, flow.title).cache, flow.title).toMatchObject({ mode: 'missed', reason: 'no-entry' }); + expect(resultByTitle(outcome, flow.title).status, flow.title).toBe('passed'); + expect(actStepOf(outcome, flow.title).cache, flow.title).toMatchObject({ mode: 'missed', reason: 'no-entry' }); } }); it('replays hover, drag, check, upload, and scroll into view without a model call, and runs a round trip that changed nothing live', () => { expect(secondRun.exitCode).toBe(0); + expect(secondRun.results.filter((result) => result.status !== 'skipped').map((result) => result.test.title).toSorted()).toEqual(flows.map((flow) => flow.title).toSorted()); // A round trip leaves the screen and the route as it found them: nothing a // replay could check, so it is never recorded and the model runs it again. const roundTrip = actStepOf(secondRun, 'opens a page and comes back'); diff --git a/packages/e2e/tests/integration/agent-act.test.ts b/packages/e2e/tests/integration/agent-act.test.ts index 15e07f39b..a4be76ea7 100644 --- a/packages/e2e/tests/integration/agent-act.test.ts +++ b/packages/e2e/tests/integration/agent-act.test.ts @@ -10,7 +10,6 @@ import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { installFakeLoopModel, loopCalls, nodeIdFor } from '../helpers/fake-loop-model.ts'; -import { assertValidReport } from '../helpers/report-schema.ts'; import { resultByTitle, runProject, type FixtureProject } from '../helpers/run-project.ts'; import type { RunOutcome } from '../helpers/run-project.ts'; import type { StepExecutor, StepExecutorContext } from '../../src/agent/executor.ts'; @@ -147,7 +146,6 @@ describe('agent.act with a hand-rolled step executor', () => { expect(step!.metrics!.actionSteps).toBe(1); expect(step!.explanation).toContain('the counter shows 1'); const engineEvents = step!.events.filter((event) => event.kind === 'engine'); - const observations = step!.events.filter((event) => event.kind === 'observation'); expect(engineEvents).toHaveLength(1); const modelEvents = step!.events.filter((event) => event.kind === 'model'); expect(modelEvents).toHaveLength(1); @@ -155,17 +153,6 @@ describe('agent.act with a hand-rolled step executor', () => { // time than the engine event, later in the list. expect(step!.events.indexOf(modelEvents[0]!)).toBeGreaterThan(step!.events.indexOf(engineEvents[0]!)); expect(Date.parse(modelEvents[0]!.startedAt)).toBeLessThanOrEqual(Date.parse(engineEvents[0]!.startedAt)); - // The cache baseline also supplies the executor's first look. The other - // captures are its post-action look and the cache's passing observation, - // whose delta from the baseline becomes the staged trace's end anchors. - expect(observations).toHaveLength(3); - }); - - it('emits a schema-valid report for executor-driven steps', () => { - const report = JSON.parse( - readFileSync(path.join(project.dir, '.e2e', 'report.json'), 'utf8'), - ) as object; - assertValidReport(report); }); }); @@ -318,39 +305,6 @@ describe('agent.act verdict mapping', () => { } }, 120_000); - it('routes agent.assert through a custom executor with assert semantics', async () => { - const seen: string[] = []; - const executor: StepExecutor = { - name: 'judging-executor', - async runStep(context: StepExecutorContext) { - seen.push(`${context.step.kind}:${context.step.instruction}`); - if (context.step.instruction.includes('checkout')) { - return { status: 'failed' as const, summary: 'no checkout page exists here' }; - } - const observation = await context.observe(); - return /status "Counter" text="0"|status.*"0"/.test(observation.text) - ? { status: 'passed' as const, summary: 'the counter reads 0' } - : { status: 'failed' as const, summary: 'the counter is not zero' }; - }, - }; - const { outcome, project } = await runProject( - { 'tests/assert.e2e.ts': ASSERT_SUITE }, - { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', agents: { default: { executor } } } }, - ); - try { - const result = resultByTitle(outcome, 'custom executor judges assertions'); - expect(result.status).toBe('failed'); - // The failed assertion maps to ASSERTION_FAILED, not ACTION_FAILED. - expect(result.attempts.at(-1)!.error?.code).toBe('ASSERTION_FAILED'); - expect(seen[0]).toBe('assert:the counter shows zero'); - const steps = result.attempts.at(-1)!.steps.filter((s) => s.api === 'agent.assert'); - expect(steps[0]!.status).toBe('passed'); - expect(steps[1]!.status).toBe('failed'); - } finally { - project.cleanup(); - } - }, 120_000); - it('fills a declared secret through typeSecret, never exposing the value', async () => { const executor: StepExecutor = { name: 'login-executor', @@ -566,15 +520,6 @@ describe('agent.act engine operations are bounded by actionTimeout', () => { }, 120_000); }); -const ASSERT_SUITE = `import { test } from 'e2e'; - -test('custom executor judges assertions', async ({ app, agent }) => { - await app.open(); - await agent.assert('the counter shows zero'); - await agent.assert('the checkout page is visible'); -}); -`; - const SECRET_SUITE = `import { test, credentials, expect } from 'e2e'; test('executor fills a declared secret', async ({ app, agent, screen }) => { @@ -680,10 +625,7 @@ describe('agent.act with the default ToolLoopAgent executor', () => { if (call.lastToolResult === '') { // First turn: the prompt carries the instruction and initial screen. const id = nodeIdFor(call.prompt, /button "Increment"/); - return { - toolCalls: [{ toolName: 'tap', input: { target: id } }], - reasoning: 'The counter starts at 0; tapping Increment once.', - }; + return [{ toolName: 'tap', input: { target: id } }]; } // The tap result reports the change; conclude once the counter reads 1. if (/text="1"/.test(call.lastToolResult)) { @@ -775,20 +717,6 @@ describe('agent.act with the default ToolLoopAgent executor', () => { step!.metrics!.modelCalls, ); }); - - it('carries a turn\'s reasoning onto its model event and into the valid report', () => { - const attempt = resultByTitle(outcome, 'default agent increments the counter').attempts.at( - -1, - )!; - const step = attempt.steps.find((candidate) => candidate.api === 'agent.act'); - const modelEvents = step!.events.filter((event) => event.kind === 'model'); - expect(modelEvents[0]!.reasoning).toBe('The counter starts at 0; tapping Increment once.'); - expect(modelEvents.at(-1)).not.toHaveProperty('reasoning'); - const report = JSON.parse( - readFileSync(path.join(project.dir, '.e2e', 'report.json'), 'utf8'), - ) as object; - assertValidReport(report); - }); }); const DELAYED_SUITE = `import { test, expect } from 'e2e'; diff --git a/packages/e2e/tests/integration/agent-ai-trace.test.ts b/packages/e2e/tests/integration/agent-ai-trace.test.ts index 456bcf858..1dbce415d 100644 --- a/packages/e2e/tests/integration/agent-ai-trace.test.ts +++ b/packages/e2e/tests/integration/agent-ai-trace.test.ts @@ -115,39 +115,6 @@ describe('--ai-trace on the in-process transport', () => { expect(steps.every((step) => step.model_id === 'scripted-loop')).toBe(true); expect(steps.every((step) => step.provider === 'fake-loop')).toBe(true); }); - - it('records the prompt the model saw, its tool definitions with schemas, and its answer', () => { - const document = readTrace(project); - const [first, second] = document.steps; - const input = JSON.parse(first!.input) as { - prompt: { role: string; content: unknown }[]; - tools: { name: string; description?: string; parameters?: { type?: string } }[]; - }; - expect(input.prompt[0]!.role).toBe('system'); - expect(String(input.prompt[0]!.content)).toContain('autonomous end-to-end testing agent'); - const user = input.prompt.find((message) => message.role === 'user')!.content; - // A pixel-mode opening prompt is content parts: the text part carries the instruction. - const userText = Array.isArray(user) - ? (user as { type: string; text?: string }[]).filter((part) => part.type === 'text').map((part) => part.text).join('\n') - : String(user); - expect(userText).toContain('increment the counter once'); - const names = input.tools.map((tool) => tool.name); - expect(names).toContain('tap'); - expect(names).toContain('complete_step'); - for (const tool of input.tools) expect(tool.parameters?.type).toBe('object'); - - const output = JSON.parse(first!.output!) as { - finishReason: string; - response: { messages: { role: string }[] }; - }; - expect(output.finishReason).toBe('tool-calls'); - expect(output.response.messages.map((message) => message.role)).toEqual(['assistant', 'tool']); - expect(JSON.parse(first!.usage!)).toMatchObject({ inputTokens: 100, outputTokens: 20 }); - - // The second turn's prompt carries the first turn's tool result. - const later = JSON.parse(second!.input) as { prompt: { role: string }[] }; - expect(later.prompt.map((message) => message.role)).toEqual(['system', 'user', 'assistant', 'tool']); - }); }); describe('--ai-trace on child-process workers', () => { diff --git a/packages/e2e/tests/integration/agent-fallback-handoff.test.ts b/packages/e2e/tests/integration/agent-fallback-handoff.test.ts index 57b746773..436092b9b 100644 --- a/packages/e2e/tests/integration/agent-fallback-handoff.test.ts +++ b/packages/e2e/tests/integration/agent-fallback-handoff.test.ts @@ -1,11 +1,12 @@ -/** Pixel-only evidence survives the cache handoff and every default-agent screen update. */ +/** Pixel-only evidence survives the cache handoff and every default-agent screen update, and no model sees it once a secret fill taints the attempt. */ -import { describe, expect, it } from 'vitest'; -import { defineEngine, EngineError, type EngineSnapshot } from '../../src/engine/index.ts'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { defineEngine, EngineError, type EngineObserveOptions, type EngineSnapshot } from '../../src/engine/index.ts'; import { buildTraceEntry, type RecordedAction } from '../../src/cache/trace.ts'; import type { StepExecutor } from '../../src/agent/executor.ts'; import { installFakeLoopModel, loopCalls } from '../helpers/fake-loop-model.ts'; -import { runProject } from '../helpers/run-project.ts'; +import { fakeCalls, installFakeModel, judgment } from '../helpers/fake-model.ts'; +import { resultByTitle, runProject, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; const SUITE = `import { test } from 'e2e'; @@ -25,7 +26,7 @@ function pixelSnapshot(value = 1): EngineSnapshot { } describe('semantic fallback handoff', () => { - it.each([true, false, undefined])('requests pixels=%s after a semantic cache probe', async (pixels) => { + it.each([true, undefined])('requests pixels=%s after a semantic cache probe', async (pixels) => { const captures: boolean[] = []; const engine = defineEngine({ name: 'requested-pixels-fixture', version: '1', spiVersion: 1, platform: 'fixture', @@ -128,7 +129,10 @@ describe('semantic fallback handoff', () => { expect(captures).toEqual([true]); expect(loopCalls).toHaveLength(1); expect(loopCalls[0]?.imageParts).toBe(1); + expect(loopCalls[0]?.prompt).toContain('Screenshot attached: 2 by 2'); expect(loopCalls[0]?.prompt).toContain('semantic capture unavailable'); + expect(loopCalls[0]?.prompt).toContain('never infer absence'); + expect(outcome.report.run.results[0]!.attempts[0]!.steps.find((step) => step.api === 'agent.act')?.visionInput).toBe(true); assertValidReport(outcome.report); } finally { project.cleanup(); @@ -312,3 +316,86 @@ describe('semantic fallback handoff', () => { } }); }); + +const SEMANTIC_SUITE = `import { test, credentials } from 'e2e'; +test('vision judgment receives fallback', async ({ agent }) => { + await agent.assert('the screenshot shows the result', { vision: true }); +}); +test('tree-only judgment cannot infer absence', async ({ agent }) => { + await agent.assert('nothing is visible', { vision: false }); +}); +test('act cannot recover pixels after a secret fill', async ({ agent, screen }) => { + await screen.getByRole('textbox').fill(credentials.user('member').password); + await agent.act('read the tainted screenshot'); +}); +test('judgment cannot recover pixels after a secret fill', async ({ agent, screen }) => { + await screen.getByRole('textbox').fill(credentials.user('member').password); + await agent.assert('read the tainted result', { vision: true }); +});`; + +describe('agent semantic fallback through the engine contract', () => { + let project: FixtureProject; + let outcome: RunOutcome; + const captures: { filled: boolean; options: EngineObserveOptions | undefined }[] = []; + + beforeAll(async () => { + let filled = false; + const engine = defineEngine({ + name: 'masked-fixture', version: '1', spiVersion: 1, platform: 'fixture', + actions: ['fill'], + startAttempt: async () => { filled = false; }, + locate: async () => [{ ref: { id: 'password', revision: '' }, role: 'textbox', inputPurpose: 'password', states: { secure: true } }], + perform: async () => { filled = true; }, + observe: async (_operation, options) => { + captures.push({ filled, options }); + if (options?.pixelFallback !== true) { + throw new EngineError('OPERATION_TIMEOUT', 'semantic capture timed out', { retryable: false }); + } + return { + root: { ref: { id: 'root', revision: '' } }, + location: 'fixture:result', + viewport: { width: 2, height: 2 }, + treeUnavailable: true, + pixels: { data: new Uint8Array([1, 2, 3]), mediaType: 'image/png', width: 2, height: 2, scale: 1 }, + maskedRegionCount: 0, + }; + }, + }); + const model = installFakeLoopModel(() => [{ toolName: 'complete_step', input: { status: 'passed', summary: 'read pixels' } }]); + const judge = installFakeModel(() => judgment(true, 'visible in the screenshot')); + const result = await runProject({ 'tests/fallback.e2e.ts': SEMANTIC_SUITE }, { + appUrl: 'https://fixture.test', + config: { + tests: 'tests/**/*.e2e.ts', + targets: [{ name: 'fixture', engine }], + agents: { default: { model, judge } }, + credentials: { member: { username: 'member', password: 'fixture-secret-value' } }, + }, + }); + project = result.project; + outcome = result.outcome; + expect(outcome.report.run.errors).toEqual([]); + }); + + afterAll(() => project?.cleanup()); + + it('judges the current screenshot with vision enabled', () => { + expect(resultByTitle(outcome, 'vision judgment receives fallback').status).toBe('passed'); + expect(fakeCalls).toHaveLength(1); + expect(fakeCalls[0]!.images).toEqual([{ mediaType: 'image/png', bytes: 3 }]); + expect(fakeCalls[0]!.observation).toContain('semantic capture unavailable'); + }); + + it('stops tree-only and tainted judgments before a model call', () => { + for (const title of ['tree-only judgment cannot infer absence', 'act cannot recover pixels after a secret fill', 'judgment cannot recover pixels after a secret fill']) { + const result = resultByTitle(outcome, title); + expect(result.status).toBe('failed'); + expect(result.attempts[0]!.error?.message).toContain('timed out'); + } + const tainted = captures.filter((capture) => capture.filled); + expect(tainted.length).toBeGreaterThanOrEqual(2); + expect(tainted.every((capture) => capture.options?.pixelFallback !== true && capture.options?.pixels !== true)).toBe(true); + expect(loopCalls).toEqual([]); + assertValidReport(outcome.report); + }); +}); diff --git a/packages/e2e/tests/integration/agent-policy.test.ts b/packages/e2e/tests/integration/agent-policy.test.ts index 4544bb47d..06a4c20e6 100644 --- a/packages/e2e/tests/integration/agent-policy.test.ts +++ b/packages/e2e/tests/integration/agent-policy.test.ts @@ -10,9 +10,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { OBSERVED_NAME_LIMIT, OBSERVED_TEXT_LIMIT, type SemanticNode } from '../../src/engine/contract.ts'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { installFakeLoopModel, loopCalls, nodeIdFor } from '../helpers/fake-loop-model.ts'; -import { extracted, installFakeModel, judgment } from '../helpers/fake-model.ts'; +import { extracted, fakeCalls, installFakeModel, judgment } from '../helpers/fake-model.ts'; import type { FakeCall } from '../helpers/fake-model.ts'; -import { assertValidReport } from '../helpers/report-schema.ts'; import { createProject, resultByTitle, runExisting, runProject, type FixtureProject } from '../helpers/run-project.ts'; import type { RunOutcome } from '../helpers/run-project.ts'; import type { SdkLanguageModel } from '../../src/agent/ai-sdk.ts'; @@ -21,10 +20,10 @@ import { createFakeEngine, FAKE_APP, FAKE_APP_URL } from '../helpers/fake-engine const SUITE = `import { test, credentials } from 'e2e'; -test('fills a secret into a password field', async ({ app, agent, screen }) => { - await app.open(); - await screen.getByLabel('Password').fill(credentials.user('member').password); - await agent.assert('the password field has a value'); +test('fills a secret into a field the page echoes', async ({ app, agent, screen }) => { + await app.open('/echo'); + await screen.getByLabel('Token').fill(credentials.user('member').password); + await agent.assert('the token echo shows a value'); }); test('repairs an extraction that fails the caller schema', async ({ app, agent }) => { @@ -107,6 +106,7 @@ describe('agent policy and error classification', () => { let app: FixtureApp; let outcome: RunOutcome; let project: FixtureProject; + let modelCalls: FakeCall[]; let unconfigured: RunOutcome; let unconfiguredProject: FixtureProject; let ghost: RunOutcome; @@ -130,6 +130,7 @@ describe('agent policy and error classification', () => { ); outcome = main.outcome; project = main.project; + modelCalls = [...fakeCalls]; const missingCredential = await runProject( { 'tests/ghost.e2e.ts': UNCONFIGURED_SUITE }, @@ -189,24 +190,15 @@ describe('agent policy and error classification', () => { expect(step.model).toMatchObject({ provider: 'fake', model: 'scripted' }); }); - it('fills a secret into a purpose-compatible secure field', () => { - expect(resultByTitle(outcome, 'fills a secret into a password field').status).toBe('passed'); - }); - - it('suppresses screenshot evidence once the viewport is pixel-tainted', () => { - const attempt = resultByTitle(outcome, 'fills a secret into a password field').attempts.at(-1)!; - const assertion = attempt.steps.find((step) => step.api === 'agent.assert')!; - expect(assertion.artifacts).toEqual([]); - expect( - assertion.events.some( - (event) => event.kind === 'policy' && event.name === 'assert.screenshot' && event.decision === 'denied', - ), - ).toBe(true); - }); - it('never sends a secret value to the model', () => { - const result = resultByTitle(outcome, 'fills a secret into a password field'); - expect(JSON.stringify(result.attempts)).not.toContain('hunter2-secret'); + expect(resultByTitle(outcome, 'fills a secret into a field the page echoes').status).toBe('passed'); + const echoed = modelCalls.filter((call) => call.instruction === 'the token echo shows a value'); + expect(echoed).toHaveLength(1); + expect(echoed[0]!.observation).toContain(''); + expect(modelCalls.length).toBeGreaterThanOrEqual(2); + for (const call of modelCalls) { + expect(`${call.system}\n${call.prompt}`.toLowerCase()).not.toContain('hunter2-secret'); + } }); it('reports an unconfigured credential as a configuration failure', () => { @@ -240,57 +232,6 @@ describe('agent policy and error classification', () => { }); }); -describe('serial group artifacts', () => { - let app: FixtureApp; - let outcome: RunOutcome; - let project: FixtureProject; - - const SERIAL_SUITE = `import { test } from 'e2e'; - -test.describe('group', { serial: true }, () => { - test('captures evidence from a shared session', async ({ app, agent }) => { - await app.open(); - await agent.assert('the Home heading is visible'); - }); -}); -`; - - beforeAll(async () => { - app = await startFixtureApp(); - const model = installFakeModel(() => judgment(true, 'the heading is present')); - const result = await runProject( - { 'tests/serial.e2e.ts': SERIAL_SUITE }, - { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - agents: { default: { model } }, - }, - }, - ); - outcome = result.outcome; - project = result.project; - }, 120_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it('records a resolvable path, size, and digest for member artifacts', () => { - // A shared session writes into the group directory, so a member that - // registers against its own directory produces an unresolvable artifact. - assertValidReport(outcome.report); - const group = outcome.report.run.serialGroups[0]!; - const screenshot = group.attempts - .at(-1)! - .artifacts.find((artifact) => artifact.kind === 'screenshot')!; - expect(screenshot.path).toBeDefined(); - expect(screenshot.size).toBeGreaterThan(0); - expect(screenshot.sha256).toMatch(/^[0-9a-f]{64}$/); - }); -}); - describe('tool calls outside the vocabulary', () => { let app: FixtureApp; diff --git a/packages/e2e/tests/integration/agent-scroll-settle.test.ts b/packages/e2e/tests/integration/agent-scroll-settle.test.ts index 59091d35e..39abc3ef2 100644 --- a/packages/e2e/tests/integration/agent-scroll-settle.test.ts +++ b/packages/e2e/tests/integration/agent-scroll-settle.test.ts @@ -63,7 +63,6 @@ describe('the look after scrolling to text', () => { expect(executorCalls).toBe(1); const replay = resultByTitle(replayed, 'reaches and taps the row').attempts[0]!.steps.find((step) => step.api === 'agent.act'); expect(replay?.cache).toMatchObject({ mode: 'self-finalized', replayedActions: 2 }); - console.info(`scroll-to-text ${String(pages)} pages: ${String(replay?.durationMs)} ms replay, ${String(afterScroll)} captures after the live action`); expect(afterScroll).toBe(2); if (pages > 0) expect(beforeTap).toEqual([4, 4]); } finally { diff --git a/packages/e2e/tests/integration/agent-semantic-fallback.test.ts b/packages/e2e/tests/integration/agent-semantic-fallback.test.ts deleted file mode 100644 index 109100ebc..000000000 --- a/packages/e2e/tests/integration/agent-semantic-fallback.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { defineEngine, EngineError, type EngineObserveOptions } from '../../src/engine/index.ts'; -import { installFakeLoopModel, loopCalls } from '../helpers/fake-loop-model.ts'; -import { fakeCalls, installFakeModel, judgment } from '../helpers/fake-model.ts'; -import { resultByTitle, runProject, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; -import { assertValidReport } from '../helpers/report-schema.ts'; - -const SUITE = `import { test, credentials } from 'e2e'; -test('act receives fallback on its first observation', async ({ agent }) => { - await agent.act('read the visible screenshot'); -}); -test('vision judgment receives fallback', async ({ agent }) => { - await agent.assert('the screenshot shows the result', { vision: true }); -}); -test('tree-only judgment cannot infer absence', async ({ agent }) => { - await agent.assert('nothing is visible', { vision: false }); -}); -test('act cannot recover pixels after a secret fill', async ({ agent, screen }) => { - await screen.getByRole('textbox').fill(credentials.user('member').password); - await agent.act('read the tainted screenshot'); -}); -test('judgment cannot recover pixels after a secret fill', async ({ agent, screen }) => { - await screen.getByRole('textbox').fill(credentials.user('member').password); - await agent.assert('read the tainted result', { vision: true }); -});`; - -describe('agent semantic fallback through the engine contract', () => { - let project: FixtureProject; - let outcome: RunOutcome; - const captures: { filled: boolean; options: EngineObserveOptions | undefined }[] = []; - - beforeAll(async () => { - let filled = false; - const engine = defineEngine({ - name: 'masked-fixture', version: '1', spiVersion: 1, platform: 'fixture', - actions: ['fill'], - startAttempt: async () => { filled = false; }, - locate: async () => [{ ref: { id: 'password', revision: '' }, role: 'textbox', inputPurpose: 'password', states: { secure: true } }], - perform: async () => { filled = true; }, - observe: async (_operation, options) => { - captures.push({ filled, options }); - if (options?.pixelFallback !== true) { - throw new EngineError('OPERATION_TIMEOUT', 'semantic capture timed out', { retryable: false }); - } - return { - root: { ref: { id: 'root', revision: '' } }, - location: 'fixture:result', - viewport: { width: 2, height: 2 }, - treeUnavailable: true, - pixels: { data: new Uint8Array([1, 2, 3]), mediaType: 'image/png', width: 2, height: 2, scale: 1 }, - maskedRegionCount: 0, - }; - }, - }); - const model = installFakeLoopModel(() => [{ toolName: 'complete_step', input: { status: 'passed', summary: 'read pixels' } }]); - const judge = installFakeModel(() => judgment(true, 'visible in the screenshot')); - const result = await runProject({ 'tests/fallback.e2e.ts': SUITE }, { - appUrl: 'https://fixture.test', - config: { - tests: 'tests/**/*.e2e.ts', - targets: [{ name: 'fixture', engine }], - agents: { default: { model, judge } }, - credentials: { member: { username: 'member', password: 'fixture-secret-value' } }, - }, - }); - project = result.project; - outcome = result.outcome; - expect(outcome.report.run.errors).toEqual([]); - }); - - afterAll(() => project?.cleanup()); - - it('sends the first fallback screenshot and the warning to the acting model', () => { - expect(resultByTitle(outcome, 'act receives fallback on its first observation').status).toBe('passed'); - expect(loopCalls).toHaveLength(1); - expect(loopCalls[0]!.imageParts).toBe(1); - expect(loopCalls[0]!.prompt).toContain('Screenshot attached: 2 by 2'); - expect(loopCalls[0]!.prompt).toContain('semantic capture unavailable'); - expect(loopCalls[0]!.prompt).toContain('never infer absence'); - expect(captures[0]?.options).toEqual({ pixelFallback: true }); - const step = resultByTitle(outcome, 'act receives fallback on its first observation').attempts[0]!.steps.find((entry) => entry.api === 'agent.act'); - expect(step?.visionInput).toBe(true); - }); - - it('judges the current screenshot with vision enabled', () => { - expect(resultByTitle(outcome, 'vision judgment receives fallback').status).toBe('passed'); - expect(fakeCalls).toHaveLength(1); - expect(fakeCalls[0]!.images).toEqual([{ mediaType: 'image/png', bytes: 3 }]); - expect(fakeCalls[0]!.observation).toContain('semantic capture unavailable'); - }); - - it('stops tree-only and tainted judgments before a model call', () => { - for (const title of ['tree-only judgment cannot infer absence', 'act cannot recover pixels after a secret fill', 'judgment cannot recover pixels after a secret fill']) { - const result = resultByTitle(outcome, title); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error?.message).toContain('timed out'); - } - const tainted = captures.filter((capture) => capture.filled); - expect(tainted.length).toBeGreaterThanOrEqual(2); - expect(tainted.every((capture) => capture.options?.pixelFallback !== true && capture.options?.pixels !== true)).toBe(true); - assertValidReport(outcome.report); - }); -}); diff --git a/packages/e2e/tests/integration/agent-trace-cache.test.ts b/packages/e2e/tests/integration/agent-trace-cache.test.ts index e6c0a4917..369a6e168 100644 --- a/packages/e2e/tests/integration/agent-trace-cache.test.ts +++ b/packages/e2e/tests/integration/agent-trace-cache.test.ts @@ -99,88 +99,6 @@ function entryFileState(project: FixtureProject): { bytes: string; mtimeMs: numb return { bytes: readFileSync(file, 'utf8'), mtimeMs: statSync(file).mtimeMs }; } -describe('trace cache: record then zero-turn replay', () => { - let app: FixtureApp; - let project: FixtureProject; - let firstRun: RunOutcome; - let secondRun: RunOutcome; - let recordedFile: { bytes: string; mtimeMs: number }; - const first: ExecutorRecord = { calls: 0, prefixes: [] }; - const second: ExecutorRecord = { calls: 0, prefixes: [] }; - - beforeAll(async () => { - app = await startFixtureApp(); - project = createProject({ 'tests/act.e2e.ts': SUITE }); - const options = (record: ExecutorRecord) => ({ - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - agents: { default: { executor: twoTapExecutor(record) } }, - cache: 'read-write' as const, - }, - }); - firstRun = await runExisting(project, options(first)); - recordedFile = entryFileState(project); - secondRun = await runExisting(project, options(second)); - }, 240_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it('runs the executor on the first pass and records one entry', () => { - expect(firstRun.exitCode).toBe(0); - expect(first.calls).toBe(1); - expect(first.prefixes).toEqual([undefined]); - const step = actStep(firstRun); - expect(step.cache).toEqual({ - mode: 'missed', - reason: 'no-entry', - replayedActions: 0, - totalActions: 0, - }); - expect(readdirSync(cacheDir(project))).toHaveLength(1); - const { entry } = readOnlyEntry(project); - expect(entry.schemaVersion).toBe('trace-1'); - expect(entry.payload.actions).toHaveLength(2); - expect(entry.payload.executor.name).toBe('two-tap-executor'); - expect(entry.payload.startPath).toBe('/'); - // The recording's own check, kept as data: the counter reading 2 appeared - // during the step, so a replay must show it again before passing alone. - expect(entry.payload.endAnchors).toContainEqual({ role: 'status', name: 'Counter', text: '2' }); - }); - - it('replays the second run zero-turn without invoking the executor', () => { - expect(secondRun.exitCode).toBe(0); - expect(second.calls).toBe(0); - const step = actStep(secondRun); - expect(step.status).toBe('passed'); - expect(step.cache).toEqual({ - mode: 'self-finalized', - replayedActions: 2, - totalActions: 2, - }); - expect(step.metrics!.modelCalls).toBe(0); - expect(step.metrics!.actionSteps).toBe(2); - expect(step.explanation).toContain('zero-turn'); - // A step the cache replayed whole never rewrites its entry: the file - // keeps its bytes and its modification time, so a committed cache - // directory stays clean across local runs. - expect(entryFileState(project)).toEqual(recordedFile); - const { entry } = readOnlyEntry(project); - expect(entry.payload.summary).toBe('the counter shows 2'); - expect(entry.payload.endPath).toBe('/'); - }); - - it('emits schema-valid reports for both cached and uncached runs', () => { - const report = JSON.parse( - readFileSync(path.join(project.dir, '.e2e', 'report.json'), 'utf8'), - ) as object; - assertValidReport(report); - }); -}); - describe('trace cache: divergence hands the step over mid-step', () => { let app: FixtureApp; let project: FixtureProject; @@ -243,7 +161,7 @@ const WRONG_EXPECT_SUITE = `import { test, expect } from 'e2e'; test('cached step increments twice', async ({ app, agent, screen }) => { await app.open(); await agent.act('increment the counter twice'); - await expect(screen.getByRole('status')).toHaveText('3'); + await expect(screen.getByRole('status')).toHaveText('3', { timeout: 500 }); }); `; @@ -256,7 +174,7 @@ test.afterEach(async ({ app }) => { test('cached step increments twice', async ({ app, agent, screen }) => { await app.open(); await agent.act('increment the counter twice'); - await expect(screen.getByRole('status')).toHaveText('3'); + await expect(screen.getByRole('status')).toHaveText('3', { timeout: 500 }); }); `; @@ -287,16 +205,6 @@ describe('trace cache: unconfirmed traces are withheld and poisoned entries evic await app?.close(); }); - it('never writes a trace whose following assertion failed', async () => { - project = createProject({ 'tests/act.e2e.ts': WRONG_EXPECT_SUITE }); - const outcome = await runExisting(project, options()); - expect(outcome.exitCode).not.toBe(0); - // The act passed (the executor saw 2), the expect demanded 3: the staged - // trace is unconfirmed and nothing may reach the store. - expect(existsSync(cacheDir(project))).toBe(false); - project.cleanup(); - }, 120_000); - it('teardown steps passing after the failure cannot confirm the implicated trace', async () => { project = createProject({ 'tests/act.e2e.ts': WRONG_EXPECT_WITH_TEARDOWN_SUITE }); const outcome = await runExisting(project, options()); @@ -330,20 +238,10 @@ test('cached step increments twice', async ({ app, agent }) => { }); `; -const CONFIRMED_THEN_PROVIDER_DOWN_SUITE = `import { test, expect } from 'e2e'; - -test('cached step increments twice', async ({ app, agent, screen }) => { - await app.open(); - await agent.act('increment the counter twice'); - await expect(screen.getByRole('status')).toHaveText('2'); - await agent.assert('the counter shows 2', { agent: 'judge' }); -}); -`; - const PROVIDER_DOWN_THEN_WRONG_AFTER_EACH_SUITE = `import { test, expect } from 'e2e'; test.afterEach(async ({ screen }) => { - await expect(screen.getByRole('status')).toHaveText('3', { timeout: 1_000 }); + await expect(screen.getByRole('status')).toHaveText('3', { timeout: 500 }); }); test('cached step increments twice', async ({ app, agent }) => { @@ -409,20 +307,26 @@ describe('trace cache: a model that never answered implicates nothing', () => { project.cleanup(); }, 240_000); - it('still writes a fresh recording a check confirmed before a judgment found no model', async () => { - project = createProject({ 'tests/act.e2e.ts': CONFIRMED_THEN_PROVIDER_DOWN_SUITE }); + it('keeps an entry whose replay handed off to a model that never answered', async () => { + await recordEntry(); + rewriteOnlyEntry(project, (payload) => { + const [firstTap, secondTap] = payload.actions; + if (firstTap === undefined || secondTap?.name !== 'tap') throw new Error(`expected two taps, got ${JSON.stringify(payload.actions)}`); + return { ...payload, actions: [firstTap, { ...secondTap, target: { ...secondTap.target, name: 'No Such Button' } }] }; + }); + const handedOff = entryFileState(project); const outcome = await runExisting(project, { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', - agents: { default: { executor: twoTapExecutor(record) }, judge: { model: unreachableModel } }, + agents: { default: { model: unreachableModel } }, cache: 'read-write' as const, }, }); expect(outcome.exitCode).not.toBe(0); - expect(actStep(outcome).cache?.mode).toBe('missed'); - expect(stepErrorCode(outcome, 'agent.assert')).toBe('MODEL_PROVIDER_FAILED'); - expect(readOnlyEntry(project).entry.payload.actions).toHaveLength(2); + expect(actStep(outcome).cache).toMatchObject({ mode: 'agent-concluded', reason: 'target-not-found', replayedActions: 1 }); + expect(stepErrorCode(outcome, 'agent.act')).toBe('MODEL_PROVIDER_FAILED'); + expect(entryFileState(project)).toEqual(handedOff); project.cleanup(); }, 240_000); @@ -443,28 +347,6 @@ describe('trace cache: a model that never answered implicates nothing', () => { expect(readEntries(project)).toHaveLength(0); project.cleanup(); }, 240_000); - - it('keeps an entry whose replay handed off to a model that never answered', async () => { - await recordEntry(); - rewriteOnlyEntry(project, (payload) => { - const [firstTap, secondTap] = payload.actions; - if (firstTap === undefined || secondTap?.name !== 'tap') throw new Error(`expected two taps, got ${JSON.stringify(payload.actions)}`); - return { ...payload, actions: [firstTap, { ...secondTap, target: { ...secondTap.target, name: 'No Such Button' } }] }; - }); - const handedOff = entryFileState(project); - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - agents: { default: { model: unreachableModel } }, - cache: 'read-write' as const, - }, - }); - expect(outcome.exitCode).not.toBe(0); - expect(actStep(outcome).cache).toMatchObject({ mode: 'agent-concluded', reason: 'target-not-found', replayedActions: 1 }); - expect(stepErrorCode(outcome, 'agent.act')).toBe('MODEL_PROVIDER_FAILED'); - expect(entryFileState(project)).toEqual(handedOff); - }, 240_000); }); const STORAGE_SUITE = `import { test, expect } from 'e2e'; @@ -476,12 +358,8 @@ test('saves the marker', async ({ app, agent, screen }) => { }); `; -/** - * Taps "Save marker" once (unless a replayed prefix already did) and checks - * the marker. With `repair`, an end-mismatch hand-off makes it tap again — the - * shape of an executor that found the replayed flow had not taken effect. - */ -function saveMarkerExecutor(record: ExecutorRecord, options: { repair?: boolean } = {}): StepExecutor { +/** Taps "Save marker" once (unless a replayed prefix already did) and checks the marker. */ +function saveMarkerExecutor(record: ExecutorRecord): StepExecutor { return { name: 'save-marker-executor', version: 'test', @@ -489,11 +367,7 @@ function saveMarkerExecutor(record: ExecutorRecord, options: { repair?: boolean record.calls += 1; record.prefixes.push(context.replayedPrefix); let observation = await context.observe(); - const prefix = context.replayedPrefix; - const mustAct = - (prefix?.replayedActions.length ?? 0) === 0 || - (options.repair === true && prefix?.stopReason === 'end-mismatch'); - if (mustAct) { + if ((context.replayedPrefix?.replayedActions.length ?? 0) === 0) { await context.actions.tap({ id: nodeIdFor(observation.text, /button "Save marker"/) }); observation = await context.observe(); } @@ -510,7 +384,7 @@ describe('trace cache: the recorded end state gates self-finalization', () => { let project: FixtureProject; const records: ExecutorRecord[] = []; - const options = (executor: { repair?: boolean } = {}) => { + const options = () => { const record: ExecutorRecord = { calls: 0, prefixes: [] }; records.push(record); return { @@ -518,7 +392,7 @@ describe('trace cache: the recorded end state gates self-finalization', () => { config: { tests: 'tests/**/*.e2e.ts', reporters: ['json'] as const, - agents: { default: { executor: saveMarkerExecutor(record, executor) } }, + agents: { default: { executor: saveMarkerExecutor(record) } }, cache: 'read-write' as const, }, }; @@ -548,88 +422,6 @@ describe('trace cache: the recorded end state gates self-finalization', () => { const step = resultByTitle(second, 'saves the marker').attempts.at(-1)!.steps.find((s) => s.api === 'agent.act')!; expect(step.cache).toMatchObject({ mode: 'self-finalized', replayedActions: 1, totalActions: 1 }); }, 240_000); - - it('hands off with end-mismatch when the recorded effect is not on screen after a full replay', async () => { - // Every recorded action still replays; only the recorded end state is - // made unreachable. Mechanics alone must not pass the step. - rewriteOnlyEntry(project, (payload) => ({ ...payload, endAnchors: [{ role: 'status', name: 'Marker', text: 'never-saved' }] })); - - const outcome = await runExisting(project, options()); - expect(outcome.exitCode).toBe(0); - const record = records.at(-1)!; - expect(record.calls).toBe(1); - expect(record.prefixes[0]).toMatchObject({ - stopReason: 'end-mismatch', - replayedActions: ['tap button "Save marker"'], - totalActions: 1, - }); - const step = resultByTitle(outcome, 'saves the marker').attempts.at(-1)!.steps.find((s) => s.api === 'agent.act')!; - expect(step.cache).toEqual({ - mode: 'agent-concluded', - reason: 'end-mismatch', - replayedActions: 1, - totalActions: 1, - }); - // The executor's pass re-stages the entry with the live anchors: healed. - expect(readOnlyEntry(project).entry.payload.endAnchors).toContainEqual({ - role: 'status', - name: 'Marker', - text: 'saved', - }); - }, 240_000); - - it('evicts the entry when the executor had to act again after an end-mismatch', async () => { - rewriteOnlyEntry(project, (payload) => ({ ...payload, endAnchors: [{ role: 'status', name: 'Marker', text: 'never-saved' }] })); - - const outcome = await runExisting(project, options({ repair: true })); - expect(outcome.exitCode).toBe(0); - const record = records.at(-1)!; - expect(record.prefixes[0]?.stopReason).toBe('end-mismatch'); - // The replayed flow plus its repair is not a flow worth replaying: the - // entry is gone, and the next passing run records a clean one. - expect(readdirSync(cacheDir(project)).filter((name) => name.endsWith('.json'))).toHaveLength(0); - }, 240_000); -}); - -const TRAILING_ACT_SUITE = `import { test } from 'e2e'; - -test('cached step increments twice', async ({ app, agent }) => { - await app.open(); - await agent.act('increment the counter twice'); -}); -`; - -describe('trace cache: only a verification step confirms a write', () => { - let app: FixtureApp; - - beforeAll(async () => { - app = await startFixtureApp(); - }, 60_000); - - afterAll(async () => { - await app?.close(); - }); - - it('never writes the trace of a trailing act nothing asserted on, even though the attempt passed', async () => { - const project = createProject({ 'tests/act.e2e.ts': TRAILING_ACT_SUITE }); - try { - const record: ExecutorRecord = { calls: 0, prefixes: [] }; - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - reporters: ['json'] as const, - agents: { default: { executor: twoTapExecutor(record) } }, - cache: 'read-write' as const, - }, - }); - expect(outcome.exitCode).toBe(0); - expect(record.calls).toBe(1); - expect(existsSync(cacheDir(project))).toBe(false); - } finally { - project.cleanup(); - } - }, 120_000); }); const TOOLS_ONLY_SUITE = `import { test } from 'e2e'; @@ -639,30 +431,7 @@ test('tools-only step', async ({ agent }) => { }); `; -/** Never observes, never acts: the shape of an executor that only uses its own tools. */ -function toolsOnlyExecutor(record: ExecutorRecord): StepExecutor { - return { - name: 'tools-only-executor', - version: 'test', - async runStep(context: StepExecutorContext) { - record.calls += 1; - record.prefixes.push(context.replayedPrefix); - return { status: 'passed' as const, summary: 'done without the screen' }; - }, - }; -} - describe('trace cache: an engine-independent executor is not gated by the cache', () => { - let app: FixtureApp; - - beforeAll(async () => { - app = await startFixtureApp(); - }, 60_000); - - afterAll(async () => { - await app?.close(); - }); - it('skips the end-state observation when the executor recorded no actions', async () => { const project = createProject({ 'tests/tools.e2e.ts': TOOLS_ONLY_SUITE }); let executorFinished = false; @@ -702,31 +471,9 @@ describe('trace cache: an engine-independent executor is not gated by the cache' project.cleanup(); } }); - - it('runs the executor with caching on, without an opened app, and stages nothing', async () => { - const project = createProject({ 'tests/tools.e2e.ts': TOOLS_ONLY_SUITE }); - try { - const record: ExecutorRecord = { calls: 0, prefixes: [] }; - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - reporters: ['json'] as const, - agents: { default: { executor: toolsOnlyExecutor(record) } }, - cache: 'read-write' as const, - }, - }); - expect(outcome.exitCode).toBe(0); - expect(record.calls).toBe(1); - expect(record.prefixes).toEqual([undefined]); - expect(existsSync(cacheDir(project))).toBe(false); - } finally { - project.cleanup(); - } - }, 120_000); }); -describe('trace cache: modes that never write', () => { +describe('trace cache: modes', () => { let app: FixtureApp; beforeAll(async () => { @@ -737,24 +484,6 @@ describe('trace cache: modes that never write', () => { await app?.close(); }); - it('caches by default: a config without a cache key records entries', async () => { - const project = createProject({ 'tests/act.e2e.ts': SUITE }); - try { - const record: ExecutorRecord = { calls: 0, prefixes: [] }; - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - agents: { default: { executor: twoTapExecutor(record) } }, - }, - }); - expect(outcome.exitCode).toBe(0); - expect(readdirSync(cacheDir(project))).toHaveLength(1); - } finally { - project.cleanup(); - } - }, 120_000); - it('--no-cache overrides the config: nothing is recorded, and an entry recorded without it is left untouched', async () => { const project = createProject({ 'tests/act.e2e.ts': SUITE }); const options = (record: ExecutorRecord, noCache: boolean) => ({ @@ -790,68 +519,36 @@ describe('trace cache: modes that never write', () => { } }, 180_000); - it('read-only mode never creates the store', async () => { - const project = createProject({ 'tests/act.e2e.ts': SUITE }); - try { - const record: ExecutorRecord = { calls: 0, prefixes: [] }; - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - agents: { default: { executor: twoTapExecutor(record) } }, - cache: 'read-only' as const, - }, - }); - expect(outcome.exitCode).toBe(0); - expect(record.calls).toBe(1); - expect(existsSync(cacheDir(project))).toBe(false); - } finally { - project.cleanup(); - } - }, 120_000); - - it('CI demotes an unset cache mode to read-only', async () => { + it('caches by default, and CI demotes an unset mode to read-only: it replays the entry and never rewrites it', async () => { const project = createProject({ 'tests/act.e2e.ts': SUITE }); + const options = (record: ExecutorRecord, env?: NodeJS.ProcessEnv) => ({ + appUrl: app.url, + config: { + tests: 'tests/**/*.e2e.ts', + agents: { default: { executor: twoTapExecutor(record) } }, + }, + ...(env === undefined ? {} : { runOptions: { env } }), + }); try { - const record: ExecutorRecord = { calls: 0, prefixes: [] }; - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - agents: { default: { executor: twoTapExecutor(record) } }, - }, - runOptions: { - env: { ...process.env, APP_URL: app.url, CI: '1' }, - }, - }); - expect(outcome.exitCode).toBe(0); - expect(existsSync(cacheDir(project))).toBe(false); - } finally { - project.cleanup(); - } - }, 120_000); + const first = await runExisting(project, options({ calls: 0, prefixes: [] })); + expect(first.exitCode).toBe(0); + expect(readdirSync(cacheDir(project))).toHaveLength(1); - it('CI honors an explicit read-write as the project stating its trust', async () => { - const project = createProject({ 'tests/act.e2e.ts': SUITE }); - try { - const record: ExecutorRecord = { calls: 0, prefixes: [] }; - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - agents: { default: { executor: twoTapExecutor(record) } }, - cache: 'read-write' as const, - }, - runOptions: { - env: { ...process.env, APP_URL: app.url, CI: '1' }, - }, - }); - expect(outcome.exitCode).toBe(0); - expect(existsSync(cacheDir(project))).toBe(true); + // An entry from before the occurrence fields: a read-write replay would complete and rewrite it. + const { file, entry } = readOnlyEntry(project); + const { testId, targetId, instructionDigest } = entry.payload.recordedFor!; + writeFileSync(file, JSON.stringify({ ...entry, payload: { ...entry.payload, recordedFor: { testId, targetId, instructionDigest } } }), 'utf8'); + const legacy = entryFileState(project); + const inCi: ExecutorRecord = { calls: 0, prefixes: [] }; + const second = await runExisting(project, options(inCi, { ...process.env, APP_URL: app.url, CI: '1' })); + expect(second.exitCode).toBe(0); + expect(inCi.calls).toBe(0); + expect(actStep(second).cache).toEqual({ mode: 'self-finalized', replayedActions: 2, totalActions: 2 }); + expect(entryFileState(project)).toEqual(legacy); } finally { project.cleanup(); } - }, 120_000); + }, 180_000); }); const PIN_SUITE = `import { test, expect } from 'e2e'; @@ -923,25 +620,6 @@ describe('trace cache: a bare-point tap replays like a coordinate-driven tool', expect(step.cache).toMatchObject({ mode: 'self-finalized', replayedActions: 1, totalActions: 1 }); expect(step.events.filter((event) => event.kind === 'engine').map((event) => event.name)).toEqual(['tapAt']); }, 240_000); - - it('hands the step to the executor when the recorded viewport is not the live one', async () => { - rewriteOnlyEntry(project, (payload) => { - const [tapAt] = payload.actions; - if (tapAt?.name !== 'tapAt') throw new Error(`expected a tapAt, got ${JSON.stringify(payload.actions)}`); - return { ...payload, actions: [{ ...tapAt, viewport: { width: 390, height: 844 } }] }; - }); - - const outcome = await runExisting(project, options()); - expect(outcome.exitCode).toBe(0); - const record = records.at(-1)!; - expect(record.calls).toBe(1); - // Nothing replayed, so the executor starts from the top with no prefix; the miss carries the reason. - expect(record.prefixes[0]).toBeUndefined(); - const step = resultByTitle(outcome, 'picks the red pin').attempts.at(-1)!.steps.find((s) => s.api === 'agent.act')!; - expect(step.cache).toMatchObject({ mode: 'missed', reason: 'viewport-changed', replayedActions: 0, totalActions: 1 }); - // The pass re-stages the entry with the live viewport: healed. - expect(readOnlyEntry(project).entry.payload.actions[0]).toMatchObject({ viewport: { width: 1280, height: 720 } }); - }, 240_000); }); const TODOS_SUITE = `import { test, expect } from 'e2e'; diff --git a/packages/e2e/tests/integration/agent.test.ts b/packages/e2e/tests/integration/agent.test.ts index 7ae71f86d..1e3e1aa7f 100644 --- a/packages/e2e/tests/integration/agent.test.ts +++ b/packages/e2e/tests/integration/agent.test.ts @@ -420,51 +420,6 @@ describe('agent judgment tier', () => { }); }); -describe('the built-in agent with a judge', () => { - const SUITE = `import { test } from 'e2e'; - -test('the judge judges the built-in agent assertions', async ({ app, agent }) => { - await app.open(); - await agent.assert('the Home heading is visible'); - await agent.assert('the checkout page is visible'); -}); -`; - let app: FixtureApp; - let outcome: RunOutcome; - let project: FixtureProject; - - beforeAll(async () => { - app = await startFixtureApp(); - // The built-in agent's assertions must reach the judgment tier and the - // judge, not its own act loop on the actor model. - const actor = installFakeModel(respond, { modelId: 'actor' }); - const judge = installFakeModel(respond, { modelId: 'judge' }); - const result = await runProject( - { 'tests/judge.e2e.ts': SUITE }, - { appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', agents: { default: { model: actor, judge } } } }, - ); - outcome = result.outcome; - project = result.project; - }, 120_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it('sends both assertions to the judge as single judgments and fails on the false one', () => { - const result = resultByTitle(outcome, 'the judge judges the built-in agent assertions'); - expect(result.status).toBe('failed'); - expect(result.attempts.at(-1)!.error?.code).toBe('ASSERTION_FAILED'); - const judged = fakeCalls.filter((call) => call.schemaName === 'agent-judgment-2'); - expect(judged).toHaveLength(2); - for (const call of judged) expect(call.modelId).toBe('judge'); - const steps = result.attempts.at(-1)!.steps.filter((step) => step.api === 'agent.assert'); - expect(steps.map((step) => step.status)).toEqual(['passed', 'failed']); - for (const step of steps) expect(step.model).toMatchObject({ model: 'judge', calls: 1 }); - }); -}); - describe('an inconclusive judgment after a secret fill', () => { const SUITE = `import { test, credentials } from 'e2e'; diff --git a/packages/e2e/tests/integration/artifact-store-serial.test.ts b/packages/e2e/tests/integration/artifact-store-serial.test.ts index b1e7b7811..2d90848f4 100644 --- a/packages/e2e/tests/integration/artifact-store-serial.test.ts +++ b/packages/e2e/tests/integration/artifact-store-serial.test.ts @@ -7,9 +7,12 @@ * report record carries. Both refs land on the group attempt's record. */ +import { existsSync } from 'node:fs'; +import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { ArtifactStore, StoredArtifact } from '../../src/types.ts'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; +import { assertValidReport } from '../helpers/report-schema.ts'; import { createProject, runExisting, type FixtureProject } from '../helpers/run-project.ts'; const SERIAL_SUITE = `import { test, expect } from 'e2e'; @@ -91,7 +94,13 @@ describe('ArtifactStore across a serial group', () => { for (const put of shots) expect(put.attemptId).toBe(groupAttempt.id); const shotRecords = groupAttempt.artifacts.filter((artifact) => artifact.kind === 'screenshot'); expect(shotRecords).toHaveLength(2); - for (const record of shotRecords) expect(record.ref).toMatch(/^store:\/\/screenshot\//); + for (const record of shotRecords) { + expect(record.ref).toMatch(/^store:\/\/screenshot\//); + expect(existsSync(path.join(project.dir, '.e2e', 'artifacts', record.path!))).toBe(true); + expect(record.size).toBeGreaterThan(0); + expect(record.sha256).toMatch(/^[0-9a-f]{64}$/); + } + assertValidReport(outcome.report); // Every store identity is a real report identity. const reportedIds = new Set(groups.flatMap((group) => group.attempts.map((attempt) => attempt.id))); diff --git a/packages/e2e/tests/integration/cli-boot.test.ts b/packages/e2e/tests/integration/cli-boot.test.ts index 70018a74d..9f2f11c4d 100644 --- a/packages/e2e/tests/integration/cli-boot.test.ts +++ b/packages/e2e/tests/integration/cli-boot.test.ts @@ -34,13 +34,10 @@ const HOOK = [ const WITHOUT_AI = ['--import', `data:text/javascript,${encodeURIComponent(HOOK)}`]; describe('the CLI without the ai package', () => { - it('is really without it: the hook fails the import the way a missing package does', async () => { + it('answers --version and --help', async () => { const script = "await import('ai').then(() => process.stdout.write('resolved'), (error) => process.stdout.write(error.code));"; const { stdout } = await execFileAsync(process.execPath, [...WITHOUT_AI, '--input-type=module', '-e', script]); expect(stdout).toBe('ERR_MODULE_NOT_FOUND'); - }); - - it('answers --version and --help', async () => { const version = await execFileAsync(process.execPath, [...WITHOUT_AI, CLI, '--version']); expect(version.stdout.trim()).toBe(VERSION); const help = await execFileAsync(process.execPath, [...WITHOUT_AI, CLI, '--help']); diff --git a/packages/e2e/tests/integration/cli-init.test.ts b/packages/e2e/tests/integration/cli-init.test.ts index 047059926..ddcbe75e5 100644 --- a/packages/e2e/tests/integration/cli-init.test.ts +++ b/packages/e2e/tests/integration/cli-init.test.ts @@ -154,7 +154,7 @@ describe('initializing standalone projects', () => { }); it.each([undefined, '{}', '{"type":"commonjs"}'])( - 'loads a .ts config and runs .ts tests with their helpers when package.json is %s', + 'loads a .ts config and collects .ts tests with their helpers when package.json is %s, and runs them under commonjs', async (manifest) => { if (manifest !== undefined) writeFileSync(path.join(dir, 'package.json'), manifest); writeFileSync(path.join(dir, 'e2e.config.ts'), CONFIG); @@ -170,6 +170,7 @@ describe('initializing standalone projects', () => { const config = resolveConfig(raw, { projectRoot: dir, env: {} }); const collection = await collect(config); expect(collection.tests.map((test) => test.title)).toEqual(['helpers load as ES modules']); + if (manifest !== '{"type":"commonjs"}') return; const { stdout } = await execFileAsync(process.execPath, [CLI, 'run', '--workers', '1', '--no-cache'], { cwd: dir }); expect(stdout).toContain('1 passed'); @@ -246,25 +247,6 @@ describe('initializing standalone projects', () => { }); }); - it('explains a removed export and a wrong subpath in the config', async () => { - await execFileAsync(process.execPath, [CLI, 'init', '--yes'], { cwd: dir }); - linkPackages('e2e'); - writeFileSync( - path.join(dir, 'e2e.config.ts'), - "import { defineConfig } from 'e2e';\nexport default defineConfig({ targets: [{ name: 'local', platform: 'test' }] });\n", - ); - await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ - code: 'CONFIG_LOAD_FAILED', - message: expect.stringContaining('defineConfig was removed in e2e 0.5'), - }); - // The import must be used, or the TypeScript transform elides it. - writeFileSync(path.join(dir, 'e2e.config.ts'), "import { test } from 'e2e/test';\nexport default { marker: test };\n"); - await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ - code: 'CONFIG_LOAD_FAILED', - message: expect.stringContaining('e2e exports e2e, e2e/agent, e2e/engine'), - }); - }); - it('names look-alike test files when the globs match nothing', async () => { await execFileAsync(process.execPath, [CLI, 'init', '--yes'], { cwd: dir }); linkPackages('e2e', 'web'); @@ -279,48 +261,4 @@ describe('initializing standalone projects', () => { }); }); - it('preserves the original load error', async () => { - writeFileSync(path.join(dir, 'e2e.config.ts'), "throw new Error('config setup failed');\n"); - await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ - code: 'CONFIG_LOAD_FAILED', - message: `failed to load config ${path.join(dir, 'e2e.config.ts')}: config setup failed`, - }); - }); - - it('keeps the code of a configuration error the config throws while it evaluates', async () => { - linkPackages('e2e'); - writeFileSync( - path.join(dir, 'e2e.config.ts'), - "import { ConfigurationError } from 'e2e/engine';\nthrow new ConfigurationError('INVALID_CONFIG', 'web({ video }) was renamed web({ screencast })');\n", - ); - await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ - code: 'INVALID_CONFIG', - message: 'web({ video }) was renamed web({ screencast })', - }); - }); - - it('keeps the code of a configuration error from another copy of e2e, and only a configuration one', async () => { - const foreign = (category: string) => - `const error = new Error('refused by a second copy');\nObject.assign(error, { [Symbol.for('e2e.error.v1')]: true, category: '${category}', code: 'INVALID_CONFIG', retryable: false });\nthrow error;\n`; - writeFileSync(path.join(dir, 'e2e.config.ts'), foreign('configuration')); - await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ - code: 'INVALID_CONFIG', - message: 'refused by a second copy', - }); - writeFileSync(path.join(dir, 'e2e.config.ts'), foreign('test')); - await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ code: 'CONFIG_LOAD_FAILED' }); - }); - - it('loads a config through a symlink', async () => { - const sourceDir = path.join(dir, 'source'); - mkdirSync(sourceDir); - const source = path.join(sourceDir, 'e2e.config.ts'); - writeFileSync(source, CONFIG); - const linked = path.join(dir, 'e2e.config.ts'); - symlinkSync(source, linked, 'file'); - - await expect(loadConfigModule(linked)).resolves.toMatchObject({ - targets: [{ name: 'local', platform: 'test' }], - }); - }); }); diff --git a/packages/e2e/tests/integration/cli-run.test.ts b/packages/e2e/tests/integration/cli-run.test.ts new file mode 100644 index 000000000..b0e7bd2c2 --- /dev/null +++ b/packages/e2e/tests/integration/cli-run.test.ts @@ -0,0 +1,147 @@ +/** + * `e2e run` and `e2e list` through the built CLI on the fake engine, for what + * only the process boundary decides: the exit code a crashed worker maps to, + * shards that split one selection between two machines, and the defaults a + * worker resolves for itself when `CI` is set. + */ + +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { Report1Document } from '../../src/report/build.ts'; +import { assertValidReport } from '../helpers/report-schema.ts'; +import { createProject, type FixtureProject } from '../helpers/run-project.ts'; + +const CLI = fileURLToPath(new URL('../../dist/cli/bin.js', import.meta.url)); +const projects: FixtureProject[] = []; + +afterEach(() => { + for (const project of projects.splice(0)) project.cleanup(); +}); + +/** A config on the fake engine, `behavior` its `createFakeEngine` options, `extra` more config keys. */ +function fakeConfig(behavior = '{}', extra = ''): string { + return `import type { E2EConfig } from 'e2e'; +import { createFakeEngine, FAKE_APP } from '../../helpers/fake-engine.ts'; + +export default { + tests: 'tests/**/*.e2e.ts', + targets: [{ name: 'fake', platform: 'fake', engine: createFakeEngine(${behavior}).engine, app: FAKE_APP }], + workers: 1, + cache: 'off',${extra} +} satisfies E2EConfig; +`; +} + +/** A fixture project removed after the test. */ +function fixture(files: Record): FixtureProject { + const created = createProject(files); + projects.push(created); + return created; +} + +/** Writes a project and runs one CLI command in it; `CI` is unset unless `env` sets it. */ +function cli(files: Record, args: readonly string[], env: Record = {}) { + const created = fixture(files); + return { project: created, ...rerun(created, args, env) }; +} + +/** Runs one more CLI command in an existing project. */ +function rerun(existing: FixtureProject, args: readonly string[], env: Record = {}) { + const result = spawnSync(process.execPath, [CLI, ...args], { + cwd: existing.dir, + env: { ...process.env, CI: '', NO_COLOR: '1', ...env }, + encoding: 'utf8', + timeout: 60_000, + }); + expect(result.error).toBeUndefined(); + return { exitCode: result.status, output: result.stdout + result.stderr, stdout: result.stdout }; +} + +/** The report the last run wrote. */ +function reportOf(ran: FixtureProject): Report1Document { + const report = JSON.parse(readFileSync(path.join(ran.dir, '.e2e', 'report.json'), 'utf8')) as Report1Document; + assertValidReport(report); + return report; +} + +const test = (title: string, body = '') => `import { test } from 'e2e';\ntest('${title}', async ({ app }) => {\n await app.open();\n ${body}\n});\n`; + +describe('the run command through the CLI', () => { + it('exits 3 when a worker dies under a test', () => { + const { project, exitCode } = cli( + { 'e2e.config.ts': fakeConfig(), 'tests/crash.e2e.ts': test('crashes the worker', 'process.exit(7);') }, + ['run'], + ); + expect(exitCode).toBe(3); + const report = reportOf(project); + expect(report.run.exitCode).toBe(3); + expect(report.run.results.map((result) => result.attempts[0]?.error?.code)).toEqual(['WORKER_CRASH']); + }); + + it('splits one selection into shards that cover every test once, each with the setup its tests need', () => { + const files: Record = { + 'e2e.config.ts': fakeConfig('{ state: true }'), + 'tests/auth.setup.e2e.ts': `import { test } from 'e2e';\ntest.setup('sign in', { sessions: ['member'] }, async ({ app, session }) => {\n await app.open();\n await session.save('member');\n});\n`, + }; + for (const name of ['a', 'b', 'c', 'd', 'e']) { + files[`tests/${name}.e2e.ts`] = `import { test } from 'e2e';\ntest('${name}', { session: 'member' }, async ({ app }) => {\n await app.open();\n});\n`; + } + const sharded = fixture(files); + const listed = (shard: string) => { + const { exitCode, stdout } = rerun(sharded, ['list', '--reporter', 'json', '--shard', shard]); + expect(exitCode).toBe(0); + const { pairs } = JSON.parse(stdout) as { pairs: { title: string; kind: string }[] }; + return pairs; + }; + const [first, second] = [listed('1/2'), listed('2/2')]; + const tests = (pairs: { title: string; kind: string }[]) => pairs.filter((pair) => pair.kind === 'test').map((pair) => pair.title); + expect([...tests(first), ...tests(second)].toSorted()).toEqual(['a', 'b', 'c', 'd', 'e']); + expect(tests(first).filter((title) => tests(second).includes(title))).toEqual([]); + expect(tests(first).length).toBeGreaterThan(0); + expect(tests(second).length).toBeGreaterThan(0); + for (const pairs of [first, second]) { + expect(pairs.filter((pair) => pair.kind === 'setup').map((pair) => pair.title)).toEqual(['sign in']); + } + + const ran = rerun(sharded, ['run', '--shard', '2/2']); + expect(ran.exitCode).toBe(0); + const executed = reportOf(sharded).run.results.filter((result) => result.attempts.length > 0); + expect(executed.map((result) => result.titlePath.at(-1)).toSorted()).toEqual(['sign in', ...tests(second)].toSorted()); + }); + + it('under CI, refuses a focused test and records a trace only on the first retry', () => { + const focused = cli( + { 'e2e.config.ts': fakeConfig('{ trace: true }'), 'tests/only.e2e.ts': `import { test } from 'e2e';\ntest.only('focused', async () => {});\ntest('other', async () => {});\n` }, + ['run'], + { CI: 'true' }, + ); + expect(focused.exitCode).toBe(2); + expect(focused.output).toContain('ONLY_IN_CI'); + + const flaky = cli( + { + 'e2e.config.ts': fakeConfig('{ trace: true }'), + 'tests/flaky.e2e.ts': `import { existsSync, writeFileSync } from 'node:fs'; +import { test } from 'e2e'; +test('fails once', { retries: 1 }, async ({ app }) => { + await app.open(); + const marker = new URL('./failed-once', import.meta.url); + if (!existsSync(marker)) { + writeFileSync(marker, ''); + throw new Error('first attempt fails'); + } +}); +`, + }, + ['run'], + { CI: 'true' }, + ); + expect(flaky.exitCode).toBe(0); + const [result] = reportOf(flaky.project).run.results; + expect(result?.status).toBe('flaky'); + expect(result?.attempts.map((attempt) => attempt.artifacts.some((artifact) => artifact.kind === 'trace'))).toEqual([false, true]); + }); +}); diff --git a/packages/e2e/tests/integration/config-load.test.ts b/packages/e2e/tests/integration/config-load.test.ts index 173a87a2f..8f37e3d73 100644 --- a/packages/e2e/tests/integration/config-load.test.ts +++ b/packages/e2e/tests/integration/config-load.test.ts @@ -1,6 +1,6 @@ -/** Loading a config module: a config error it raises while it evaluates keeps its code, and any other throw is CONFIG_LOAD_FAILED. */ +/** Loading a config module: a config error it raises while it evaluates keeps its code, any other throw is CONFIG_LOAD_FAILED with the original message, and a symlinked config loads. */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -50,8 +50,46 @@ describe('loadConfigModule', () => { await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ category: 'infrastructure', code: 'BROWSER_MISSING', message: 'no browser' }); }); - it('reports any other throw while the config evaluates as CONFIG_LOAD_FAILED', async () => { - writeFileSync(path.join(dir, 'e2e.config.ts'), `throw new Error('boom');\nexport default {};\n`); + it('preserves the original load error', async () => { + writeFileSync(path.join(dir, 'e2e.config.ts'), "throw new Error('config setup failed');\n"); + await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ + code: 'CONFIG_LOAD_FAILED', + message: `failed to load config ${path.join(dir, 'e2e.config.ts')}: config setup failed`, + }); + }); + + it('keeps the code of a configuration error the config throws while it evaluates', async () => { + writeFileSync( + path.join(dir, 'e2e.config.ts'), + `import { ConfigurationError } from '${ENGINE}';\nthrow new ConfigurationError('INVALID_CONFIG', 'web({ video }) was renamed web({ screencast })');\n`, + ); + await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ + code: 'INVALID_CONFIG', + message: 'web({ video }) was renamed web({ screencast })', + }); + }); + + it('keeps the code of a configuration error from another copy of e2e, and only a configuration one', async () => { + const foreign = (category: string) => + `const error = new Error('refused by a second copy');\nObject.assign(error, { [Symbol.for('e2e.error.v1')]: true, category: '${category}', code: 'INVALID_CONFIG', retryable: false });\nthrow error;\n`; + writeFileSync(path.join(dir, 'e2e.config.ts'), foreign('configuration')); + await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ + code: 'INVALID_CONFIG', + message: 'refused by a second copy', + }); + writeFileSync(path.join(dir, 'e2e.config.ts'), foreign('test')); await expect(loadConfigModule(path.join(dir, 'e2e.config.ts'))).rejects.toMatchObject({ code: 'CONFIG_LOAD_FAILED' }); }); + + it('loads a config through a symlink', async () => { + const sourceDir = path.join(dir, 'source'); + mkdirSync(sourceDir); + const source = path.join(sourceDir, 'e2e.config.ts'); + writeFileSync(source, "export default { targets: [{ name: 'local', platform: 'test' }] };\n"); + const linked = path.join(dir, 'e2e.config.ts'); + symlinkSync(source, linked, 'file'); + await expect(loadConfigModule(linked)).resolves.toMatchObject({ + targets: [{ name: 'local', platform: 'test' }], + }); + }); }); diff --git a/packages/e2e/tests/integration/config-names.test.ts b/packages/e2e/tests/integration/config-names.test.ts index 804519223..4bbfda564 100644 --- a/packages/e2e/tests/integration/config-names.test.ts +++ b/packages/e2e/tests/integration/config-names.test.ts @@ -1,4 +1,4 @@ -/** Target and agent names are artifact path segments: a name that is only dots is refused before anything runs. */ +/** Target names are artifact path segments: a name that is only dots is refused before anything runs. */ import { existsSync } from 'node:fs'; import path from 'node:path'; @@ -32,20 +32,4 @@ describe('all-dot names', () => { project.cleanup(); } }); - - it('refuses an agent named `.` as INVALID_CONFIG', async () => { - const { outcome, project } = await runProject(SUITE, { - appUrl: APP_URL, - config: { targets: [{ name: 'fake', platform: 'custom', engine: fakeEngine() }], agents: { '.': {} } }, - }); - try { - expect(outcome.exitCode).toBe(2); - expect(outcome.report.run.errors.map((error) => [error.code, error.message])).toEqual([ - ['INVALID_CONFIG', 'invalid agent name ".": names are ASCII letters, numbers, "_", "-", or ".", and cannot be only dots'], - ]); - expect(outcome.results).toHaveLength(0); - } finally { - project.cleanup(); - } - }); }); diff --git a/packages/e2e/tests/integration/engine-contract.test.ts b/packages/e2e/tests/integration/engine-contract.test.ts index 2362854e0..9b0af062d 100644 --- a/packages/e2e/tests/integration/engine-contract.test.ts +++ b/packages/e2e/tests/integration/engine-contract.test.ts @@ -383,7 +383,7 @@ test.describe('wizard', { serial: true, retries: 1 }, () => { ); it( - 'classifies EngineErrors from fixture surfaces (app.open) with the canonical mapping', + 'classifies EngineErrors from fixture surfaces (app.open) with the canonical mapping, and a plain Error as ENGINE_FAILURE', async () => { const failure = createFakeEngine({ onNavigate() { @@ -416,6 +416,23 @@ test.describe('wizard', { serial: true, retries: 1 }, () => { expect(secondResult.attempts[0]!.error?.code).toBe('UNSUPPORTED_CAPABILITY'); expect(second.outcome.exitCode).toBe(2); second.project.cleanup(); + + const plain = createFakeEngine({ + onNavigate() { + throw new TypeError('renderer gone'); + }, + }); + const third = await runProject( + { 'tests/plain-error.e2e.ts': PASSING_TEST }, + { appUrl: APP_URL, config: engineConfig(plain.engine) }, + ); + const thirdResult = resultByTitle(third.outcome, 'taps a node'); + expect(thirdResult.status).toBe('failed'); + expect(thirdResult.attempts[0]!.error?.category).toBe('infrastructure'); + expect(thirdResult.attempts[0]!.error?.code).toBe('ENGINE_FAILURE'); + expect(thirdResult.attempts[0]!.error?.message).toContain('renderer gone'); + expect(third.outcome.exitCode).toBe(3); + third.project.cleanup(); }, 60_000, ); @@ -664,46 +681,6 @@ test('consumes session', { session: 'acct' }, async ({ app }) => { 60_000, ); - it( - 'retries retryable stale nodes against the engine and never repeats a possibly committed action', - async () => { - let performCalls = 0; - const fake = createFakeEngine({ - perform() { - performCalls += 1; - if (performCalls === 1) { - throw engineFailure('NODE_STALE', 'node went stale', true); - } - }, - }); - const { outcome, project } = await runProject( - { 'tests/stale.e2e.ts': PASSING_TEST }, - { appUrl: APP_URL, config: engineConfig(fake.engine) }, - ); - expect(resultByTitle(outcome, 'taps a node').status).toBe('passed'); - expect(performCalls).toBe(2); - - let committedCalls = 0; - const committed = createFakeEngine({ - perform() { - committedCalls += 1; - throw engineFailure('ACTION_MAY_HAVE_COMMITTED', 'maybe committed'); - }, - }); - const second = await runProject( - { 'tests/committed.e2e.ts': PASSING_TEST }, - { appUrl: APP_URL, config: engineConfig(committed.engine) }, - ); - const result = resultByTitle(second.outcome, 'taps a node'); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error?.code).toBe('ACTION_FAILED'); - expect(committedCalls).toBe(1); - project.cleanup(); - second.project.cleanup(); - }, - 60_000, - ); - it( 're-observes when an engine reports a retryable observation failure', async () => { @@ -769,27 +746,6 @@ test('consumes session', { session: 'acct' }, async ({ app }) => { 60_000, ); - it( - 'words the vision hint as a condition on an engine that declares screenshots but has produced no pixels', - async () => { - // The SPI has no pixel-capture declaration: an engine with the artifacts - // capability may still return no pixels from observe, as this one does. - const fake = createFakeEngine({ artifacts: true }); - const model = installFakeModel(() => judgment('inconclusive', 'the tree lists no state for the Submit button')); - const { outcome, project } = await runProject( - { 'tests/observe-inconclusive-artifacts.e2e.ts': OBSERVE_TEST }, - { appUrl: APP_URL, config: engineConfig(fake.engine, { agents: { default: { model } } }) }, - ); - const error = resultByTitle(outcome, 'asserts a node').attempts.at(-1)!.error!; - expect(error.code).toBe('ASSERTION_INCONCLUSIVE'); - expect(error.message).toBe( - 'the tree lists no state for the Submit button; the judge saw the semantic tree only; if the engine captures pixels, pass vision: true when the answer is in pixels', - ); - project.cleanup(); - }, - 60_000, - ); - it( 'leaves vision out of an inconclusive judgment once a pixel request of the attempt was degraded', async () => { @@ -833,12 +789,13 @@ test('asserts after a degraded pixel request', async ({ app, agent }) => { async () => { // An observation is handed whatever remains of the invocation deadline, // so one starting near the end cannot finish. The engine failure that - // follows describes a truncated budget, not a broken app. + // follows describes a truncated budget, not a broken app. Only the + // second poll stalls, so the failure evidence after it observes at once. let observeCalls = 0; const fake = createFakeEngine({ async observe(operation) { observeCalls += 1; - if (observeCalls === 1) return; + if (observeCalls !== 2) return; await new Promise((resolve) => setTimeout(resolve, operation.timeoutMs + 50)); throw engineFailure('ENGINE_FAILURE', 'observation ran out of budget'); }, @@ -875,29 +832,6 @@ test('asserts after a degraded pixel request', async ({ app, agent }) => { 60_000, ); - it( - 'normalizes a plain Error thrown by an engine member to infrastructure ENGINE_FAILURE', - async () => { - const fake = createFakeEngine({ - onNavigate() { - throw new TypeError('renderer gone'); - }, - }); - const { outcome, project } = await runProject( - { 'tests/plain-error.e2e.ts': PASSING_TEST }, - { appUrl: APP_URL, config: engineConfig(fake.engine) }, - ); - const result = resultByTitle(outcome, 'taps a node'); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error?.category).toBe('infrastructure'); - expect(result.attempts[0]!.error?.code).toBe('ENGINE_FAILURE'); - expect(result.attempts[0]!.error?.message).toContain('renderer gone'); - expect(outcome.exitCode).toBe(3); - project.cleanup(); - }, - 60_000, - ); - it( 'ends the attempt when session restore fails after it started', async () => { @@ -1020,7 +954,7 @@ test('needs web', { requires: ['web'] }, async () => {}); ); it( - 'registers an engine screenshot as an attempt artifact and reports unsupported gestures honestly', + 'registers an engine screenshot as an attempt artifact', async () => { const fake = createFakeEngine({ artifacts: true }); const file = `import { test } from 'e2e'; @@ -1029,10 +963,6 @@ test('takes evidence', async ({ app }) => { await app.open('/'); await app.screenshot('after-open'); }); - -test('swipes without a swipe capability', async ({ screen }) => { - await screen.swipe({ direction: 'down' }); -}); `; const { outcome, project } = await runProject( { 'tests/evidence.e2e.ts': file }, @@ -1042,10 +972,6 @@ test('swipes without a swipe capability', async ({ screen }) => { expect(evidence.status).toBe('passed'); expect(evidence.attempts[0]!.artifacts.some((artifact) => artifact.kind === 'screenshot')).toBe(true); expect(fake.operations.some((op) => op.method === 'artifacts.screenshot(after-open)')).toBe(true); - const swipes = resultByTitle(outcome, 'swipes without a swipe capability'); - expect(swipes.status).toBe('failed'); - expect(swipes.attempts[0]!.error?.code).toBe('UNSUPPORTED_CAPABILITY'); - expect(swipes.attempts[0]!.error?.message).toContain('swipe'); project.cleanup(); }, 60_000, @@ -1223,20 +1149,6 @@ test('fails on purpose', async ({ app }) => { 60_000, ); - it( - 'records every retry with on-all-retries', - async () => { - const fake = createFakeEngine({ video: true }); - const { outcome, project } = await runProject( - { 'tests/fail.e2e.ts': FAILING_TEST }, - { appUrl: APP_URL, config: engineConfig(fake.engine, { video: 'on-all-retries', retries: 2 }) }, - ); - expect([0, 1, 2].map((attempt) => videosOf(outcome, 'fails on purpose', attempt).length)).toEqual([0, 1, 1]); - project.cleanup(); - }, - 60_000, - ); - it( "lets a test's own video win over --video, and a target's win over the config", async () => { diff --git a/packages/e2e/tests/integration/engine-target.test.ts b/packages/e2e/tests/integration/engine-target.test.ts index 12933eca3..5ea94571b 100644 --- a/packages/e2e/tests/integration/engine-target.test.ts +++ b/packages/e2e/tests/integration/engine-target.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest'; -import { defineEngine, type EngineFixtureContext } from '../../src/engine/index.ts'; +import { defineEngine } from '../../src/engine/index.ts'; import type { StepExecutor } from '../../src/agent/executor.ts'; import type { SemanticNode } from '../../src/engine/surface.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; @@ -67,41 +67,15 @@ export default { } satisfies E2EConfig; `; -const FIXTURE_SUITE = `import { test, expect } from 'e2e'; - -test('a contributed fixture runs with harness discipline', async (fixtures) => { - const device = (fixtures as unknown as { device: { reset(): Promise; shake(): Promise } }).device; - await expect(await device.reset()).toBe('reset-done'); - await expect(await device.shake()).toBe('shaken'); -}); -`; - -const STATE_SUITE = `import { test, expect } from 'e2e'; - -test.setup('seed the counter', { sessions: ['seeded'] }, async ({ screen, session }) => { - await screen.getByRole('button', { name: 'Increment' }).tap(); - await session.save('seeded'); -}); - -test('restores the seeded counter', { session: 'seeded' }, async ({ screen }) => { - await expect(screen.getByRole('status')).toHaveText('1'); -}); -`; - - /** A two-node screen: a counter value and a button that increments it. */ function toyEngine( options: { withLocate?: boolean; - withFixtures?: boolean; - withState?: boolean; - withIsolation?: boolean; withoutInit?: boolean; withPrepare?: 'ok' | 'fail' | 'hang-finish'; } = {}, ) { const lifecycle: string[] = []; - const fixtureCalls: string[] = []; let initEnv: Record = {}; let count = 0; const nodes = (): SemanticNode[] => [ @@ -146,17 +120,6 @@ function toyEngine( info.log(`booted ${info.env['TOY_POOL']?.split(',')[0] ?? 'no device'}`); }, }), - ...(options.withIsolation !== true - ? {} - : { - async startAttempt(context: { attemptId: string; artifactsDir: string }) { - lifecycle.push(`startAttempt:${context.artifactsDir.length > 0 ? 'dir' : 'nodir'}`); - count = 0; - }, - async endAttempt() { - lifecycle.push('endAttempt'); - }, - }), async dispose() { lifecycle.push('dispose'); }, @@ -188,37 +151,8 @@ function toyEngine( ); }, }), - ...(options.withFixtures !== true - ? {} - : { - fixtures: { - device: (context: EngineFixtureContext) => context.fixture('device', { - async reset() { - fixtureCalls.push(`reset:${context.targetName}`); - count = 0; - return 'reset-done'; - }, - async shake() { - fixtureCalls.push('shake'); - return 'shaken'; - }, - }, { reset: { kind: 'resource' }, shake: { kind: 'resource' } }), - }, - }), - ...(options.withState !== true - ? {} - : { - state: { - async capture() { - return { format: 'toy', version: 1, data: { count } }; - }, - async restore(state: { data: unknown }) { - count = (state.data as { count: number }).count; - }, - }, - }), }); - return { initEnv: () => initEnv, engine, lifecycle, fixtureCalls, current: () => count }; + return { initEnv: () => initEnv, engine, lifecycle, current: () => count }; } /** Observes, taps until the counter reads the goal, verifies, concludes. */ @@ -287,116 +221,8 @@ describe('engine targets', () => { env: { ...process.env, APP_URL: '', CI: '' }, quiet: true, }); - expect(outcome.exitCode).not.toBe(0); - const result = outcome.results[0]; - const message = result?.attempts.at(-1)?.error?.message ?? ''; - expect(message).toMatch(/no engine capability|UNSUPPORTED/i); - } finally { - project.cleanup(); - } - }); - - it('runs the deterministic screen/expect tier over engine.locate', async () => { - const toy = toyEngine({ withLocate: true }); - const project = createProject({ 'tests/screen.e2e.ts': DETERMINISTIC_SUITE }); - try { - const outcome = await run({ - cwd: project.dir, - rawConfig: { - targets: [{ name: 'toy-sim', platform: 'ios', engine: toy.engine }], - cache: 'off', - }, - env: { ...process.env, APP_URL: '', CI: '' }, - quiet: true, - }); - expect(outcome.exitCode).toBe(0); - expect(toy.current()).toBe(2); - assertValidReport(outcome.report); - } finally { - project.cleanup(); - } - }); - - it('runs a contributed fixture with harness step discipline', async () => { - const toy = toyEngine({ withFixtures: true }); - const project = createProject({ 'tests/fixture.e2e.ts': FIXTURE_SUITE }); - try { - const outcome = await run({ - cwd: project.dir, - rawConfig: { - targets: [{ name: 'toy-sim', platform: 'ios', engine: toy.engine }], - cache: 'off', - }, - env: { ...process.env, APP_URL: '', CI: '' }, - quiet: true, - }); - expect(outcome.exitCode).toBe(0); - expect(outcome.exitCode).toBe(0); - expect(toy.fixtureCalls).toEqual(['reset:toy-sim', 'shake']); - // Every contributed call is a recorded step, named .. - const steps = outcome.results[0]?.attempts[0]?.steps ?? []; - const names = steps.map((step) => step.api); - expect(names).toContain('device.reset'); - expect(names).toContain('device.shake'); - assertValidReport(outcome.report); - } finally { - project.cleanup(); - } - }); - - it('captures and restores opaque engine state across a session', async () => { - const toy = toyEngine({ withLocate: true, withState: true }); - const project = createProject({ 'tests/state.e2e.ts': STATE_SUITE }); - try { - const outcome = await run({ - cwd: project.dir, - rawConfig: { - targets: [{ name: 'toy-sim', platform: 'ios', engine: toy.engine }], - cache: 'off', - }, - env: { ...process.env, APP_URL: '', CI: '' }, - quiet: true, - }); - expect(outcome.exitCode).toBe(0); - assertValidReport(outcome.report); - } finally { - project.cleanup(); - } - }); - - - it('resets per-attempt state via startAttempt/endAttempt isolation', async () => { - const toy = toyEngine({ withLocate: true, withIsolation: true }); - const suite = `import { test, expect } from 'e2e'; - -test('first attempt starts fresh', async ({ screen }) => { - await screen.getByRole('button', { name: 'Increment' }).tap(); - await expect(screen.getByRole('status')).toHaveText('1'); -}); - -test('second attempt also starts fresh', async ({ screen }) => { - await screen.getByRole('button', { name: 'Increment' }).tap(); - await expect(screen.getByRole('status')).toHaveText('1'); -}); -`; - const project = createProject({ 'tests/iso.e2e.ts': suite }); - try { - const outcome = await run({ - cwd: project.dir, - rawConfig: { - targets: [{ name: 'toy-sim', platform: 'ios', engine: toy.engine }], - cache: 'off', - }, - env: { ...process.env, APP_URL: '', CI: '' }, - quiet: true, - }); - expect(outcome.exitCode).toBe(0); - expect(toy.lifecycle.filter((e) => e.startsWith('startAttempt'))).toEqual([ - 'startAttempt:dir', - 'startAttempt:dir', - ]); - expect(toy.lifecycle.filter((e) => e === 'endAttempt')).toHaveLength(2); - assertValidReport(outcome.report); + expect(outcome.exitCode).toBe(2); + expect(outcome.results[0]?.attempts.at(-1)?.error).toMatchObject({ code: 'UNSUPPORTED_CAPABILITY', category: 'configuration' }); } finally { project.cleanup(); } @@ -577,31 +403,4 @@ test('second attempt also starts fresh', async ({ screen }) => { project.cleanup(); } }); - - it('gates an undeclared fixture at selection via requires', async () => { - const toy = toyEngine(); // no fixtures declared - const project = createProject({ - 'tests/req.e2e.ts': `import { test } from 'e2e'; - -test('needs device', { requires: ['device'] }, async () => {}); -`, - }); - try { - const outcome = await run({ - cwd: project.dir, - rawConfig: { - targets: [{ name: 'toy-sim', platform: 'ios', engine: toy.engine }], - cache: 'off', - }, - env: { ...process.env, APP_URL: '', CI: '' }, - quiet: true, - passWithNoTests: true, - }); - const result = outcome.results[0]; - expect(result?.status).toBe('skipped'); - expect(result?.skip?.cause).toBe('capability-unavailable'); - } finally { - project.cleanup(); - } - }); }); diff --git a/packages/e2e/tests/integration/expect-poll-scope.test.ts b/packages/e2e/tests/integration/expect-poll-scope.test.ts index bb72e508c..21c9180ca 100644 --- a/packages/e2e/tests/integration/expect-poll-scope.test.ts +++ b/packages/e2e/tests/integration/expect-poll-scope.test.ts @@ -1,10 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; import { resultByTitle, runProjectWithConfigFile, - workerConfigSource, + workerFakeConfigSource, type FixtureProject, type RunOutcome, } from '../helpers/run-project.ts'; @@ -22,13 +21,8 @@ test('an unawaited poll fails the body that started it', async () => { .toBe(true); }); -test('an unawaited poll that would pass still fails the body', async () => { - let reads = 0; - expect.poll(() => ++reads > 3, { interval: 50 }).toBe(true); -}); - test('a body error stays primary with the unawaited poll beside it', async () => { - expect.poll(() => false, { timeout: 400 }).toBe(true); + expect.poll(() => false, { timeout: 200 }).toBe(true); throw new Error('the body gave up'); }); @@ -43,7 +37,7 @@ test('an awaited passing poll passes', async () => { test('the test after them is never charged, and the abandoned poll read no more', async () => { const before = counters.abandonedReads; - await new Promise((resolve) => setTimeout(resolve, 800)); + await new Promise((resolve) => setTimeout(resolve, 500)); expect(counters.abandonedReads).toBe(before); }); `; @@ -52,27 +46,27 @@ const HOOKS_SUITE = `import { test, expect } from 'e2e'; test.describe('afterEach', () => { test.afterEach(async () => { - expect.poll(() => false, { timeout: 400 }).toBe(true); + expect.poll(() => false, { timeout: 200 }).toBe(true); }); test('owns the afterEach poll', async () => {}); }); test.describe('beforeEach', () => { test.beforeEach(async () => { - expect.poll(() => false, { timeout: 400 }).toBe(true); + expect.poll(() => false, { timeout: 200 }).toBe(true); }); test('owns the beforeEach poll', async () => {}); }); test.describe('afterAll', () => { test.afterAll(async () => { - expect.poll(() => false, { timeout: 400 }).toBe(true); + expect.poll(() => false, { timeout: 200 }).toBe(true); }); test('runs before the afterAll poll', async () => {}); }); test('the test after the hooks is never charged', async () => { - await new Promise((resolve) => setTimeout(resolve, 800)); + await new Promise((resolve) => setTimeout(resolve, 500)); }); `; @@ -82,7 +76,7 @@ const LEAKY_FIXTURE_SUITE = `import { test as base, expect } from 'e2e'; const test = base.extend<{ leaky: number }>({ leaky: async (_fixtures, use) => { await use(1); - expect.poll(() => false, { timeout: 400 }).toBe(true); + expect.poll(() => false, { timeout: 200 }).toBe(true); }, }); @@ -96,7 +90,7 @@ const TIDY_FIXTURE_SUITE = `import { test as base, expect } from 'e2e'; const test = base.extend<{ tidy: number }>({ tidy: async (_fixtures, use) => { await use(1); - await expect.poll(() => true, { timeout: 400 }).toBe(true); + await expect.poll(() => true, { timeout: 200 }).toBe(true); }, }); @@ -109,38 +103,48 @@ const BEFORE_ALL_SUITE = `import { test, expect } from 'e2e'; test.describe('beforeAll', () => { test.beforeAll(async () => { - expect.poll(() => false, { timeout: 400 }).toBe(true); + expect.poll(() => false, { timeout: 200 }).toBe(true); }); test('sits under the beforeAll poll', async () => {}); }); test('the test after the beforeAll is never charged', async () => { - await new Promise((resolve) => setTimeout(resolve, 800)); + await new Promise((resolve) => setTimeout(resolve, 500)); }); `; /** A body the timeout cut keeps running; the poll it starts later is its own, never the next test's. */ const TIMED_OUT_SUITE = `import { test, expect } from 'e2e'; -test('times out, then polls from its leftover body', { timeout: 1000 }, async () => { - await new Promise((resolve) => setTimeout(resolve, 1500)); - await expect.poll(() => false, { timeout: 2000 }).toBe(true); +test('times out, then polls from its leftover body', { timeout: 300 }, async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + await expect.poll(() => false, { timeout: 500 }).toBe(true); }); test('runs while the leftover body polls, and is never charged', async () => { - await new Promise((resolve) => setTimeout(resolve, 1500)); + await new Promise((resolve) => setTimeout(resolve, 1000)); +}); +`; + +const STEPS_SUITE = `import { test } from 'e2e'; + +test('a step call without await', async ({ app }) => { + app.open(); +}); + +test('a step call without await before the body throws', async ({ app }) => { + app.open(); + throw new Error('the body gave up'); }); `; const NOT_AWAITED = /returned before expect\.poll\(\.\.\.\)\.toBe\(\.\.\.\) finished; put `await` in front of every expect\.poll call$/; describe('expect.poll not awaited, in a run', () => { - let app: FixtureApp; let outcome: RunOutcome; let project: FixtureProject; beforeAll(async () => { - app = await startFixtureApp(); ({ outcome, project } = await runProjectWithConfigFile( { 'tests/a-body.e2e.ts': BODY_SUITE, @@ -149,14 +153,14 @@ describe('expect.poll not awaited, in a run', () => { 'tests/b-tidy-fixture.e2e.ts': TIDY_FIXTURE_SUITE, 'tests/c-before-all.e2e.ts': BEFORE_ALL_SUITE, 'tests/d-timed-out.e2e.ts': TIMED_OUT_SUITE, + 'tests/e-steps.e2e.ts': STEPS_SUITE, }, - { appUrl: app.url, configSource: workerConfigSource(1) }, + { configSource: workerFakeConfigSource(1) }, )); }, 180_000); afterAll(async () => { project?.cleanup(); - await app?.close(); }); it('produces a schema-valid report and fails the run', () => { @@ -175,17 +179,28 @@ describe('expect.poll not awaited, in a run', () => { expect(attempt.secondaryErrors).toEqual([]); }); - it('fails the body for an unawaited poll that would have passed', () => { - const attempt = resultByTitle(outcome, 'an unawaited poll that would pass still fails the body').attempts[0]!; - expect(attempt.error?.code).toBe('STEP_NOT_AWAITED'); - }); - it('keeps the body error primary and notes the unawaited poll beside it', () => { const attempt = resultByTitle(outcome, 'a body error stays primary with the unawaited poll beside it').attempts[0]!; expect(attempt.error).toMatchObject({ code: 'ERROR', message: 'the body gave up' }); expect(attempt.secondaryErrors.map((error) => [error.code, error.phase])).toEqual([['STEP_NOT_AWAITED', 'body']]); }); + it('fails a body that returns before a step it started finished with STEP_NOT_AWAITED', () => { + const attempt = resultByTitle(outcome, 'a step call without await').attempts[0]!; + expect(attempt.error).toMatchObject({ code: 'STEP_NOT_AWAITED', phase: 'body' }); + expect(attempt.error?.message).toContain('app.open'); + expect(attempt.error?.source?.file).toBe('tests/e-steps.e2e.ts'); + expect(attempt.steps).toHaveLength(1); + expect(attempt.steps[0]).toMatchObject({ api: 'app.open', status: 'failed', error: { code: 'STEP_NOT_AWAITED' } }); + }); + + it('keeps the body error primary and notes the un-awaited step beside it', () => { + const attempt = resultByTitle(outcome, 'a step call without await before the body throws').attempts[0]!; + expect(attempt.error).toMatchObject({ code: 'ERROR', message: 'the body gave up' }); + expect(attempt.secondaryErrors.map((error) => error.code)).toEqual(['STEP_NOT_AWAITED']); + expect(attempt.steps[0]).toMatchObject({ api: 'app.open', status: 'failed', error: { code: 'STEP_NOT_AWAITED' } }); + }); + it('leaves awaited polls alone', () => { const failing = resultByTitle(outcome, 'an awaited failing poll fails with its own error').attempts[0]!; expect(failing.error?.code).toBe('ASSERTION_FAILED'); diff --git a/packages/e2e/tests/integration/expect-soft.test.ts b/packages/e2e/tests/integration/expect-soft.test.ts index a8f7a723d..9916baef3 100644 --- a/packages/e2e/tests/integration/expect-soft.test.ts +++ b/packages/e2e/tests/integration/expect-soft.test.ts @@ -1,24 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; import { resultByTitle, runProject, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; -const SOFT_SUITE = `import { test } from '@e2e-dev/web'; -import { expect } from 'e2e'; +const SOFT_SUITE = `import { test, expect } from 'e2e'; -test('soft failures let the body run on', async ({ app, screen, browser }) => { +test('soft failures let the body run on', async ({ app, screen }) => { await app.open(); expect.soft(1, 'the count').toBe(2); - await expect.soft(screen.getByRole('heading', { name: 'Nowhere' })).toBeVisible({ timeout: 300 }); - await expect.soft(browser).toHaveURL('/nowhere', { timeout: 300 }); + await expect.soft(screen.getByRole('button', { name: 'Submit' })).toBeHidden({ timeout: 300 }); expect.soft('ok').toBe('ok'); - await expect(screen.getByRole('heading', { name: 'Home' })).toBeVisible(); -}); - -test('every soft matcher passing keeps the attempt green', async ({ app, screen }) => { - await app.open(); - expect.soft({ id: 1, name: 'Ada' }).toMatchObject({ id: expect.any(Number) }); - await expect.soft(screen.getByRole('heading', { name: 'Home' })).toBeVisible(); + await expect(screen.getByRole('button', { name: 'Submit' })).toBeVisible(); }); test('a body error stays primary over the soft failures', async ({ app }) => { @@ -27,7 +18,7 @@ test('a body error stays primary over the soft failures', async ({ app }) => { throw new Error('the body gave up'); }); -test('a timed-out body still settles its soft failures', { timeout: 1000 }, async ({ app }) => { +test('a timed-out body still settles its soft failures', { timeout: 300 }, async ({ app }) => { await app.open(); expect.soft(1).toBe(2); await new Promise(() => {}); @@ -45,18 +36,15 @@ test.describe('soft in a hook', () => { `; describe('expect.soft in a run', () => { - let app: FixtureApp; let outcome: RunOutcome; let project: FixtureProject; beforeAll(async () => { - app = await startFixtureApp(); - ({ outcome, project } = await runProject({ 'tests/soft.e2e.ts': SOFT_SUITE }, { appUrl: app.url })); + ({ outcome, project } = await runProject({ 'tests/soft.e2e.ts': SOFT_SUITE }, {})); }, 180_000); afterAll(async () => { project?.cleanup(); - await app?.close(); }); it('produces a schema-valid report', () => { @@ -70,29 +58,21 @@ describe('expect.soft in a run', () => { const attempt = result.attempts[0]!; expect(attempt.error).toMatchObject({ code: 'ASSERTION_FAILED', phase: 'body' }); const lines = attempt.error!.message.split('\n'); - expect(lines[0]).toBe('3 soft assertions failed'); + expect(lines[0]).toBe('2 soft assertions failed'); expect(lines[1]).toBe('1. the count: expected 1 to be 2'); - expect(lines[2]).toBe('2. expect.toBeVisible failed'); - expect(lines.filter((line) => line.startsWith(' locator: ') && line.includes('Nowhere'))).toHaveLength(1); - expect(attempt.error!.message).toMatch(/\n3\. expect\.toHaveURL failed/); + expect(lines[2]).toBe('2. expect.toBeHidden failed'); + expect(lines.filter((line) => line.startsWith(' locator: ') && line.includes('Submit'))).toHaveLength(1); expect(attempt.error?.source?.file).toBe('tests/soft.e2e.ts'); - // The failed locator and web matchers are recorded steps; the hard matcher after them still ran and passed. + // The failed locator matcher is a recorded step; the hard matcher after it still ran and passed. expect(attempt.steps.map((step) => [step.api, step.status])).toEqual([ ['app.open', 'passed'], - ['expect.toBeVisible', 'failed'], - ['expect.toHaveURL', 'failed'], + ['expect.toBeHidden', 'failed'], ['expect.toBeVisible', 'passed'], ]); expect(attempt.steps[1]?.error?.code).toBe('ASSERTION_FAILED'); expect(attempt.secondaryErrors).toEqual([]); }); - it('passes when every soft matcher passes', () => { - const result = resultByTitle(outcome, 'every soft matcher passing keeps the attempt green'); - expect(result.status).toBe('passed'); - expect(result.attempts[0]!.steps.map((step) => step.status)).toEqual(['passed', 'passed']); - }); - it('keeps the body error primary and notes the soft failures beside it', () => { const result = resultByTitle(outcome, 'a body error stays primary over the soft failures'); expect(result.status).toBe('failed'); diff --git a/packages/e2e/tests/integration/explore.test.ts b/packages/e2e/tests/integration/explore.test.ts index 05834ecc9..ffe03385c 100644 --- a/packages/e2e/tests/integration/explore.test.ts +++ b/packages/e2e/tests/integration/explore.test.ts @@ -11,7 +11,6 @@ import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { defineTool } from '../../src/agent/public.ts'; import type { StepExecutor } from '../../src/agent/executor.ts'; -import type { AgentConfig } from '../../src/types.ts'; import { explore, type ExploreOptions, type ExploreOutcome } from '../../src/explore/index.ts'; import { FINDING_TOOL_NAME } from '../../src/explore/executor.ts'; import type { ModelInstance } from '../../src/types.ts'; @@ -65,15 +64,14 @@ async function runExplore( project: FixtureProject, app: FixtureApp, model: ModelInstance, - options: Partial & { projectAgent?: AgentConfig | undefined } = {}, + options: Partial = {}, ): Promise { - const { projectAgent, ...rest } = options; const notices: string[] = []; const outcome = await explore({ cwd: project.dir, rawConfig: { targets: [{ name: 'web', engine: web(), app: { url: app.url } }] as never, - agents: { default: projectAgent ?? { model } }, + agents: { default: { model } }, // The scripted loop answers instantly; the deterministic engine budget is the one that matters. actionTimeout: 10_000, }, @@ -81,7 +79,7 @@ async function runExplore( maxSteps: 2, timeoutMs: 180_000, notice: (message) => notices.push(message), - ...rest, + ...options, }); return Object.assign(outcome, { notices }); } @@ -191,9 +189,8 @@ describe('e2e explore', () => { expect(written.run.explore).toEqual(record); }, 120_000); - it('passes with warnings only, and offers the project tools of the agents entry', async () => { + it('passes with warnings only', async () => { let plans = 0; - const seen: string[][] = []; const model = installExploreModel({ plan: () => { plans += 1; @@ -202,24 +199,13 @@ describe('e2e explore', () => { : { decision: 'finish', summary: 'Only polish items.' }; }, loop: (call) => { - seen.push([...call.toolNames]); - if (call.turn === 1) return [{ toolName: 'ping', input: {} }]; - if (call.turn === 2) return [{ toolName: FINDING_TOOL_NAME, input: { ...COUNTER_FINDING, kind: 'warning', severity: 1 } }]; + if (call.turn === 1) return [{ toolName: FINDING_TOOL_NAME, input: { ...COUNTER_FINDING, kind: 'warning', severity: 1 } }]; return [{ toolName: 'complete_step', input: { status: 'passed', summary: 'Menu toggles' } }]; }, }); - const ping = defineTool( - { description: 'answers pong', inputSchema: z.object({}), execute: async () => 'pong' }, - { mutates: false }, - ); - const outcome = await runExplore(project, app, model, { - projectAgent: { model, tools: { ping }, system: 'Project guidance line.' }, - }); + const outcome = await runExplore(project, app, model); expect(outcome.exitCode).toBe(0); expect(outcome.status).toBe('passed'); - expect(seen[0]).toContain('ping'); - expect(seen[0]).toContain(FINDING_TOOL_NAME); - expect(loopCalls.at(-1)!.toolResults).toContain('pong'); expect(outcome.report.run.explore!.findings[0]).toMatchObject({ kind: 'warning', severity: 1 }); expect((outcome as unknown as { notices: string[] }).notices).toEqual([]); }, 120_000); @@ -298,26 +284,6 @@ describe('e2e explore', () => { ]); }, 120_000); - it('is blocked when the agent explores nothing, and replaces a custom executor with a notice', async () => { - const model = installExploreModel({ - plan: () => ({ decision: 'finish', summary: 'Nothing here.' }), - loop: () => [{ toolName: 'complete_step', input: { status: 'passed', summary: 'unused' } }], - }); - const custom: StepExecutor = { - name: 'house-brain', - runStep: async () => ({ status: 'passed', summary: 'never runs' }), - }; - const outcome = await runExplore(project, app, model, { - projectAgent: { model, executor: custom }, - }); - expect(outcome.status).toBe('blocked'); - expect(outcome.report.run.explore).toMatchObject({ ended: 'finished', steps: [], findings: [] }); - expect(outcome.report.run.results[0]!.attempts[0]!.error?.code).toBe('AUTOMATION_UNSUPPORTED'); - expect((outcome as unknown as { notices: string[] }).notices).toEqual([ - 'agents.default is a custom executor ("house-brain"); explore runs the built-in agent instead', - ]); - }, 120_000); - it.each([ { firstStatus: 'blocked', expectedStatus: 'blocked', exitCode: 3 }, { firstStatus: 'passed', expectedStatus: 'passed', exitCode: 0 }, @@ -395,6 +361,7 @@ describe('e2e explore', () => { }, loop: (call) => { seen.push([...call.toolNames]); + if (call.turn === 1) return [{ toolName: 'ping', input: {} }]; return [{ toolName: 'complete_step', input: { status: 'passed', summary: 'looked' } }]; }, }); @@ -421,6 +388,7 @@ describe('e2e explore', () => { // The ux agent's tool is in the explorer's vocabulary. expect(seen[0]).toContain('ping'); expect(seen[0]).toContain(FINDING_TOOL_NAME); + expect(loopCalls.at(-1)!.toolResults).toContain('pong'); await expect( explore({ cwd: project.dir, rawConfig: { targets: [{ name: 'web', engine: web(), app: { url: app.url } }] as never, agents: { default: { model } } }, agent: 'nope' }), @@ -441,6 +409,27 @@ describe('e2e explore', () => { expect(notices).toContain('agents.custom is a custom executor ("math-brain"); explore runs the built-in agent instead'); }, 60_000); + it('replaces a custom executor with the built-in agent on the model it brought, and is blocked when nothing is explored', async () => { + const model = installExploreModel({ + plan: () => ({ decision: 'finish', summary: 'Nothing here.' }), + loop: () => [{ toolName: 'complete_step', input: { status: 'passed', summary: 'unused' } }], + }); + const custom: StepExecutor = { name: 'house-brain', model, runStep: async () => ({ status: 'passed', summary: 'never runs' }) }; + const notices: string[] = []; + const outcome = await explore({ + cwd: project.dir, + rawConfig: { targets: [{ name: 'web', engine: web(), app: { url: app.url } }] as never, agents: { default: { executor: custom } } }, + goal: 'Explore the home page and find bugs', + maxSteps: 2, + timeoutMs: 180_000, + notice: (message) => notices.push(message), + }); + expect(outcome.status).toBe('blocked'); + expect(outcome.report.run.explore).toMatchObject({ ended: 'finished', steps: [], findings: [] }); + expect(outcome.report.run.results[0]!.attempts[0]!.error?.code).toBe('AUTOMATION_UNSUPPORTED'); + expect(notices).toEqual(['agents.default is a custom executor ("house-brain"); explore runs the built-in agent instead, with the model that executor brought']); + }, 120_000); + it('lets the explorer sign in with a configured credential through type_secret, never seeing the password', async () => { const planPrompts: string[] = []; let fillResult: string | undefined; diff --git a/packages/e2e/tests/integration/extended-fixtures.test.ts b/packages/e2e/tests/integration/extended-fixtures.test.ts index aadb8532e..05920a548 100644 --- a/packages/e2e/tests/integration/extended-fixtures.test.ts +++ b/packages/e2e/tests/integration/extended-fixtures.test.ts @@ -1,21 +1,12 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; +import { describe, expect, it, vi } from 'vitest'; +import { createFakeEngine } from '../helpers/fake-engine.ts'; +import { engineConfig } from '../helpers/fixture-config.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; import { resultByTitle, runProject } from '../helpers/run-project.ts'; describe('test.extend fixtures', () => { - let app: FixtureApp; - - beforeAll(async () => { - app = await startFixtureApp(); - }); - - afterAll(async () => { - await app?.close(); - }); - it( 'sets fixtures up in order, hands them to hooks and the body, and tears them down last first', async () => { @@ -77,18 +68,21 @@ silent('never calls use', async ({ silent }) => { log('unreachable:' + silent); }); -const clash = base.extend<{ browser: string }>({ - browser: async (_fixtures, use) => { - await use('not the browser'); +const clash = base.extend<{ gadget: string }>({ + gadget: async (_fixtures, use) => { + await use('not the gadget'); }, }); -clash('redefines an engine fixture', async ({ browser }) => { - log('unreachable:' + String(browser)); +clash('redefines an engine fixture', async ({ gadget }) => { + log('unreachable:' + String(gadget)); }); `; const logPath = path.join('/tmp', `e2e-extend-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/extend.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject( + { 'tests/extend.e2e.ts': file }, + { config: engineConfig(createFakeEngine({ fixtures: true }).engine) }, + ); assertValidReport(outcome.report); const passes = resultByTitle(outcome, 'sees the values'); @@ -115,17 +109,17 @@ clash('redefines an engine fixture', async ({ browser }) => { expect(clash.status).toBe('failed'); expect(clash.attempts[0]!.error?.phase).toBe('beforeEach'); expect(clash.attempts[0]!.error?.code).toBe('TEST_SETUP_FAILED'); - expect(clash.attempts[0]!.error?.message).toContain('fixture "browser" is contributed by engine web'); + expect(clash.attempts[0]!.error?.message).toContain('fixture "gadget" is contributed by engine fake'); expect(readFileSync(logPath, 'utf8').trim().split('\n')).toEqual([ - 'setup:first:web', + 'setup:first:fake', 'setup:second:one', 'beforeEach:one-two', 'body:one:one-two', 'afterEach:one', 'teardown:second', 'teardown:first', - 'setup:first:web', + 'setup:first:fake', 'setup:second:one', 'beforeEach:one-two', 'body:failing:one-two', @@ -151,19 +145,19 @@ const log = (entry: string) => appendFileSync(process.env.HOOK_LOG!, entry + '\\ const test = base.extend<{ slow: string }>({ slow: async (_fixtures, use) => { log('setup:slow'); - await new Promise((resolve) => setTimeout(resolve, 2500)); + await new Promise((resolve) => setTimeout(resolve, 1000)); await use('slow'); log('teardown:slow'); }, }); -test('times out in setup', { timeout: 1000 }, async ({ slow }) => { +test('times out in setup', { timeout: 500 }, async ({ slow }) => { log('unreachable:' + slow); }); `; const logPath = path.join('/tmp', `e2e-extend-late-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/late.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/late.e2e.ts': file }, {}); const result = resultByTitle(outcome, 'times out in setup'); expect(result.status).toBe('timed-out'); expect(result.attempts[0]!.error?.phase).toBe('beforeEach'); diff --git a/packages/e2e/tests/integration/interrupt-signals.test.ts b/packages/e2e/tests/integration/interrupt-signals.test.ts index f9612b3da..9e103be8c 100644 --- a/packages/e2e/tests/integration/interrupt-signals.test.ts +++ b/packages/e2e/tests/integration/interrupt-signals.test.ts @@ -2,7 +2,8 @@ * Ctrl-C against the built CLI, the way a terminal delivers it: SIGINT to the * whole process group, runner and workers alike. The engine is a file-logging * fake declared in the fixture config, so the test can see, from outside, - * whether the engine was disposed and whether the worker outlived the runner. + * whether the engine was disposed, whether the runner released what its + * `prepare` acquired, and whether the worker outlived the runner. */ import { spawn, type ChildProcess } from 'node:child_process'; @@ -41,6 +42,8 @@ export default { name: 'signal-fake', version: '1.0.0', spiVersion: 1, + async prepare() { log('prepare'); }, + async finish() { log('finish'); }, async init() { log('init'); }, async startAttempt() { log('startAttempt'); }, async endAttempt() { log('endAttempt'); }, @@ -270,7 +273,7 @@ describe('interrupt signals against the CLI', () => { expect(code).toBe(130); // Well inside the 60 s test timeout: the interrupt, not the timeout, ended the body. expect(elapsed).toBeLessThan(20_000); - expect(run.events()).toEqual(['init', 'startAttempt', 'test.start', 'endAttempt', 'dispose']); + expect(run.events()).toEqual(['prepare', 'init', 'startAttempt', 'test.start', 'endAttempt', 'dispose', 'finish']); expect(run.output()).toContain('interrupted: stopping the running test'); expect(alive(run.workerPid)).toBe(false); }), @@ -322,6 +325,7 @@ describe('interrupt signals against the CLI', () => { const pid = run.workerPid; await waitFor(() => !alive(pid), CLEANUP_TIMEOUT_MS + 5_000, 'the worker to exit'); expect(run.events()).toContain('dispose'); + expect(run.events().filter((event) => event === 'finish')).toEqual(['finish']); }), 60_000, ); diff --git a/packages/e2e/tests/integration/list-reporter.test.ts b/packages/e2e/tests/integration/list-reporter.test.ts index 59b0a1db1..bc4e95aff 100644 --- a/packages/e2e/tests/integration/list-reporter.test.ts +++ b/packages/e2e/tests/integration/list-reporter.test.ts @@ -4,56 +4,11 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { describe, expect, it, vi } from 'vitest'; -import type { StepExecutor } from '../../src/agent/executor.ts'; import { defineEngine } from '../../src/engine/index.ts'; -import { visibleWidth } from '../../src/report/format.ts'; import { runProject, type FixtureProject } from '../helpers/run-project.ts'; import { snapshot } from '../helpers/snapshot.ts'; -/** Forty CJK glyphs: eighty columns, over the step label's budget of seventy-two. */ -const WIDE_LABEL = '日'.repeat(40); - -/** An executor that passes every step without a model, so the run needs neither a browser nor a key. */ -const passing: StepExecutor = { name: 'passing', async runStep() { return { status: 'passed', summary: 'done' }; } }; - describe('list reporter output', () => { - it('clips a wide-glyph agent step label to its 72-column budget on the permanent step line', async () => { - const written: string[] = []; - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { - written.push(String(chunk)); - return true; - }); - let project: FixtureProject | undefined; - try { - const run = await runProject( - { 'tests/wide.e2e.ts': `import { test } from 'e2e'; - test('acts on a wide label', async ({ agent }) => { await agent.act(${JSON.stringify(WIDE_LABEL)}); });` }, - { - appUrl: 'http://127.0.0.1:4599', - config: { - targets: [{ name: 'fake', platform: 'custom', engine: defineEngine({ name: 'fake', version: '1', spiVersion: 1, observe: async () => snapshot([]) }) }], - agents: { default: { executor: passing } }, - cache: 'off', - }, - runOptions: { quiet: false }, - }, - ); - project = run.project; - stdoutWrite.mockRestore(); - expect(run.outcome.status).toBe('passed'); - const stepLines = stripVTControlCharacters(written.join('')).split('\n').filter((line) => line.includes('agent.act "')); - expect(stepLines.length).toBeGreaterThan(0); - for (const line of stepLines) { - const label = /"([^"]*)"/.exec(line)?.[1]; - expect(label).toBe(`${'日'.repeat(35)}…`); - expect(visibleWidth(label!)).toBe(71); - } - } finally { - stdoutWrite.mockRestore(); - project?.cleanup(); - } - }); - it('tallies a --repeat-each run: the tests that passed every run, and the flaky runs of the one that did not with the code each retry recovered from', async () => { const written: string[] = []; const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { diff --git a/packages/e2e/tests/integration/managed-process.test.ts b/packages/e2e/tests/integration/managed-process.test.ts index ba2dc3ef9..c78c0eb2c 100644 --- a/packages/e2e/tests/integration/managed-process.test.ts +++ b/packages/e2e/tests/integration/managed-process.test.ts @@ -41,20 +41,12 @@ async function isReachable(url: string): Promise { } describe('ManagedProcess', () => { - it('starts the app, waits for readiness, and stops the process group', async () => { - const port = await freePort(); - const app = nodeApp(port); - await app.start(); - expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(true); - await app.stop(); - expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(false); - }); - - it('stop is idempotent and safe before start', async () => { + it('starts the app, waits for readiness, and stops the process group, idempotently and safely before start', async () => { const port = await freePort(); const app = nodeApp(port); await app.stop(); await app.start(); + expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(true); await app.stop(); await app.stop(); expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(false); @@ -106,7 +98,7 @@ describe('ManagedProcess', () => { { executable: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'], - startupTimeout: 1_500, + startupTimeout: 500, shutdownTimeout: 2_000, }, os.tmpdir(), @@ -127,37 +119,4 @@ describe('ManagedProcess', () => { expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(false); }); - it('without reuseExisting, a server another process already runs fails the launch before spawning', async () => { - const port = await freePort(); - const devServer = nodeApp(port); - await devServer.start(); - try { - const failure = await nodeApp(port) - .start() - .catch((error: unknown) => error); - expect(failure).toBeInstanceOf(InfrastructureError); - expect((failure as InfrastructureError).code).toBe('APP_ALREADY_RUNNING'); - expect((failure as InfrastructureError).message).toContain(`http://127.0.0.1:${port}/ already answered before app.command started`); - expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(true); - } finally { - await devServer.stop(); - } - }); - - it('with reuseExisting, attaches to a server another process already runs and never stops it', async () => { - const port = await freePort(); - const devServer = nodeApp(port); - await devServer.start(); - try { - // Without reuse this spawn would lose the port; with it, the command is never started. - const app = nodeApp(port, { reuseExisting: true }); - await app.start(); - expect(app.spawned).toBe(false); - await app.stop(); - expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(true); - } finally { - await devServer.stop(); - } - expect(await isReachable(`http://127.0.0.1:${port}/`)).toBe(false); - }); }); diff --git a/packages/e2e/tests/integration/matcher-options.test.ts b/packages/e2e/tests/integration/matcher-options.test.ts deleted file mode 100644 index 47f7e8a31..000000000 --- a/packages/e2e/tests/integration/matcher-options.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Playwright's matcher options through the real CLI and browser. The CLI - * transpiles without type-checking, so an option a matcher does not take - * reaches the runtime: the ones e2e implements must change the verdict the - * way Playwright's do, and every other one must fail the call instead of - * leaving the matcher to pass on its default. - */ - -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; -import { resultByTitle, runProject, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; - -const SUITE = `import { test } from '@e2e-dev/web'; -import { expect } from 'e2e'; - -const soon = { timeout: 300 }; -// Built at runtime, the way a JavaScript test or a shared helper passes options. -const flag = (name, value) => JSON.parse(JSON.stringify({ [name]: value, ...soon })); - -test('state flags expect the opposite state', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByLabel('Notifications')).toBeChecked({ checked: false }); - await expect(screen.getByLabel('Notifications')).toBeChecked(flag('checked', false)); - await expect(screen.getByText('Hidden content')).toBeVisible({ visible: false }); - await expect(screen.getByText('Never rendered')).toBeAttached({ attached: false }); - await expect(screen.getByRole('button', { name: 'Disabled action' })).toBeEnabled({ enabled: false }); - await screen.getByLabel('Notifications').check(); - await expect(screen.getByLabel('Notifications')).toBeChecked({ checked: true }); -}); - -test('ignoreCase matches text in any case', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByRole('heading')).toHaveText('HOME', { ignoreCase: true }); - await expect(screen.getByRole('heading')).toContainText('OM', flag('ignoreCase', true)); - await expect(screen.getByRole('heading')).not.toContainText('home', soon); -}); - -test('checked false fails on a checked box', async ({ app, screen }) => { - await app.open(); - await screen.getByLabel('Notifications').check(); - await expect(screen.getByLabel('Notifications')).toBeChecked(flag('checked', false)); -}); - -test('visible false fails on a visible heading', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByRole('heading')).toBeVisible(flag('visible', false)); -}); - -test('enabled false fails on an enabled button', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByRole('button', { name: 'Increment' })).toBeEnabled(flag('enabled', false)); -}); - -test('attached false fails on a hidden node', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByText('Hidden content')).toBeAttached(flag('attached', false)); -}); - -test('a negated ignoreCase match fails on the same text in another case', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByRole('heading')).not.toContainText('home', flag('ignoreCase', true)); -}); - -test('an option toBeChecked does not take is refused', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByLabel('Notifications')).toBeChecked(flag('indeterminate', true)); -}); - -test('an option toHaveTitle does not take is refused', async ({ app, browser }) => { - await app.open(); - await expect(browser).toHaveTitle('', flag('ignoreCase', true)); -}); - -test('an option waitForURL does not take is refused', async ({ app, browser }) => { - await app.open(); - await browser.waitForURL('/', flag('waitUntil', 'load')); -}); -`; - -describe('matcher options in a browser', () => { - let app: FixtureApp; - let outcome: RunOutcome; - let project: FixtureProject; - - beforeAll(async () => { - app = await startFixtureApp(); - ({ outcome, project } = await runProject( - { 'tests/options.e2e.ts': SUITE }, - { appUrl: app.url, config: { actionTimeout: 5_000, assertionTimeout: 4_000, timeout: 30_000 } }, - )); - }, 240_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it.each(['state flags expect the opposite state', 'ignoreCase matches text in any case'])('%s', (title) => { - const result = resultByTitle(outcome, title); - expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); - }); - - it.each([ - ['checked false fails on a checked box', 'expected: unchecked'], - ['visible false fails on a visible heading', 'expected: hidden or absent'], - ['enabled false fails on an enabled button', 'expected: disabled'], - ['attached false fails on a hidden node', 'expected: detached'], - ['a negated ignoreCase match fails on the same text in another case', 'expected: not text containing "home" (ignoring case)'], - ])('%s', (title, expected) => { - const result = resultByTitle(outcome, title); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error).toMatchObject({ code: 'ASSERTION_FAILED', message: expect.stringContaining(expected) }); - }); - - it.each([ - ['an option toBeChecked does not take is refused', 'expect.toBeChecked options has no key "indeterminate"'], - ['an option toHaveTitle does not take is refused', 'expect.toHaveTitle options has no key "ignoreCase"'], - ['an option waitForURL does not take is refused', 'browser.waitForURL options has no key "waitUntil"'], - ])('%s', (title, message) => { - const result = resultByTitle(outcome, title); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error).toMatchObject({ code: 'INVALID_ARGUMENT', message: expect.stringContaining(message) }); - }); -}); diff --git a/packages/e2e/tests/integration/mcp-custom.test.ts b/packages/e2e/tests/integration/mcp-custom.test.ts deleted file mode 100644 index 04eacaf40..000000000 --- a/packages/e2e/tests/integration/mcp-custom.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * `e2e mcp` with a project that has two targets, a custom engine with fewer - * verbs, and project tools from `agents.default.tools`: the server's tool - * list stays the same four, each session's catalog is what its own target - * can do (the engine's verbs, the project tools for its platform), and a - * second config in the same project opens without restarting the server. A - * project tool named like a built-in never gets here: config load refuses it. - */ - -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Client } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; -import { createProject, type FixtureProject } from '../helpers/run-project.ts'; - -const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url)); -const CLI = path.join(PACKAGE_ROOT, 'dist', 'cli', 'bin.js'); - -const KIOSK = `import { createFakeEngine } from '../../helpers/fake-engine.ts'; - -export const kiosk = createFakeEngine({ - tree: { ref: { id: 'root', revision: '' }, role: 'root', children: [ - { ref: { id: 'k-1', revision: '' }, role: 'button', name: 'Start order', states: { hidden: false } }, - { ref: { id: 'k-2', revision: '' }, role: 'textbox', name: 'Table', states: { hidden: false } }, - ] }, -}); -`; - -const CONFIG = `import type { E2EConfig } from 'e2e'; -import { defineTool, getToolContext } from 'e2e/agent'; -import { web } from '@e2e-dev/web'; -import { z } from 'zod'; -import { kiosk } from './kiosk.ts'; - -export default { - targets: [ - { name: 'web', platform: 'web', engine: web(), app: { url: process.env.APP_URL! } }, - { name: 'kiosk', platform: 'kiosk', engine: kiosk.engine }, - ], - agents: { default: { - tools: { - seed_data: defineTool( - { description: 'Seed a tenant with demo data.', inputSchema: z.object({ tenant: z.string() }), execute: async ({ tenant }: { tenant: string }) => \`Seeded \${tenant}.\` }, - { mutates: true }, - ), - count_nodes: defineTool( - { description: 'Count the nodes on screen.', inputSchema: z.object({}), execute: async (_input: unknown, options: object) => \`\${(await getToolContext(options).observe()).text.split('\\\\n').length} nodes\` }, - { mutates: false }, - ), - shake: defineTool( - { description: 'Shake the phone.', inputSchema: z.object({}), execute: async () => 'shaken' }, - { mutates: true, platforms: ['ios'] }, - ), - kiosk_reset: defineTool( - { description: 'Reset the kiosk.', inputSchema: z.object({}), execute: async () => 'kiosk reset' }, - { mutates: true, platforms: ['kiosk'] }, - ), - }, - } }, -} satisfies E2EConfig; -`; - -/** A second project config beside the first: one target, no project tools. */ -const KIOSK_ONLY_CONFIG = `import type { E2EConfig } from 'e2e'; -import { kiosk } from './kiosk.ts'; - -export default { - targets: [{ name: 'kiosk-only', platform: 'kiosk', engine: kiosk.engine }], -} satisfies E2EConfig; -`; - -interface ToolText { - readonly text: string; - readonly isError: boolean; -} - -describe('e2e mcp with project tools and a custom engine', { timeout: 120_000 }, () => { - let app: FixtureApp; - let project: FixtureProject; - let client: Client; - - const invoke = async (name: string, args: Record = {}): Promise => { - const result = (await client.callTool({ name, arguments: args }, { timeout: 110_000 })) as { - content: { type: string; text?: string }[]; - isError?: boolean; - }; - return { - text: result.content.filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n'), - isError: result.isError === true, - }; - }; - const call = (tool: string, args?: Record): Promise => invoke('call', { tool, ...(args === undefined ? {} : { args }) }); - const catalogNames = (text: string): string[] => - text - .split('\n') - .filter((line) => line.startsWith('- ')) - .map((line) => /^- (\S+?)(?: \{|:)/.exec(line)![1]!); - - beforeAll(async () => { - app = await startFixtureApp(); - project = createProject({ 'e2e.config.ts': CONFIG, 'kiosk.ts': KIOSK, 'kiosk.config.ts': KIOSK_ONLY_CONFIG }); - const transport = new StdioClientTransport({ - command: process.execPath, - args: [CLI, 'mcp', '--headless'], - cwd: project.dir, - env: { ...(process.env as Record), APP_URL: app.url, CI: '' }, - stderr: 'ignore', - }); - client = new Client({ name: 'e2e-mcp-custom-test', version: '0.0.0' }); - await client.connect(transport); - }, 60_000); - - afterAll(async () => { - await client?.close().catch(() => undefined); - project?.cleanup(); - await app?.close(); - }); - - it('serves the same four tools whatever the project declares', async () => { - const { tools } = await client.listTools(); - expect(tools.map((tool) => tool.name)).toEqual(['open_session', 'tools', 'call', 'close_session']); - }); - - it('needs a target name when the config declares several', async () => { - const result = await invoke('open_session'); - expect(result.isError).toBe(true); - expect(result.text).toContain('TARGET_REQUIRED'); - expect(result.text).toContain('web, kiosk'); - }); - - it('catalogs the project tools of a web session, runs them, and skips the foreign ones', async () => { - const opened = await invoke('open_session', { target: 'web' }); - expect(opened.isError, opened.text).toBe(false); - expect(catalogNames(opened.text)).toEqual([ - 'observe', - 'tap', - 'double_tap', - 'long_press', - 'right_click', - 'hover', - 'scroll_to', - 'type', - 'press', - 'select', - 'check', - 'drag', - 'upload', - 'scroll', - 'navigate', - 'back', - 'screenshot', - 'tap_at', - 'hover_at', - 'type_at', - 'press_at', - 'select_at', - 'locate', - 'start_recording', - 'stop_recording', - 'seed_data', - 'count_nodes', - ]); - expect(opened.text).toContain('- seed_data {tenant}: Seed a tenant with demo data.'); - expect(opened.text).toContain('- count_nodes: Count the nodes on screen. [read-only]'); - const detail = await invoke('tools', { tool: 'locate' }); - expect(detail.text).toContain('locator'); - - const seeded = await call('seed_data', { tenant: 'acme' }); - expect(seeded.isError, seeded.text).toBe(false); - expect(seeded.text).toBe('Seeded acme.'); - const counted = await call('count_nodes'); - expect(counted.isError, counted.text).toBe(false); - expect(counted.text).toMatch(/^\d+ nodes$/); - - const foreign = await call('kiosk_reset'); - expect(foreign.isError).toBe(true); - expect(foreign.text).toContain('UNKNOWN_TOOL'); - - const closed = await invoke('close_session'); - expect(closed.isError, closed.text).toBe(false); - }); - - it('drives a custom engine with the verbs it declares and nothing more', async () => { - const opened = await invoke('open_session', { target: 'kiosk' }); - expect(opened.isError, opened.text).toBe(false); - expect(opened.text).toContain('platform kiosk, engine fake'); - expect(opened.text).toContain('button "Start order"'); - const names = catalogNames(opened.text); - expect(names).toContain('kiosk_reset'); - expect(names).not.toContain('scroll'); - - const observed = await call('observe'); - const id = /#(\S+) button "Start order"/.exec(observed.text)?.[1]; - expect(id).toBeDefined(); - const tapped = await call('tap', { target: id! }); - expect(tapped.isError, tapped.text).toBe(false); - expect(tapped.text).toContain(`Tapped #${id}.`); - expect(tapped.text).toMatch(/Screen changes since revision|The screen did not change|No listed node changed/); - - // The fake declares no viewport swipe, so scroll is a verb this target lacks. - const scrolled = await call('scroll', { direction: 'down' }); - expect(scrolled.isError).toBe(true); - expect(scrolled.text).toContain('UNSUPPORTED_CAPABILITY'); - expect(scrolled.text).toContain('declares no such action'); - - const reset = await call('kiosk_reset'); - expect(reset.isError, reset.text).toBe(false); - expect(reset.text).toBe('kiosk reset'); - - const closed = await invoke('close_session'); - expect(closed.isError, closed.text).toBe(false); - expect(closed.text).not.toContain('Cleanup:'); - }); - - it('opens another config of the same project without a restart', async () => { - const opened = await invoke('open_session', { config: 'kiosk.config.ts' }); - expect(opened.isError, opened.text).toBe(false); - expect(opened.text).toContain('open on target "kiosk-only"'); - expect(opened.text).toContain('config '); - expect(opened.text).toContain('kiosk.config.ts'); - // The fake declares every node action but no swipe, no back hook, and no pointer kinds: no scroll, no back, and the point tools ride the node verbs. - expect(catalogNames(opened.text)).toEqual([ - 'observe', - 'tap', - 'double_tap', - 'long_press', - 'right_click', - 'hover', - 'scroll_to', - 'type', - 'press', - 'select', - 'check', - 'drag', - 'upload', - 'navigate', - 'screenshot', - 'tap_at', - 'hover_at', - 'type_at', - 'press_at', - 'select_at', - 'locate', - ]); - const closed = await invoke('close_session'); - expect(closed.isError, closed.text).toBe(false); - }); -}); diff --git a/packages/e2e/tests/integration/mcp-server.test.ts b/packages/e2e/tests/integration/mcp-server.test.ts index 5f3ddf6e4..c1d09f291 100644 --- a/packages/e2e/tests/integration/mcp-server.test.ts +++ b/packages/e2e/tests/integration/mcp-server.test.ts @@ -3,13 +3,13 @@ * a real MCP client. The server's tool list is four tools; everything the * session can do is a catalog behind `call`. Covers the catalog, a live * session driven through `call`, argument validation, the secret and pixel - * invariants, a video recording, a second session open beside the first, a - * second session after the first closed, an explicit config path, and stdout - * hygiene: a config that prints to stdout must not corrupt - * the protocol. + * invariants, a second session open beside the first, a second session after + * the first closed, an explicit config path, a config with two targets, a + * custom engine, and project tools (each session's catalog is what its own + * target can do), and stdout hygiene: a config that prints to stdout must not + * corrupt the protocol. */ -import { readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Client } from '@modelcontextprotocol/client'; @@ -55,6 +55,49 @@ export default { } satisfies E2EConfig; `; +const KIOSK = `import { createFakeEngine } from '../../helpers/fake-engine.ts'; + +export const kiosk = createFakeEngine({ + tree: { ref: { id: 'root', revision: '' }, role: 'root', children: [ + { ref: { id: 'k-1', revision: '' }, role: 'button', name: 'Start order', states: { hidden: false } }, + { ref: { id: 'k-2', revision: '' }, role: 'textbox', name: 'Table', states: { hidden: false } }, + ] }, +}); +`; + +// A third config: two targets, one a custom engine with fewer verbs, and +// project tools scoped by platform. +const CUSTOM_CONFIG = `import type { E2EConfig } from 'e2e'; +import { defineTool, getToolContext } from 'e2e/agent'; +import { z } from 'zod'; +import { targets } from './targets.ts'; +import { kiosk } from './kiosk.ts'; + +export default { + targets: [...targets, { name: 'kiosk', platform: 'kiosk', engine: kiosk.engine }], + agents: { default: { + tools: { + seed_data: defineTool( + { description: 'Seed a tenant with demo data.', inputSchema: z.object({ tenant: z.string() }), execute: async ({ tenant }: { tenant: string }) => \`Seeded \${tenant}.\` }, + { mutates: true }, + ), + count_nodes: defineTool( + { description: 'Count the nodes on screen.', inputSchema: z.object({}), execute: async (_input: unknown, options: object) => \`\${(await getToolContext(options).observe()).text.split('\\\\n').length} nodes\` }, + { mutates: false }, + ), + shake: defineTool( + { description: 'Shake the phone.', inputSchema: z.object({}), execute: async () => 'shaken' }, + { mutates: true, platforms: ['ios'] }, + ), + kiosk_reset: defineTool( + { description: 'Reset the kiosk.', inputSchema: z.object({}), execute: async () => 'kiosk reset' }, + { mutates: true, platforms: ['kiosk'] }, + ), + }, + } }, +} satisfies E2EConfig; +`; + interface ToolText { readonly text: string; readonly isError: boolean; @@ -92,10 +135,17 @@ describe('e2e mcp', { timeout: 120_000 }, () => { }; const catalogLines = (text: string): string[] => text.split('\n').filter((line) => line.startsWith('- ')); + const catalogNames = (text: string): string[] => catalogLines(text).map((line) => /^- (\S+?)(?: \{|:)/.exec(line)![1]!); beforeAll(async () => { app = await startFixtureApp(); - project = createProject({ 'e2e.config.ts': CONFIG, 'targets.ts': TARGETS, 'protected.config.ts': PROTECTED_CONFIG }); + project = createProject({ + 'e2e.config.ts': CONFIG, + 'targets.ts': TARGETS, + 'protected.config.ts': PROTECTED_CONFIG, + 'kiosk.ts': KIOSK, + 'custom.config.ts': CUSTOM_CONFIG, + }); transport = new StdioClientTransport({ command: process.execPath, args: [CLI, 'mcp', '--headless'], @@ -169,34 +219,36 @@ describe('e2e mcp', { timeout: 120_000 }, () => { expect(opened.text).toContain(`App: ${app.url}/`); expect(opened.text).toContain('Credentials: "admin" (username "admin", password secret "admin.password")'); expect(opened.text).toContain('Tools (run one with call {tool, args}; tools {tool} shows a tool\'s arguments):'); - expect(catalogLines(opened.text)).toEqual([ - expect.stringMatching(/^- observe: Look at the whole current screen.* \[read-only\]$/), - expect.stringMatching(/^- tap \{target\}: Tap or click one node: the gesture for a button, link, menu item, tab, checkbox, row, or field\.$/), - expect.stringMatching(/^- double_tap \{target\}: Double-click one node: only for an item that opens or enters editing on the second click/), - expect.stringMatching(/^- long_press \{target\}: Press one node and hold: only for a control with a long-press menu or action\.$/), - expect.stringMatching(/^- right_click \{target\}: Right-click one node to open its context menu, and only for that\.$/), - expect.stringMatching(/^- hover \{target\}: Move the pointer over one node without clicking/), - expect.stringMatching(/^- scroll_to \{target\?, text\?, direction\?\}: Scroll until a node is inside the viewport/), - expect.stringMatching(/^- type \{target\?, value, replace\?\}: Type a plain-text value into one input node, or into whatever has focus when target is omitted\.$/), - expect.stringMatching(/^- press \{target\?, key, times\?\}: /), - expect.stringMatching(/^- select \{target, value\}: /), - expect.stringMatching(/^- check \{target, checked\?\}: Set a checkbox, switch, or radio to a state/), - expect.stringMatching(/^- drag \{target, to\}: Drag one node and drop it on another/), - expect.stringMatching(/^- upload \{target, files\}: Attach one or more files to a file input node\.$/), - expect.stringMatching(/^- scroll \{direction, target\?, times\?\}: /), - expect.stringMatching(/^- navigate \{url\}: /), - expect.stringMatching(/^- back: Go back one step/), - expect.stringMatching(/^- screenshot: Attach a screenshot of the current viewport.* \[read-only\]$/), - expect.stringMatching(/^- tap_at \{x, y\}: Tap a point in the latest screenshot/), - expect.stringMatching(/^- hover_at \{x, y\}: Move the pointer to a point in the latest screenshot/), - expect.stringMatching(/^- type_at \{x, y, value, replace\?\}: Type a plain-text value into the field at a point/), - expect.stringMatching(/^- press_at \{x, y, key\}: Send one key/), - expect.stringMatching(/^- select_at \{x, y, value\}: Pick one option/), - expect.stringMatching(/^- type_secret \{target, name\}: /), - expect.stringMatching(/^- locate \{role\?, name\?, text\?, label\?, placeholder\?, testId\?, exact\?\}: .* \[read-only\]$/), - expect.stringMatching(/^- start_recording \{name\?\}: Start recording a video of the app, for a person to watch: .*\.$/), - expect.stringMatching(/^- stop_recording: Stop the running recording and save it: .*\.$/), + expect(catalogNames(opened.text)).toEqual([ + 'observe', + 'tap', + 'double_tap', + 'long_press', + 'right_click', + 'hover', + 'scroll_to', + 'type', + 'press', + 'select', + 'check', + 'drag', + 'upload', + 'scroll', + 'navigate', + 'back', + 'screenshot', + 'tap_at', + 'hover_at', + 'type_at', + 'press_at', + 'select_at', + 'type_secret', + 'locate', + 'start_recording', + 'stop_recording', ]); + expect(opened.text).toMatch(/^- tap \{target\}: Tap or click one node: the gesture for a button, link, menu item, tab, checkbox, row, or field\.$/m); + expect(opened.text).toMatch(/^- locate \{role\?, name\?, text\?, label\?, placeholder\?, testId\?, exact\?\}: .* \[read-only\]$/m); expect(opened.text).toMatch(/Current screen \(revision b\d+, path \/, \d+ nodes\):/); expect(opened.text).toContain('button "Increment"'); const sessionId = /^Session (\S+) open/.exec(opened.text)![1]!; @@ -319,32 +371,6 @@ describe('e2e mcp', { timeout: 120_000 }, () => { expect(gone.text).toContain('the previous session ended: closed by the agent'); }); - it('records the app between start_recording and stop_recording, and saves a recording still running at close', async () => { - const opened = await invoke('open_session'); - expect(opened.isError, opened.text).toBe(false); - const sessionId = /^Session (\S+) open/.exec(opened.text)![1]!; - const recordings = path.join(project.dir, '.e2e', 'videos', sessionId); - - const started = await call('start_recording', { name: 'counter' }); - expect(started.isError, started.text).toBe(false); - const observed = await call('observe'); - await call('tap', { target: nodeId(observed.text, /button "Increment"/) }); - const stopped = await call('stop_recording'); - expect(stopped.isError, stopped.text).toBe(false); - expect(stopped.text).toMatch(/^Recording 1 "counter" stopped after \d+\.\d s\.\n- \S+\.webm$/); - const file = path.join(recordings, '1-counter.webm'); - expect(stopped.text.endsWith(`- ${file}`)).toBe(true); - expect(statSync(file).size).toBeGreaterThan(0); - - await call('start_recording'); - const closed = await invoke('close_session'); - expect(closed.isError, closed.text).toBe(false); - expect(closed.text).toMatch(/\nRecording 2 stopped after \d+\.\d s\.\n- \S+$/); - expect(closed.text.endsWith(`- ${path.join(recordings, '2.webm')}`)).toBe(true); - expect(closed.text).not.toContain('Cleanup:'); - expect(readdirSync(recordings).toSorted()).toEqual(['1-counter.webm', '2.webm']); - }); - it('opens a second session after the first closed, on an explicit config path, and names an unknown target', async () => { const unknown = await invoke('open_session', { target: 'nope' }); expect(unknown.isError).toBe(true); @@ -404,6 +430,57 @@ describe('e2e mcp', { timeout: 120_000 }, () => { expect(closed.isError, closed.text).toBe(false); }); + it('needs a target name when the config declares several', async () => { + const result = await invoke('open_session', { config: 'custom.config.ts' }); + expect(result.isError).toBe(true); + expect(result.text).toContain('TARGET_REQUIRED'); + expect(result.text).toContain('web, kiosk'); + }); + + it('catalogs and runs the project tools of a custom engine target, with only the verbs it declares', async () => { + const opened = await invoke('open_session', { config: 'custom.config.ts', target: 'kiosk' }); + expect(opened.isError, opened.text).toBe(false); + expect(opened.text).toContain('platform kiosk, engine fake'); + expect(opened.text).toContain('button "Start order"'); + expect(opened.text).toContain('- seed_data {tenant}: Seed a tenant with demo data.'); + expect(opened.text).toContain('- count_nodes: Count the nodes on screen. [read-only]'); + const names = catalogNames(opened.text); + expect(names).toEqual(expect.arrayContaining(['seed_data', 'count_nodes', 'kiosk_reset'])); + expect(names).not.toContain('shake'); + expect(names).not.toContain('scroll'); + + const seeded = await call('seed_data', { tenant: 'acme' }); + expect(seeded.isError, seeded.text).toBe(false); + expect(seeded.text).toBe('Seeded acme.'); + const counted = await call('count_nodes'); + expect(counted.isError, counted.text).toBe(false); + expect(counted.text).toMatch(/^\d+ nodes$/); + const reset = await call('kiosk_reset'); + expect(reset.isError, reset.text).toBe(false); + expect(reset.text).toBe('kiosk reset'); + const foreign = await call('shake'); + expect(foreign.isError).toBe(true); + expect(foreign.text).toContain('UNKNOWN_TOOL'); + + const observed = await call('observe'); + const id = /#(\S+) button "Start order"/.exec(observed.text)?.[1]; + expect(id).toBeDefined(); + const tapped = await call('tap', { target: id! }); + expect(tapped.isError, tapped.text).toBe(false); + expect(tapped.text).toContain(`Tapped #${id}.`); + expect(tapped.text).toMatch(/Screen changes since revision|The screen did not change|No listed node changed/); + + // The fake declares no viewport swipe, so scroll is a verb this target lacks. + const scrolled = await call('scroll', { direction: 'down' }); + expect(scrolled.isError).toBe(true); + expect(scrolled.text).toContain('UNSUPPORTED_CAPABILITY'); + expect(scrolled.text).toContain('declares no such action'); + + const closed = await invoke('close_session'); + expect(closed.isError, closed.text).toBe(false); + expect(closed.text).not.toContain('Cleanup:'); + }); + it('kept stdout for the protocol: the config\'s console.log landed on stderr', () => { expect(stderr).toContain('config loaded'); expect(stderr).toContain('e2e mcp: [info] e2e mcp'); diff --git a/packages/e2e/tests/integration/mcp-session-host.test.ts b/packages/e2e/tests/integration/mcp-session-host.test.ts index 2d386e2d0..3b721c7cb 100644 --- a/packages/e2e/tests/integration/mcp-session-host.test.ts +++ b/packages/e2e/tests/integration/mcp-session-host.test.ts @@ -14,6 +14,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { createFakeEngine, FAKE_APP, type FakeEngineBehavior, type FakeEngineHandle } from '../helpers/fake-engine.ts'; import { freePort } from '../helpers/free-port.ts'; import { gate } from '../helpers/gate.ts'; @@ -27,6 +28,8 @@ const resolveModule = new URL('../../dist/config/resolve.js', import.meta.url).h const { resolveConfig } = (await import(resolveModule)) as typeof import('../../src/config/resolve.ts'); const secretsModule = new URL('../../dist/secrets.js', import.meta.url).href; const { credentials, secrets } = (await import(secretsModule)) as typeof import('../../src/secrets.ts'); +const agentModule = new URL('../../dist/agent/public.js', import.meta.url).href; +const { defineTool } = (await import(agentModule)) as typeof import('../../src/agent/public.ts'); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -62,6 +65,8 @@ describe('SessionHost', { timeout: 60_000 }, () => { /** Runs while a config file evaluates, as its top-level code would. */ readonly evaluate?: (configPath: string) => void; readonly onSessionEnd?: (summary: McpSessionSummary) => void; + /** Project tools under `agents.default.tools`. */ + readonly tools?: Record>; } /** @@ -81,6 +86,7 @@ describe('SessionHost', { timeout: 60_000 }, () => { credentials: { admin: { username: 'admin', password: 'kiosk-pw' } }, ...(options.trace === undefined ? {} : { trace: options.trace }), ...(options.video === undefined ? {} : { video: options.video }), + ...(options.tools === undefined ? {} : { agents: { default: { tools: options.tools } } }), } as never, { projectRoot: dir, env: {}, configPath }, ); @@ -120,41 +126,22 @@ describe('SessionHost', { timeout: 60_000 }, () => { expect(fake.stats()).toMatchObject({ attemptsStarted: 2, attemptsEnded: 2, disposes: 2 }); }); - it('closes the schema of every fixed tool: an argument it does not declare fails validation', () => { - const specs = host(engines().next).toolSpecs(); - expect(specs.map((spec) => spec.name)).toEqual(['open_session', 'tools', 'call', 'close_session']); - const accepted: Record> = { - open_session: { target: 'kiosk' }, - tools: { tool: 'tap' }, - call: { tool: 'tap', args: { target: 'n1' } }, - close_session: {}, - }; - for (const spec of specs) { - expect(spec.inputSchema.safeParse(accepted[spec.name]).success, spec.name).toBe(true); - const decorated = spec.inputSchema.safeParse({ ...accepted[spec.name], force: true }); - expect(decorated.success, spec.name).toBe(false); - if (!decorated.success) expect(decorated.error.issues.map((issue) => issue.code)).toEqual(['unrecognized_keys']); - } - }); - it('closes an idle session and disposes the engine', async () => { const fakes = engines(); - const idle = host(fakes.next, { idleMs: 300 }); + const idle = host(fakes.next, { idleMs: 100 }); await idle.open({}); expect(idle.isOpen).toBe(true); - await sleep(1_000); - expect(idle.isOpen).toBe(false); + await expect.poll(() => idle.isOpen, { timeout: 5_000 }).toBe(false); expect(logs.some((line) => line.includes('idle for'))).toBe(true); expect(fakes.made[0]!.stats()).toMatchObject({ attemptsStarted: 1, attemptsEnded: 1, disposes: 1 }); }); it('ends a session at its TTL through the step deadline', async () => { const fakes = engines(); - const short = host(fakes.next, { ttlMs: 1_000 }); + const short = host(fakes.next, { ttlMs: 500 }); await short.open({}); - await sleep(3_000); - expect(short.isOpen).toBe(false); - expect(logs.some((line) => line.includes('exceeded its 1000 ms timeout'))).toBe(true); + await expect.poll(() => short.isOpen, { timeout: 5_000 }).toBe(false); + await expect.poll(() => logs.some((line) => line.includes('exceeded its 500 ms timeout')), { timeout: 5_000 }).toBe(true); expect(fakes.made[0]!.stats()).toMatchObject({ attemptsEnded: 1, disposes: 1 }); }); @@ -203,7 +190,9 @@ describe('SessionHost', { timeout: 60_000 }, () => { await navigating.promise; const second = sessionId(await pair.open({})); held.open(); - await pair.close('done', sessionId(await first)); + const firstId = sessionId(await first); + expect(second).not.toBe(firstId); + await pair.close('done', firstId); await pair.close('done', second); }); @@ -467,9 +456,8 @@ describe('SessionHost', { timeout: 60_000 }, () => { await counted.close('closed by the agent', id); await expect(counted.open({ target: 'nowhere' })).rejects.toMatchObject({ code: 'UNKNOWN_TARGET' }); await counted.open({}); - await sleep(1_000); + await expect.poll(() => summaries, { timeout: 5_000 }).toHaveLength(3); - expect(summaries).toHaveLength(3); const [closed, failed, idle] = summaries as [McpSessionSummary, McpSessionSummary, McpSessionSummary]; expect(closed).toMatchObject({ outcome: 'closed', @@ -488,6 +476,29 @@ describe('SessionHost', { timeout: 60_000 }, () => { expect(idle).toMatchObject({ outcome: 'closed', endedBy: 'idle', platform: 'kiosk' }); }); + it('redacts a secret from what a project tool returns or throws', async () => { + const echo = defineTool( + { description: 'Echo the admin password.', inputSchema: z.object({}), execute: async () => 'pw=kiosk-pw' }, + { mutates: false }, + ); + const leak = defineTool( + { description: 'Fail with the admin password.', inputSchema: z.object({}), execute: async () => { throw new Error('denied kiosk-pw'); } }, + { mutates: false }, + ); + const extra = { signal: new AbortController().signal }; + const text = (result: { content: { type: string; text?: string }[] }) => result.content.map((part) => part.text ?? '').join('\n'); + const session = host(engines().next, { tools: { echo, leak } }); + const id = sessionId(await session.open({})); + + const echoed = await session.call(id, 'echo', {}, extra); + expect(text(echoed)).toBe('pw='); + const thrown = await session.call(id, 'leak', {}, extra); + expect(thrown.isError).toBe(true); + expect(text(thrown)).toContain('denied '); + expect(text(thrown)).not.toContain('kiosk-pw'); + await session.close('done', id); + }); + it('counts an action that failed and then could not look at the screen as one failed call', async () => { let broken = false; const fake = createFakeEngine({ diff --git a/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts b/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts index a4cdd4adf..a05e70a82 100644 --- a/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts +++ b/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts @@ -66,7 +66,10 @@ describe('e2e mcp stdout backpressure', { timeout: 60_000 }, () => { for (let index = 0; index < READS; index += 1) { send({ jsonrpc: '2.0', id: 100 + index, method: 'resources/read', params: { uri: 'e2e://guide' } }); } - await sleep(2_000); + await expect + .poll(() => server.exitCode !== null || server.stdout.readableLength >= server.stdout.readableHighWaterMark, { timeout: 10_000, interval: 5 }) + .toBe(true); + await sleep(250); expect(server.exitCode, `server exited early:\n${stderr}`).toBeNull(); let output = ''; diff --git a/packages/e2e/tests/integration/model-usage.test.ts b/packages/e2e/tests/integration/model-usage.test.ts index ec55996ed..48457f30b 100644 --- a/packages/e2e/tests/integration/model-usage.test.ts +++ b/packages/e2e/tests/integration/model-usage.test.ts @@ -1,7 +1,6 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { stripVTControlCharacters } from 'node:util'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { defineEngine } from '../../src/engine/index.ts'; import { judgment } from '../helpers/fake-model.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; @@ -10,37 +9,6 @@ import { createScriptedInstance, scriptedResult } from '../helpers/scripted-mode import { snapshot } from '../helpers/snapshot.ts'; describe('reported model usage', () => { - it('names the model the steps reported on the list reporter’s AI row', async () => { - const project = createProject({ 'tests/usage.e2e.ts': `import { test } from 'e2e'; - test('judge twice', async ({ agent }) => { - await agent.assert('ready'); - await agent.assert('still ready'); - });` }); - const model = createScriptedInstance('fake-loop', 'scripted-loop', async () => - scriptedResult([{ type: 'text', text: JSON.stringify(judgment(true, 'ready')) }], 'stop')); - const written: string[] = []; - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { - written.push(String(chunk)); - return true; - }); - try { - const outcome = await runExisting(project, { appUrl: 'http://127.0.0.1:4599', config: { - targets: [{ name: 'fake', platform: 'custom', engine: defineEngine({ - name: 'fake', version: '1', spiVersion: 1, observe: async () => snapshot([]), - }) }], agents: { default: { model } }, cache: 'off', - }, runOptions: { quiet: false } }); - stdoutWrite.mockRestore(); - expect(outcome.status).toBe('passed'); - const printed = stripVTControlCharacters(written.join('')); - expect(printed).toContain('model fake-loop/scripted-loop'); - expect(printed).toMatch(/AI {2}240 tokens · 2 model calls · fake-loop\/scripted-loop\n/); - expect(printed).not.toContain('undefined'); - } finally { - stdoutWrite.mockRestore(); - project.cleanup(); - } - }); - it('keeps run totals representable when separate steps overflow their sum', async () => { const project = createProject({ 'tests/usage.e2e.ts': `import { test } from 'e2e'; test('judge twice', async ({ agent }) => { @@ -70,7 +38,7 @@ describe('reported model usage', () => { } }); - it.each([-1, 0.5, NaN, Infinity])('sanitizes malformed judgment counters (%s) before recording events', async (invalid) => { + it.each([-1, 0.5, NaN])('sanitizes malformed judgment counters (%s) before recording events', async (invalid) => { const project = createProject({ 'tests/usage.e2e.ts': `import { test } from 'e2e'; test('judge', async ({ agent }) => { await agent.assert('ready'); });` }); const model = createScriptedInstance('test', 'usage', async () => ({ diff --git a/packages/e2e/tests/integration/parallel-workers.test.ts b/packages/e2e/tests/integration/parallel-workers.test.ts index 60fadf9e8..eeb3e24e5 100644 --- a/packages/e2e/tests/integration/parallel-workers.test.ts +++ b/packages/e2e/tests/integration/parallel-workers.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readdirSync } from 'node:fs'; +import { existsSync, mkdtempSync, readdirSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -8,6 +8,7 @@ import { resultByTitle, runProjectWithConfigFile, workerConfigSource, + workerFakeConfigSource, } from '../helpers/run-project.ts'; describe('parallel worker execution', () => { @@ -36,7 +37,7 @@ test('${name} runs', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/one.e2e.ts': testFile('one'), 'tests/two.e2e.ts': testFile('two') }, - { appUrl: app.url, configSource: workerConfigSource(2) }, + { configSource: workerFakeConfigSource(2) }, ); expect(resultByTitle(outcome, 'one runs').status).toBe('passed'); expect(resultByTitle(outcome, 'two runs').status).toBe('passed'); @@ -72,6 +73,11 @@ test('starts with the seeded state', { session: 'seeded' }, async ({ app, screen await app.open('/storage'); await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('saved'); }); + +test('without a session starts clean', async ({ app, screen }) => { + await app.open('/storage'); + await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('empty'); +}); `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/auth.setup.e2e.ts': setupFile, 'tests/consumer.e2e.ts': consumerFile }, @@ -79,7 +85,10 @@ test('starts with the seeded state', { session: 'seeded' }, async ({ app, screen ); expect(resultByTitle(outcome, 'seed storage').status).toBe('passed'); expect(resultByTitle(outcome, 'starts with the seeded state').status).toBe('passed'); + expect(resultByTitle(outcome, 'without a session starts clean').status).toBe('passed'); expect(outcome.exitCode).toBe(0); + const sessionsRoot = path.join(project.dir, '.e2e', 'sessions'); + if (existsSync(sessionsRoot)) expect(readdirSync(sessionsRoot)).toEqual([]); project.cleanup(); }, 120_000, @@ -113,7 +122,7 @@ test('unrelated still runs', async ({ app }) => { 'tests/dependent.e2e.ts': dependentFile, 'tests/unrelated.e2e.ts': unrelatedFile, }, - { appUrl: app.url, configSource: workerConfigSource(2) }, + { configSource: workerFakeConfigSource(2) }, ); const dependent = resultByTitle(outcome, 'depends on broken'); expect(dependent.status).toBe('skipped'); @@ -128,7 +137,7 @@ test('unrelated still runs', async ({ app }) => { it( 'runs serial groups as one unit on one worker', async () => { - const serialFile = `import { test, expect } from 'e2e'; + const serialFile = `import { test } from 'e2e'; test.describe('wizard', { serial: true }, () => { let shared = 0; @@ -136,13 +145,12 @@ test.describe('wizard', { serial: true }, () => { test('step 1', async ({ app, screen }) => { await app.open(); shared += 1; - await screen.getByRole('button', { name: 'Increment' }).tap(); - await expect(screen.getByRole('status')).toHaveText('1'); + await screen.getByRole('button', { name: 'Submit' }).tap(); }); test('step 2 shares state', async ({ screen }) => { if (shared !== 1) throw new Error('module state was not preserved: ' + shared); - await screen.getByRole('button', { name: 'Increment' }).tap(); + await screen.getByRole('button', { name: 'Submit' }).tap(); }); }); `; @@ -154,7 +162,7 @@ test('parallel neighbor', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/wizard.e2e.ts': serialFile, 'tests/other.e2e.ts': otherFile }, - { appUrl: app.url, configSource: workerConfigSource(2) }, + { configSource: workerFakeConfigSource(2) }, ); expect(resultByTitle(outcome, 'step 1').status).toBe('passed'); expect(resultByTitle(outcome, 'step 2 shares state').status).toBe('passed'); @@ -193,7 +201,7 @@ test('survivor passes', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/crash.e2e.ts': crashFile, 'tests/survivor.e2e.ts': survivorFile }, - { appUrl: app.url, configSource: workerConfigSource(2) }, + { configSource: workerFakeConfigSource(2) }, ); const crashed = resultByTitle(outcome, 'crashes the worker'); expect(crashed.status).toBe('failed'); @@ -207,6 +215,36 @@ test('survivor passes', async ({ app }) => { const run = outcome.report['run'] as unknown as Record; const errors = run['errors'] as Record[]; expect(errors.some((error) => error['code'] === 'WORKER_EXIT')).toBe(true); + assertValidReport(JSON.parse(readFileSync(outcome.reportPath!, 'utf8'))); + project.cleanup(); + }, + 120_000, + ); + + it( + 'times out a body that never calls the harness and runs the next test of its file', + async () => { + const file = `import { test } from 'e2e'; + +test('sleeps past its timeout', { timeout: 1000 }, async ({ app }) => { + await app.open(); + await new Promise((resolve) => setTimeout(resolve, 60_000)); +}); + +test('runs after the timeout', async ({ app }) => { + await app.open(); +}); +`; + const { outcome, project } = await runProjectWithConfigFile( + { 'tests/timeout.e2e.ts': file }, + { configSource: workerFakeConfigSource(1) }, + ); + const timedOut = resultByTitle(outcome, 'sleeps past its timeout'); + expect(timedOut.status).toBe('timed-out'); + expect(timedOut.attempts[0]?.error?.message).toContain('test timed out after 1000 ms'); + expect(resultByTitle(outcome, 'runs after the timeout').status).toBe('passed'); + expect(outcome.exitCode).toBe(1); + expect(outcome.report.run.errors).toEqual([]); project.cleanup(); }, 120_000, @@ -264,16 +302,18 @@ test('sleeps a long time', { timeout: 8000 }, async ({ app }) => { test('waits in the queue', async () => {}); `; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 4_000); const { outcome, project } = await runProjectWithConfigFile( { 'tests/slow.e2e.ts': slowFile, 'tests/unstarted.e2e.ts': queuedFile }, { - appUrl: app.url, - configSource: workerConfigSource(1), - runOptions: { interruptSignal: controller.signal }, + configSource: workerFakeConfigSource(1), + runOptions: { + interruptSignal: controller.signal, + onEvent: (event) => { + if (event.type === 'test-started') controller.abort(); + }, + }, }, ); - clearTimeout(timer); expect(outcome.exitCode).toBe(130); expect(outcome.status).toBe('interrupted'); const result = resultByTitle(outcome, 'sleeps a long time'); @@ -307,7 +347,7 @@ test('would run last', async () => {}); `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/limit.e2e.ts': file }, - { appUrl: app.url, configSource: workerConfigSource(1), runOptions: { maxFailures: 1 } }, + { configSource: workerFakeConfigSource(1), runOptions: { maxFailures: 1 } }, ); expect(outcome.exitCode).toBe(1); const byDeclaration = outcome.results.toSorted((a, b) => a.test.declarationIndex - b.test.declarationIndex); @@ -336,9 +376,8 @@ test('${name} never runs', async ({ app }) => { const { outcome, project } = await runProjectWithConfigFile( Object.fromEntries(['one', 'two', 'three', 'four'].map((name) => [`tests/${name}.e2e.ts`, testFile(name)])), { - appUrl: app.url, // Every worker resolves its own project id, so none agrees with the runner's digest. - configSource: workerConfigSource(4, "\n projectId: 'p-' + Math.random().toString(36).slice(2),"), + configSource: workerFakeConfigSource(4, "\n projectId: 'p-' + Math.random().toString(36).slice(2),"), }, ); const run = outcome.report['run'] as unknown as Record; @@ -372,7 +411,7 @@ test('runs after the rejection', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/stray.e2e.ts': strayFile }, - { appUrl: app.url, configSource: workerConfigSource(1) }, + { configSource: workerFakeConfigSource(1) }, ); const stray = resultByTitle(outcome, 'leaves a rejection behind'); expect(stray.status).toBe('failed'); @@ -391,25 +430,31 @@ test('runs after the rejection', async ({ app }) => { it( 'charges a rejection that surfaces late to the test running at the time', async () => { - const leakingFile = `import { test } from 'e2e'; + const leakingFile = `import { existsSync } from 'node:fs'; +import { test } from 'e2e'; test('leaves a timer behind', async ({ app }) => { await app.open(); - setTimeout(() => { + const marker = new URL('./running', import.meta.url); + const timer = setInterval(() => { + if (!existsSync(marker)) return; + clearInterval(timer); void Promise.reject(new Error('late')); - }, 1_500); + }, 20); }); `; - const sleepingFile = `import { test } from 'e2e'; + const sleepingFile = `import { writeFileSync } from 'node:fs'; +import { test } from 'e2e'; test('is running when it surfaces', async ({ app }) => { await app.open(); - await new Promise((resolve) => setTimeout(resolve, 6_000)); + writeFileSync(new URL('./running', import.meta.url), ''); + await new Promise((resolve) => setTimeout(resolve, 2_000)); }); `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/a-leaks.e2e.ts': leakingFile, 'tests/b-sleeps.e2e.ts': sleepingFile }, - { appUrl: app.url, configSource: workerConfigSource(1) }, + { configSource: workerFakeConfigSource(1) }, ); expect(resultByTitle(outcome, 'leaves a timer behind').status).toBe('passed'); const charged = resultByTitle(outcome, 'is running when it surfaces'); @@ -445,7 +490,7 @@ test('runs on the same worker afterwards', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/a-leaks.e2e.ts': leakingFile, 'tests/b-next.e2e.ts': nextFile }, - { appUrl: app.url, configSource: workerConfigSource(1) }, + { configSource: workerFakeConfigSource(1) }, ); const finished = resultByTitle(outcome, 'finishes before the leak'); expect(finished.status).toBe('passed'); @@ -479,7 +524,7 @@ test('runs after both', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/twice.e2e.ts': file }, - { appUrl: app.url, configSource: workerConfigSource(1) }, + { configSource: workerFakeConfigSource(1) }, ); const attempt = resultByTitle(outcome, 'fails twice').attempts[0]!; expect(attempt.status).toBe('failed'); @@ -505,7 +550,7 @@ test('throws off the stack', async ({ app }) => { setTimeout(() => { throw new Error('boom-uncaught'); }); - await new Promise((resolve) => setTimeout(resolve, 2_000)); + await new Promise((resolve) => setTimeout(resolve, 300)); }); test('never starts', async ({ app }) => { @@ -514,7 +559,7 @@ test('never starts', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/fatal.e2e.ts': file }, - { appUrl: app.url, configSource: workerConfigSource(1) }, + { configSource: workerFakeConfigSource(1) }, ); const run = outcome.report['run'] as unknown as Record; const errors = run['errors'] as Record[]; @@ -558,7 +603,7 @@ test('runs on the next worker', async ({ app }) => { `; const { outcome, project } = await runProjectWithConfigFile( { 'tests/a-early.e2e.ts': earlyFile, 'tests/b-next.e2e.ts': nextFile }, - { appUrl: app.url, configSource: workerConfigSource(1) }, + { configSource: workerFakeConfigSource(1) }, ); const run = outcome.report['run'] as unknown as Record; const errors = run['errors'] as Record[]; diff --git a/packages/e2e/tests/integration/press-sequentially.test.ts b/packages/e2e/tests/integration/press-sequentially.test.ts deleted file mode 100644 index c43e3dd89..000000000 --- a/packages/e2e/tests/integration/press-sequentially.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * `locator.pressSequentially` against a real browser: the fixture's City - * field renders suggestions on key events only, so `fill` leaves the list - * empty and `pressSequentially` populates it. - */ - -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; -import { resultByTitle, runProject, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; - -const SUITE = `import { test } from '@e2e-dev/web'; -import { expect } from 'e2e'; - -test('fill sets the value without key events', async ({ app, screen }) => { - await app.open('/verbs'); - await screen.getByLabel('City').fill('Wa'); - await expect(screen.getByLabel('City')).toHaveValue('Wa'); - await expect(screen.getByRole('status', { name: 'Keys' })).toHaveText('0'); - await expect(screen.getByRole('list', { name: 'Cities' }).getByRole('listitem')).toHaveCount(0); -}); - -test('pressSequentially types the same text as keystrokes', async ({ app, screen }) => { - await app.open('/verbs'); - await screen.getByLabel('City').pressSequentially('Wa'); - await expect(screen.getByLabel('City')).toHaveValue('Wa'); - await expect(screen.getByLabel('City')).toBeFocused(); - await expect(screen.getByRole('status', { name: 'Keys' })).toHaveText('2'); - const cities = screen.getByRole('list', { name: 'Cities' }).getByRole('listitem'); - await expect(cities).toHaveCount(1); - await expect(cities.first()).toHaveText('Warsaw'); -}); - -test('a delay spaces the keystrokes', async ({ app, screen }) => { - await app.open('/verbs'); - await screen.getByLabel('City').pressSequentially('Wr', { delay: 200 }); - await expect(screen.getByLabel('City')).toHaveValue('Wr'); - await expect(screen.getByRole('status', { name: 'Keys' })).toHaveText('2'); - await expect(screen.getByRole('list', { name: 'Cities' }).getByRole('listitem').first()).toHaveText('Wroclaw'); -}); -`; - -describe('locator.pressSequentially in a browser', () => { - let app: FixtureApp; - let outcome: RunOutcome; - let project: FixtureProject; - - beforeAll(async () => { - app = await startFixtureApp(); - ({ outcome, project } = await runProject( - { 'tests/typing.e2e.ts': SUITE }, - { appUrl: app.url, config: { actionTimeout: 5_000, assertionTimeout: 4_000, timeout: 30_000 } }, - )); - }, 240_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it('fill reaches the value but never the key handlers', () => { - const result = resultByTitle(outcome, 'fill sets the value without key events'); - expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); - }); - - it('pressSequentially fires the key handlers and records one locator step', () => { - const result = resultByTitle(outcome, 'pressSequentially types the same text as keystrokes'); - expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); - const step = result.attempts[0]!.steps.find((candidate) => candidate.api === 'locator.pressSequentially'); - expect(step).toMatchObject({ kind: 'locator', label: 'getByLabel("City")', status: 'passed' }); - }); - - it('honors the delay between characters', () => { - const result = resultByTitle(outcome, 'a delay spaces the keystrokes'); - expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); - const step = result.attempts[0]!.steps.find((candidate) => candidate.api === 'locator.pressSequentially'); - expect(step?.durationMs).toBeGreaterThanOrEqual(200); - }); -}); diff --git a/packages/e2e/tests/integration/report-markdown.test.ts b/packages/e2e/tests/integration/report-markdown.test.ts deleted file mode 100644 index 36d3d43ee..000000000 --- a/packages/e2e/tests/integration/report-markdown.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** The markdown reporter through the built runner: `summary.md` beside the report, with app text kept from GitHub's autolinks. */ - -import { readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { defineEngine } from '../../src/engine/index.ts'; -import { runProject } from '../helpers/run-project.ts'; -import { snapshot } from '../helpers/snapshot.ts'; - -const HOSTILE_TITLE = 'ping @octocat see https://evil.example'; - -/** The page with its code spans removed: what GitHub's mention, issue, and URL filters get to read. */ -function outsideCodeSpans(markdown: string): string { - return markdown.replace(/(`+)[^`]*\1/g, ''); -} - -describe('markdown reporter', () => { - it('writes summary.md with a mention and a URL from a test title as code, and nothing outside a code span GitHub would link', async () => { - const { outcome, project } = await runProject( - { 'tests/hostile.e2e.ts': `import { test } from 'e2e'; - test(${JSON.stringify(HOSTILE_TITLE)}, async () => { throw new Error('boom'); });` }, - { - appUrl: 'http://127.0.0.1:4599', - config: { - targets: [{ name: 'fake', platform: 'custom', engine: defineEngine({ name: 'fake', version: '1', spiVersion: 1, observe: async () => snapshot([]) }) }], - reporters: ['markdown'], - }, - }, - ); - try { - expect(outcome.exitCode).toBe(1); - const summary = readFileSync(path.join(project.dir, '.e2e', 'summary.md'), 'utf8'); - expect(summary).toContain('ping `@octocat` see `https://evil.example`'); - expect(outsideCodeSpans(summary)).not.toContain('@octocat'); - expect(outsideCodeSpans(summary)).not.toMatch(/https:\/\/evil/); - const failuresDir = path.join(project.dir, '.e2e', 'failures'); - const pages = readdirSync(failuresDir).map((file) => readFileSync(path.join(failuresDir, file), 'utf8')); - expect(pages).toHaveLength(1); - expect(pages[0]).toContain('# ✗ ping `@octocat` see `https://evil.example`'); - expect(outsideCodeSpans(pages[0]!)).not.toContain('@octocat'); - } finally { - project.cleanup(); - } - }); -}); diff --git a/packages/e2e/tests/integration/reporters.test.ts b/packages/e2e/tests/integration/reporters.test.ts index e3728b1c8..ab64ab34d 100644 --- a/packages/e2e/tests/integration/reporters.test.ts +++ b/packages/e2e/tests/integration/reporters.test.ts @@ -7,30 +7,28 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; +import { createFakeEngine } from '../helpers/fake-engine.ts'; +import { engineConfig } from '../helpers/fixture-config.ts'; import { createProject, runExisting, type FixtureProject } from '../helpers/run-project.ts'; import type { FinishedRun, Reporter, RunEvent } from '../../src/index.ts'; const SUITE = `import { test, expect } from 'e2e'; -test('shows the counter', async ({ app, screen }) => { +test('shows the submit button', async ({ app, screen }) => { await app.open(); - await expect(screen.getByRole('status')).toHaveText('0'); + await expect(screen.getByRole('button', { name: 'Submit' })).toBeVisible(); }); `; describe('reporter objects', () => { - let app: FixtureApp; let project: FixtureProject; - beforeAll(async () => { - app = await startFixtureApp(); + beforeAll(() => { project = createProject({ 'tests/reporters.e2e.ts': SUITE }); }); - afterAll(async () => { + afterAll(() => { project?.cleanup(); - await app?.close(); }); afterEach(() => { @@ -51,8 +49,12 @@ describe('reporter objects', () => { }, }; const outcome = await runExisting(project, { - appUrl: app.url, - config: { tests: 'tests/**/*.e2e.ts', reporters: [recording], cache: 'off' as const }, + config: { + ...engineConfig(createFakeEngine({ trace: true }).engine), + tests: 'tests/**/*.e2e.ts', + reporters: [recording], + cache: 'off' as const, + }, }); expect(outcome.exitCode).toBe(0); @@ -116,8 +118,12 @@ describe('reporter objects', () => { }, }; const outcome = await runExisting(project, { - appUrl: app.url, - config: { tests: 'tests/**/*.e2e.ts', reporters: [throwing, hanging, malformed, scalar, loud, quiet], cache: 'off' as const }, + config: { + ...engineConfig(createFakeEngine({ trace: true }).engine), + tests: 'tests/**/*.e2e.ts', + reporters: [throwing, hanging, malformed, scalar, loud, quiet], + cache: 'off' as const, + }, runOptions: { reporterTimeout: 200, onEvent: () => { @@ -156,8 +162,12 @@ describe('reporter objects', () => { }, }; const outcome = await runExisting(project, { - appUrl: app.url, - config: { tests: 'tests/**/*.e2e.ts', reporters: [uploading], cache: 'off' as const }, + config: { + ...engineConfig(createFakeEngine({ trace: true }).engine), + tests: 'tests/**/*.e2e.ts', + reporters: [uploading], + cache: 'off' as const, + }, runOptions: { reporterTimeout: 30_000, forceSignal: force.signal }, }); diff --git a/packages/e2e/tests/integration/run-events.test.ts b/packages/e2e/tests/integration/run-events.test.ts index b1a0d6203..3524bc4d1 100644 --- a/packages/e2e/tests/integration/run-events.test.ts +++ b/packages/e2e/tests/integration/run-events.test.ts @@ -1,7 +1,6 @@ /** * The `onEvent` host stream end to end: a real run emits an ordered, - * JSON-serializable event sequence that mirrors what the report persists, - * while a throwing sink is quarantined without affecting the run. + * JSON-serializable event sequence that mirrors what the report persists. */ import { mkdirSync, readFileSync } from 'node:fs'; @@ -14,7 +13,7 @@ import { createProject, runExisting, runProjectWithConfigFile, - workerConfigSource, + workerFakeConfigSource, type FixtureProject, type RunOutcome, } from '../helpers/run-project.ts'; @@ -30,6 +29,14 @@ test('increments once', async ({ app, agent, screen }) => { }); `; +/** A test that touches no page beyond opening the app, for the fake engine. */ +const OPENS_THE_APP = `import { test } from 'e2e'; + +test('opens the app', async ({ app }) => { + await app.open(); +}); +`; + /** Taps Increment once and passes. No AI SDK, no model. */ const oneTapExecutor: StepExecutor = { name: 'one-tap-executor', @@ -142,7 +149,6 @@ describe('run events', () => { describe('run events: attempt and step identity', () => { it.each(['in-process', 'workers'] as const)('joins retries and serial members to their report records through %s', async (execution) => { - const app = await startFixtureApp(); const events: RunEvent[] = []; const files = { 'tests/retry.e2e.ts': `import { test } from 'e2e'; @@ -173,8 +179,7 @@ test.describe('group', { serial: true, retries: 1 }, () => { const runOptions = { onEvent: (event: RunEvent) => { events.push(event); } }; if (execution === 'workers') { const result = await runProjectWithConfigFile(files, { - appUrl: app.url, - configSource: workerConfigSource(2, ` + configSource: workerFakeConfigSource(2, ` cache: 'off', agents: { default: { executor: { name: 'retry-once', @@ -191,7 +196,6 @@ test.describe('group', { serial: true, retries: 1 }, () => { } else { project = createProject(files); outcome = await runExisting(project, { - appUrl: app.url, config: { cache: 'off', agents: { default: { executor } } }, runOptions, }); @@ -227,14 +231,12 @@ test.describe('group', { serial: true, retries: 1 }, () => { } } finally { project?.cleanup(); - await app.close(); } }, 120_000); }); describe('run events: run lifecycle hygiene', () => { it('a test\u2019s console output arrives as output events attributed to it, and never on the runner\u2019s streams', async () => { - const app = await startFixtureApp(); const events: RunEvent[] = []; const written: string[] = []; const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { @@ -258,8 +260,7 @@ test('talks', async () => { `, }, { - appUrl: app.url, - configSource: workerConfigSource(1, ` + configSource: workerFakeConfigSource(1, ` secrets: { 'stripe-key': 'sk_live_generic_4242' },`), runOptions: { onEvent: (event: RunEvent) => { events.push(event); } }, }, @@ -280,19 +281,17 @@ test('talks', async () => { "hello { from: 'the test' }\ntoken leaked twice \nsplit across writes\n", ); expect(events.some((event) => event.type === 'output' && /sk_live|eric_4242/.test(event.text))).toBe(false); - expect(inTest.every((event) => event.target === 'web' && event.pair?.agent === 'default')).toBe(true); + expect(inTest.every((event) => event.target === 'fake' && event.pair?.agent === 'default')).toBe(true); // The module's top level runs while the file loads, outside any pair. expect(output.some((event) => event.pair === undefined && event.text === 'top level\n')).toBe(true); expect(written.join('')).not.toContain('hello'); } finally { stdoutWrite.mockRestore(); project?.cleanup(); - await app.close(); } }, 120_000); it('a serial member’s unfinished line leaves with the member that wrote it, not the one after', async () => { - const app = await startFixtureApp(); const events: RunEvent[] = []; let project: FixtureProject | undefined; try { @@ -306,9 +305,8 @@ test.describe('shared', { serial: true }, () => { `, }, { - appUrl: app.url, // A registered secret is what makes the worker hold an unfinished line. - configSource: workerConfigSource(1, ` + configSource: workerFakeConfigSource(1, ` secrets: { 'stripe-key': 'sk_live_generic_4242' },`), runOptions: { onEvent: (event: RunEvent) => { events.push(event); } }, }, @@ -327,13 +325,11 @@ test.describe('shared', { serial: true }, () => { expect(outputOf('shared > second')).toEqual([' done\n']); } finally { project?.cleanup(); - await app.close(); } }, 120_000); it('a junit-write failure is one stderr line and leaves the outcome, the report, and run-finished alone', async () => { - const app = await startFixtureApp(); - const project = createProject({ 'tests/events.e2e.ts': SUITE }); + const project = createProject({ 'tests/events.e2e.ts': OPENS_THE_APP }); // A directory where junit.xml must be written makes its atomic rename fail // while report.json beside it still has a clear path. mkdirSync(path.join(project.dir, '.e2e', 'junit.xml'), { recursive: true }); @@ -341,12 +337,9 @@ test.describe('shared', { serial: true }, () => { const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); try { const outcome = await runExisting(project, { - appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', reporters: ['junit'] as const, - agents: { default: { executor: oneTapExecutor } }, - cache: 'off' as const, }, runOptions: { onEvent: (event) => { events.push(event); @@ -368,24 +361,19 @@ test.describe('shared', { serial: true }, () => { } finally { vi.restoreAllMocks(); project.cleanup(); - await app.close(); } }, 120_000); it('a report-write failure fails the run, withholds reportPath, and precedes run-finished', async () => { - const app = await startFixtureApp(); - const project = createProject({ 'tests/events.e2e.ts': SUITE }); + const project = createProject({ 'tests/events.e2e.ts': OPENS_THE_APP }); // A directory where report.json must be written makes the atomic rename fail. mkdirSync(path.join(project.dir, '.e2e', 'report.json'), { recursive: true }); const events: RunEvent[] = []; try { const outcome = await runExisting(project, { - appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', reporters: ['json'] as const, - agents: { default: { executor: oneTapExecutor } }, - cache: 'off' as const, }, runOptions: { onEvent: (event) => { events.push(event); @@ -406,22 +394,17 @@ test.describe('shared', { serial: true }, () => { expect(outcome.report.run.errors.some((entry) => entry.phase === 'report')).toBe(true); } finally { project.cleanup(); - await app.close(); } }, 120_000); it('a cancellation before any test starts ends the run as interrupted without collecting', async () => { - const app = await startFixtureApp(); - const project = createProject({ 'tests/events.e2e.ts': SUITE }); + const project = createProject({ 'tests/events.e2e.ts': OPENS_THE_APP }); const events: RunEvent[] = []; try { const outcome = await runExisting(project, { - appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', reporters: ['json'] as const, - agents: { default: { executor: oneTapExecutor } }, - cache: 'off' as const, }, runOptions: { interruptSignal: AbortSignal.abort(), @@ -439,58 +422,22 @@ test.describe('shared', { serial: true }, () => { expect(outcome.reportPath).toBeUndefined(); } finally { project.cleanup(); - await app.close(); } }, 120_000); it('clears the credential registry when the run resolves', async () => { - const app = await startFixtureApp(); - const project = createProject({ 'tests/events.e2e.ts': SUITE }); + const project = createProject({ 'tests/events.e2e.ts': OPENS_THE_APP }); try { await runExisting(project, { - appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', reporters: ['json'] as const, - agents: { default: { executor: oneTapExecutor } }, - cache: 'off' as const, credentials: { member: { username: 'member', password: 'hunter2' } }, }, }); expect(() => credentials.user('member')).toThrow(/only available while the e2e runner/); } finally { project.cleanup(); - await app.close(); - } - }, 120_000); -}); - -describe('run events: quarantined sink', () => { - it('a throwing sink never affects the run outcome', async () => { - const app = await startFixtureApp(); - const project = createProject({ 'tests/events.e2e.ts': SUITE }); - let calls = 0; - try { - const outcome = await runExisting(project, { - appUrl: app.url, - config: { - tests: 'tests/**/*.e2e.ts', - reporters: ['json'] as const, - agents: { default: { executor: oneTapExecutor } }, - cache: 'off' as const, - }, - runOptions: { - onEvent: () => { - calls += 1; - throw new Error('broken sink'); - }, - }, - }); - expect(outcome.exitCode).toBe(0); - expect(calls).toBe(1); - } finally { - project.cleanup(); - await app.close(); } }, 120_000); }); diff --git a/packages/e2e/tests/integration/runner-lifecycle.test.ts b/packages/e2e/tests/integration/runner-lifecycle.test.ts index a845dc4db..16660b462 100644 --- a/packages/e2e/tests/integration/runner-lifecycle.test.ts +++ b/packages/e2e/tests/integration/runner-lifecycle.test.ts @@ -4,7 +4,17 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; import { resultId } from '../../src/internal/ids.ts'; -import { createProject, listProject, resultByTitle, runExisting, runProject, type RunOutcome } from '../helpers/run-project.ts'; +import { + createProject, + listProject, + resultByTitle, + runExisting, + runProject, + runExistingWithConfigFile, + runProjectWithConfigFile, + workerFakeConfigSource, + type RunOutcome, +} from '../helpers/run-project.ts'; import type { FinishedRun } from '../../src/index.ts'; describe('runner lifecycle', () => { @@ -52,10 +62,7 @@ test.afterEach(() => log('afterEach:file-late')); `; const logPath = path.join('/tmp', `e2e-hooks-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject( - { 'tests/hooks.e2e.ts': hooksFile }, - { appUrl: app.url }, - ); + const { outcome, project } = await runProject({ 'tests/hooks.e2e.ts': hooksFile }, {}); expect(resultByTitle(outcome, 'inside group').status).toBe('passed'); const entries = readFileSync(logPath, 'utf8').trim().split('\n'); expect(entries).toEqual([ @@ -65,7 +72,7 @@ test.afterEach(() => log('afterEach:file-late')); 'beforeEach:file-late', 'beforeEach:group', 'beforeEach:group-late', - 'body:web', + 'body:fake', 'afterEach:group-late', 'afterEach:group', 'afterEach:file-late', @@ -73,6 +80,7 @@ test.afterEach(() => log('afterEach:file-late')); 'afterAll:group', 'afterAll:file', ]); + expect(existsSync(path.join(project.dir, '.e2e', 'junit.xml'))).toBe(false); project.cleanup(); }, 120_000, @@ -105,7 +113,7 @@ test.setup('sign in', { sessions: ['admin'] }, async ({ session }) => { test('consumer', { session: 'admin' }, async () => {}); `; - const { outcome, project } = await runProject({ 'tests/runtime-skip.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/runtime-skip.e2e.ts': file }, {}); const conditional = resultByTitle(outcome, 'single organization'); expect(conditional.status).toBe('skipped'); expect(conditional.skip).toEqual({ cause: 'explicit', reason: 'the demo tenant has a single organization' }); @@ -155,7 +163,7 @@ test.describe('wizard', { serial: true }, () => { `; const logPath = path.join('/tmp', `e2e-skiprealm-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/skip-realm.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/skip-realm.e2e.ts': file }, {}); expect(resultByTitle(outcome, 'skips first').status).toBe('skipped'); expect(resultByTitle(outcome, 'runs second').status).toBe('passed'); const step1 = resultByTitle(outcome, 'step 1 skips'); @@ -185,7 +193,7 @@ test.describe('wizard', { serial: true }, () => { test.skip(true, 'not here'); test('never registered', async () => {}); `; - const { outcome, project } = await runProject({ 'tests/skip-outside.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/skip-outside.e2e.ts': file }, {}); expect(outcome.exitCode).toBe(2); expect(outcome.report.run.errors.map((error) => error.code)).toEqual(['COLLECTION_ERROR']); expect(outcome.report.run.errors[0]?.message).toMatch(/must be called inside a test body/); @@ -205,7 +213,6 @@ test('never registered', async () => {}); }; const notices: string[] = []; const narrowed = await runProject(files, { - appUrl: app.url, runOptions: { files: ['tests/good.e2e.ts'], onEvent: (event) => { if (event.type === 'notice' && event.target === 'collect') notices.push(event.message); } }, }); expect(narrowed.outcome.exitCode).toBe(0); @@ -217,13 +224,13 @@ test('never registered', async () => {}); ]); narrowed.project.cleanup(); - const named = await runProject(files, { appUrl: app.url, runOptions: { files: ['tests/broken.e2e.ts'] } }); + const named = await runProject(files, { runOptions: { files: ['tests/broken.e2e.ts'] } }); expect(named.outcome.exitCode).toBe(2); expect(named.outcome.report.run.errors.map((error) => error.code)).toEqual(['COLLECTION_ERROR']); expect(named.outcome.report.run.errors[0]?.message).toMatch(/^failed to collect tests\/broken\.e2e\.ts: /); named.project.cleanup(); - const whole = await runProject(files, { appUrl: app.url }); + const whole = await runProject(files, {}); expect(whole.outcome.exitCode).toBe(2); expect(whole.outcome.report.run.errors.map((error) => error.code)).toEqual(['COLLECTION_ERROR']); expect(whole.outcome.report.run.errors[0]?.message).toMatch(/failed to collect tests\/broken\.e2e\.ts/); @@ -248,7 +255,7 @@ test('independent survives', async ({ app }) => { await app.open(); }); `; - const { outcome, project } = await runProject({ 'tests/hookfail.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/hookfail.e2e.ts': file }, {}); const skipped = resultByTitle(outcome, 'unreachable'); expect(skipped.status).toBe('skipped'); expect(skipped.skip?.cause).toBe('hook-failed'); @@ -282,7 +289,7 @@ test('second passes', async ({ app }) => { `; const logPath = path.join('/tmp', `e2e-discard-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/discard.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/discard.e2e.ts': file }, {}); expect(resultByTitle(outcome, 'first fails').status).toBe('failed'); expect(resultByTitle(outcome, 'second passes').status).toBe('passed'); // The failed realm is discarded but its afterAll still runs; the fresh @@ -338,7 +345,7 @@ test('consumer', { session: 'seeded' }, async ({ app }) => { `; const logPath = path.join('/tmp', `e2e-suitehooks-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/hooks.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/hooks.e2e.ts': file }, {}); expect(resultByTitle(outcome, 'seed').status).toBe('passed'); expect(resultByTitle(outcome, 'step 1').status).toBe('passed'); expect(resultByTitle(outcome, 'step 2').status).toBe('passed'); @@ -379,7 +386,7 @@ test.describe('wizard', { serial: true }, () => { test('step 2', async () => {}); }); `; - const { outcome, project } = await runProject({ 'tests/serialhook.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/serialhook.e2e.ts': file }, {}); for (const title of ['step 1', 'step 2']) { const result = resultByTitle(outcome, title); expect(result.status).toBe('skipped'); @@ -413,7 +420,7 @@ test('fails then cannot retry', { retries: 1 }, async ({ app }) => { `; const marker = path.join('/tmp', `e2e-retryhook-${Date.now()}`); process.env['RETRY_MARKER'] = marker; - const { outcome, project } = await runProject({ 'tests/retryhook.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/retryhook.e2e.ts': file }, {}); const result = resultByTitle(outcome, 'fails then cannot retry'); expect(result.status).toBe('failed'); expect(result.attempts).toHaveLength(1); @@ -441,7 +448,7 @@ test('flaky test', { retries: 2 }, async ({ app }) => { `; const marker = path.join('/tmp', `e2e-flaky-${Date.now()}`); process.env['FLAKY_MARKER'] = marker; - const { outcome, project } = await runProject({ 'tests/flaky.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/flaky.e2e.ts': file }, {}); const result = resultByTitle(outcome, 'flaky test'); expect(result.status).toBe('flaky'); expect(result.attempts).toHaveLength(2); @@ -463,7 +470,7 @@ test('sleeps forever', { timeout: 1500 }, async ({ app }) => { await new Promise((resolve) => setTimeout(resolve, 60_000)); }); `; - const { outcome, project } = await runProject({ 'tests/slow.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/slow.e2e.ts': file }, {}); const result = resultByTitle(outcome, 'sleeps forever'); expect(result.status).toBe('timed-out'); expect(outcome.exitCode).toBe(1); @@ -472,81 +479,6 @@ test('sleeps forever', { timeout: 1500 }, async ({ app }) => { 120_000, ); - it( - 'produces sessions in setup tests and restores them for consumers', - async () => { - const setupFile = `import { test, expect } from 'e2e'; - -test.setup('seed storage', { sessions: ['seeded'] }, async ({ app, screen, session }) => { - await app.open('/storage'); - await screen.getByRole('button', { name: 'Save marker' }).tap(); - await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('saved'); - await session.save('seeded'); -}); -`; - const consumerFile = `import { test } from '@e2e-dev/web'; -import { expect } from 'e2e'; - -test('starts with the seeded state', { session: 'seeded' }, async ({ app, screen, browser }) => { - await app.open('/storage'); - await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('saved'); - const cookies = await browser.cookies(); - if (!cookies.some((cookie) => cookie.name === 'fixture')) { - throw new Error('expected the fixture cookie from the session'); - } -}); - -test('without a session starts clean', async ({ app, screen }) => { - await app.open('/storage'); - await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('empty'); -}); -`; - const { outcome, project } = await runProject( - { 'tests/auth.setup.e2e.ts': setupFile, 'tests/consumer.e2e.ts': consumerFile }, - { appUrl: app.url }, - ); - expect(resultByTitle(outcome, 'seed storage').status).toBe('passed'); - expect(resultByTitle(outcome, 'starts with the seeded state').status).toBe('passed'); - expect(resultByTitle(outcome, 'without a session starts clean').status).toBe('passed'); - expect(outcome.exitCode).toBe(0); - const sessionsRoot = path.join(project.dir, '.e2e', 'sessions'); - if (existsSync(sessionsRoot)) { - expect(readdirSync(sessionsRoot)).toEqual([]); - } - project.cleanup(); - }, - 120_000, - ); - - it( - 'skips session consumers when their setup fails', - async () => { - const file = `import { test } from 'e2e'; - -test.setup('failing setup', { sessions: ['broken'] }, async ({ app }) => { - await app.open(); - throw new Error('cannot authenticate'); -}); - -test('depends on broken', { session: 'broken' }, async ({ app }) => { - await app.open(); -}); - -test('unrelated still runs', async ({ app }) => { - await app.open(); -}); -`; - const { outcome, project } = await runProject({ 'tests/dep.e2e.ts': file }, { appUrl: app.url }); - const dependent = resultByTitle(outcome, 'depends on broken'); - expect(dependent.status).toBe('skipped'); - expect(dependent.skip?.cause).toBe('setup-failed'); - expect(resultByTitle(outcome, 'unrelated still runs').status).toBe('passed'); - expect(outcome.exitCode).toBe(1); - project.cleanup(); - }, - 120_000, - ); - it( 'runs serial groups as one unit with shared state and predecessor skips', async () => { @@ -606,36 +538,18 @@ test.describe('wizard', { serial: true }, () => { 120_000, ); - it( - 'reports config errors as exit 2 without executing tests', - async () => { - const { outcome, project } = await runProject( - { 'tests/none.e2e.ts': `import { test } from 'e2e';\ntest('x', async () => {});\n` }, - { - appUrl: app.url, - config: { reporters: ['json', 'list'] as never }, - runOptions: { quiet: true }, - }, - ); - expect(outcome.exitCode).toBe(2); - expect(outcome.status).toBe('error'); - project.cleanup(); - }, - 120_000, - ); - it( 'writes junit.xml beside the report from the same document when the junit reporter is selected', async () => { const file = `import { test } from 'e2e'; test('passes', { tags: ['smoke'] }, async () => {}); test('fails', async () => { - throw new Error('junit & "reports" this'); + throw new Error('junit reports this'); }); `; const { outcome, project } = await runProject( { 'tests/junit.e2e.ts': file }, - { appUrl: app.url, config: { reporters: ['junit'] } }, + { config: { reporters: ['junit'] } }, ); expect(outcome.exitCode).toBe(1); expect(outcome.report.run.results.map((result) => result.tags)).toEqual([['smoke'], []]); @@ -645,29 +559,12 @@ test('fails', async () => { expect(xml).toContain( ' name.endsWith('.tmp'))).toEqual([]); project.cleanup(); }, 120_000, ); - it( - 'writes no junit.xml unless the reporter is selected', - async () => { - const { outcome, project } = await runProject( - { 'tests/no-junit.e2e.ts': `import { test } from 'e2e';\ntest('x', async () => {});\n` }, - { appUrl: app.url }, - ); - expect(outcome.exitCode).toBe(0); - expect(existsSync(path.join(project.dir, '.e2e', 'junit.xml'))).toBe(false); - project.cleanup(); - }, - 120_000, - ); - it( 'lists the selected pairs in report order without running anything', async () => { @@ -683,12 +580,12 @@ test.describe('group', () => { test('other', { tags: ['smoke'] }, async () => {}); `, }; - const { pairs, project } = await listProject(files, { appUrl: 'http://127.0.0.1:9' }); + const { pairs, project } = await listProject(files, {}); expect(pairs.map((pair) => [pair.file, pair.titlePath.join(' > '), pair.target, pair.disposition])).toEqual([ - ['tests/list.e2e.ts', 'plain', 'web', 'run'], - ['tests/list.e2e.ts', 'group > nested', 'web', 'run'], - ['tests/list.e2e.ts', 'group > left out', 'web', 'skip'], - ['tests/other.e2e.ts', 'other', 'web', 'run'], + ['tests/list.e2e.ts', 'plain', 'fake', 'run'], + ['tests/list.e2e.ts', 'group > nested', 'fake', 'run'], + ['tests/list.e2e.ts', 'group > left out', 'fake', 'skip'], + ['tests/other.e2e.ts', 'other', 'fake', 'run'], ]); expect(pairs.map((pair) => pair.tags)).toEqual([[], ['smoke'], [], ['smoke']]); expect(pairs[2]?.skipReason).toBe('not today'); @@ -697,30 +594,10 @@ test('other', { tags: ['smoke'] }, async () => {}); // A config glob spelled with a leading `./` selects the same files. const dotted = await listProject(files, { - appUrl: 'http://127.0.0.1:9', config: { tests: './tests/**/*.e2e.ts' }, }); expect(dotted.pairs.map((pair) => pair.title)).toEqual(['plain', 'nested', 'left out', 'other']); dotted.project.cleanup(); - - const tagged = await listProject(files, { - appUrl: 'http://127.0.0.1:9', - listOptions: { tags: ['smoke'], files: ['tests/other.e2e.ts'] }, - }); - expect(tagged.pairs.map((pair) => pair.title)).toEqual(['other']); - tagged.project.cleanup(); - - const grepped = await listProject(files, { - appUrl: 'http://127.0.0.1:9', - listOptions: { grepInvert: [/^group/], excludeTags: ['smoke'] }, - }); - expect(grepped.pairs.map((pair) => pair.title)).toEqual(['plain']); - grepped.project.cleanup(); - - - await expect(listProject({ 'tests/empty.txt': '' }, { appUrl: 'http://127.0.0.1:9' })).rejects.toMatchObject({ - code: 'NO_TESTS', - }); }, 120_000, ); @@ -739,7 +616,7 @@ test('breaks the second time', async () => { }); `, }; - const { outcome, project } = await runProject(files, { appUrl: app.url, runOptions: { repeatEach: 3, retries: 0 } }); + const { outcome, project } = await runProject(files, { runOptions: { repeatEach: 3, retries: 0 } }); expect(outcome.exitCode).toBe(1); const results = outcome.report.run.results.toSorted((a, b) => a.declarationIndex - b.declarationIndex || a.repeat - b.repeat); expect(results.map((result) => [result.titlePath[0], result.repeat, result.status])).toEqual([ @@ -751,7 +628,7 @@ test('breaks the second time', async () => { ['breaks the second time', 2, 'failed'], ]); expect(new Set(results.map((result) => result.id)).size).toBe(6); - expect(results[0]!.id).toBe(resultId(results[0]!.testId, 'web', 'default')); + expect(results[0]!.id).toBe(resultId(results[0]!.testId, 'fake', 'default')); const paths = results[4]!.attempts[0]!.artifacts.flatMap((artifact) => (artifact.path === undefined ? [] : [artifact.path])); expect(paths.length).toBeGreaterThan(0); expect(paths.every((artifactPath) => artifactPath.includes('/repeat-1/'))).toBe(true); @@ -761,7 +638,6 @@ test('breaks the second time', async () => { // and hands its reporters the report it selected from. let handed: FinishedRun | undefined; const rerun = await runExisting(project, { - appUrl: app.url, runOptions: { lastFailed: true }, config: { reporters: [ @@ -777,7 +653,7 @@ test('breaks the second time', async () => { expect(rerun.results.filter((result) => result.selected).map((result) => result.test.title)).toEqual(['breaks the second time']); expect(handed?.lastRun?.run.id).toBe(outcome.report.run.id); - const invalid = await runExisting(project, { appUrl: app.url, runOptions: { repeatEach: 0 } }); + const invalid = await runExisting(project, { runOptions: { repeatEach: 0 } }); expect(invalid.exitCode).toBe(2); expect(invalid.report.run.errors.map((error) => error.message)).toEqual(['repeatEach must be a positive safe integer, got 0']); project.cleanup(); @@ -798,7 +674,6 @@ test('never runs either', async () => { throw new Error('three'); }); }; const events: string[] = []; const { outcome, project } = await runProject(files, { - appUrl: app.url, runOptions: { maxFailures: 2, onEvent: (event) => { if (event.type === 'run-stopped') events.push(`${event.failures}/${event.limit}`); } }, }); expect(outcome.exitCode).toBe(1); @@ -814,7 +689,7 @@ test('never runs either', async () => { throw new Error('three'); }); expect(byDeclaration[2]!.skip?.reason).toBe('run stopped after 2 failures (--max-failures 2)'); // The SDK path is bounded like the flag: a count below one is a configuration error, not a run that stops at once. - const invalid = await runExisting(project, { appUrl: app.url, runOptions: { maxFailures: 0 } }); + const invalid = await runExisting(project, { runOptions: { maxFailures: 0 } }); expect(invalid.exitCode).toBe(2); expect(invalid.report.run.errors.map((error) => [error.code, error.message])).toEqual([ ['INVALID_CONFIG', 'maxFailures must be a positive safe integer, got 0'], @@ -840,7 +715,6 @@ test('after the group too', async () => {}); }; let planned = 0; const { outcome, project } = await runProject(files, { - appUrl: app.url, runOptions: { maxFailures: 1, onEvent: (event) => { if (event.type === 'plan') planned = event.total; } }, }); expect(outcome.exitCode).toBe(1); @@ -875,11 +749,11 @@ test('sleeps until interrupted', async () => { test('never started', async () => {}); test('never started either', async () => {}); `, + 'e2e.config.ts': workerFakeConfigSource(1), }); const controller = new AbortController(); let planned = 0; - const interrupted = await runExisting(project, { - appUrl: app.url, + const interrupted = await runExistingWithConfigFile(project, { runOptions: { interruptSignal: controller.signal, onEvent: (event) => { @@ -903,7 +777,7 @@ test('never started either', async () => {}); // The rerun reads the report the interrupt wrote; the body that slept passes at once now. writeFileSync(path.join(project.dir, 'tests', 'first.e2e.ts'), sleeping.replace('setTimeout(resolve, 60_000)', 'setTimeout(resolve, 0)')); - const rerun = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); + const rerun = await runExistingWithConfigFile(project, { runOptions: { lastFailed: true } }); expect(rerun.exitCode).toBe(0); const byFile = rerun.results.toSorted((a, b) => (a.test.file === b.test.file ? a.test.declarationIndex - b.test.declarationIndex : a.test.file < b.test.file ? -1 : 1)); expect(byFile.map((result) => [result.test.title, result.selected, result.status])).toEqual([ @@ -919,17 +793,18 @@ test('never started either', async () => {}); it( 'an interrupt during a serial group retry keeps each member\'s verdict from the attempt before it', async () => { - const project = createProject({}); - const marker = path.join(project.dir, 'failed-once'); - mkdirSync(path.join(project.dir, 'tests'), { recursive: true }); - writeFileSync( - path.join(project.dir, 'tests', 'flow.e2e.ts'), - `import { existsSync, writeFileSync } from 'node:fs'; + const controller = new AbortController(); + let starts = 0; + const startedAt = Date.now(); + const { outcome, project } = await runProjectWithConfigFile( + { + 'tests/flow.e2e.ts': `import { existsSync, writeFileSync } from 'node:fs'; import { test } from 'e2e'; +const marker = new URL('./failed-once', import.meta.url); test.describe('flow', { serial: true }, () => { test('first step', async () => { - if (!existsSync(${JSON.stringify(marker)})) { - writeFileSync(${JSON.stringify(marker)}, ''); + if (!existsSync(marker)) { + writeFileSync(marker, ''); throw new Error('first attempt fails'); } await new Promise((resolve) => setTimeout(resolve, 60_000)); @@ -937,21 +812,20 @@ test.describe('flow', { serial: true }, () => { test('second step', async () => {}); }); `, - ); - const controller = new AbortController(); - let starts = 0; - const outcome = await runExisting(project, { - appUrl: app.url, - config: { tests: 'tests/**/*.e2e.ts', retries: 1 }, - runOptions: { - interruptSignal: controller.signal, - onEvent: (event) => { - // A serial member starts once per group attempt: the second start is the retry. - if (event.type === 'test-started' && event.title === 'flow > first step' && (starts += 1) === 2) controller.abort(); + }, + { + configSource: workerFakeConfigSource(1, `\n tests: 'tests/**/*.e2e.ts',\n retries: 1,`), + runOptions: { + interruptSignal: controller.signal, + onEvent: (event) => { + // A serial member starts once per group attempt: the second start is the retry. + if (event.type === 'test-started' && event.title === 'flow > first step' && (starts += 1) === 2) controller.abort(); + }, }, }, - }); + ); expect(outcome.exitCode).toBe(130); + expect(Date.now() - startedAt).toBeLessThan(30_000); expect(outcome.report.run.serialGroups[0]?.attempts.map((attempt) => attempt.status)).toEqual(['failed', 'interrupted']); expect(resultByTitle(outcome, 'first step').status).toBe('failed'); expect(outcome.report.run.summary).toMatchObject({ failed: 1, interrupted: 0 }); @@ -960,7 +834,6 @@ test.describe('flow', { serial: true }, () => { }, 120_000, ); - it( 'a forced interrupt still writes the junit and markdown files, so none of the previous run is left beside the report', async () => { @@ -1013,7 +886,6 @@ test('queued behind it too', async () => {}); `, }); const aborted = await runExisting(project, { - appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', workers: 1 }, }); expect(aborted.exitCode).toBe(2); @@ -1038,7 +910,6 @@ test('needs the model', async ({ app }) => { `, ); const rerun = await runExisting(project, { - appUrl: app.url, config: { tests: 'tests/**/*.e2e.ts', workers: 1 }, runOptions: { lastFailed: true }, }); @@ -1066,14 +937,14 @@ test('fails', async () => { test('also passes', async () => {}); `, }; - const fresh = await runProject(files, { appUrl: app.url, runOptions: { lastFailed: true } }); + const fresh = await runProject(files, { runOptions: { lastFailed: true } }); expect(fresh.outcome.exitCode).toBe(2); expect(fresh.outcome.report.run.errors.map((error) => [error.code, error.phase])).toEqual([['NO_LAST_RUN', 'collection']]); expect(fresh.outcome.report.run.errors[0]!.message).toContain(path.join(fresh.project.dir, '.e2e', 'report.json')); - const first = await runExisting(fresh.project, { appUrl: app.url }); + const first = await runExisting(fresh.project, {}); expect(first.exitCode).toBe(1); - const second = await runExisting(fresh.project, { appUrl: app.url, runOptions: { lastFailed: true } }); + const second = await runExisting(fresh.project, { runOptions: { lastFailed: true } }); expect(second.exitCode).toBe(1); const byTitle = second.results.toSorted((a, b) => a.test.declarationIndex - b.test.declarationIndex); expect(byTitle.map((result) => [result.test.title, result.selected, result.status])).toEqual([ @@ -1115,25 +986,25 @@ test('fails', async () => { const hookErrors = (outcome: RunOutcome) => outcome.report.run.errors.map((error) => [error.code, error.phase, error.scopeId, error.scope]); - const first = await runExisting(project, { appUrl: app.url }); + const first = await runExisting(project, {}); expect(first.exitCode).toBe(1); - expect(hookErrors(first)).toEqual([['HOOK_FAILED', 'afterAll', 'teardown', { file: 'tests/hooks.e2e.ts', targetId: 'web', titlePath: ['teardown'] }]]); + expect(hookErrors(first)).toEqual([['HOOK_FAILED', 'afterAll', 'teardown', { file: 'tests/hooks.e2e.ts', targetId: 'fake', titlePath: ['teardown'] }]]); assertValidReport(first.report); // The body is fixed, the hook is not: the scope runs again and still fails the run. writeFileSync(path.join(project.dir, 'tests', 'body.e2e.ts'), brokenBody.replace(`throw new Error('still broken');`, '')); - const second = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); + const second = await runExisting(project, { runOptions: { lastFailed: true } }); expect(second.exitCode).toBe(1); expect(selection(second)).toEqual([ ['fails', true, 'passed'], ['passes in the scope', true, 'passed'], ['passes outside it', false, 'skipped'], ]); - expect(hookErrors(second)).toEqual([['HOOK_FAILED', 'afterAll', 'teardown', { file: 'tests/hooks.e2e.ts', targetId: 'web', titlePath: ['teardown'] }]]); + expect(hookErrors(second)).toEqual([['HOOK_FAILED', 'afterAll', 'teardown', { file: 'tests/hooks.e2e.ts', targetId: 'fake', titlePath: ['teardown'] }]]); // The hook is fixed: its scope runs once more and the error is gone, not carried. writeFileSync(path.join(project.dir, 'tests', 'hooks.e2e.ts'), hooks('')); - const third = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); + const third = await runExisting(project, { runOptions: { lastFailed: true } }); expect(third.exitCode).toBe(0); expect(selection(third)).toEqual([ ['fails', false, 'skipped'], @@ -1141,7 +1012,7 @@ test('fails', async () => { ['passes outside it', false, 'skipped'], ]); expect(third.report.run.errors).toEqual([]); - const fourth = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); + const fourth = await runExisting(project, { runOptions: { lastFailed: true } }); expect(fourth.exitCode).toBe(2); expect(fourth.report.run.errors.map((error) => error.code)).toEqual(['NO_TESTS']); project.cleanup(); @@ -1162,7 +1033,7 @@ test('${title}', async () => { const selection = (outcome: RunOutcome) => outcome.results.toSorted((a, b) => (a.test.file < b.test.file ? -1 : 1)).map((result) => [result.test.title, result.selected, result.status, result.skip?.cause]); - const limited = await runExisting(project, { appUrl: app.url, config, runOptions: { maxFailures: 1 } }); + const limited = await runExisting(project, { config, runOptions: { maxFailures: 1 } }); expect(limited.exitCode).toBe(1); expect(selection(limited)).toEqual([ ['first', true, 'failed', undefined], @@ -1171,7 +1042,7 @@ test('${title}', async () => { // The failure limit stopped `second` before it ran, so it is owed; a rerun narrowed to `first` carries it. writeFileSync(path.join(project.dir, 'tests', 'first.e2e.ts'), `import { test } from 'e2e';\ntest('first', async () => {});\n`); - const rerun = await runExisting(project, { appUrl: app.url, config, runOptions: { lastFailed: true, grep: [/first/] } }); + const rerun = await runExisting(project, { config, runOptions: { lastFailed: true, grep: [/first/] } }); expect(rerun.exitCode).toBe(0); expect(selection(rerun)).toEqual([ ['first', true, 'passed', undefined], @@ -1181,7 +1052,7 @@ test('${title}', async () => { assertValidReport(rerun.report); // Left out by --grep, `second` is still owed: the next --last-failed runs it, and only it. - const owed = await runExisting(project, { appUrl: app.url, config, runOptions: { lastFailed: true } }); + const owed = await runExisting(project, { config, runOptions: { lastFailed: true } }); expect(owed.exitCode).toBe(1); expect(selection(owed)).toEqual([ ['first', false, 'skipped', 'filtered'], @@ -1193,165 +1064,6 @@ test('${title}', async () => { 120_000, ); - it( - '--last-failed narrowed past a file that fails to import keeps owing its tests until a run carries them out', - async () => { - const failing = (title: string) => `import { test } from 'e2e'; -test('${title}', async () => { - throw new Error('${title} is broken'); -}); -`; - const project = createProject({ 'tests/first.e2e.ts': failing('first'), 'tests/second.e2e.ts': failing('second') }); - const first = await runExisting(project, { appUrl: app.url }); - expect(first.exitCode).toBe(1); - - // `second` is owed and its file no longer imports; a rerun narrowed to `first` cannot collect it, so it carries it. - writeFileSync(path.join(project.dir, 'tests', 'first.e2e.ts'), `import { test } from 'e2e';\ntest('first', async () => {});\n`); - writeFileSync(path.join(project.dir, 'tests', 'second.e2e.ts'), `import './not-written-yet.ts';\n${failing('second')}`); - const narrowed = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true, files: ['tests/first.e2e.ts'] } }); - expect(narrowed.exitCode).toBe(0); - expect(narrowed.results.map((result) => [result.test.title, result.status])).toEqual([['first', 'passed']]); - expect(narrowed.report.run.carried?.results.map((result) => [result.titlePath.at(-1), result.status])).toEqual([['second', 'failed']]); - assertValidReport(narrowed.report); - - // Once the file imports again, the next --last-failed runs `second`, and only it. - writeFileSync(path.join(project.dir, 'tests', 'second.e2e.ts'), `import { test } from 'e2e';\ntest('second', async () => {});\n`); - const owed = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); - expect(owed.exitCode).toBe(0); - expect(owed.results.filter((result) => result.selected).map((result) => result.test.title)).toEqual(['second']); - expect(owed.report.run.carried).toBeUndefined(); - project.cleanup(); - }, - 120_000, - ); - - it( - '--last-failed carries a failed hook another filter kept from running again, until its scope runs', - async () => { - const hooks = (teardown: string) => `import { test } from 'e2e'; -test.afterAll(() => { - ${teardown} -}); -test('in the hook scope', async () => {}); -`; - const project = createProject({ - 'tests/hooks.e2e.ts': hooks(`throw new Error('teardown broke');`), - 'tests/body.e2e.ts': `import { test } from 'e2e';\ntest('body', async () => {\n throw new Error('still broken');\n});\n`, - }); - const ran = (outcome: RunOutcome) => outcome.results.filter((result) => result.selected).map((result) => [result.test.title, result.status]); - const hookScope = { file: 'tests/hooks.e2e.ts', targetId: 'web', titlePath: [] }; - - const first = await runExisting(project, { appUrl: app.url }); - expect(first.exitCode).toBe(1); - - writeFileSync(path.join(project.dir, 'tests', 'body.e2e.ts'), `import { test } from 'e2e';\ntest('body', async () => {});\n`); - const narrowed = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true, grep: [/body/] } }); - expect(narrowed.exitCode).toBe(0); - expect(ran(narrowed)).toEqual([['body', 'passed']]); - expect(narrowed.report.run.errors).toEqual([]); - expect(narrowed.report.run.carried?.results.map((result) => result.titlePath.at(-1))).toEqual(['in the hook scope']); - expect(narrowed.report.run.carried?.errors.map((error) => [error.code, error.phase, error.scope])).toEqual([['HOOK_FAILED', 'afterAll', hookScope]]); - assertValidReport(narrowed.report); - - // The scope runs again and the hook still fails: reported by the run itself, carried no more. - const again = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); - expect(again.exitCode).toBe(1); - expect(ran(again)).toEqual([['in the hook scope', 'passed']]); - expect(again.report.run.errors.map((error) => [error.code, error.scope])).toEqual([['HOOK_FAILED', hookScope]]); - expect(again.report.run.carried).toBeUndefined(); - - writeFileSync(path.join(project.dir, 'tests', 'hooks.e2e.ts'), hooks('')); - const fixed = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); - expect(fixed.exitCode).toBe(0); - expect(ran(fixed)).toEqual([['in the hook scope', 'passed']]); - expect(fixed.report.run.carried).toBeUndefined(); - project.cleanup(); - }, - 120_000, - ); - - it( - '--last-failed runs a setup whose afterAll failed again through the tests that consume its session', - async () => { - const auth = (teardown: string) => `import { test } from 'e2e'; -test.afterAll(() => { - ${teardown} -}); -test.setup('sign in', { sessions: ['member'] }, async ({ app, session }) => { - await app.open(); - await session.save('member'); -}); -`; - const project = createProject({ - 'tests/auth.e2e.ts': auth(`throw new Error('sign out broke');`), - 'tests/member.e2e.ts': `import { test } from 'e2e'; -test('signed in', { session: 'member' }, async () => {}); -`, - 'tests/other.e2e.ts': `import { test } from 'e2e'; -test('unrelated', async () => { - throw new Error('still broken'); -}); -`, - }); - const ran = (outcome: RunOutcome) => - outcome.results.filter((result) => result.selected).map((result) => [result.test.title, result.status]).toSorted(); - - const first = await runExisting(project, { appUrl: app.url }); - expect(first.exitCode).toBe(1); - expect(first.report.run.errors.map((error) => [error.code, error.phase, error.scope])).toEqual([ - ['HOOK_FAILED', 'afterAll', { file: 'tests/auth.e2e.ts', targetId: 'web', titlePath: [] }], - ]); - - writeFileSync(path.join(project.dir, 'tests', 'other.e2e.ts'), `import { test } from 'e2e'; -test('unrelated', async () => {}); -`); - // A rerun that needs none of the setup's sessions carries its failed afterAll, with no row for the setup. - const narrowed = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true, grep: [/unrelated/] } }); - expect(narrowed.exitCode).toBe(0); - expect(ran(narrowed)).toEqual([['unrelated', 'passed']]); - expect(narrowed.report.run.carried?.results.map((result) => [result.kind, result.titlePath.at(-1)])).toEqual([['setup', 'sign in']]); - expect(narrowed.report.run.carried?.errors.map((error) => error.code)).toEqual(['HOOK_FAILED']); - assertValidReport(narrowed.report); - - const second = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); - expect(second.exitCode).toBe(1); - expect(ran(second)).toEqual([ - ['sign in', 'passed'], - ['signed in', 'passed'], - ]); - expect(second.report.run.errors.map((error) => error.code)).toEqual(['HOOK_FAILED']); - - writeFileSync(path.join(project.dir, 'tests', 'auth.e2e.ts'), auth('')); - const third = await runExisting(project, { appUrl: app.url, runOptions: { lastFailed: true } }); - expect(third.exitCode).toBe(0); - expect(ran(third)).toEqual([ - ['sign in', 'passed'], - ['signed in', 'passed'], - ]); - expect(third.report.run.errors).toEqual([]); - project.cleanup(); - }, - 120_000, - ); - - it( - 'fails with NO_TESTS unless --pass-with-no-tests', - async () => { - const empty = { 'tests/empty.txt': 'not a test' }; - const first = await runProject(empty, { appUrl: app.url }); - expect(first.outcome.exitCode).toBe(2); - first.project.cleanup(); - - const second = await runProject(empty, { - appUrl: app.url, - runOptions: { passWithNoTests: true }, - }); - expect(second.outcome.exitCode).toBe(0); - second.project.cleanup(); - }, - 120_000, - ); - it( 'selects positional directories and globs, and names the ones that matched nothing', async () => { @@ -1369,14 +1081,13 @@ test('unrelated', async () => {}); const unselected = (outcome: RunOutcome) => outcome.results.filter((result) => !result.selected).map((result) => result.test.title).toSorted(); - const directory = await runProject(files, { appUrl: app.url, runOptions: { files: ['tests/agent'] } }); + const directory = await runProject(files, { runOptions: { files: ['tests/agent'] } }); expect(directory.outcome.exitCode).toBe(0); expect(titles(directory.outcome)).toEqual(['agent one', 'agent two']); expect(unselected(directory.outcome)).toEqual(['other three', 'top']); directory.project.cleanup(); const glob = await runProject(files, { - appUrl: app.url, runOptions: { files: ['tests/*/*.e2e.ts', 'tests/top.e2e.ts'] }, }); expect(glob.outcome.exitCode).toBe(0); @@ -1384,7 +1095,6 @@ test('unrelated', async () => {}); glob.project.cleanup(); const missing = await runProject(files, { - appUrl: app.url, runOptions: { files: ['tests/agnet', 'tests/*.spec.ts'] }, }); expect(missing.outcome.exitCode).toBe(2); @@ -1400,70 +1110,6 @@ test('unrelated', async () => {}); }, 120_000, ); - it( - 'closes a scope when its last test finishes and keeps same-titled siblings apart', - async () => { - const file = `import { appendFileSync } from 'node:fs'; -import { test } from 'e2e'; - -const log = (entry: string) => appendFileSync(process.env.HOOK_LOG!, entry + '\\n'); - -test.beforeAll(() => log('beforeAll:file')); -test.afterAll(() => log('afterAll:file')); - -test.describe('A', () => { - test.beforeAll(() => log('beforeAll:A')); - test.afterAll(() => log('afterAll:A')); - test.beforeEach(() => log('beforeEach:A')); - test('a1', async () => { log('body:a1'); }); - test('a2', async () => { log('body:a2'); }); -}); - -test.describe('B', () => { - test.beforeAll(() => log('beforeAll:B')); - test.afterAll(() => log('afterAll:B')); - test('b1', async () => { log('body:b1'); }); -}); - -// A second group titled "A" is a scope of its own, not a re-entry of the first. -test.describe('A', () => { - test.beforeAll(() => log('beforeAll:A2')); - test.afterAll(() => log('afterAll:A2')); - test.beforeEach(() => log('beforeEach:A2')); - test('a3', async () => { log('body:a3'); }); -}); - -test('top', async () => { log('body:top'); }); -`; - const logPath = path.join('/tmp', `e2e-scopes-${Date.now()}.log`); - process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/scopes.e2e.ts': file }, { appUrl: app.url }); - expect(outcome.exitCode).toBe(0); - // A's teardown runs once a2 is done, before B enters; the file scope - // stays open across all of them and closes with the realm. - expect(readFileSync(logPath, 'utf8').trim().split('\n')).toEqual([ - 'beforeAll:file', - 'beforeAll:A', - 'beforeEach:A', - 'body:a1', - 'beforeEach:A', - 'body:a2', - 'afterAll:A', - 'beforeAll:B', - 'body:b1', - 'afterAll:B', - 'beforeAll:A2', - 'beforeEach:A2', - 'body:a3', - 'afterAll:A2', - 'body:top', - 'afterAll:file', - ]); - project.cleanup(); - }, - 120_000, - ); - it( 'discards the realm after an afterAll failure so later tests start fresh', async () => { @@ -1487,7 +1133,7 @@ test('later', async () => { log('body:later'); }); `; const logPath = path.join('/tmp', `e2e-afterall-fail-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/afterallfail.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/afterallfail.e2e.ts': file }, {}); expect(resultByTitle(outcome, 'first').status).toBe('passed'); expect(resultByTitle(outcome, 'later').status).toBe('passed'); // The failed teardown ends that suite instance: the file scope closes @@ -1525,7 +1171,7 @@ test.describe('wizard', { serial: true, retries: 1 }, () => { test('step 2', async () => {}); }); `; - const { outcome, project } = await runProject({ 'tests/serialafterall.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/serialafterall.e2e.ts': file }, {}); const skipped = resultByTitle(outcome, 'step 2'); expect(skipped.status).toBe('skipped'); expect(skipped.skip?.cause).toBe('hook-failed'); @@ -1566,7 +1212,7 @@ test.describe('wizard', { serial: true }, () => { `; const logPath = path.join('/tmp', `e2e-serialhooks-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/serialhooks.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/serialhooks.e2e.ts': file }, {}); expect(outcome.exitCode).toBe(0); expect(readFileSync(logPath, 'utf8').trim().split('\n')).toEqual([ 'beforeAll:inner', @@ -1605,7 +1251,7 @@ test('never runs', async () => { log('body'); }); `; const logPath = path.join('/tmp', `e2e-beforeeach-${Date.now()}.log`); process.env['HOOK_LOG'] = logPath; - const { outcome, project } = await runProject({ 'tests/beforeeach.e2e.ts': file }, { appUrl: app.url }); + const { outcome, project } = await runProject({ 'tests/beforeeach.e2e.ts': file }, {}); const result = resultByTitle(outcome, 'never runs'); expect(result.status).toBe('failed'); expect(result.attempts[0]!.error?.phase).toBe('beforeEach'); @@ -1701,7 +1347,7 @@ test('passes', async ({ app }) => { await app.open(); }); process.env['HOOK_LOG'] = logPath; const { outcome, project } = await runProject( { 'tests/overrun.e2e.ts': file }, - { appUrl: app.url, config: { cleanupTimeout: 1000 } }, + { config: { cleanupTimeout: 1000 } }, ); const result = resultByTitle(outcome, 'passes'); expect(result.status).toBe('timed-out'); @@ -1713,35 +1359,4 @@ test('passes', async ({ app }) => { await app.open(); }); 120_000, ); - it( - 'times out suite hooks against their budgets and names the scope in run errors', - async () => { - const file = `import { test } from 'e2e'; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -test.afterAll(() => sleep(5000)); - -test.describe('slow scope', () => { - test.beforeAll(() => sleep(5000)); - test('unreachable', async () => {}); -}); -`; - const { outcome, project } = await runProject( - { 'tests/hooktimeouts.e2e.ts': file }, - { appUrl: app.url, config: { timeout: 1000, cleanupTimeout: 1000 } }, - ); - const skipped = resultByTitle(outcome, 'unreachable'); - expect(skipped.status).toBe('skipped'); - expect(skipped.skip?.cause).toBe('hook-failed'); - expect(outcome.report.run.errors.map((error) => [error.phase, error.scopeId, error.code])).toEqual([ - ['beforeAll', 'slow scope', 'HOOK_FAILED'], - ['afterAll', 'file', 'HOOK_FAILED'], - ]); - assertValidReport(outcome.report); - expect(outcome.exitCode).toBe(1); - project.cleanup(); - }, - 120_000, - ); }); diff --git a/packages/e2e/tests/integration/scripted-actions.test.ts b/packages/e2e/tests/integration/scripted-actions.test.ts index b822a29c7..5b9fa0ea2 100644 --- a/packages/e2e/tests/integration/scripted-actions.test.ts +++ b/packages/e2e/tests/integration/scripted-actions.test.ts @@ -1,9 +1,7 @@ /** - * Node actions, pointer actions, the keyboard, and the app hooks through the - * real runner against the scripted engine: every action kind reaches the - * engine with the ref and payload the test named, every pointer action lands - * at its point, a malformed key is refused before resolving, an engine - * refusal maps to ACTION_FAILED, and `app.open`, `back`, `restart`, and + * Node actions, the keyboard, and the app hooks through the real runner + * against the scripted engine: every action kind reaches the engine with the + * ref and payload the test named, and `app.open`, `back`, `restart`, and * `clearState` reach the session hooks. */ @@ -11,8 +9,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { FAKE_APP_URL, type RecordedSessionCall } from '../helpers/fake-engine.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; -import { SCENE_ROOT_ID } from '../helpers/scripted-scene.ts'; -import { failed, passed, scriptedSuite, step } from '../helpers/scripted-suite.ts'; +import { passed, scriptedSuite, step } from '../helpers/scripted-suite.ts'; const ACTIONS = `import { test, expect } from 'e2e'; @@ -68,26 +65,6 @@ test('every node action reaches the engine', async ({ app, screen }) => { await expect(screen.getByRole('button', { name: 'Bold', pressed: true })).toBeVisible(); }); -test('every pointer action reaches the engine', async ({ app, screen }) => { - await app.open('/'); - await screen.tapAt({ x: 5, y: 6 }); - await screen.getByRole('image', { name: 'Map' }).tap({ position: { x: 10, y: 20 } }); - await screen.getByRole('image', { name: 'Map' }).click({ position: { x: 0, y: 0 } }); - await screen.swipe({ from: { x: 1, y: 2 }, to: { x: 3, y: 4 } }); - await screen.swipe({ direction: 'up', momentum: 'fast' }); - await screen.swipe({ direction: 'down' }); -}); - -test('a malformed key is INVALID_ARGUMENT', async ({ app, screen }) => { - await app.open('/'); - await screen.getByLabel('City').press('Bogus'); -}); - -test('an unknown option is ACTION_FAILED', async ({ app, screen }) => { - await app.open('/'); - await screen.getByLabel('Plan').selectOption('Enterprise'); -}); - test('app hooks reach the session', async ({ app, screen }) => { await app.open('/'); await app.open('/settings'); @@ -98,7 +75,7 @@ test('app hooks reach the session', async ({ app, screen }) => { }); `; -describe('scripted engine: actions, pointer actions, and app hooks', () => { +describe('scripted engine: actions and app hooks', () => { const run = scriptedSuite('actions.e2e.ts', ACTIONS); it('dispatches every action kind with the expected ref and payload', () => { @@ -157,33 +134,6 @@ describe('scripted engine: actions, pointer actions, and app hooks', () => { expect(step(attempt, 'locator.pressSequentially').label).toBe('getByLabel("City")'); }); - it('dispatches every pointer action at the point the test named', () => { - const attempt = passed(run.outcome, 'every pointer action reaches the engine'); - expect(run.fake.callsOf(attempt.id, 'performAt').map((entry) => [entry.point, entry.action])).toEqual([ - [{ x: 5, y: 6 }, { kind: 'tap' }], - [{ x: 110, y: 320 }, { kind: 'tap' }], - [{ x: 100, y: 300 }, { kind: 'tap' }], - [{ x: 1, y: 2 }, { kind: 'swipeTo', target: { x: 3, y: 4 } }], - ]); - // A positioned tap scrolls the node into view first; a direction swipe is the root swipe. - expect(run.fake.callsOf(attempt.id, 'perform').map((entry) => [entry.id, entry.action])).toEqual([ - ['map', { kind: 'scrollIntoView' }], - ['map', { kind: 'scrollIntoView' }], - [SCENE_ROOT_ID, { kind: 'swipe', direction: 'up', momentum: 'fast' }], - [SCENE_ROOT_ID, { kind: 'swipe', direction: 'down' }], - ]); - expect(step(attempt, 'screen.tapAt').label).toBe('(5, 6)'); - expect(step(attempt, 'screen.swipe').label).toBe('(1, 2) → (3, 4)'); - expect(step(attempt, 'locator.tap').label).toBe('getByRole("image", name: "Map") at (10, 20)'); - }); - - it('refuses a malformed key before resolving and maps an engine refusal to ACTION_FAILED', () => { - const malformed = failed(run.outcome, 'a malformed key is INVALID_ARGUMENT', 'INVALID_ARGUMENT', 'press key "Bogus" is not a key'); - expect(run.fake.callsOf(malformed.id, 'perform')).toEqual([]); - const refused = failed(run.outcome, 'an unknown option is ACTION_FAILED', 'ACTION_FAILED', 'no option matches "Enterprise"'); - expect(run.fake.callsOf(refused.id, 'perform')).toHaveLength(1); - }); - it('routes app.open, back, restart, and clearState to the session hooks', () => { const attempt = passed(run.outcome, 'app hooks reach the session'); const session = (method: RecordedSessionCall['method'], url?: string): RecordedSessionCall => ({ diff --git a/packages/e2e/tests/integration/scripted-capabilities.test.ts b/packages/e2e/tests/integration/scripted-capabilities.test.ts deleted file mode 100644 index 0a6018daa..000000000 --- a/packages/e2e/tests/integration/scripted-capabilities.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Undeclared capabilities through the real runner against a scripted engine - * that declares only `tap` and `focus`, the `tap` pointer kind, and no - * keyboard: a declared action still works, a positioned tap skips the - * `scrollIntoView` the engine lacks, and every undeclared kind fails with - * UNSUPPORTED_CAPABILITY before reaching the engine. - */ - -import { describe, expect, it } from 'vitest'; -import { assertValidReport } from '../helpers/report-schema.ts'; -import { failed, passed, scriptedSuite } from '../helpers/scripted-suite.ts'; - -const LIMITED = `import { test } from 'e2e'; - -test('a declared action still works', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Submit' }).tap(); -}); - -test('a positioned tap skips scrollIntoView when the engine lacks it', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('image', { name: 'Map' }).tap({ position: { x: 1, y: 2 } }); -}); - -test('an undeclared node action is UNSUPPORTED_CAPABILITY', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Submit' }).hover(); -}); - -test('an undeclared pointer action is UNSUPPORTED_CAPABILITY', async ({ app, screen }) => { - await app.open('/'); - await screen.swipe({ from: { x: 1, y: 2 }, to: { x: 3, y: 4 } }); -}); - -test('a direction swipe needs the swipe action', async ({ app, screen }) => { - await app.open('/'); - await screen.swipe({ direction: 'down' }); -}); - -test('pressSequentially needs a keyboard', async ({ app, screen }) => { - await app.open('/'); - await screen.getByLabel('City').pressSequentially('Wa'); -}); -`; - -describe('scripted engine: undeclared capabilities', () => { - const run = scriptedSuite('limited.e2e.ts', LIMITED, { actions: ['tap', 'focus'], pointerActions: ['tap'], keyboard: false }); - - it('honors what the engine declares', () => { - assertValidReport(run.outcome.report); - const tapped = passed(run.outcome, 'a declared action still works'); - expect(run.fake.callsOf(tapped.id, 'perform').map((entry) => entry.action.kind)).toEqual(['tap']); - const positioned = passed(run.outcome, 'a positioned tap skips scrollIntoView when the engine lacks it'); - expect(run.fake.callsOf(positioned.id, 'perform')).toEqual([]); - expect(run.fake.callsOf(positioned.id, 'performAt').map((entry) => entry.point)).toEqual([{ x: 101, y: 302 }]); - }); - - it('fails an undeclared kind with UNSUPPORTED_CAPABILITY before reaching the engine', () => { - const cases: [string, string][] = [ - ['an undeclared node action is UNSUPPORTED_CAPABILITY', 'the "hover" action is not available on this target: its engine declares tap, focus'], - ['an undeclared pointer action is UNSUPPORTED_CAPABILITY', 'the "swipeTo" action at a point is not available on this target: its engine declares tap'], - ['a direction swipe needs the swipe action', 'target "fake" has no engine capability for swipe gestures: engine fake does not implement it'], - ['pressSequentially needs a keyboard', 'pressSequentially is not available on this target: its engine declares no keyboard'], - ]; - for (const [title, message] of cases) { - const attempt = failed(run.outcome, title, 'UNSUPPORTED_CAPABILITY', message); - expect(attempt.error?.category, title).toBe('configuration'); - expect(run.fake.callsOf(attempt.id, 'perform'), title).toEqual([]); - expect(run.fake.callsOf(attempt.id, 'performAt'), title).toEqual([]); - } - }); -}); diff --git a/packages/e2e/tests/integration/scripted-matchers.test.ts b/packages/e2e/tests/integration/scripted-matchers.test.ts index 89f03a8ec..3b3592cd6 100644 --- a/packages/e2e/tests/integration/scripted-matchers.test.ts +++ b/packages/e2e/tests/integration/scripted-matchers.test.ts @@ -1,6 +1,6 @@ /** * `expect(locator)` through the real runner against the scripted engine: - * every matcher and its negation pass on the reference screen, every failure + * every matcher and a few negations pass on the reference screen, every failure * names the observed state, a matcher honors its timeout, and an ambiguous * locator fails at once. */ @@ -44,26 +44,13 @@ test('every matcher passes on the scripted screen', async ({ app, screen }) => { await expect(screen.getByTestId('missing')).toHaveCount(0); }); -test('every negated matcher passes on the scripted screen', async ({ app, screen }) => { +test('negated matchers pass on the scripted screen', async ({ app, screen }) => { await app.open('/'); // Half a second: a negation holds for the smaller of the grace window and the // budget, and the first observation must land inside the budget on a loaded CI box. const soon = { timeout: 500 }; await expect(screen.getByText('Hidden content')).not.toBeVisible(soon); - await expect(screen.getByRole('heading', { name: 'Dashboard' })).not.toBeHidden(soon); - await expect(screen.getByText('Never rendered')).not.toBeAttached(soon); - await expect(screen.getByRole('button', { name: 'Disabled action' })).not.toBeEnabled(soon); - await expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled(soon); - await expect(screen.getByRole('checkbox', { name: 'Notifications' })).not.toBeChecked(soon); - await expect(screen.getByRole('tab', { name: 'Open' })).not.toBeSelected(soon); - await expect(screen.getByRole('button', { name: 'Menu' })).not.toBeExpanded(soon); - await expect(screen.getByLabel('Email')).not.toBeFocused(soon); - await expect(screen.getByRole('heading', { level: 1 })).not.toHaveText('Recent', soon); - await expect(screen.getByTestId('card')).not.toContainText('footer', soon); await expect(screen.getByLabel('Notes')).not.toHaveValue('line1 line2', soon); - await expect(screen.getByTestId('card')).not.toHaveAttribute('hidden', soon); - await expect(screen.getByTestId('card')).not.toHaveAttribute('class', 'card inactive', soon); - await expect(screen.getByRole('list', { name: 'Todos' })).not.toHaveAccessibleName('Done', soon); // The third todo arrives on the scene's clock; wait for it so the negation is about the settled count. await expect(screen.getByTestId('todo')).toHaveCount(3); await expect(screen.getByTestId('todo')).not.toHaveCount(2, soon); @@ -225,7 +212,7 @@ const SECURE_DENIALS = [ const TITLES = [ 'every matcher passes on the scripted screen', - 'every negated matcher passes on the scripted screen', + 'negated matchers pass on the scripted screen', ...FAILURES.map(([title]) => title), 'a matcher honors its timeout', 'an ambiguous locator fails an assertion at once', @@ -235,12 +222,9 @@ const TITLES = [ describe('scripted engine: expect(locator) matchers', () => { const run = scriptedSuite('matchers.e2e.ts', MATCHERS); - it('produces a valid report with one result per test in the file', () => { + it('passes every matcher and its negation, in a valid report with one result per test', () => { assertValidReport(run.outcome.report); expect(run.outcome.results.map((result) => result.test.title).toSorted()).toEqual(TITLES.toSorted()); - }); - - it('passes every matcher and its negation', () => { const attempt = passed(run.outcome, 'every matcher passes on the scripted screen'); const apis = new Set(attempt.steps.map((entry) => entry.api)); for (const matcher of [ @@ -251,8 +235,8 @@ describe('scripted engine: expect(locator) matchers', () => { expect(apis, matcher).toContain(`expect.${matcher}`); } expect(attempt.steps.every((entry) => entry.status === 'passed')).toBe(true); - const negated = passed(run.outcome, 'every negated matcher passes on the scripted screen'); - expect(negated.steps.filter((entry) => entry.api.startsWith('expect.not.'))).toHaveLength(17); + const negated = passed(run.outcome, 'negated matchers pass on the scripted screen'); + expect(negated.steps.filter((entry) => entry.api.startsWith('expect.not.'))).toHaveLength(4); expect(step(negated, 'expect.not.toBeVisible')).toMatchObject({ kind: 'assertion', label: 'getByText("Hidden content")' }); }); diff --git a/packages/e2e/tests/integration/scripted-polling.test.ts b/packages/e2e/tests/integration/scripted-polling.test.ts index 62bab05e7..63761dcf8 100644 --- a/packages/e2e/tests/integration/scripted-polling.test.ts +++ b/packages/e2e/tests/integration/scripted-polling.test.ts @@ -1,16 +1,11 @@ /** - * Polling, staleness, failure mapping, and scrolling through the real runner - * against the scripted engine: matchers poll until the tree catches up, a - * negated matcher holds for its grace window, a missing node is reported - * after the deadline and an ambiguous one at once, a stale node is - * re-resolved once, engine refusals map to ACTION_FAILED without a repeat, - * and `scrollUntilVisible` swipes the viewport until the target shows. + * Polling through the real runner against the scripted engine: matchers poll + * until the tree catches up, and a negated matcher holds for its grace window. */ import { describe, expect, it } from 'vitest'; import { assertValidReport } from '../helpers/report-schema.ts'; -import { SCENE_ROOT_ID } from '../helpers/scripted-scene.ts'; -import { failed, passed, scriptedSuite, step } from '../helpers/scripted-suite.ts'; +import { passed, scriptedSuite, step } from '../helpers/scripted-suite.ts'; const POLLING = `import { test, expect } from 'e2e'; @@ -48,52 +43,9 @@ test('a negated matcher waits for the flip, then the grace window', async ({ app await expect(screen.getByRole('status', { name: 'Loading' })).not.toBeVisible({ timeout: 2500 }); await screen.getByRole('status', { name: 'Loading' }).waitFor({ state: 'hidden' }); }); - -test('waitFor attached and detached read presence, not visibility', async ({ app, screen }) => { - await app.open('/'); - await screen.getByText('Hidden content').waitFor({ state: 'attached' }); - await screen.getByText('Never rendered').waitFor({ state: 'detached' }); -}); - -test('a missing node is LOCATOR_NOT_FOUND after the deadline', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Never exists' }).tap({ timeout: 250 }); -}); - -test('an ambiguous locator fails an action at once', async ({ app, screen }) => { - await app.open('/'); - await screen.getByText('Duplicated').tap({ timeout: 3000 }); -}); - -test('a stale node is re-resolved and the action retried once', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Stale' }).tap(); -}); - -test('a possibly committed action is ACTION_FAILED and never repeated', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Commit' }).tap(); -}); - -test('a non-actionable node is ACTION_FAILED', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Flaky' }).tap(); -}); - -test('scrollUntilVisible reveals a node after two root swipes', async ({ app, screen }) => { - await app.open('/'); - await expect(screen.getByRole('button', { name: 'Below the fold' })).toHaveCount(0); - await screen.scrollUntilVisible(screen.getByRole('button', { name: 'Below the fold' }), { timeout: 3000 }); - await expect(screen.getByRole('button', { name: 'Below the fold' })).toBeVisible(); -}); - -test('scrollUntilVisible times out with LOCATOR_NOT_FOUND', async ({ app, screen }) => { - await app.open('/'); - await screen.scrollUntilVisible(screen.getByRole('button', { name: 'Never exists' }), { timeout: 350, direction: 'up' }); -}); `; -describe('scripted engine: polling, staleness, failure mapping, and scrolling', () => { +describe('scripted engine: polling', () => { const run = scriptedSuite('polling.e2e.ts', POLLING); it('polls until the tree catches up', () => { @@ -112,49 +64,4 @@ describe('scripted engine: polling, staleness, failure mapping, and scrolling', const flipped = passed(run.outcome, 'a negated matcher waits for the flip, then the grace window'); expect(step(flipped, 'expect.not.toBeVisible').durationMs).toBeGreaterThanOrEqual(1_150); }); - - it('waits for presence with the attached and detached states', () => { - const attempt = passed(run.outcome, 'waitFor attached and detached read presence, not visibility'); - expect(attempt.steps.filter((entry) => entry.api === 'locator.waitFor').map((entry) => entry.label)).toEqual([ - 'getByText("Hidden content") → attached', - 'getByText("Never rendered") → detached', - ]); - }); - - it('reports a missing node after the deadline and an ambiguous one at once', () => { - const missing = failed(run.outcome, 'a missing node is LOCATOR_NOT_FOUND after the deadline', 'LOCATOR_NOT_FOUND', 'matched no nodes within'); - expect(missing.error?.details).toMatchObject({ role: 'button', name: 'Never exists', waitedMs: expect.any(Number) }); - expect(missing.error?.details?.waitedMs).toBeGreaterThanOrEqual(250); - expect(run.fake.callsOf(missing.id, 'perform')).toEqual([]); - const ambiguous = failed(run.outcome, 'an ambiguous locator fails an action at once', 'LOCATOR_AMBIGUOUS', 'matched 2 nodes'); - // At once: well inside the 3000 ms the test allowed the action. - expect(step(ambiguous, 'locator.tap').durationMs).toBeLessThan(3_000); - expect(run.fake.callsOf(ambiguous.id, 'perform')).toEqual([]); - }); - - it('re-resolves a stale node and retries the action once', () => { - const attempt = passed(run.outcome, 'a stale node is re-resolved and the action retried once'); - expect(run.fake.callsOf(attempt.id, 'perform').map((entry) => entry.id)).toEqual(['stale', 'stale~relocated']); - }); - - it('maps engine refusals to ACTION_FAILED without repeating them', () => { - const committed = failed(run.outcome, 'a possibly committed action is ACTION_FAILED and never repeated', 'ACTION_FAILED', 'the tap may have landed'); - expect(run.fake.callsOf(committed.id, 'perform')).toHaveLength(1); - expect(committed.error?.category).toBe('test'); - const blocked = failed(run.outcome, 'a non-actionable node is ACTION_FAILED', 'ACTION_FAILED', 'covered by an overlay'); - expect(run.fake.callsOf(blocked.id, 'perform')).toHaveLength(1); - }); - - it('scrolls the viewport until the target shows, or reports LOCATOR_NOT_FOUND', () => { - const revealed = passed(run.outcome, 'scrollUntilVisible reveals a node after two root swipes'); - expect(run.fake.callsOf(revealed.id, 'perform').map((entry) => [entry.id, entry.action])).toEqual([ - [SCENE_ROOT_ID, { kind: 'swipe', direction: 'down', momentum: 'slow' }], - [SCENE_ROOT_ID, { kind: 'swipe', direction: 'down', momentum: 'slow' }], - ]); - expect(step(revealed, 'screen.scrollUntilVisible').label).toBe('getByRole("button", name: "Below the fold")'); - const gaveUp = failed(run.outcome, 'scrollUntilVisible times out with LOCATOR_NOT_FOUND', 'LOCATOR_NOT_FOUND', 'did not become visible while scrolling'); - const swipes = run.fake.callsOf(gaveUp.id, 'perform'); - expect(swipes.length).toBeGreaterThanOrEqual(1); - expect(swipes.every((entry) => entry.id === SCENE_ROOT_ID && entry.action.kind === 'swipe' && entry.action.direction === 'up')).toBe(true); - }); }); diff --git a/packages/e2e/tests/integration/scripted-reads.test.ts b/packages/e2e/tests/integration/scripted-reads.test.ts index 9be8f8b43..54bcb0ae7 100644 --- a/packages/e2e/tests/integration/scripted-reads.test.ts +++ b/packages/e2e/tests/integration/scripted-reads.test.ts @@ -1,9 +1,7 @@ /** - * Locator reads and queries through the real runner against the scripted - * engine: every read answers from the current tree without waiting, secure - * fields refuse value reads, strictness applies to reads, every query kind - * and refinement resolves, and malformed refinements are refused before any - * query runs. + * Locator reads through the real runner against the scripted engine: every + * read answers from the current tree without waiting, secure fields refuse + * value reads, and malformed refinements are refused before any query runs. */ import { describe, expect, it } from 'vitest'; @@ -76,75 +74,6 @@ test('allTextContents over a secure field is denied', async ({ app, screen }) => await screen.getByRole('textbox').allTextContents(); }); -test('a read of zero matches is LOCATOR_NOT_FOUND', async ({ app, screen }) => { - await app.open('/'); - await screen.getByText('Never rendered').textContent(); -}); - -test('a read of several matches is LOCATOR_AMBIGUOUS', async ({ app, screen }) => { - await app.open('/'); - await screen.getByText('Duplicated').textContent(); -}); - -test('isVisible on several matches is LOCATOR_AMBIGUOUS', async ({ app, screen }) => { - await app.open('/'); - await screen.getByText('Duplicated').isVisible(); -}); - -test('isDisabled on zero matches is LOCATOR_NOT_FOUND', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Never exists' }).isDisabled(); -}); - -test('waitFor times out with LOCATOR_NOT_FOUND', async ({ app, screen }) => { - await app.open('/'); - await screen.getByRole('button', { name: 'Never exists' }).waitFor({ timeout: 50 }); -}); - -test('every query kind and refinement resolves', async ({ app, screen }) => { - await app.open('/'); - await expect(screen.getByRole('button', { name: 'Submit' })).toBeVisible(); - await expect(screen.getByRole('button', { name: 'sub', exact: false })).toBeVisible(); - await expect(screen.getByRole('button', { name: /^Sub/ })).toBeVisible(); - await expect(screen.getByRole('img', { name: 'Map' })).toBeVisible(); - await expect(screen.getByRole('heading', { level: 2 })).toHaveText('Recent'); - await expect(screen.getByRole('heading')).toHaveCount(2); - await expect(screen.getByRole('tab', { selected: true })).toHaveText('All'); - await expect(screen.getByRole('button', { disabled: true })).toHaveAccessibleName('Disabled action'); - await expect(screen.getByRole('checkbox', { checked: true })).toHaveCount(1); - await expect(screen.getByRole('button', { name: 'Menu', expanded: false })).toBeVisible(); - await expect(screen.getByRole('button', { name: 'Bold', pressed: false })).toBeVisible(); - await expect(screen.getByText('Ready')).toHaveCount(2); - await expect(screen.getByRole('status', { name: 'Ready state' })).toHaveCount(1); - await expect(screen.getByText('Ready', { visible: true })).toHaveCount(1); - await expect(screen.getByText('Ready', { visible: true })).toBeVisible(); - await expect(screen.getByLabel('Email')).toBeVisible(); - await expect(screen.getByLabel('email', { exact: false })).toBeVisible(); - await expect(screen.getByLabel(/^Em/)).toBeVisible(); - await expect(screen.getByPlaceholder('you@example.test')).toHaveAccessibleName('Email'); - await expect(screen.getByPlaceholder('EXAMPLE', { exact: false })).toHaveCount(1); - await expect(screen.getByText('Card body')).toBeVisible(); - await expect(screen.getByText('card BODY', { exact: false })).toBeVisible(); - await expect(screen.getByText(/^Card/)).toBeVisible(); - await expect(screen.getByText('Echoed')).toHaveCount(1); - await expect(screen.getByDisplayValue('hello-value')).toHaveAccessibleName('Search'); - await expect(screen.getByDisplayValue(/^hello/)).toHaveCount(1); - await expect(screen.getByDisplayValue('hunter2')).toHaveCount(0); - await expect(screen.getByTestId('card')).toBeVisible(); - await expect(screen.getByTestId('todos').getByRole('checkbox')).toHaveCount(3); - await expect(screen.getByTestId('todos').getByRole('listitem').filter({ hasText: 'ship' })).toHaveText('Ship runner'); - await expect(screen.getByTestId('todo').filter({ hasText: /^Write/ })).toHaveCount(1); - await expect(screen.getByTestId('todo').filter({ has: screen.getByRole('checkbox', { checked: true }) })).toHaveText('Ship runner'); - await expect(screen.getByTestId('todo').filter({ hasText: 'Release', has: screen.getByRole('checkbox') })).toHaveCount(1); - await expect(screen.getByTestId('todo').filter({ hasText: 'Release', has: screen.getByRole('checkbox', { checked: true }) })).toHaveCount(0); - await expect(screen.getByTestId('todo').first()).toHaveText('Write spec'); - await expect(screen.getByTestId('todo').last()).toHaveText('Release'); - await expect(screen.getByTestId('todo').nth(1)).toHaveText('Ship runner'); - await expect(screen.getByTestId('todo').nth(7)).toHaveCount(0); - await expect(screen.getByTestId('todo').filter({ hasText: 'Ship' }).getByRole('checkbox')).toBeChecked(); - await expect(screen.getByRole('list', { name: 'Todos' }).getByText('Release')).toBeVisible(); -}); - test('filter without a predicate is INVALID_LOCATOR', async ({ app, screen }) => { await app.open('/'); screen.getByTestId('todo').filter({}); @@ -183,19 +112,6 @@ describe('scripted engine: locator reads and queries', () => { } }); - it('applies strictness to reads', () => { - failed(run.outcome, 'a read of zero matches is LOCATOR_NOT_FOUND', 'LOCATOR_NOT_FOUND', 'matched no nodes', 'getByText("Never rendered")'); - failed(run.outcome, 'a read of several matches is LOCATOR_AMBIGUOUS', 'LOCATOR_AMBIGUOUS', 'matched 2 nodes, expected exactly one'); - failed(run.outcome, 'isVisible on several matches is LOCATOR_AMBIGUOUS', 'LOCATOR_AMBIGUOUS', 'matched 2 nodes'); - failed(run.outcome, 'isDisabled on zero matches is LOCATOR_NOT_FOUND', 'LOCATOR_NOT_FOUND', 'matched no nodes'); - const waited = failed(run.outcome, 'waitFor times out with LOCATOR_NOT_FOUND', 'LOCATOR_NOT_FOUND', 'did not become visible'); - expect(step(waited, 'locator.waitFor').status).toBe('failed'); - }); - - it('resolves every query kind and refinement', () => { - passed(run.outcome, 'every query kind and refinement resolves'); - }); - it('rejects malformed refinements before any query runs', () => { failed(run.outcome, 'filter without a predicate is INVALID_LOCATOR', 'INVALID_LOCATOR', 'filter() requires hasText and/or has'); failed(run.outcome, 'a negative index is INVALID_LOCATOR', 'INVALID_LOCATOR', 'nonnegative integer'); diff --git a/packages/e2e/tests/integration/selected-secrets.test.ts b/packages/e2e/tests/integration/selected-secrets.test.ts index 7defb5cd9..b0b765643 100644 --- a/packages/e2e/tests/integration/selected-secrets.test.ts +++ b/packages/e2e/tests/integration/selected-secrets.test.ts @@ -28,7 +28,7 @@ test('selects part of a secret in a plain field', async ({ app, agent, screen }) await agent.assert('part of the plain token is selected'); await screen.getByLabel('Token').fill(credentials.user('member').password); await agent.assert('part of the secret token is selected'); - await screen.getByRole('button', { name: 'Not on this page' }).tap(); + await screen.getByRole('button', { name: 'Not on this page' }).tap({ timeout: 500 }); }); `; diff --git a/packages/e2e/tests/integration/skip-after-failure.test.ts b/packages/e2e/tests/integration/skip-after-failure.test.ts index b6950b5a1..d551374d2 100644 --- a/packages/e2e/tests/integration/skip-after-failure.test.ts +++ b/packages/e2e/tests/integration/skip-after-failure.test.ts @@ -81,9 +81,9 @@ describe('a runtime skip after a failure', () => { expect(output).toContain('Skipped After Failure 1'); }); - it.each(['', 'failOnSkippedFailure: true,'])('shows the original failure when a retry skips (%s)', (policy) => { - const { report, output, exitCode } = runSkipped(RETRY_SKIP, policy); - expect(exitCode).toBe(policy === '' ? 0 : 1); + it('shows the original failure when a retry skips', () => { + const { report, output, exitCode } = runSkipped(RETRY_SKIP, 'failOnSkippedFailure: true,'); + expect(exitCode).toBe(1); expect(report.run.results[0]?.attempts.map((attempt) => attempt.status)).toEqual(['failed', 'skipped']); expect(output).toContain('Skipped After Failure 1'); expect(output).toContain('original failure: expected 1 to be 2'); @@ -127,15 +127,4 @@ describe('a runtime skip after a failure', () => { expect(exitCode).toBe(0); expect(output).not.toContain('Skipped After Failure'); }); - - it('shows the original failure even when the skipped retry also fails cleanup', () => { - const { output, exitCode } = runSkipped( - RETRY_SKIP, - 'failOnSkippedFailure: true,', - 0, - `async endAttempt() { throw new Error('cleanup failed'); },`, - ); - expect(exitCode).toBe(1); - expect(output).toContain('original failure: expected 1 to be 2'); - }); }); diff --git a/packages/e2e/tests/integration/trace-cache-replay.test.ts b/packages/e2e/tests/integration/trace-cache-replay.test.ts index 1054f023a..42e5188c0 100644 --- a/packages/e2e/tests/integration/trace-cache-replay.test.ts +++ b/packages/e2e/tests/integration/trace-cache-replay.test.ts @@ -6,7 +6,7 @@ * an entry may hold is checked against the report and the entry files. */ -import { writeFileSync } from 'node:fs'; +import { readFileSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -14,6 +14,7 @@ import type { TraceEntry } from '../../src/cache/trace.ts'; import { main } from '../../src/cli/index.ts'; import type { E2EConfig } from '../../src/index.ts'; import { loopCalls } from '../helpers/fake-loop-model.ts'; +import { assertValidReport } from '../helpers/report-schema.ts'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { createProject, runExisting, webTarget, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; import { @@ -35,6 +36,14 @@ import { const FLOWS_FILE = 'tests/flows.e2e.ts'; +/** The counter flow's entry file: its bytes and modification time, to prove a replay left it alone. */ +function counterEntryFile(project: FixtureProject): { bytes: string; mtimeMs: number } { + const counter = readEntries(project).filter(({ entry }) => entry.payload.recordedFor?.testId.endsWith(`::${encodeURIComponent('increments the counter')}`)); + expect(counter).toHaveLength(1); + const { file } = counter[0]!; + return { bytes: readFileSync(file, 'utf8'), mtimeMs: statSync(file).mtimeMs }; +} + /** One entry per act step every flow makes. */ const ENTRY_COUNT = FLOWS.reduce((total, flow) => total + flow.actions.length, 0); @@ -45,6 +54,8 @@ describe('trace cache: every flow records on the first run and replays without a let recordedModelCalls = 0; /** The archive entry as the first run wrote it; the second run opens the record under another id. */ let recordedArchive: TraceEntry | undefined; + /** The counter entry's bytes and modification time as the first run wrote them. */ + let recordedCounter: { bytes: string; mtimeMs: number } | undefined; let replayed: RunOutcome; beforeAll(async () => { @@ -53,6 +64,7 @@ describe('trace cache: every flow records on the first run and replays without a recorded = await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()) }); recordedModelCalls = loopCalls.length; [recordedArchive] = entriesFor(project, 'archives a record'); + recordedCounter = counterEntryFile(project); writeFileSync(path.join(project.dir, FLOWS_FILE), flowsSuite(FLOWS, 'replay'), 'utf8'); // The twins form moves every node, and the offers reverse, under the URL the recording saw. app.setVariant('b'); @@ -64,9 +76,11 @@ describe('trace cache: every flow records on the first run and replays without a await app?.close(); }); - it('passes every flow on both runs', () => { + it('passes every flow on both runs and reports both in the schema', () => { expectPassed(recorded); expectPassed(replayed); + assertValidReport(recorded.report); + assertValidReport(replayed.report); }); it('misses every step on the first run and writes one entry per act step', () => { @@ -81,6 +95,7 @@ describe('trace cache: every flow records on the first run and replays without a expect(entriesFor(project, flow.title), flow.title).toHaveLength(flow.actions.length); } expect(readEntries(project)).toHaveLength(ENTRY_COUNT); + expect(readEntries(project).map(({ entry }) => entry.schemaVersion)).toEqual(Array.from({ length: ENTRY_COUNT }, () => 'trace-1')); }); it('replays every step on the second run with zero model calls across the whole run', () => { @@ -93,6 +108,10 @@ describe('trace cache: every flow records on the first run and replays without a expect(readEntries(project)).toHaveLength(ENTRY_COUNT); }); + it('never rewrites an entry the cache replayed whole: the file keeps its bytes and its modification time', () => { + expect(counterEntryFile(project)).toEqual(recordedCounter); + }); + it('keeps a unique() value as a slot in the typed text, the percent-encoded path, and the anchors', () => { const [entry] = entriesFor(project, 'creates a company'); const { payload } = entry!; @@ -168,9 +187,11 @@ const DIVERGENCE: readonly Flow[] = [ { ...archives, title: 'retires a record', open: () => '/records/2b3c4d5e6f7a' }, ]; -describe('trace cache: a changed screen hands the step to the agent, which re-records it', () => { +describe('trace cache: a changed screen hands the step to the agent, which re-records it, and --strict-cache fails it instead', () => { let app: FixtureApp; let project: FixtureProject; + let strict: RunOutcome; + let strictModelCalls = 0; let replayed: RunOutcome; beforeAll(async () => { @@ -179,6 +200,9 @@ describe('trace cache: a changed screen hands the step to the agent, which re-re expectPassed(await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()) })); writeFileSync(path.join(project.dir, FLOWS_FILE), flowsSuite(DIVERGENCE, 'replay'), 'utf8'); app.setVariant('renamed'); + // A strict run first: it keeps the stale entries it failed on, so the lenient run after it meets the same recordings. + strict = await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()), runOptions: { strictCache: true } }); + strictModelCalls = loopCalls.length; replayed = await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()) }); }, 240_000); @@ -187,7 +211,23 @@ describe('trace cache: a changed screen hands the step to the agent, which re-re await app?.close(); }); - it('passes every flow on the second run too', () => { + it('fails every diverged step with REPLAY_STALE, exit 2, without a model call or a retry', () => { + expect(strict.exitCode).toBe(2); + expect(strictModelCalls).toBe(0); + for (const [title, reason] of [ + ['creates a company', 'target-not-found'], + ['archives a record', 'wrong-context'], + ['retires a record', 'target-not-found'], + ] as const) { + const step = onlyActStep(strict, title); + expect(step.error?.code, title).toBe('REPLAY_STALE'); + expect(step.error?.message, title).toContain(reason); + expect(step.error?.message, title).toContain('re-record it with a read-write run without --strict-cache and commit the changed entry under .e2e/cache'); + expect(step.cache?.reason, title).toBe(reason); + } + }); + + it('passes every flow on the lenient run', () => { expectPassed(replayed); }); @@ -219,44 +259,6 @@ describe('trace cache: a changed screen hands the step to the agent, which re-re }); }); -describe('trace cache: --strict-cache fails a stale recording instead of handing it to the agent', () => { - let app: FixtureApp; - let project: FixtureProject; - let strict: RunOutcome; - let strictModelCalls = 0; - - beforeAll(async () => { - app = await startFixtureApp(); - project = createProject({ [FLOWS_FILE]: flowsSuite(DIVERGENCE, 'record') }); - expectPassed(await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()) })); - writeFileSync(path.join(project.dir, FLOWS_FILE), flowsSuite(DIVERGENCE, 'replay'), 'utf8'); - app.setVariant('renamed'); - strict = await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()), runOptions: { strictCache: true } }); - strictModelCalls = loopCalls.length; - }, 240_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it('fails every diverged step with REPLAY_STALE, exit 2, without a model call or a retry', () => { - expect(strict.exitCode).toBe(2); - expect(strictModelCalls).toBe(0); - for (const [title, reason] of [ - ['creates a company', 'target-not-found'], - ['archives a record', 'wrong-context'], - ['retires a record', 'target-not-found'], - ] as const) { - const step = onlyActStep(strict, title); - expect(step.error?.code, title).toBe('REPLAY_STALE'); - expect(step.error?.message, title).toContain(reason); - expect(step.error?.message, title).toContain('re-record it with a read-write run without --strict-cache and commit the changed entry under .e2e/cache'); - expect(step.cache?.reason, title).toBe(reason); - } - }); -}); - /** The one-flow suite the target, repeat, and re-keyed suites run: the counter, incremented twice. */ const COUNTER_SUITE = flowsSuite([flowByTitle('increments the counter')], 'record'); diff --git a/packages/e2e/tests/integration/trace-cache-speed.test.ts b/packages/e2e/tests/integration/trace-cache-speed.test.ts deleted file mode 100644 index 33792b41d..000000000 --- a/packages/e2e/tests/integration/trace-cache-speed.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * How fast a trace replay is against the deterministic API it stands in for. - * One flow, three ways, on the fixture app: `locator.fill` and `locator.click` - * as a test would write them, the same flow as an `agent.act` recorded through - * a scripted model, and that recording replayed with no model call. Both sides - * are measured alike, as the whole flow after `app.open` with its trailing - * assertion, from the report's step timings, and the fastest repetition after - * the first stands for each: the first pays for the warm-up, and a loaded - * runner slows any single one. The gate is a small factor of the twin plus a - * ceiling well above what a healthy replay takes; like the other PR gates, it - * catches a replay that stopped being cheap, not a benchmark regression. - */ - -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createProject, resultByTitle, runExisting, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; -import { cacheConfig, flowByTitle, flowsModel, flowsSuite, onlyActStep } from '../helpers/trace-cache.ts'; - -const REPETITIONS = [1, 2, 3, 4] as const; -/** A replay may take this many times its deterministic twin. */ -const REPLAY_RATIO = 3; -/** And never longer than this, whatever the twin took: an order of magnitude above a healthy replay. */ -const REPLAY_CEILING_MS = 2_000; - -const company = flowByTitle('creates a company'); - -/** The company flow as a test writes it by hand. */ -const DETERMINISTIC_BODY = ` const name = 'E2E ' + String(Date.now()) + ' Company'; - await screen.getByLabel('Company name').fill(name); - await screen.getByRole('button', { name: 'Create' }).click(); - await expect(screen.getByRole('heading', { name })).toBeVisible();`; - -const SUITE = flowsSuite( - REPETITIONS.flatMap((n) => [ - { title: `deterministic ${String(n)}`, open: company.open, body: DETERMINISTIC_BODY }, - { title: `agent ${String(n)}`, open: company.open, body: company.body }, - ]), - 'record', -); - -/** The flow's own time: every step after `app.open`, the trailing expect included, since a replay checks its end state too. */ -function flowMs(outcome: RunOutcome, title: string): number { - return resultByTitle(outcome, title) - .attempts.at(-1)! - .steps.filter((step) => step.api !== 'app.open') - .reduce((total, step) => total + step.durationMs, 0); -} - -/** The fastest repetition after the first. */ -function fastest(outcome: RunOutcome, prefix: string): number { - return Math.min(...REPETITIONS.slice(1).map((n) => flowMs(outcome, `${prefix} ${String(n)}`))); -} - -describe('trace cache: a replay runs at the speed of the deterministic API', () => { - let app: FixtureApp; - let project: FixtureProject; - let recorded: RunOutcome; - let replayed: RunOutcome; - - beforeAll(async () => { - app = await startFixtureApp(); - project = createProject({ 'tests/speed.e2e.ts': SUITE }); - recorded = await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()) }); - replayed = await runExisting(project, { appUrl: app.url, config: cacheConfig(flowsModel()) }); - }, 240_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it('passes both runs, recording on the first and replaying with no model call on the second', () => { - expect(recorded.exitCode).toBe(0); - expect(replayed.exitCode).toBe(0); - for (const n of REPETITIONS) { - const step = onlyActStep(replayed, `agent ${String(n)}`); - expect(step.cache).toEqual({ mode: 'self-finalized', replayedActions: 2, totalActions: 2 }); - expect(step.metrics?.modelCalls).toBe(0); - } - }); - - it('replays within a small factor of the deterministic twin, under the ceiling, and faster than it recorded', () => { - const deterministic = fastest(replayed, 'deterministic'); - const record = fastest(recorded, 'agent'); - const replay = fastest(replayed, 'agent'); - const numbers = - `deterministic ${String(deterministic)} ms · record ${String(record)} ms · replay ${String(replay)} ms · ` + - `ratio ${(replay / deterministic).toFixed(2)} (bound ${String(REPLAY_RATIO)}) · ceiling ${String(REPLAY_CEILING_MS)} ms`; - console.info(`trace cache speed (fastest of ${String(REPETITIONS.length - 1)} after warm-up): ${numbers}`); - expect(replay, numbers).toBeLessThanOrEqual(REPLAY_RATIO * deterministic); - expect(replay, numbers).toBeLessThanOrEqual(REPLAY_CEILING_MS); - expect(replay, numbers).toBeLessThanOrEqual(record); - }); -}); diff --git a/packages/e2e/tests/integration/trace-modes.test.ts b/packages/e2e/tests/integration/trace-modes.test.ts index c5d037d5c..f54d1a0d1 100644 --- a/packages/e2e/tests/integration/trace-modes.test.ts +++ b/packages/e2e/tests/integration/trace-modes.test.ts @@ -63,11 +63,11 @@ describe('trace modes', () => { }); it( - 'records retries only under on-all-retries, and a test mode wins over the config', + 'records retries only under on-all-retries, --trace wins over the config, and a test mode wins over the flag', async () => { const { outcome, project } = await runProject( { 'tests/suite.e2e.ts': SUITE }, - { appUrl: app.url, config: { retries: 2, cache: 'off', trace: 'on-all-retries' } }, + { appUrl: app.url, config: { retries: 2, cache: 'off', trace: 'off' }, runOptions: { trace: 'on-all-retries' } }, ); try { expect(tracesPerAttempt(outcome, 'passes first time')).toEqual([0]); @@ -88,38 +88,4 @@ describe('trace modes', () => { }, 180_000, ); - - it( - 'records every attempt by default outside CI', - async () => { - const { outcome, project } = await runProject( - { 'tests/suite.e2e.ts': SUITE }, - { appUrl: app.url, config: { retries: 1, cache: 'off' } }, - ); - try { - expect(tracesPerAttempt(outcome, 'passes first time')).toEqual([1]); - expect(tracesPerAttempt(outcome, 'always fails')).toEqual([1, 1]); - } finally { - project.cleanup(); - } - }, - 180_000, - ); - - it( - 'turns off with --trace off, and a test mode still wins over the flag', - async () => { - const { outcome, project } = await runProject( - { 'tests/suite.e2e.ts': SUITE }, - { appUrl: app.url, config: { retries: 1, cache: 'off', trace: 'on' }, runOptions: { trace: 'off' } }, - ); - try { - expect(tracesPerAttempt(outcome, 'always fails')).toEqual([0, 0]); - expect(tracesPerAttempt(outcome, 'first retry only')).toEqual([0, 1]); - } finally { - project.cleanup(); - } - }, - 180_000, - ); }); diff --git a/packages/e2e/tests/integration/transformed-secrets.test.ts b/packages/e2e/tests/integration/transformed-secrets.test.ts index 5f3254654..0404d1c90 100644 --- a/packages/e2e/tests/integration/transformed-secrets.test.ts +++ b/packages/e2e/tests/integration/transformed-secrets.test.ts @@ -9,12 +9,11 @@ * still passes. */ -import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { RunOutcome } from '../../src/run/runner.ts'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; -import { createProject, resultByTitle, runExisting, type FixtureProject } from '../helpers/run-project.ts'; +import { contentsUnder, createProject, resultByTitle, runExisting, type FixtureProject } from '../helpers/run-project.ts'; const TOKEN = 'Tok-9f3aC0DeB1e7'; const NOTE = 'first line 4417\nsecond line Qx'; @@ -50,14 +49,6 @@ test('matches the transformed text', async ({ app, screen }) => { }); `; -/** Every regular file under `dir`, recursively. */ -function filesUnder(dir: string): string[] { - return readdirSync(dir).flatMap((name) => { - const file = path.join(dir, name); - return statSync(file).isDirectory() ? filesUnder(file) : [file]; - }); -} - describe('secrets shown transformed', () => { let app: FixtureApp; let project: FixtureProject; @@ -101,12 +92,14 @@ describe('secrets shown transformed', () => { for (const form of FORMS) expect(text).not.toContain(form); }); - it.each(['plain', 'upper', 'lower', 'note-echo'])('fails on the %s echo with the received text redacted', (id) => { - const result = resultByTitle(outcome, `fails on the ${id} echo`); - expect(result.status).toBe('failed'); - const message = result.attempts[0]!.error!.message; - expect(message).toContain(id === 'note-echo' ? '' : ''); - for (const form of FORMS) expect(message).not.toContain(form); + it('fails on each echo with the received text redacted', () => { + for (const id of ['plain', 'upper', 'lower', 'note-echo']) { + const result = resultByTitle(outcome, `fails on the ${id} echo`); + expect(result.status, id).toBe('failed'); + const message = result.attempts[0]!.error!.message; + expect(message).toContain(id === 'note-echo' ? '' : ''); + for (const form of FORMS) expect(message).not.toContain(form); + } }); it('matches the transformed text as the page shows it', () => { @@ -116,10 +109,10 @@ describe('secrets shown transformed', () => { it('leaves no form of either value in the report or any file the reporters write', () => { const report = JSON.stringify(outcome.report); for (const form of FORMS) expect(report).not.toContain(form); - const files = filesUnder(path.join(project.dir, '.e2e')); - expect(files.some((file) => file.endsWith('.md'))).toBe(true); - for (const file of files) { - const text = readFileSync(file, 'utf8'); + const contents = contentsUnder(path.join(project.dir, '.e2e')); + expect(contents.some(([file]) => file.endsWith('.md'))).toBe(true); + expect(contents.some(([file]) => file.includes('.zip!'))).toBe(true); + for (const [file, text] of contents) { for (const form of FORMS) expect(text, file).not.toContain(form); } }); diff --git a/packages/e2e/tests/integration/truncated-secrets.test.ts b/packages/e2e/tests/integration/truncated-secrets.test.ts index 23b75c9a2..94d714d23 100644 --- a/packages/e2e/tests/integration/truncated-secrets.test.ts +++ b/packages/e2e/tests/integration/truncated-secrets.test.ts @@ -30,7 +30,7 @@ test('echoes a secret across the observation limits', async ({ app, agent, scree await app.open('/echo-cut'); await screen.getByLabel('Source').fill(credentials.user('member').password); await agent.assert('the echo is on screen'); - await screen.getByRole('button', { name: 'Not on this page' }).tap(); + await screen.getByRole('button', { name: 'Not on this page' }).tap({ timeout: 500 }); }); `; @@ -85,8 +85,5 @@ describe('secrets cut short by observation limits', () => { // The trace was scanned inside, and its plain text survived the rewrite. const trace = contents.filter(([file]) => file.includes('.zip!')); expect(trace.some(([, text]) => text.includes(CONTROL_KEPT))).toBe(true); - // The same rewrite dropped the screencast of the tainted viewport. - expect(trace.filter(([file]) => file.includes('.zip!screencast/'))).toEqual([]); - expect(trace.some(([, text]) => text.includes('"screencast-frame"'))).toBe(false); }); }); diff --git a/packages/e2e/tests/integration/web-class-assertions.test.ts b/packages/e2e/tests/integration/web-class-assertions.test.ts deleted file mode 100644 index 51a68c6b8..000000000 --- a/packages/e2e/tests/integration/web-class-assertions.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * `expect(browser).toHaveClass` against the web engine loaded the way a project - * loads it: from a config file, so the engine's `e2e/engine` and the runner's - * core are two module copies and `instanceof` cannot tell a runner error apart. - * A node that is not there yet must keep the matcher polling; an ambiguous - * locator must still fail at once. `toHaveURL` takes Playwright's - * `ignoreCase`. - */ - -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; -import { - resultByTitle, - runProjectWithConfigFile, - workerConfigSource, - type FixtureProject, - type RunOutcome, -} from '../helpers/run-project.ts'; - -const SUITE = `import { test } from '@e2e-dev/web'; -import { expect } from 'e2e'; - -test('class assertions poll for a node that arrives late', async ({ app, browser }) => { - await app.open('/classes'); - await expect(browser).toHaveClass(browser.locator('#late-card'), 'card late', { timeout: 1800 }); -}); - -test('class assertions on an ambiguous locator fail at once', async ({ app, browser }) => { - await app.open('/classes'); - await expect(browser).toHaveClass(browser.locator('.dup'), 'dup', { timeout: 1800 }); -}); - -test('an empty class attribute is an empty class list', async ({ app, browser }) => { - await app.open('/classes'); - await expect(browser).toHaveClass(browser.locator('#blank-card'), ''); -}); - -test('a missing class attribute is not an empty class list', async ({ app, browser, screen }) => { - await app.open('/classes'); - await expect(browser).toHaveClass(screen.getByTestId('items'), '', { timeout: 300 }); -}); - -test('toHaveURL ignoreCase folds the comparison', async ({ app, browser }) => { - await app.open('/classes'); - await expect(browser).toHaveURL('/CLASSES', { ignoreCase: true }); - await expect(browser).toHaveURL(/CLASSES$/, { ignoreCase: true }); - await expect(browser).not.toHaveURL('/CLASSES', { timeout: 300 }); -}); - -test('a negated toHaveURL ignoreCase fails on a case-folded match', async ({ app, browser }) => { - await app.open('/classes'); - await expect(browser).not.toHaveURL('/CLASSES', { ignoreCase: true, timeout: 300 }); -}); -`; - -describe('web class assertions', () => { - let app: FixtureApp; - let outcome: RunOutcome; - let project: FixtureProject; - - beforeAll(async () => { - app = await startFixtureApp(); - ({ outcome, project } = await runProjectWithConfigFile( - { 'tests/classes.e2e.ts': SUITE }, - { appUrl: app.url, configSource: workerConfigSource(1, "\n cache: 'off',") }, - )); - }, 240_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it('keeps polling while the node is missing and passes once it arrives', () => { - const result = resultByTitle(outcome, 'class assertions poll for a node that arrives late'); - expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); - const step = result.attempts[0]!.steps.find((candidate) => candidate.api === 'expect.toHaveClass'); - expect(step?.durationMs).toBeGreaterThan(500); - }); - - it('fails an ambiguous locator with LOCATOR_AMBIGUOUS before the deadline', () => { - const result = resultByTitle(outcome, 'class assertions on an ambiguous locator fail at once'); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error?.code).toBe('LOCATOR_AMBIGUOUS'); - const step = result.attempts[0]!.steps.find((candidate) => candidate.api === 'expect.toHaveClass'); - expect(step?.durationMs).toBeLessThan(1800); - }); - - it('matches an empty string against class="" and not against a missing attribute', () => { - expect(resultByTitle(outcome, 'an empty class attribute is an empty class list').status).toBe('passed'); - const missing = resultByTitle(outcome, 'a missing class attribute is not an empty class list'); - expect(missing.status).toBe('failed'); - expect(missing.attempts[0]!.error?.code).toBe('ASSERTION_FAILED'); - expect(missing.attempts[0]!.error?.message).toContain('observed: no class attribute'); - }); - - it('honors ignoreCase on toHaveURL, negated too', () => { - const folded = resultByTitle(outcome, 'toHaveURL ignoreCase folds the comparison'); - expect(folded.status, JSON.stringify(folded.attempts[0]?.error)).toBe('passed'); - const negated = resultByTitle(outcome, 'a negated toHaveURL ignoreCase fails on a case-folded match'); - expect(negated.status).toBe('failed'); - expect(negated.attempts[0]!.error?.code).toBe('ASSERTION_FAILED'); - expect(negated.attempts[0]!.error?.message).toContain('expected: not URL /CLASSES (ignoring case)'); - }); - -}); diff --git a/packages/e2e/tests/integration/web-platform.test.ts b/packages/e2e/tests/integration/web-platform.test.ts index e998dfd30..e7680fe8f 100644 --- a/packages/e2e/tests/integration/web-platform.test.ts +++ b/packages/e2e/tests/integration/web-platform.test.ts @@ -3,8 +3,13 @@ import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; import { assertValidReport } from '../helpers/report-schema.ts'; -import { resultByTitle, runProject, type FixtureProject } from '../helpers/run-project.ts'; -import type { RunOutcome } from '../helpers/run-project.ts'; +import { + resultByTitle, + runProjectWithConfigFile, + workerConfigSource, + type FixtureProject, + type RunOutcome, +} from '../helpers/run-project.ts'; const KITCHEN_SINK = `import { test } from '@e2e-dev/web'; import { expect } from 'e2e'; @@ -20,9 +25,6 @@ test('deterministic queries and reads', async ({ app, screen, browser }) => { await expect(screen.getByTestId('items')).toBeVisible(); await expect(screen.getByTestId('item')).toHaveCount(3); - await expect(screen.getByTestId('item').first()).toHaveText('Item Alpha'); - await expect(screen.getByTestId('item').last()).toHaveText('Item Gamma'); - await expect(screen.getByTestId('item').nth(1)).toHaveText('Item Beta'); await expect( screen.getByTestId('item').filter({ hasText: 'Beta' }), ).toHaveCount(1); @@ -39,33 +41,8 @@ test('deterministic queries and reads', async ({ app, screen, browser }) => { await expect(screen.getByTestId('item')).toHaveText(['Item Alpha', /Beta/, 'Item Gamma']); await expect(screen.getByTestId('item')).toContainText(['Alpha', 'Beta', 'Gamma']); await expect(screen.getByTestId('item')).toContainText(['Alpha', 'Gamma']); - await expect(screen.getByTestId('item')).not.toContainText(['Gamma', 'Alpha']); - await expect(screen.getByTestId('item')).not.toHaveText(['Item Alpha', 'Item Beta']); - - await expect(screen.getByText('Hidden content')).toBeAttached(); - await expect(screen.getByText('Hidden content')).toBeHidden(); - await expect(screen.getByText('Never rendered')).not.toBeAttached(); - expect(await screen.getByText('Hidden content').isHidden()).toBe(true); - expect(await screen.getByText('Never rendered').isHidden()).toBe(true); - await expect(screen.getByTestId('hidden-svg')).toBeHidden(); - await expect(screen.getByTestId('zero-box')).toBeHidden(); - await expect(screen.getByRole('button', { name: 'Inside folded details' })).toBeHidden(); - await expect(screen.getByTestId('empty-contents')).toBeHidden(); - await expect(screen.getByTestId('painted-contents')).toBeVisible(); - expect(await screen.getByTestId('hidden-svg').isVisible()).toBe(false); - expect(await screen.getByTestId('zero-box').isVisible()).toBe(false); - expect(await screen.getByRole('button', { name: 'Inside folded details' }).isVisible()).toBe(false); - expect(await screen.getByTestId('empty-contents').isVisible()).toBe(false); - expect(await screen.getByTestId('painted-contents').isVisible()).toBe(true); - expect(await screen.getByRole('button', { name: 'Disabled action' }).isDisabled()).toBe(true); - expect(await screen.getByRole('button', { name: 'Increment' }).isDisabled()).toBe(false); - - await expect(screen.getByRole('button', { name: 'Disabled action' })).toBeDisabled(); - await expect(screen.getByRole('button', { name: 'Increment' })).toBeEnabled(); - expect(await screen.getByRole('button', { name: 'Fenced action' }).isDisabled()).toBe(true); - expect(await screen.getByRole('button', { name: 'Legend action' }).isDisabled()).toBe(false); - await expect(screen.getByRole('button', { name: 'Fenced action' })).toBeDisabled(); - await expect(screen.getByRole('button', { name: 'Legend action' })).toBeEnabled(); + await expect(screen.getByTestId('item')).not.toContainText(['Gamma', 'Alpha'], { timeout: 200 }); + await expect(screen.getByTestId('item')).not.toHaveText(['Item Alpha', 'Item Beta'], { timeout: 200 }); expect(await screen.getByTestId('items').getAttribute('class')).toBeNull(); expect(await browser.locator('#class-card').getAttribute('class')).toBe('card active'); @@ -77,161 +54,6 @@ test('deterministic queries and reads', async ({ app, screen, browser }) => { expect(await screen.getByTestId('items').getAttribute('__proto__')).toBeNull(); }); -test('role vocabulary: tabs, menus, progress, toolbars, images', async ({ app, screen }) => { - await app.open('/roles'); - - await expect(screen.getByRole('image', { name: 'Fixture logo' })).toBeVisible(); - await expect(screen.getByRole('img', { name: 'Fixture logo' })).toBeVisible(); - await expect(screen.getByRole('image', { name: 'Sales chart' })).toBeVisible(); - await expect(screen.getByRole('img')).toHaveCount(2); - await expect(screen.getByRole('image')).toHaveCount(2); - - const tabs = screen.getByRole('tablist', { name: 'Filter' }); - await expect(tabs.getByRole('tab')).toHaveCount(2); - await expect(tabs.getByRole('tab', { selected: true })).toHaveText('All'); - await expect(screen.getByRole('tabpanel', { name: 'All' })).toHaveText('Everything'); - - const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); - await toolbar.getByRole('button', { name: 'Bold' }).tap(); - await expect(toolbar.getByRole('button', { name: 'Bold' })).toHaveAttribute('aria-pressed', 'true'); - await expect(toolbar.getByRole('spinbutton', { name: 'Font size' })).toHaveValue('12'); - - await screen.getByRole('button', { name: 'View' }).tap(); - const menu = screen.getByRole('menu', { name: 'View' }); - await expect(menu.getByRole('menuitem', { name: 'Zoom in' })).toBeVisible(); - await expect(menu.getByRole('menuitemcheckbox', { name: 'Show grid' })).toBeChecked(); - - await expect(screen.getByRole('progressbar', { name: 'Upload' })).toBeVisible(); - await expect(screen.getByRole('group', { name: 'Notifications' }).getByRole('checkbox')).toHaveCount(1); - await expect(screen.getByRole('group', { name: 'Notifications' })).toHaveAccessibleName('Notifications'); - await expect(screen.getByRole('form', { name: 'Sign in' }).getByRole('textbox')).toHaveCount(1); - await expect(screen.getByRole('separator')).toHaveCount(1); - await expect(screen.getByRole('article', { name: 'First post' })).toHaveText('Body'); - - // Name from content for the roles accname allows, the same name on both - // sides: the role query resolves it and the reader reports it. - const named: readonly [role: string, name: string][] = [ - ['switch', 'Dark mode'], - ['columnheader', 'Plan'], - ['cell', 'Monthly'], - ['button', 'Shadow action'], - ['button', 'Clear form'], - ['button', 'Reset'], - ]; - for (const [role, name] of named) { - const control = screen.getByRole(role as 'button', { name, exact: true }); - await expect(control).toHaveCount(1); - await expect(control).toHaveAccessibleName(name); - } -}); - -test('actions and state', async ({ app, screen, browser }) => { - await app.open(); - - await screen.getByRole('button', { name: 'Increment' }).tap(); - await screen.getByRole('button', { name: 'Increment' }).click(); - await expect(screen.getByRole('status')).toHaveText('2'); - - await screen.getByLabel('Email').fill('user@example.test'); - await expect(screen.getByLabel('Email')).toHaveValue('user@example.test'); - await screen.getByLabel('Email').clear(); - await expect(screen.getByLabel('Email')).toHaveValue(''); - - await screen.getByLabel('Notifications').check(); - await expect(screen.getByLabel('Notifications')).toBeChecked(); - await screen.getByLabel('Notifications').uncheck(); - await expect(screen.getByLabel('Notifications')).not.toBeChecked(); - - await screen.getByLabel('Plan').selectOption('Pro'); - await expect(screen.getByLabel('Plan')).toHaveValue('pro'); - await screen.getByLabel('Plan').selectOption({ index: 2 }); - await expect(screen.getByLabel('Plan')).toHaveValue('team'); - await screen.getByLabel('Plan').selectOption({ value: 'free' }); - await expect(screen.getByLabel('Plan')).toHaveValue('free'); - - await screen.getByRole('button', { name: 'Menu' }).tap(); - await expect(screen.getByRole('button', { name: 'Menu' })).toBeExpanded(); - - await screen.getByLabel('Email').fill('draft'); - await screen.getByLabel('Email').press('Backspace'); - await expect(screen.getByLabel('Email')).toHaveValue('draf'); - - await expect(screen.getByLabel('Readonly')).toHaveAttribute('readonly'); - await expect(browser.locator('#class-card')).toHaveAttribute('class', /active/); - await expect(screen.getByLabel('Readonly')).not.toHaveAttribute('hidden'); - await expect(browser.locator('#class-card')).toHaveAttribute('constructor', 'own'); - await expect(screen.getByLabel('Readonly')).not.toHaveAttribute('constructor'); - await expect(screen.getByLabel('Readonly')).not.toHaveAttribute('toString'); - await expect(screen.getByLabel('Readonly')).not.toHaveAttribute('__proto__'); - - await screen.getByLabel('Focus target').focus(); - await expect(screen.getByLabel('Focus target')).toBeFocused(); - await expect(screen.getByLabel('Email')).not.toBeFocused(); - - await expect(browser).toHaveClass(browser.locator('#class-card'), 'card active'); - await expect(browser).toHaveClass(browser.locator('#class-card'), /active/); - await expect(browser).not.toHaveClass(browser.locator('#class-card'), 'card inactive'); -}); - -test('check and uncheck refuse an unknown option and leave the box untouched', async ({ app, screen, browser }) => { - await app.open(); - await browser.evaluate(() => { - const box = document.querySelector('#notifications')!; - box.dataset.events = '0'; - for (const type of ['input', 'change', 'click']) { - box.addEventListener(type, () => { box.dataset.events = String(Number(box.dataset.events) + 1); }); - } - }); - const box = screen.getByLabel('Notifications'); - const code = async (call: () => Promise) => { - try { - await call(); - } catch (error) { - return (error as { code?: string }).code; - } - return 'no error'; - }; - - expect(await code(() => box.check({ trial: true } as never))).toBe('INVALID_ARGUMENT'); - await expect(box).not.toBeChecked(); - await expect(box).toHaveAttribute('data-events', '0'); - await box.check(); - await expect(box).toBeChecked(); - await expect(box).toHaveAttribute('data-events', '3'); - - expect(await code(() => box.uncheck({ trial: true } as never))).toBe('INVALID_ARGUMENT'); - await expect(box).toBeChecked(); - await expect(box).toHaveAttribute('data-events', '3'); - await box.uncheck(); - await expect(box).not.toBeChecked(); - await expect(box).toHaveAttribute('data-events', '6'); -}); - -test('unsupported filter and selectOption shapes fail before acting', async ({ app, screen, browser }) => { - await app.open(); - const code = async (call: () => unknown) => { - try { - await call(); - } catch (error) { - return (error as { code?: string }).code; - } - return 'no error'; - }; - const digest = () => - browser.evaluate(() => [...document.querySelector('#digest')!.selectedOptions].map((option) => option.value)); - - const mixed = { hasText: 'Item', hasNotText: 'Alpha' } as never; - expect(await code(() => screen.getByTestId('item').filter(mixed).first().textContent())).toBe('INVALID_LOCATOR'); - await expect(screen.getByTestId('item').filter({ hasText: 'Item' })).toHaveCount(3); - await expect(screen.getByTestId('item').filter({ hasText: 'Item' }).first()).toHaveText('Item Alpha'); - - const several = ['daily', 'weekly'] as never; - expect(await code(() => screen.getByLabel('Digest').selectOption(several))).toBe('INVALID_ARGUMENT'); - expect(await digest()).toEqual([]); - await screen.getByLabel('Digest').selectOption({ value: 'weekly' }); - expect(await digest()).toEqual(['weekly']); -}); - test('class assertions report the observed class on failure', async ({ app, browser }) => { await app.open(); try { @@ -242,40 +64,22 @@ test('class assertions report the observed class on failure', async ({ app, brow } }); -test('assertions poll until the app settles', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByRole('button', { name: 'Late arrival' })).toBeVisible({ - timeout: 3000, - }); - await screen.getByRole('button', { name: 'Late arrival' }).waitFor({ state: 'visible' }); -}); - -test('ambiguous locators fail immediately', async ({ app, screen }) => { - await app.open(); - await screen.getByText('Duplicated').tap({ timeout: 2000 }); -}); - -test('missing locators poll until the deadline', async ({ app, screen }) => { - await app.open(); - await screen.getByRole('button', { name: 'Never exists' }).tap({ timeout: 800 }); -}); - test('a textarea value compares raw', async ({ app, screen }) => { await app.open(); await expect(screen.getByLabel('Notes')).toHaveValue('line1\\n\\nline2 '); await expect(screen.getByLabel('Notes')).toHaveValue(/^line1\\n\\nline2 {2}$/); - await expect(screen.getByLabel('Notes')).not.toHaveValue('line1 line2'); + await expect(screen.getByLabel('Notes')).not.toHaveValue('line1 line2', { timeout: 200 }); }); test('a textarea value is never normalized to match', async ({ app, screen }) => { await app.open(); - await expect(screen.getByLabel('Notes')).toHaveValue('line1 line2'); + await expect(screen.getByLabel('Notes')).toHaveValue('line1 line2', { timeout: 500 }); }); test('toHaveAttribute reads the value attribute of plain fields', async ({ app, screen }) => { await app.open('/value-attributes'); await expect(screen.getByLabel('Plain')).toHaveAttribute('value', 'marker-5e0c'); - await expect(screen.getByLabel('Blank')).not.toHaveAttribute('value'); + await expect(screen.getByLabel('Blank')).not.toHaveAttribute('value', { timeout: 200 }); }); test('toHaveAttribute never judges a secure field', async ({ app, screen }) => { @@ -311,33 +115,6 @@ test('toHaveValue reads a plain field that holds the same marker', async ({ app, await expect(screen.getByLabel('Email')).toHaveValue('', { timeout: 500 }); }); -test('acting before open fails with APP_NOT_OPEN', async ({ screen }) => { - await screen.getByRole('button', { name: 'Increment' }).tap({ timeout: 500 }); -}); - -test('web navigation, urls, and titles', async ({ app, browser }) => { - await app.open(); - await browser.goto('/about'); - await expect(browser).toHaveURL('/about'); - await expect(browser).toHaveTitle('About page'); - await browser.back(); - await expect(browser).toHaveURL('/'); - await browser.forward(); - await expect(browser).toHaveURL(/about/); - const title = await browser.title(); - expect(title).toBe('About page'); -}); - -test('routes intercept and fulfill', async ({ app, browser, screen }) => { - await app.open(); - await browser.route('**/api/flags', (route) => route.fulfill({ json: { betaBoard: true } })); - await browser.goto('/flags'); - await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta on'); - await browser.unroute('**/api/flags'); - await browser.reload(); - await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta off'); -}); - test('routes are attempt-scoped: registered before the first page, kept across restart and clearState', async ({ app, browser, @@ -354,26 +131,8 @@ test('routes are attempt-scoped: registered before the first page, kept across r await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta on'); }); -test('waitForResponse observes network traffic', async ({ app, browser }) => { +test('evaluate keeps a nested named binding the loader compiled', async ({ app, browser }) => { await app.open(); - const [response] = await Promise.all([ - browser.waitForResponse('**/api/flags'), - browser.goto('/flags'), - ]); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body).toEqual({ betaBoard: false }); -}); - -test('evaluate runs trusted code with JSON arguments', async ({ app, browser }) => { - await app.open(); - const result = await browser.evaluate((input: { a: number; b: number }) => input.a + input.b, { - a: 2, - b: 40, - }); - expect(result).toBe(42); - const href = await browser.evaluate(() => document.location.pathname); - expect(href).toBe('/'); // A nested named binding survives serialization whatever the loader // compiled it to (esbuild keepNames once wrapped it in a \`__name\` helper). const total = await browser.evaluate((input: { a: number; b: number }) => { @@ -383,55 +142,29 @@ test('evaluate runs trusted code with JSON arguments', async ({ app, browser }) expect(total).toBe(42); }); -test('cookies round-trip through policy checks', async ({ app, browser }) => { - await app.open(); - await browser.setCookies([{ name: 'flavor', value: 'oatmeal', url: (await browser.url()) }]); - const cookies = await browser.cookies(); - const flavor = cookies.find((cookie) => cookie.name === 'flavor'); - expect(flavor?.value).toBe('oatmeal'); -}); - -test('dialogs are handled by registered handlers', async ({ app, browser, screen }) => { - await app.open('/dialog'); - const dispose = await browser.onDialog('accept'); - await screen.getByRole('button', { name: 'Ask' }).tap(); - await expect(screen.getByRole('status', { name: 'Answer' })).toHaveText('accepted'); - await dispose(); -}); - -test('frame locators scope queries into iframes', async ({ app, browser }) => { +test('reads under an absent frame answer with no match', async ({ app, browser }) => { await app.open('/frame'); - const frame = browser.frameLocator('#child'); - await frame.getByRole('button', { name: 'Frame button' }).tap(); - await new Promise((resolve) => setTimeout(resolve, 100)); - const absent = browser.frameLocator('#absent').getByRole('button'); - const started = Date.now(); expect(await absent.count()).toBe(0); expect(await absent.all()).toEqual([]); expect(await absent.allTextContents()).toEqual([]); expect(await absent.isVisible()).toBe(false); expect(await absent.isHidden()).toBe(true); - expect(Date.now() - started).toBeLessThan(2_000); }); test('a nested absent frame counts as zero matches at once', async ({ app, browser }) => { await app.open('/frame-nested'); - const started = Date.now(); expect(await browser.frameLocator('#outer').frameLocator('#absent').getByRole('button').count()).toBe(0); - expect(Date.now() - started).toBeLessThan(2_000); }); test('absence matchers and waits under an absent frame pass at once', async ({ app, browser }) => { await app.open('/frame'); const absent = browser.frameLocator('#absent').getByRole('button'); - const started = Date.now(); await expect(absent).toBeAttached({ attached: false, timeout: 3000 }); await expect(absent).toBeVisible({ visible: false, timeout: 3000 }); await expect(absent).toBeHidden({ timeout: 3000 }); await absent.waitFor({ state: 'detached', timeout: 3000 }); await absent.waitFor({ state: 'hidden', timeout: 3000 }); - expect(Date.now() - started).toBeLessThan(2_000); }); test('an action under an absent frame polls for the frame until its timeout', async ({ app, browser }) => { @@ -439,41 +172,6 @@ test('an action under an absent frame polls for the frame until its timeout', as await browser.frameLocator('#absent').getByRole('button').tap({ timeout: 800 }); }); -test('contenteditable hosts are textboxes: reached by label, filled, and read as a value', async ({ app, screen }) => { - await app.open('/editor'); - const notes = screen.getByLabel('Notes'); - await expect(notes).toBeVisible(); - await notes.fill('Hello'); - await expect(notes).toHaveValue('Hello'); - expect(await notes.inputValue()).toBe('Hello'); - await expect(screen.getByTestId('notes')).toHaveValue('Hello'); - // getByRole resolves through Playwright's role selector, which knows a - // textbox only by an explicit role: a bare host is not found, one with - // role="textbox" is. - await expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveCount(0); - await expect(screen.getByRole('textbox', { name: 'Message' })).toHaveCount(1); - await screen.getByRole('textbox', { name: 'Message' }).fill('Hi'); - await expect(screen.getByLabel('Message')).toHaveValue('Hi'); -}); - -test('an editor value keeps the whitespace it renders, and an empty editor reads as empty', async ({ app, screen }) => { - await app.open('/editor'); - const code = screen.getByLabel('Code'); - await expect(code).toHaveValue(' keep spaces '); - await expect(code).not.toHaveValue('keep spaces'); - expect(await code.inputValue()).toBe(' keep spaces '); - const notes = screen.getByLabel('Notes'); - await expect(notes).toHaveValue(''); - expect(await notes.inputValue()).toBe(''); -}); - -test('nested frame locators resolve each frame inside the one before it', async ({ app, browser }) => { - await app.open('/frame-nested'); - const inner = browser.frameLocator('#outer').frameLocator('#child'); - await inner.getByRole('button', { name: 'Frame button' }).tap(); - await expect(inner.getByRole('button', { name: 'Frame clicked' })).toBeVisible(); -}); - test('downloads are captured as artifacts', async ({ app, browser }) => { await app.open('/downloads'); const download = await browser.waitForDownload(() => browser.locator('a[download]').tap()); @@ -534,37 +232,16 @@ test('a viewport set before the first navigation opens no page', async ({ screen await screen.getByTestId('items').tap({ timeout: 200 }); }); -test('css selectors via browser.locator', async ({ app, browser }) => { +test('a *css= capture is refused before the last selector part', async ({ app, browser }) => { await app.open(); - await expect(browser.locator('ul[data-testid="items"] li').first()).toHaveText('Item Alpha'); await expect(browser.locator('css=ul[data-testid="items"] >> *css=li')).toHaveCount(3); let capture = 'no error'; try { await browser.locator('*css=ul[data-testid="items"] >> li').count(); } catch (error) { - capture = (error as { code?: string }).code ?? 'no code'; + capture = (error as Error).message; } - expect(capture).toBe('INVALID_LOCATOR'); -}); - -test('app lifecycle: restart preserves storage, clearState clears it', async ({ app, screen }) => { - await app.open('/storage'); - await screen.getByRole('button', { name: 'Save marker' }).tap(); - await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('saved'); - - await app.restart(); - await app.open('/storage'); - await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('saved'); - - await app.clearState(); - await app.open('/storage'); - await expect(screen.getByRole('status', { name: 'Marker' })).toHaveText('empty'); -}); - -test('screenshots land in the artifact directory', async ({ app }) => { - await app.open(); - const shot = await app.screenshot('home'); - if (!shot.includes('screenshots/')) throw new Error('unexpected screenshot path: ' + shot); + expect(capture).toContain('a selector that captures with * (*css=article >> text=Hello) is not supported'); }); test('forbidden URL schemes are refused', async ({ app }) => { @@ -589,14 +266,6 @@ test('a dialog handler assertion that no step follows', async ({ app, browser, s await screen.getByRole('button', { name: 'Ask' }).tap(); }); -test('a step call without await', async ({ app }) => { - app.open(); -}); - -test('a step call without await before the body throws', async ({ app }) => { - app.open(); - throw new Error('the body gave up'); -}); `; const LATE_HANDLER_THEN_TEARDOWN = `import { test } from '@e2e-dev/web'; @@ -647,6 +316,100 @@ test('browser.goto refuses view-source', async ({ app, browser }) => { }); `; +// Loaded from a config file, as a project loads it: the engine's `e2e/engine` +// and the runner's core are two module copies, so `instanceof` cannot tell a +// runner error apart and a missing node must still keep the matcher polling. +const CLASSES = `import { test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +test('class assertions poll for a node that arrives late', async ({ app, browser }) => { + await app.open('/classes'); + await expect(browser).toHaveClass(browser.locator('#late-card'), 'card late', { timeout: 1800 }); +}); + +test('class assertions on an ambiguous locator fail at once', async ({ app, browser }) => { + await app.open('/classes'); + await expect(browser).toHaveClass(browser.locator('.dup'), 'dup', { timeout: 1800 }); +}); + +test('an empty class attribute is an empty class list', async ({ app, browser }) => { + await app.open('/classes'); + await expect(browser).toHaveClass(browser.locator('#blank-card'), ''); +}); + +test('a missing class attribute is not an empty class list', async ({ app, browser, screen }) => { + await app.open('/classes'); + await expect(browser).toHaveClass(screen.getByTestId('items'), '', { timeout: 300 }); +}); + +test('toHaveURL ignoreCase folds the comparison', async ({ app, browser }) => { + await app.open('/classes'); + await expect(browser).toHaveURL('/CLASSES', { ignoreCase: true }); + await expect(browser).toHaveURL(/CLASSES$/, { ignoreCase: true }); + await expect(browser).not.toHaveURL('/CLASSES', { timeout: 300 }); +}); + +test('a negated toHaveURL ignoreCase fails on a case-folded match', async ({ app, browser }) => { + await app.open('/classes'); + await expect(browser).not.toHaveURL('/CLASSES', { ignoreCase: true, timeout: 300 }); +}); +`; + +// A predicate, which Playwright accepts and a migrated test may still pass, is +// refused instead of reading as a pattern that matches everything. An option +// the call does not take is refused instead of leaving it on its default. +const URL_PATTERNS = `import { test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +const toOther: any = (url: URL) => url.pathname === '/other'; +const flag = (name, value) => JSON.parse(JSON.stringify({ [name]: value, timeout: 300 })); + +test('waitForResponse with a predicate', async ({ app, browser }) => { + await app.open(); + await Promise.all([browser.waitForResponse(toOther, { timeout: 2_000 }), browser.goto('/flags')]); +}); + +test('route with a predicate', async ({ app, browser, screen }) => { + await browser.route(toOther, (route) => route.abort()); + await app.open('/flags'); + await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta off'); +}); + +test('unroute with a predicate', async ({ app, browser }) => { + await app.open(); + await browser.unroute(toOther); +}); + +test('string and RegExp URL patterns match what they name', async ({ app, browser, screen }) => { + await app.open('/about'); + await expect(browser).toHaveURL('/about'); + await expect(browser).toHaveURL(/\\/about$/); + await expect(browser).not.toHaveURL('/other', { timeout: 200 }); + await expect(browser).not.toHaveURL(/\\/other$/, { timeout: 200 }); + await browser.waitForURL(/\\/about$/); + await browser.route(/\\/other$/, (route) => route.abort()); + await browser.route(/\\/api\\/flags$/, (route) => route.fulfill({ json: { betaBoard: true } })); + const [response] = await Promise.all([browser.waitForResponse(/\\/api\\/flags$/), browser.goto('/flags')]); + expect(response.url).toMatch(/\\/api\\/flags$/); + expect(await response.json()).toEqual({ betaBoard: true }); + await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta on'); + await browser.unroute(/\\/api\\/flags$/); + const [real] = await Promise.all([browser.waitForResponse('**/api/flags'), browser.reload()]); + expect(await real.json()).toEqual({ betaBoard: false }); + await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta off'); +}); + +test('an option toHaveTitle does not take is refused', async ({ app, browser }) => { + await app.open(); + await expect(browser).toHaveTitle('', flag('ignoreCase', true)); +}); + +test('an option waitForURL does not take is refused', async ({ app, browser }) => { + await app.open(); + await browser.waitForURL('/', flag('waitUntil', 'load')); +}); +`; + describe('web platform integration', () => { let app: FixtureApp; let outcome: RunOutcome; @@ -654,16 +417,18 @@ describe('web platform integration', () => { beforeAll(async () => { app = await startFixtureApp(); - ({ outcome, project } = await runProject( + ({ outcome, project } = await runProjectWithConfigFile( { 'tests/kitchen.e2e.ts': KITCHEN_SINK, 'tests/late-handler.e2e.ts': LATE_HANDLER_THEN_TEARDOWN, 'tests/schemes.e2e.ts': WRAPPED_SCHEMES, + 'tests/classes.e2e.ts': CLASSES, + 'tests/url-patterns.e2e.ts': URL_PATTERNS, 'marker.txt': 'marker-local-file\n', }, { appUrl: app.url, - config: { actionTimeout: 5_000, assertionTimeout: 4_000, timeout: 30_000 }, + configSource: workerConfigSource(1, "\n cache: 'off',\n actionTimeout: 5_000,\n assertionTimeout: 4_000,\n timeout: 30_000,"), }, )); }, 240_000); @@ -673,37 +438,32 @@ describe('web platform integration', () => { await app?.close(); }); - it('passes every deterministic behavior test', () => { - const shouldPass = [ - 'deterministic queries and reads', - 'a textarea value compares raw', - 'toHaveAttribute reads the value attribute of plain fields', - 'role vocabulary: tabs, menus, progress, toolbars, images', - 'actions and state', - 'check and uncheck refuse an unknown option and leave the box untouched', - 'unsupported filter and selectOption shapes fail before acting', - 'assertions poll until the app settles', - 'web navigation, urls, and titles', - 'routes intercept and fulfill', - 'routes are attempt-scoped: registered before the first page, kept across restart and clearState', - 'waitForResponse observes network traffic', - 'evaluate runs trusted code with JSON arguments', - 'cookies round-trip through policy checks', - 'dialogs are handled by registered handlers', - 'frame locators scope queries into iframes', - 'a nested absent frame counts as zero matches at once', - 'absence matchers and waits under an absent frame pass at once', - 'nested frame locators resolve each frame inside the one before it', - 'css selectors via browser.locator', - 'contenteditable hosts are textboxes: reached by label, filled, and read as a value', - 'downloads are captured as artifacts', - 'app lifecycle: restart preserves storage, clearState clears it', - 'screenshots land in the artifact directory', - ]; - for (const title of shouldPass) { - const result = resultByTitle(outcome, title); - expect(result.status, `${title}: ${JSON.stringify(result.attempts[0]?.error)}`).toBe('passed'); - } + it.each([ + 'deterministic queries and reads', + 'class assertions report the observed class on failure', + 'a textarea value compares raw', + 'toHaveAttribute reads the value attribute of plain fields', + 'routes are attempt-scoped: registered before the first page, kept across restart and clearState', + 'reads under an absent frame answer with no match', + 'a nested absent frame counts as zero matches at once', + 'absence matchers and waits under an absent frame pass at once', + 'a *css= capture is refused before the last selector part', + 'downloads are captured as artifacts', + 'clicks hold the modifiers they name', + 'a viewport set before the first navigation holds through app.open', + 'an empty class attribute is an empty class list', + 'toHaveURL ignoreCase folds the comparison', + 'string and RegExp URL patterns match what they name', + ])('passes %s', (title) => { + const result = resultByTitle(outcome, title); + expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); + }); + + it('decides absence under an absent frame without waiting out the deadline', () => { + const result = resultByTitle(outcome, 'absence matchers and waits under an absent frame pass at once'); + const steps = result.attempts[0]!.steps.filter((step) => step.api.startsWith('expect.') || step.api === 'locator.waitFor'); + expect(steps).toHaveLength(5); + for (const step of steps) expect(step.durationMs, step.api).toBeLessThan(3000); }); it('fails an action under an absent frame with LOCATOR_NOT_FOUND once its timeout has run', () => { @@ -715,10 +475,6 @@ describe('web platform integration', () => { expect(tap?.durationMs).toBeGreaterThanOrEqual(800); }); - it('holds click modifiers on the web engine', () => { - expect(resultByTitle(outcome, 'clicks hold the modifiers they name').status).toBe('passed'); - }); - it('fails a download that never started with the wait and the trigger time, not a bare timeout', () => { const result = resultByTitle(outcome, 'a trigger that starts no download times out saying so'); expect(result.status).toBe('failed'); @@ -730,7 +486,6 @@ describe('web platform integration', () => { }); it('sets a viewport before the first navigation without opening a page', () => { - expect(resultByTitle(outcome, 'a viewport set before the first navigation holds through app.open').status).toBe('passed'); const result = resultByTitle(outcome, 'a viewport set before the first navigation opens no page'); expect(result.attempts[0]!.steps.map((step) => [step.api, step.status])).toEqual([ ['browser.setViewport', 'passed'], @@ -739,18 +494,6 @@ describe('web platform integration', () => { expect(result.attempts[0]!.error?.code).toBe('APP_NOT_OPEN'); }); - it('fails ambiguous locators immediately with LOCATOR_AMBIGUOUS', () => { - const result = resultByTitle(outcome, 'ambiguous locators fail immediately'); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error?.code).toBe('LOCATOR_AMBIGUOUS'); - }); - - it('fails missing locators with LOCATOR_NOT_FOUND after polling', () => { - const result = resultByTitle(outcome, 'missing locators poll until the deadline'); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error?.code).toBe('LOCATOR_NOT_FOUND'); - }); - it('fails toHaveValue on a textarea against the raw value, and prints what it compared', () => { // The web engine reports the value raw, and the matcher neither collapses // the newlines to match nor prints a string it never compared. @@ -796,29 +539,6 @@ describe('web platform integration', () => { expect(control.attempts[0]!.error?.message).toContain('observed: value "marker-9b1f"'); }); - it('fails a body that returns before a step it started finished with STEP_NOT_AWAITED', () => { - const result = resultByTitle(outcome, 'a step call without await'); - expect(result.status).toBe('failed'); - const attempt = result.attempts[0]!; - expect(attempt.error).toMatchObject({ code: 'STEP_NOT_AWAITED', phase: 'body' }); - expect(attempt.error?.message).toContain('app.open'); - // A vitest fork reports raw transformed positions; test-source.test.ts covers the mapped line. - expect(attempt.error?.source?.file).toBe('tests/kitchen.e2e.ts'); - expect(attempt.steps).toHaveLength(1); - expect(attempt.steps[0]).toMatchObject({ api: 'app.open', status: 'failed', error: { code: 'STEP_NOT_AWAITED' } }); - expect(outcome.report.run.errors.map((error) => error.code)).toEqual([]); - }); - - it('keeps the body error primary and notes the un-awaited step beside it', () => { - const result = resultByTitle(outcome, 'a step call without await before the body throws'); - expect(result.status).toBe('failed'); - const attempt = result.attempts[0]!; - expect(attempt.error).toMatchObject({ code: 'ERROR', message: 'the body gave up' }); - expect(attempt.secondaryErrors.map((error) => error.code)).toEqual(['STEP_NOT_AWAITED']); - expect(attempt.steps[0]).toMatchObject({ api: 'app.open', status: 'failed', error: { code: 'STEP_NOT_AWAITED' } }); - expect(outcome.report.run.errors.map((error) => error.code)).toEqual([]); - }); - it.each([ ['route', 'a route handler assertion that no step follows'], ['dialog', 'a dialog handler assertion that no step follows'], @@ -845,12 +565,6 @@ describe('web platform integration', () => { expect(text).not.toContain('heading "Home"'); }); - it('fails UI operations before open with APP_NOT_OPEN', () => { - const result = resultByTitle(outcome, 'acting before open fails with APP_NOT_OPEN'); - expect(result.status).toBe('failed'); - expect(result.attempts[0]!.error?.code).toBe('APP_NOT_OPEN'); - }); - it('denies navigation to a forbidden scheme', () => { const result = resultByTitle(outcome, 'forbidden URL schemes are refused'); expect(result.status).toBe('failed'); @@ -915,15 +629,54 @@ describe('web platform integration', () => { expect(download).toMatchObject({ redaction: 'incomplete', mediaType: 'text/csv' }); }); - it('records steps and screenshot artifacts on attempts', () => { - const result = resultByTitle(outcome, 'screenshots land in the artifact directory'); + it('keeps a class assertion polling while the node is missing and passes once it arrives', () => { + const result = resultByTitle(outcome, 'class assertions poll for a node that arrives late'); + expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); + const step = result.attempts[0]!.steps.find((candidate) => candidate.api === 'expect.toHaveClass'); + expect(step?.durationMs).toBeGreaterThan(500); + }); + + it('fails a class assertion on an ambiguous locator with LOCATOR_AMBIGUOUS before the deadline', () => { + const result = resultByTitle(outcome, 'class assertions on an ambiguous locator fail at once'); + expect(result.status).toBe('failed'); + expect(result.attempts[0]!.error?.code).toBe('LOCATOR_AMBIGUOUS'); + const step = result.attempts[0]!.steps.find((candidate) => candidate.api === 'expect.toHaveClass'); + expect(step?.durationMs).toBeLessThan(1800); + }); + + it('matches an empty class string against no class attribute only when one is present', () => { + const missing = resultByTitle(outcome, 'a missing class attribute is not an empty class list'); + expect(missing.status).toBe('failed'); + expect(missing.attempts[0]!.error?.code).toBe('ASSERTION_FAILED'); + expect(missing.attempts[0]!.error?.message).toContain('observed: no class attribute'); + }); + + it('fails a negated toHaveURL ignoreCase on a case-folded match', () => { + const negated = resultByTitle(outcome, 'a negated toHaveURL ignoreCase fails on a case-folded match'); + expect(negated.status).toBe('failed'); + expect(negated.attempts[0]!.error?.code).toBe('ASSERTION_FAILED'); + expect(negated.attempts[0]!.error?.message).toContain('expected: not URL /CLASSES (ignoring case)'); + }); + + it.each([ + ['waitForResponse with a predicate', 'browser.waitForResponse'], + ['route with a predicate', 'browser.route'], + ['unroute with a predicate', 'browser.unroute'], + ])('%s fails with INVALID_ARGUMENT at %s', (title, api) => { + const result = resultByTitle(outcome, title); + expect(result.status).toBe('failed'); const attempt = result.attempts[0]!; - expect(attempt.steps.length).toBeGreaterThan(1); - expect(attempt.steps.map((step) => step.api)).toContain('app.screenshot'); - const screenshot = attempt.artifacts.find((artifact) => artifact.kind === 'screenshot'); - expect(screenshot).toBeDefined(); - expect(screenshot!.path).toBeDefined(); - const artifactsRoot = path.join(project.dir, '.e2e', 'artifacts'); - expect(existsSync(path.join(artifactsRoot, screenshot!.path!))).toBe(true); + expect(attempt.error?.code).toBe('INVALID_ARGUMENT'); + expect(attempt.error?.message).toContain('string or RegExp'); + expect(attempt.steps.find((step) => step.status === 'failed')?.api).toBe(api); + }); + + it.each([ + ['an option toHaveTitle does not take is refused', 'expect.toHaveTitle options has no key "ignoreCase"'], + ['an option waitForURL does not take is refused', 'browser.waitForURL options has no key "waitUntil"'], + ])('%s', (title, message) => { + const result = resultByTitle(outcome, title); + expect(result.status).toBe('failed'); + expect(result.attempts[0]!.error).toMatchObject({ code: 'INVALID_ARGUMENT', message: expect.stringContaining(message) }); }); }); diff --git a/packages/e2e/tests/integration/web-replaced-nodes.test.ts b/packages/e2e/tests/integration/web-replaced-nodes.test.ts index e009e20fa..b11287d5d 100644 --- a/packages/e2e/tests/integration/web-replaced-nodes.test.ts +++ b/packages/e2e/tests/integration/web-replaced-nodes.test.ts @@ -16,7 +16,7 @@ import { resultByTitle, runProject, type FixtureProject, type RunOutcome } from const SUITE = `import { test } from '@e2e-dev/web'; import { expect } from 'e2e'; -const READS = 30; +const READS = 10; /** * Runs body while this process's event loop is blocked in 20 ms slices, so a @@ -58,7 +58,7 @@ test('a field replaced every frame never reads as hidden', async ({ app, screen test('a field replaced every frame never reads as hidden on a starved runner', async ({ app, screen }) => { await app.open('/replaced'); - expect(await starved(() => hiddenReads(fields(screen), 10))).toEqual({ testId: 0, label: 0, displayValue: 0 }); + expect(await starved(() => hiddenReads(fields(screen), 3))).toEqual({ testId: 0, label: 0, displayValue: 0 }); }); test('a stable field beside one replaced every frame reads as shown', async ({ app, screen }) => { @@ -109,7 +109,7 @@ describe('reads of a node replaced every frame', () => { beforeAll(async () => { app = await startFixtureApp(); - // Ninety sequential reads against a page that swaps nodes every frame: on a + // Thirty sequential reads against a page that swaps nodes every frame: on a // loaded CI runner each read takes far longer than locally, and the suite // asserts what the reads return, not how fast they are. The hook's own // budget covers all eight tests at that deadline. diff --git a/packages/e2e/tests/integration/web-url-patterns.test.ts b/packages/e2e/tests/integration/web-url-patterns.test.ts deleted file mode 100644 index 199c6ec3d..000000000 --- a/packages/e2e/tests/integration/web-url-patterns.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * URL patterns in a real browser: `toHaveURL`, `waitForURL`, `route`, - * `unroute`, and `waitForResponse` take a string or a RegExp, as does the - * text pattern of `toHaveTitle`. A predicate function, which Playwright - * accepts and a migrated test may still pass, is refused with - * INVALID_ARGUMENT instead of reading as a pattern that matches everything. - */ - -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { startFixtureApp, type FixtureApp } from '../helpers/fixture-app.ts'; -import { resultByTitle, runProject, type FixtureProject, type RunOutcome } from '../helpers/run-project.ts'; - -const SUITE = `import { test } from '@e2e-dev/web'; -import { expect } from 'e2e'; - -const toOther: any = (url: URL) => url.pathname === '/other'; - -test('toHaveURL with a predicate', async ({ app, browser }) => { - await app.open('/about'); - await expect(browser).toHaveURL(toOther, { timeout: 500 }); -}); - -test('waitForURL with a predicate', async ({ app, browser }) => { - await app.open('/about'); - await browser.waitForURL(toOther, { timeout: 500 }); -}); - -test('waitForResponse with a predicate', async ({ app, browser }) => { - await app.open(); - await Promise.all([browser.waitForResponse(toOther, { timeout: 2_000 }), browser.goto('/flags')]); -}); - -test('route with a predicate', async ({ app, browser, screen }) => { - await browser.route(toOther, (route) => route.abort()); - await app.open('/flags'); - await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta off'); -}); - -test('unroute with a predicate', async ({ app, browser }) => { - await app.open(); - await browser.unroute(toOther); -}); - -test('toHaveTitle with a predicate', async ({ app, browser }) => { - await app.open(); - await expect(browser).toHaveTitle(toOther, { timeout: 500 }); -}); - -test('string and RegExp URL patterns match what they name', async ({ app, browser, screen }) => { - await app.open('/about'); - await expect(browser).toHaveURL('/about'); - await expect(browser).toHaveURL(/\\/about$/); - await expect(browser).not.toHaveURL('/other'); - await expect(browser).not.toHaveURL(/\\/other$/); - await browser.waitForURL(/\\/about$/); - await browser.route(/\\/other$/, (route) => route.abort()); - await browser.route(/\\/api\\/flags$/, (route) => route.fulfill({ json: { betaBoard: true } })); - const [response] = await Promise.all([browser.waitForResponse(/\\/api\\/flags$/), browser.goto('/flags')]); - expect(response.url).toMatch(/\\/api\\/flags$/); - expect(await response.json()).toEqual({ betaBoard: true }); - await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta on'); - await browser.unroute(/\\/api\\/flags$/); - const [real] = await Promise.all([browser.waitForResponse('**/api/flags'), browser.reload()]); - expect(await real.json()).toEqual({ betaBoard: false }); - await expect(screen.getByRole('status', { name: 'Flags' })).toHaveText('beta off'); -}); -`; - -describe('web URL patterns in a browser', () => { - let app: FixtureApp; - let outcome: RunOutcome; - let project: FixtureProject; - - beforeAll(async () => { - app = await startFixtureApp(); - ({ outcome, project } = await runProject( - { 'tests/url-patterns.e2e.ts': SUITE }, - { appUrl: app.url, config: { actionTimeout: 5_000, assertionTimeout: 4_000, timeout: 30_000 } }, - )); - }, 240_000); - - afterAll(async () => { - project?.cleanup(); - await app?.close(); - }); - - it.each([ - ['toHaveURL with a predicate', 'expect.toHaveURL'], - ['waitForURL with a predicate', 'browser.waitForURL'], - ['waitForResponse with a predicate', 'browser.waitForResponse'], - ['route with a predicate', 'browser.route'], - ['unroute with a predicate', 'browser.unroute'], - ['toHaveTitle with a predicate', 'expect.toHaveTitle'], - ])('%s fails with INVALID_ARGUMENT at %s', (title, api) => { - const result = resultByTitle(outcome, title); - expect(result.status).toBe('failed'); - const attempt = result.attempts[0]!; - expect(attempt.error?.code).toBe('INVALID_ARGUMENT'); - expect(attempt.error?.message).toContain('string or RegExp'); - expect(attempt.steps.find((step) => step.status === 'failed')?.api).toBe(api); - }); - - it('keeps string and RegExp patterns matching only what they name', () => { - const result = resultByTitle(outcome, 'string and RegExp URL patterns match what they name'); - expect(result.status, JSON.stringify(result.attempts[0]?.error)).toBe('passed'); - }); -}); diff --git a/packages/e2e/tests/unit/config.test.ts b/packages/e2e/tests/unit/config.test.ts index 41cc17f14..683f04526 100644 --- a/packages/e2e/tests/unit/config.test.ts +++ b/packages/e2e/tests/unit/config.test.ts @@ -988,6 +988,8 @@ describe('resolveConfig', () => { expect(own.targets[0]![kind]).toEqual({ mode: 'on-all-retries', source: 'target' }); const flagged = resolveConfig({ targets: [web('off')], [kind]: 'off' }, { projectRoot: ROOT, env: BASE_ENV, cli: { [kind]: 'on' } }); expect(flagged.targets[0]![kind]).toEqual({ mode: 'on', source: 'run' }); + const off = resolveConfig({ targets: TARGETS, [kind]: 'on' }, { projectRoot: ROOT, env: BASE_ENV, cli: { [kind]: 'off' } }); + expect(off.targets[0]![kind]).toEqual({ mode: 'off', source: 'run' }); }); it('keeps the mode, at the top and on a target, out of the digest', () => { From e488bdef1bcf1182f62db01089d59a87ca45debe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20V=C4=83n=20Hi=E1=BA=BFu?= <132248583+nguyenvanhieu1710@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:53:19 +0700 Subject: [PATCH 02/46] chore(deps): update fast-uri to address security advisories (#846) --- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 269c2a5f0..f85c43a78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,7 +106,7 @@ settings: excludeLinksFromLockfile: false overrides: - fast-uri@>=3.0.0 <3.1.6: 3.1.6 + fast-uri@>=3.0.0 <3.1.8: 3.1.8 js-yaml@<3.15.2: 3.15.2 js-yaml@>=4.0.0 <4.3.2: 4.3.2 nanoid@<3.3.18: 3.3.18 @@ -4472,8 +4472,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -10711,7 +10711,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -12037,7 +12037,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.6: {} + fast-uri@3.1.8: {} fast-wrap-ansi@0.2.2: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db5c37e8d..c99fdef0e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,7 @@ allowBuilds: sharp: false overrides: - 'fast-uri@>=3.0.0 <3.1.6': 3.1.6 + 'fast-uri@>=3.0.0 <3.1.8': 3.1.8 'js-yaml@<3.15.2': 3.15.2 'js-yaml@>=4.0.0 <4.3.2': 4.3.2 'nanoid@<3.3.18': 3.3.18 From c681005af83962628c82ea8ea7cc419ac587a1eb Mon Sep 17 00:00:00 2001 From: Feco Linhares <963774+fecolinhares@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:32:01 -0300 Subject: [PATCH 03/46] test(web): wait for a painted frame before closing a video segment (#804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(web): hold each segment's wait for a frame that segment received The observer remembers every page it saw paint, so the wait that closed a segment was satisfied by the first frame that page ever delivered. A segment that painted nothing was then recorded as though it had, and the video assertions fail on it later with `inked` below its threshold. The wait now forgets what earlier segments painted. The added test stops the segment that did paint, opens another, and asserts that closing it runs its timeout out; removing the forget makes it fail. Rebased onto the file as it stands after #798, which moved the observer inside the test that owns it, so the wait is now the one taking a page rather than reaching for it. Spies are restored and forgotten together, so a failed test cannot leave one behind for the next. * test(web): wait for a painted frame before stopping each mid-attempt video segment The flaking test is the mid-attempt split one, not the per-page one: its first segment sometimes stopped before the screencast delivered a frame, and Playwright writes a white frame then. Share one paint observer between both video tests and drop the test that only checked its own copy. --------- Co-authored-by: Feco Linhares Co-authored-by: Oskar Kwaśniewski --- .../web/tests/integration/lifecycle.test.ts | 108 +++++++++++------- 1 file changed, 64 insertions(+), 44 deletions(-) diff --git a/packages/web/tests/integration/lifecycle.test.ts b/packages/web/tests/integration/lifecycle.test.ts index e452acb74..fc36f5aa3 100644 --- a/packages/web/tests/integration/lifecycle.test.ts +++ b/packages/web/tests/integration/lifecycle.test.ts @@ -97,6 +97,59 @@ async function lastFrame( }, [...webm]); } +/** + * Watches the frames the engine's screencast delivers, so a test can hold a + * video segment open until it received a painted one: Playwright writes a + * white frame for a segment stopped before its first frame arrived. + */ +function watchPaint() { + const painted = new WeakSet(); + const restores: (() => void)[] = []; + const watchPage = (page: Page): void => { + const start = page.screencast.start.bind(page.screencast); + const spy = vi.spyOn(page.screencast, 'start').mockImplementation((options) => start({ + ...options, + onFrame: async (frame) => { + const hasPaint = await page.evaluate(async (bytes) => { + const bitmap = await createImageBitmap(new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' })); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d')!; + context.drawImage(bitmap, 0, 0); + bitmap.close(); + const { data } = context.getImageData(0, 0, canvas.width, canvas.height); + for (let index = 0; index < data.length; index += 4) { + if (data[index]! < 200 || data[index + 1]! < 200 || data[index + 2]! < 200) return true; + } + return false; + }, [...frame.data]).catch(() => false); + if (hasPaint) painted.add(page); + await options?.onFrame?.(frame); + }, + })); + restores.push(() => spy.mockRestore()); + }; + return { + /** Observes the context's pages, including those it opens later, before their recording starts. */ + watchContext(context: BrowserContext): void { + context.on('page', watchPage); + for (const page of context.pages()) watchPage(page); + }, + /** Waits for a painted frame delivered after this call, so a page that painted for an earlier segment cannot pass for this one. */ + async settle(page: Page): Promise { + painted.delete(page); + // A static page may not repaint after navigation overtakes its first screencast frame. + await page.screenshot(); + await expect.poll(() => painted.has(page), { timeout: 5_000, message: 'the recorder received a painted frame' }).toBe(true); + }, + /** Removes every observer, so none reaches the next test. */ + restore(): void { + while (restores.length > 0) restores.pop()!(); + }, + }; +} + async function withAttempt( engine: EngineHandle, app: FixtureApp, @@ -934,55 +987,18 @@ describe('web engine lifecycle', () => { const engine = web(); const videoDir = mkdtempSync(path.join(tmpdir(), 'e2e-video-')); const nodesOf = (snapshot: { root: SemanticNode }) => [...walk(snapshot.root)]; - const painted = new WeakSet(); + const paint = watchPaint(); const restores: (() => void)[] = []; - /** Observe the same delivered frames the recorder writes, including on replacement pages. */ - const watchPage = (page: Page): void => { - const start = page.screencast.start.bind(page.screencast); - const spy = vi.spyOn(page.screencast, 'start').mockImplementation((options) => start({ - ...options, - onFrame: async (frame) => { - const hasPaint = await page.evaluate(async (bytes) => { - const bitmap = await createImageBitmap(new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' })); - const canvas = document.createElement('canvas'); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - const context = canvas.getContext('2d')!; - context.drawImage(bitmap, 0, 0); - bitmap.close(); - const { data } = context.getImageData(0, 0, canvas.width, canvas.height); - for (let index = 0; index < data.length; index += 4) { - if (data[index]! < 200 || data[index + 1]! < 200 || data[index + 2]! < 200) return true; - } - return false; - }, [...frame.data]).catch(() => false); - if (hasPaint) painted.add(page); - await options?.onFrame?.(frame); - }, - })); - restores.push(() => spy.mockRestore()); - }; - /** Install the frame observer before a newly created page starts its recording. */ - const watchContext = (context: BrowserContext): void => { - context.on('page', watchPage); - for (const page of context.pages()) watchPage(page); - }; - const settle = async (): Promise => { - const page = surfaceOf(engine)!.page(); - // A static page may not repaint after navigation overtakes its first screencast frame. - await page.screenshot(); - await expect.poll(() => painted.has(page), { timeout: 5_000, message: 'the recorder received a painted frame' }).toBe(true); - }; try { await withAttempt(engine, app, videoDir, 'v1', async () => { await engine.session!.open!(`${app.url}/form`, operation('v1')); const context = surfaceOf(engine)!.context(); - watchContext(context); + paint.watchContext(context); const browser = context.browser()!; const newContext = browser.newContext.bind(browser); const contextSpy = vi.spyOn(browser, 'newContext').mockImplementation(async (options) => { const next = await newContext(options); - watchContext(next); + paint.watchContext(next); return next; }); restores.push(() => contextSpy.mockRestore()); @@ -997,19 +1013,19 @@ describe('web engine lifecycle', () => { const textbox = observed.find((node) => node.role === 'textbox' && node.name === 'First'); await engine.perform!(textbox!.ref, { kind: 'fill', value: 'recorded', sensitive: false }, operation('v1')); await engine.artifacts!.screenshot('shot', operation('v1')); - await settle(); + await paint.settle(surfaceOf(engine)!.page()); // A restart closes the page and opens another, blank until the harness // reopens the app: the recording continues in a second segment. await engine.session!.restart!(operation('v1')); expect(surfaceOf(engine)!.page()).not.toBe(page); expect(surfaceOf(engine)!.page().url()).toBe('about:blank'); await engine.session!.open!(`${app.url}/form`, operation('v1')); - await settle(); + await paint.settle(surfaceOf(engine)!.page()); // A state reset recreates the context; the recording continues in a third. await engine.session!.reset!(operation('v1')); expect(surfaceOf(engine)!.page().url()).toBe('about:blank'); await engine.session!.open!(`${app.url}/`, operation('v1')); - await settle(); + await paint.settle(surfaceOf(engine)!.page()); const segments = videoFiles(await engine.artifacts!.stopVideo!(operation('v1'))); // The restart and the state reset each closed a trace segment before the final archive. expect(await engine.artifacts!.stopTrace!(operation('v1'))).toEqual([ @@ -1041,6 +1057,7 @@ describe('web engine lifecycle', () => { }); } finally { for (const restore of restores) restore(); + paint.restore(); rmSync(videoDir, { recursive: true, force: true }); } }); @@ -1048,18 +1065,20 @@ describe('web engine lifecycle', () => { it('splits a running trace around a video started mid-attempt, so every recording fills the viewport', async () => { const engine = web(); const videoDir = mkdtempSync(path.join(tmpdir(), 'e2e-video-')); + const paint = watchPaint(); try { await withAttempt(engine, app, videoDir, 'v3', async () => { // A host that records on demand: the trace already sized the screencast when the video starts. await engine.artifacts!.startTrace!(operation('v3')); await engine.session!.open!(`${app.url}/`, operation('v3')); + paint.watchContext(surfaceOf(engine)!.context()); const viewer = surfaceOf(engine)!.page(); const viewport = viewer.viewportSize()!; const segments: VideoFile[] = []; for (const url of [`${app.url}/form`, `${app.url}/`]) { await engine.artifacts!.startVideo!(operation('v3')); await engine.session!.open!(url, operation('v3')); - await viewer.screenshot(); + await paint.settle(viewer); segments.push(...videoFiles(await engine.artifacts!.stopVideo!(operation('v3')))); } expect(segments.map((segment) => segment.path)).toEqual(['video/video.webm', 'video/video-part2.webm']); @@ -1077,6 +1096,7 @@ describe('web engine lifecycle', () => { ]); }); } finally { + paint.restore(); rmSync(videoDir, { recursive: true, force: true }); } }); From 99959329cd00cc905f4fc2838d7ace3f698ad8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwa=C5=9Bniewski?= Date: Mon, 5 Oct 2026 11:44:13 +0200 Subject: [PATCH 04/46] fix(run): charge stray rejections in in-process runs like a worker process (#809) * fix(run): catch stray rejections and exceptions in in-process runs An in-process run (rawConfig, e2e explore) installed no unhandledRejection or uncaughtException handling, so a test that left a rejection unhandled crashed the host instead of failing. The in-process runner now routes both the way the worker entry does, for as long as it lives. * fix(run): end an in-process worker on a fatal stray as a process would * fix(run): keep engine disposal errors from a crashed in-process worker * docs(changeset): narrow the in-process fatal rejection case --- .changeset/in-process-stray-rejections.md | 5 + packages/e2e/src/run/in-process.ts | 59 ++++++- .../integration/in-process-strays.test.ts | 166 ++++++++++++++++++ 3 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 .changeset/in-process-stray-rejections.md create mode 100644 packages/e2e/tests/integration/in-process-strays.test.ts diff --git a/.changeset/in-process-stray-rejections.md b/.changeset/in-process-stray-rejections.md new file mode 100644 index 000000000..db9aca57f --- /dev/null +++ b/.changeset/in-process-stray-rejections.md @@ -0,0 +1,5 @@ +--- +"e2e": patch +--- + +A run that executes in the host process, such as `e2e explore`, no longer crashes that process when a test leaves a promise rejection or an exception uncaught. It handles them as a worker process does: a rejection fails the test running when it surfaces, or is recorded as `UNHANDLED_REJECTION` between tests. An uncaught exception, or a rejection that surfaces with no test running before any test has finished, is a run error that fails the test on its way with `WORKER_CRASH` and skips the rest of its file. The handlers are removed when the run ends. diff --git a/packages/e2e/src/run/in-process.ts b/packages/e2e/src/run/in-process.ts index bc06ae54a..00a9f52ed 100644 --- a/packages/e2e/src/run/in-process.ts +++ b/packages/e2e/src/run/in-process.ts @@ -9,6 +9,7 @@ import type { ModuleRegistration } from '../collect/registry.ts'; import type { Selection, TestTargetPair } from '../collect/select.ts'; import type { ResolvedConfig, ResolvedTarget } from '../config/resolve.ts'; +import { isAbandonedRejection } from '../internal/abandoned.ts'; import { classifyError, ConfigurationError, serializeError } from '../internal/errors.ts'; import type { DebugTrace } from '../internal/debug.ts'; import type { InMemoryAttempts } from './execute.ts'; @@ -47,6 +48,20 @@ class InProcessRunner implements UnitRunner { private readonly finish: () => void; private exited = false; private closing = false; + /** + * Set by a fatal error: the worker is dead to the scheduler from then on, + * as a crashed process would be, so nothing it reports about its unit + * while it winds down counts, and the unit's results are synthesized when + * it exits. Unlike a dead process it still disposes its engine, so the + * failures of that disposal (`shutdown-done`) are still reported. + */ + private crashed = false; + /** Takes what this process failed to catch while the runner lives; see `catchStrays`. */ + private readonly onRejection = (cause: unknown): void => { + if (isAbandonedRejection(cause)) return; + if (!this.worker.strayRejection(cause)) this.fail(cause); + }; + private readonly onException = (cause: unknown): void => this.fail(cause); constructor( private readonly targetName: string, @@ -62,20 +77,15 @@ class InProcessRunner implements UnitRunner { this.worker = new TargetWorker( { emit: (message) => { - if (!this.exited) this.events.onMessage(message); - }, - fatal: (cause) => { - if (this.exited) return; - this.events.onMessage({ - type: 'fatal', - error: serializeError(classifyError(cause)), - }); - this.close(); + if (this.exited || (this.crashed && message.type !== 'shutdown-done')) return; + this.events.onMessage(message); }, + fatal: (cause) => this.fail(cause), finished: () => this.end('shut down'), }, () => this.bootstrap(), ); + this.catchStrays(); this.worker.start(); } @@ -109,6 +119,35 @@ class InProcessRunner implements UnitRunner { this.close(); } + /** + * Catches what a test leaves uncaught the way a worker process does + * (`worker/entry.ts`): a rejection nobody handled is charged to the attempt + * in flight, or recorded against the last test that finished, and is + * otherwise fatal to this worker, as an uncaught exception is (see + * `fail`). The + * listeners live exactly as long as the runner, so a host process keeps its + * own handling before and after the run. The scheduler keeps at most one + * in-process runner alive at a time, so nothing is charged twice. + */ + private catchStrays(): void { + process.on('unhandledRejection', this.onRejection); + process.on('uncaughtException', this.onException); + } + + /** + * Reports an unrecoverable failure and ends the worker the way a process + * dies: its unit is interrupted and nothing more it reports is passed on, + * so on exit the scheduler fails the test in flight with `WORKER_CRASH` + * and skips the unit's others, as it does for a worker process. + */ + private fail(cause: unknown): void { + if (this.exited || this.crashed) return; + this.events.onMessage({ type: 'fatal', error: serializeError(classifyError(cause)) }); + this.crashed = true; + this.worker.handle({ type: 'interrupt' }); + this.close(); + } + /** Stops accepting work and asks the queue to wind down. */ private close(): void { if (this.exited || this.closing) return; @@ -119,6 +158,8 @@ class InProcessRunner implements UnitRunner { private end(detail: string): void { if (this.exited) return; this.exited = true; + process.off('unhandledRejection', this.onRejection); + process.off('uncaughtException', this.onException); this.events.onExit(detail); this.finish(); } diff --git a/packages/e2e/tests/integration/in-process-strays.test.ts b/packages/e2e/tests/integration/in-process-strays.test.ts new file mode 100644 index 000000000..afc6237e8 --- /dev/null +++ b/packages/e2e/tests/integration/in-process-strays.test.ts @@ -0,0 +1,166 @@ +import { spawnSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createProject, type FixtureProject } from '../helpers/run-project.ts'; + +const projects: FixtureProject[] = []; + +afterEach(() => { + for (const project of projects.splice(0)) project.cleanup(); +}); + +/** + * A host process of its own: vitest's handlers would see the stray errors + * first, and the point is what an in-process run does to a host that has + * none. It runs the fixture through a `rawConfig` on a noop engine with + * `engineMembers`, prints what came back and which process listeners it left + * behind. + */ +const hostScript = (engineMembers: string) => `import { defineEngine } from 'e2e/engine'; +const { run } = await import(new URL('../../../dist/run/runner.js', import.meta.url).href); +const listeners = () => [process.listenerCount('unhandledRejection'), process.listenerCount('uncaughtException')]; +const before = listeners(); +const engine = defineEngine({ name: 'noop', version: '1', spiVersion: 1, ${engineMembers} }); +const outcome = await run({ + cwd: process.cwd(), + rawConfig: { targets: [{ name: 'headless', platform: 'test', engine }] }, + env: { ...process.env, CI: '' }, + quiet: true, +}); +console.log(JSON.stringify({ + results: Object.fromEntries(outcome.results.map((r) => [r.test.title, [r.status, r.attempts[0]?.error?.code ?? r.skip?.cause, r.attempts[0]?.error?.message]])), + exitCode: outcome.exitCode, + runErrors: outcome.report.run.errors.map((e) => [e.code, e.message]), + listeners: { before, after: listeners() }, +})); +`; + +/** Runs `tests` in-process inside a fresh host process. */ +function runInHost(tests: Readonly>, engineMembers = '') { + const project = createProject({ ...tests, 'host.mjs': hostScript(engineMembers) }); + projects.push(project); + const host = spawnSync(process.execPath, ['host.mjs'], { + cwd: project.dir, + env: { ...process.env, E2E_TELEMETRY_DISABLED: '1', NO_COLOR: '1' }, + encoding: 'utf8', + timeout: 60_000, + }); + expect(host.stderr).toBe(''); + expect(host.status).toBe(0); + return JSON.parse(host.stdout.trim().split('\n').at(-1)!) as { + results: Record; + exitCode: number; + runErrors: [string, string][]; + listeners: { before: number[]; after: number[] }; + }; +} + +describe('an in-process run', () => { + it('fails the test that left a rejection unhandled, as a worker process does, and leaves the host its own handlers', () => { + const outcome = runInHost({ + 'tests/stray.e2e.ts': `import { test } from 'e2e'; + +test('leaves a rejection behind', async () => { + void Promise.reject(new Error('boom')); + await new Promise((resolve) => setTimeout(resolve, 100)); +}); + +test('runs after the rejection', async () => {}); +`, + }); + expect(outcome.results).toEqual({ + 'leaves a rejection behind': ['failed', 'ERROR', 'boom'], + 'runs after the rejection': ['passed', null, null], + }); + expect(outcome.runErrors).toEqual([]); + expect(outcome.listeners).toEqual({ before: [0, 0], after: [0, 0] }); + }); + + it('records a rejection between tests against the last test that finished', () => { + const outcome = runInHost({ + 'tests/a-leaks.e2e.ts': `import { test } from 'e2e'; + +test('finishes before the leak', async () => {}); + +test.afterAll(async () => { + void Promise.reject(new Error('late')); + await new Promise((resolve) => setTimeout(resolve, 100)); +}); +`, + 'tests/b-next.e2e.ts': `import { test } from 'e2e'; + +test('runs afterwards', async () => {}); +`, + }); + expect(outcome.results['finishes before the leak']?.[0]).toBe('passed'); + expect(outcome.results['runs afterwards']?.[0]).toBe('passed'); + expect(outcome.runErrors).toHaveLength(1); + expect(outcome.runErrors[0]?.[0]).toBe('UNHANDLED_REJECTION'); + expect(outcome.runErrors[0]?.[1]).toContain('late'); + }); + + it('ends the worker on an uncaught exception, as a crashed worker process, instead of crashing the host', () => { + const outcome = runInHost({ + 'tests/throws.e2e.ts': `import { test } from 'e2e'; + +test('throws off the stack', async () => { + setTimeout(() => { + throw new Error('boom-uncaught'); + }); + await new Promise((resolve) => setTimeout(resolve, 2_000)); +}); + +test('never starts', async () => {}); +`, + }); + expect(outcome.results).toEqual({ + 'throws off the stack': ['failed', 'WORKER_CRASH', expect.any(String)], + 'never starts': ['skipped', 'infrastructure-unavailable', null], + }); + expect(outcome.runErrors).toEqual([['ERROR', 'boom-uncaught']]); + expect(outcome.exitCode).toBe(3); + expect(outcome.listeners).toEqual({ before: [0, 0], after: [0, 0] }); + }); + + it('still reports what disposing the engine failed with after a fatal', () => { + const outcome = runInHost( + { + 'tests/throws.e2e.ts': `import { test } from 'e2e'; + +test('throws off the stack', async () => { + setTimeout(() => { + throw new Error('boom-uncaught'); + }); + await new Promise((resolve) => setTimeout(resolve, 2_000)); +}); +`, + }, + `dispose: () => { throw new Error('dispose-failed'); },`, + ); + expect(outcome.runErrors).toEqual([ + ['ERROR', 'boom-uncaught'], + ['ENGINE_FAILURE', expect.stringContaining('dispose-failed')], + ]); + }); + + it('ends the worker on a rejection before any test has finished in it', () => { + const outcome = runInHost({ + 'tests/early.e2e.ts': `import { test } from 'e2e'; + +test.beforeAll(async () => { + void Promise.reject(new Error('early')); + await new Promise((resolve) => setTimeout(resolve, 500)); +}); + +test('is on its way when it surfaces', async () => {}); + +test('follows in the same file', async () => {}); +`, + }); + expect(outcome.results).toEqual({ + 'is on its way when it surfaces': ['failed', 'WORKER_CRASH', expect.any(String)], + 'follows in the same file': ['skipped', 'infrastructure-unavailable', null], + }); + expect(outcome.runErrors).toEqual([['ERROR', 'early']]); + expect(outcome.exitCode).toBe(3); + }); +}); From 52228f4dc572c82bf4089c9b5504063c59f369fb Mon Sep 17 00:00:00 2001 From: Szymon Rybczak Date: Mon, 5 Oct 2026 12:29:49 +0200 Subject: [PATCH 05/46] docs(readme): GitHub Trending repository of the day badge (#852) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 93a3778ff..59df5057f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ Join the community on Discord

+

+ GitHub Trending Repository of the Day +

+ # e2e [e2e](https://tester.army/e2e?utm_source=e2e&utm_medium=github&utm_campaign=readme_intro) is an end-to-end testing framework for web and mobile apps. Describe a goal in natural language and an agent drives the app to reach it. Check the result with locators and assertions in the same test. From 2e26ec65510356508f680d70e85624d86a8c4c25 Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:57:09 -0700 Subject: [PATCH 06/46] fix(cli): forward flags in npm exec hints (#847) --- .changeset/npm-exec-cli-hint.md | 5 +++++ docs/reference/cli.mdx | 2 ++ packages/e2e/src/internal/package-manager.ts | 2 +- packages/e2e/tests/unit/cli-init.test.ts | 6 +++--- packages/e2e/tests/unit/cli.test.ts | 6 +++--- packages/e2e/tests/unit/package-manager.test.ts | 2 +- skills/e2e/references/running.md | 1 + 7 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 .changeset/npm-exec-cli-hint.md diff --git a/.changeset/npm-exec-cli-hint.md b/.changeset/npm-exec-cli-hint.md new file mode 100644 index 000000000..a98ba22e1 --- /dev/null +++ b/.changeset/npm-exec-cli-hint.md @@ -0,0 +1,5 @@ +--- +"e2e": patch +--- + +Include npm's argument separator in CLI hints so flags such as `--headed` reach the installed `e2e` binary. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 59f08775d..04a7eb04d 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -346,6 +346,8 @@ A positional starting with `-` that names no file is a usage error, exit 2. This is what `pnpm test:e2e -- --headed` produces: the `--` reaches e2e and `--headed` would otherwise be taken for a file. The message names the direct form, `pnpm exec e2e run --headed`. +For npm, the direct form is `npm exec -- e2e run --headed`; npm needs the +separator before the binary to pass its flags through. When nothing is left to run, `NO_TESTS` names the cause: no file matched the globs (with look-alike files such as `tests/login.test.ts`), a positional diff --git a/packages/e2e/src/internal/package-manager.ts b/packages/e2e/src/internal/package-manager.ts index 3e44e3a59..de1797e46 100644 --- a/packages/e2e/src/internal/package-manager.ts +++ b/packages/e2e/src/internal/package-manager.ts @@ -54,7 +54,7 @@ export function runScriptCommand(manager: PackageManager, name: string): string export function execCommand(manager: PackageManager, command: string): string { switch (manager) { case 'npm': - return `npm exec ${command}`; + return `npm exec -- ${command}`; case 'pnpm': return `pnpm exec ${command}`; case 'yarn': diff --git a/packages/e2e/tests/unit/cli-init.test.ts b/packages/e2e/tests/unit/cli-init.test.ts index 95a4db774..5830149b6 100644 --- a/packages/e2e/tests/unit/cli-init.test.ts +++ b/packages/e2e/tests/unit/cli-init.test.ts @@ -379,8 +379,8 @@ describe('e2e init', () => { it.each([ ['e2e run --workers 1', 'npm run test:e2e'], - ['e2e runner --ci', 'npm exec e2e run'], - ['vitest', 'npm exec e2e run'], + ['e2e runner --ci', 'npm exec -- e2e run'], + ['vitest', 'npm exec -- e2e run'], ])('keeps an existing test:e2e script (%s) and points the run step at %s', async (script, step) => { writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify({ name: 'existing-app', scripts: { 'test:e2e': script } })}\n`); vi.mocked(clack.select).mockResolvedValueOnce('web').mockResolvedValueOnce('none'); @@ -667,7 +667,7 @@ describe('e2e init', () => { expect(existsSync(path.join(dir, '.agents'))).toBe(false); expect(existsSync(path.join(dir, '.claude'))).toBe(false); expect(existsSync(path.join(dir, '.mcp.json'))).toBe(false); - expect(output()).toContain('npm exec e2e guide'); + expect(output()).toContain('npm exec -- e2e guide'); expect(output()).toContain('claude mcp add e2e -- npx e2e mcp'); }); diff --git a/packages/e2e/tests/unit/cli.test.ts b/packages/e2e/tests/unit/cli.test.ts index 512f8c5fb..ac6c5b518 100644 --- a/packages/e2e/tests/unit/cli.test.ts +++ b/packages/e2e/tests/unit/cli.test.ts @@ -99,7 +99,7 @@ describe('e2e run argument parsing', () => { expect(runMock).not.toHaveBeenCalled(); expect(process.exitCode).toBe(2); expect(written(stderrSpy)).toMatch( - /^error: "--headed" is a flag, not a test file\. It arrived as a file because a "--" came before it; "(?:pnpm|npm run|yarn|bun run) test:e2e -- --headed" reaches e2e as "run -- --headed" when the package manager forwards the separator\. Run the CLI directly instead: (?:pnpm exec|npm exec|yarn|bun run) e2e run --headed\n\(add --help for usage\)\n$/u, + /^error: "--headed" is a flag, not a test file\. It arrived as a file because a "--" came before it; "(?:pnpm|npm run|yarn|bun run) test:e2e -- --headed" reaches e2e as "run -- --headed" when the package manager forwards the separator\. Run the CLI directly instead: (?:pnpm exec|npm exec --|yarn|bun run) e2e run --headed\n\(add --help for usage\)\n$/u, ); }); @@ -108,7 +108,7 @@ describe('e2e run argument parsing', () => { expect(runMock).not.toHaveBeenCalled(); expect(process.exitCode).toBe(2); expect(written(stderrSpy)).toMatch( - /Run the CLI directly instead: (?:pnpm exec|npm exec|yarn|bun run) e2e run --tag smoke tests\/b\.e2e\.ts\n/u, + /Run the CLI directly instead: (?:pnpm exec|npm exec --|yarn|bun run) e2e run --tag smoke tests\/b\.e2e\.ts\n/u, ); }); @@ -453,7 +453,7 @@ describe('e2e list', () => { expect(process.exitCode).toBe(2); const stderr = written(stderrSpy); expect(stderr.startsWith('error: "--tag" is a flag, not a test file.')).toBe(true); - expect(stderr).toMatch(/Run the CLI directly instead: (?:pnpm exec|npm exec|yarn|bun run) e2e list --tag smoke\n/u); + expect(stderr).toMatch(/Run the CLI directly instead: (?:pnpm exec|npm exec --|yarn|bun run) e2e list --tag smoke\n/u); expect(stderr.endsWith('(add --help for usage)\n')).toBe(true); }); diff --git a/packages/e2e/tests/unit/package-manager.test.ts b/packages/e2e/tests/unit/package-manager.test.ts index 4f847f413..c4dd44f73 100644 --- a/packages/e2e/tests/unit/package-manager.test.ts +++ b/packages/e2e/tests/unit/package-manager.test.ts @@ -38,7 +38,7 @@ describe('runScriptCommand', () => { describe('execCommand', () => { it('runs the installed binary with each manager', () => { - expect(execCommand('npm', 'e2e guide')).toBe('npm exec e2e guide'); + expect(execCommand('npm', 'e2e guide')).toBe('npm exec -- e2e guide'); expect(execCommand('pnpm', 'e2e guide')).toBe('pnpm exec e2e guide'); expect(execCommand('yarn', 'e2e guide')).toBe('yarn e2e guide'); expect(execCommand('bun', 'e2e guide')).toBe('bun run e2e guide'); diff --git a/skills/e2e/references/running.md b/skills/e2e/references/running.md index e3b24732f..39b6674df 100644 --- a/skills/e2e/references/running.md +++ b/skills/e2e/references/running.md @@ -67,6 +67,7 @@ npx e2e list tests/signup.e2e.ts --tag smoke --reporter json With a `package.json` script `"test:e2e": "e2e run"`, pnpm forwards `--` literally: `pnpm test:e2e -- --headed` reaches e2e as `run -- --headed` and exits 2. Write `pnpm test:e2e --headed` or `pnpm exec e2e run --headed`. +With npm, use `npm exec -- e2e run --headed` to pass flags to e2e. ## The replay cache From dc2afca0c02a882e2631a18f9d49768c4e4d84f2 Mon Sep 17 00:00:00 2001 From: Krzysztof Moch Date: Mon, 5 Oct 2026 12:58:11 +0200 Subject: [PATCH 07/46] feat(mcp)!: e2e mcp is headless by default, and open_session takes headed (#851) * feat(mcp)!: e2e mcp runs headless by default and takes --headed, like run Sessions were headed outside CI and took --headless, the opposite of e2e run and e2e explore. --headless stays accepted and hidden: it now asks for the default, and a refused flag would reach an MCP host as nothing but a failed connection. * docs(skill): --headed shows the UI when the engine supports it * feat(mcp): open_session takes headed, so the agent picks per session The server's --headed sets the default for sessions that do not say, and headed: true or false overrides it, so a user who asks to watch needs no change to the MCP client config. --- .changeset/mcp-headless-default.md | 5 ++++ docs/reference/cli.mdx | 6 ++--- docs/reference/mcp.mdx | 10 +++---- packages/e2e/src/cli/index.ts | 9 ++++--- packages/e2e/src/cli/mcp.ts | 5 ++-- packages/e2e/src/mcp/server.ts | 3 ++- packages/e2e/src/mcp/session.ts | 26 +++++++++++++++---- .../tests/integration/mcp-disconnect.test.ts | 2 +- .../e2e/tests/integration/mcp-server.test.ts | 25 ++++++++++++++++-- .../integration/mcp-session-host.test.ts | 14 ++++++++++ .../mcp-stdout-backpressure.test.ts | 2 +- skills/e2e/references/mcp.md | 7 ++--- 12 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 .changeset/mcp-headless-default.md diff --git a/.changeset/mcp-headless-default.md b/.changeset/mcp-headless-default.md new file mode 100644 index 000000000..fcb8d361e --- /dev/null +++ b/.changeset/mcp-headless-default.md @@ -0,0 +1,5 @@ +--- +"e2e": minor +--- + +`e2e mcp` sessions are now headless by default, the same as `e2e run` and `e2e explore`, and the agent decides per session: `open_session {headed: true}` shows the browser or simulator, `headed: false` hides it. `--headed` makes headed the default for sessions that do not say. Before, sessions were headed outside CI and only the server's `--headless` could hide them; `--headless` is still accepted, since it now asks for the default, but it is no longer documented. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 04a7eb04d..86723faa9 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -281,15 +281,15 @@ See [Coding agents](/coding-agents). ```bash npm -npx e2e mcp [--config ] [--target ] [--headless] [--max-sessions ] +npx e2e mcp [--config ] [--target ] [--headed] [--max-sessions ] ``` ```bash pnpm -pnpm exec e2e mcp [--config ] [--target ] [--headless] [--max-sessions ] +pnpm exec e2e mcp [--config ] [--target ] [--headed] [--max-sessions ] ``` ```bash bun -bunx e2e mcp [--config ] [--target ] [--headless] [--max-sessions ] +bunx e2e mcp [--config ] [--target ] [--headed] [--max-sessions ] ``` diff --git a/docs/reference/mcp.mdx b/docs/reference/mcp.mdx index b0334209b..54121ed5d 100644 --- a/docs/reference/mcp.mdx +++ b/docs/reference/mcp.mdx @@ -5,15 +5,15 @@ description: Let a coding agent inspect and interact with your app over MCP. ```bash npm -npx e2e mcp [--config ] [--target ] [--headless] [--max-sessions ] +npx e2e mcp [--config ] [--target ] [--headed] [--max-sessions ] ``` ```bash pnpm -pnpm exec e2e mcp [--config ] [--target ] [--headless] [--max-sessions ] +pnpm exec e2e mcp [--config ] [--target ] [--headed] [--max-sessions ] ``` ```bash bun -bunx e2e mcp [--config ] [--target ] [--headless] [--max-sessions ] +bunx e2e mcp [--config ] [--target ] [--headed] [--max-sessions ] ``` @@ -64,7 +64,7 @@ Start the server in the project directory. Config discovery works like | --- | --- | | `--config ` | The config every session loads unless `open_session` names one. Configs are reloaded on every `open_session`, with every file they import by path. | | `--target ` | The target every session opens on. Otherwise `open_session` names one, or the only target is used; several targets and no name is `TARGET_REQUIRED`. | -| `--headless` | Hide the browser or simulator. Sessions are headed by default outside CI. | +| `--headed` | Show the browser or simulator, when the engine supports it, in every session whose `open_session` does not set `headed`. Sessions are headless by default, as in `e2e run`. | | `--max-sessions ` | How many sessions may be open at once, 1 through 16; default 4. Each session is its own browser or device. | Operator output goes to stderr, prefixed `e2e mcp:`. Stdout carries only the @@ -74,7 +74,7 @@ protocol; anything a config module writes to stdout is redirected. | Tool | Arguments | Result | | --- | --- | --- | -| `open_session` | `target` (optional), `config` (optional path) | Loads the config, starts the target's app command, boots the engine, starts one attempt, opens the app URL when the engine can navigate. Returns the session id, the catalog, and the first observation. | +| `open_session` | `target` (optional), `config` (optional path), `headed` (optional boolean) | Loads the config, starts the target's app command, boots the engine, starts one attempt, opens the app URL when the engine can navigate. Returns the session id, the catalog, and the first observation. `headed: true` shows this session's browser or simulator, `false` hides it; without it the session follows `--headed`. | | `tools` | `tool` (optional), `session` (required while several are open) | The catalog, one line per tool. With `tool`, its full description and argument schema. | | `call` | `tool`, `args` (optional), `session` (required while several are open) | Runs one catalog tool. Arguments are validated against the tool's closed schema first: a wrong type or an argument the tool does not declare is `INVALID_ARGUMENT`, and nothing runs. An action that fails is an error result that still carries the screen it re-observed, and leads with the tool, its target, and the code: `navigate file:///etc/passwd failed: POLICY_DENIED: forbidden URL scheme: file:`. | | `close_session` | `session` (required while several are open) | Saves a recording still running, ends the attempt, disposes the engine, stops the app processes the session started once no other session uses them. | diff --git a/packages/e2e/src/cli/index.ts b/packages/e2e/src/cli/index.ts index 36aa1590a..1a9d8f3a1 100644 --- a/packages/e2e/src/cli/index.ts +++ b/packages/e2e/src/cli/index.ts @@ -496,7 +496,10 @@ function createProgram(version: string, telemetry: Telemetry): Command { ) .option('--config ', 'config file (default: the nearest e2e.config.ts)') .option('--target ', 'target every session opens on (default: the only target, or the one open_session names)') - .option('--headless', 'hide the UI during live sessions (default: headed outside CI)') + .option('--headed', 'show the UI in sessions whose open_session does not set headed, when the engine supports it') + // Sessions were headed by default before 0.18 and took --headless; a client config that still passes it + // asks for the default now, and a refused flag would surface as nothing but a failed connection. + .addOption(new Option('--headless').hideHelp()) .option( '--max-sessions ', `sessions open at once, each with its own browser or device, ${SESSION_BOUNDS.min} through ${SESSION_BOUNDS.max} (default: ${SESSION_BOUNDS.default})`, @@ -506,12 +509,12 @@ function createProgram(version: string, telemetry: Telemetry): Command { 'after', [ '', - examples(['e2e mcp', 'e2e mcp --target web --headless', 'e2e mcp --max-sessions 8', 'claude mcp add e2e -- npx e2e mcp']), + examples(['e2e mcp', 'e2e mcp --target web --headed', 'e2e mcp --max-sessions 8', 'claude mcp add e2e -- npx e2e mcp']), '', docsLine('/reference/mcp'), ].join('\n'), ) - .action(async (options: { config?: string; target?: string; headless?: boolean; maxSessions?: number }) => { + .action(async (options: { config?: string; target?: string; headed?: boolean; maxSessions?: number }) => { process.exitCode = await mcp(version, options, telemetry); }); diff --git a/packages/e2e/src/cli/mcp.ts b/packages/e2e/src/cli/mcp.ts index dc140086f..7b810e430 100644 --- a/packages/e2e/src/cli/mcp.ts +++ b/packages/e2e/src/cli/mcp.ts @@ -7,7 +7,6 @@ */ import { Writable } from 'node:stream'; -import { isCiMode } from '../config/resolve.ts'; import { errorMessage, exitCodeForCategory, classifyError } from '../internal/errors.ts'; import { serveMcp, type ServeOptions } from '../mcp/server.ts'; import { mcpSessionEvent } from '../telemetry/events.ts'; @@ -16,7 +15,7 @@ import type { Telemetry } from '../telemetry/telemetry.ts'; export interface McpCommandOptions { config?: string | undefined; target?: string | undefined; - headless?: boolean | undefined; + headed?: boolean | undefined; maxSessions?: number | undefined; } @@ -74,7 +73,7 @@ export async function mcp(version: string, options: McpCommandOptions, telemetry cwd: process.cwd(), configPath: options.config, target: options.target, - headed: options.headless !== true && !isCiMode(process.env), + headed: options.headed === true, maxSessions: options.maxSessions, env: process.env, version, diff --git a/packages/e2e/src/mcp/server.ts b/packages/e2e/src/mcp/server.ts index e1c46b790..94fe180c5 100644 --- a/packages/e2e/src/mcp/server.ts +++ b/packages/e2e/src/mcp/server.ts @@ -29,6 +29,7 @@ export interface ServeOptions { readonly configPath?: string | undefined; /** The target every session opens on; otherwise a call names one, or the only one is used. */ readonly target?: string | undefined; + /** Whether a session shows its UI when `open_session` does not set `headed`. */ readonly headed: boolean; /** How many sessions may be open at once, from `--max-sessions`. */ readonly maxSessions?: number | undefined; @@ -46,7 +47,7 @@ export interface ServeOptions { } const INSTRUCTIONS = `e2e is a local-first end-to-end test runner; this server drives an e2e project's app (its e2e.config.ts) live. -Call open_session (optionally with a target and a config path) to get a session, its tool catalog, and the first observation. Then call {tool, args} runs any catalog tool: observe, the grammar its engine honors, type_secret, locate, screenshot, start_recording and stop_recording when the engine records video (a video of the app for a pull request), and the project's own tools; tools lists them, tools {tool} shows one tool's arguments. close_session when done. +Call open_session (optionally with a target, a config path, and headed: true when the user wants to watch) to get a session, its tool catalog, and the first observation. Then call {tool, args} runs any catalog tool: observe, the grammar its engine honors, type_secret, locate, screenshot, start_recording and stop_recording when the engine records video (a video of the app for a pull request), and the project's own tools; tools lists them, tools {tool} shows one tool's arguments. close_session when done. Several sessions can be open at once, each with its own browser or device, so parallel agents (subagents) each open their own: pass the session id from open_session to every tools, call, and close_session. Write deterministic tests (tests/*.e2e.ts) from what you saw and run them with the CLI: npx e2e run . Resources e2e://guide and e2e://guide/{topic} hold the writing guide.`; diff --git a/packages/e2e/src/mcp/session.ts b/packages/e2e/src/mcp/session.ts index 8f90a66e2..744e4fe8f 100644 --- a/packages/e2e/src/mcp/session.ts +++ b/packages/e2e/src/mcp/session.ts @@ -47,6 +47,7 @@ interface LiveSession { readonly id: string; readonly target: ResolvedTarget; readonly configPath: string; + readonly headed: boolean; readonly attempt: StandaloneAttempt; readonly step: InteractiveStep; readonly screen: ScreenPresenter; @@ -65,6 +66,8 @@ export interface OpenSessionOptions { readonly target?: string | undefined; /** A config file to load instead of the server's default, relative to the server's directory. */ readonly config?: string | undefined; + /** Whether this session shows its UI; otherwise the server's `headed`. */ + readonly headed?: boolean | undefined; } export interface SessionHostOptions { @@ -73,6 +76,7 @@ export interface SessionHostOptions { /** Loads the config at an absolute path fresh for each session, so an edited config applies without a restart. */ readonly loadConfig: (configPath: string) => Promise; readonly env: NodeJS.ProcessEnv; + /** Whether a session shows its UI when `open_session` does not say, from `--headed`. */ readonly headed: boolean; /** The target every session opens on, from `--target`; a call may still name one. */ readonly defaultTarget?: string | undefined; @@ -116,8 +120,9 @@ export class SessionHost { * it, or disconnects, aborts the open and what it started. */ open(options: OpenSessionOptions, signal?: AbortSignal): Promise { - const usage = new SessionUsage(this.sessions.liveCount, this.options.headed); - return this.sessions.admit((id) => this.openSession(id, options, usage, signal)).catch((cause: unknown) => { + const headed = options.headed ?? this.options.headed; + const usage = new SessionUsage(this.sessions.liveCount, headed); + return this.sessions.admit((id) => this.openSession(id, options, headed, usage, signal)).catch((cause: unknown) => { this.options.onSessionEnd?.(usage.openFailed(classifyError(cause).code)); throw cause; }); @@ -209,7 +214,13 @@ export class SessionHost { ); } - private async openSession(id: string, options: OpenSessionOptions, usage: SessionUsage, request: AbortSignal | undefined): Promise { + private async openSession( + id: string, + options: OpenSessionOptions, + headed: boolean, + usage: SessionUsage, + request: AbortSignal | undefined, + ): Promise { // The catalog renders synchronously, so the optional SDK is loaded once // here when installed. Without it the catalog reads the tools' Standard // Schemas, and only a model-backed call needs the package. @@ -242,7 +253,7 @@ export class SessionHost { // one attempt that closes as passed, so it traces under `on` only. config, target: { ...target, video: { mode: 'off', source: 'default' } }, - headed: this.options.headed, + headed, env: this.options.env, signal: abort.signal, timeoutMs: ttlMs + CLOSE_GRACE_MS, @@ -282,6 +293,7 @@ export class SessionHost { id, target, configPath: loaded.configPath, + headed, attempt, step, screen, @@ -371,7 +383,7 @@ export class SessionHost { private openingText(live: LiveSession, config: ResolvedConfig, screen: string): string { const engine = live.target.engine; const lines = [ - `Session ${live.id} open on target "${live.target.name}" (platform ${live.target.platform}, engine ${engine === undefined ? 'none' : `${engine.name} ${engine.version}`}), ${this.options.headed ? 'headed' : 'headless'}; config ${live.configPath}.`, + `Session ${live.id} open on target "${live.target.name}" (platform ${live.target.platform}, engine ${engine === undefined ? 'none' : `${engine.name} ${engine.version}`}), ${live.headed ? 'headed' : 'headless'}; config ${live.configPath}.`, ]; if (live.target.app.base !== undefined) { lines.push(`App: ${live.target.app.base.href}.`); @@ -458,6 +470,10 @@ export class SessionHost { inputSchema: z.object({ target: z.string().min(1).optional().describe('Target name from the config; required when the config declares several'), config: z.string().min(1).optional().describe("Path to an e2e config file, relative to the server's directory; default: the nearest e2e.config.ts"), + headed: z + .boolean() + .optional() + .describe('Show the browser or simulator, when the engine supports it; pass true when the user wants to watch. Default: headless, unless the server was started with --headed'), }).strict(), readOnly: false, call: async (args, extra) => textResult(await this.open(args, extra.signal)), diff --git a/packages/e2e/tests/integration/mcp-disconnect.test.ts b/packages/e2e/tests/integration/mcp-disconnect.test.ts index bb4918b52..a18bab05f 100644 --- a/packages/e2e/tests/integration/mcp-disconnect.test.ts +++ b/packages/e2e/tests/integration/mcp-disconnect.test.ts @@ -99,7 +99,7 @@ describe('e2e mcp when the client goes away', { timeout: 90_000 }, () => { project = createProject({ 'e2e.config.ts': config(await freePort()), 'app.cjs': APP }); stderr = ''; responses = new Map(); - server = spawn(process.execPath, [CLI, 'mcp', '--headless'], { + server = spawn(process.execPath, [CLI, 'mcp'], { cwd: project.dir, env: { ...process.env, CI: '' }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/packages/e2e/tests/integration/mcp-server.test.ts b/packages/e2e/tests/integration/mcp-server.test.ts index c1d09f291..a47becb24 100644 --- a/packages/e2e/tests/integration/mcp-server.test.ts +++ b/packages/e2e/tests/integration/mcp-server.test.ts @@ -148,7 +148,7 @@ describe('e2e mcp', { timeout: 120_000 }, () => { }); transport = new StdioClientTransport({ command: process.execPath, - args: [CLI, 'mcp', '--headless'], + args: [CLI, 'mcp'], cwd: project.dir, env: { ...(process.env as Record), APP_URL: app.url, CI: '' }, stderr: 'pipe', @@ -173,7 +173,7 @@ describe('e2e mcp', { timeout: 120_000 }, () => { expect(callTool.description).toContain('call {tool: "tap", args: {target: "n42"}, session: ""}'); expect(callTool.inputSchema).toMatchObject({ type: 'object', required: ['tool'] }); expect(tools.find((tool) => tool.name === 'tools')?.annotations).toMatchObject({ readOnlyHint: true }); - expect(tools.find((tool) => tool.name === 'open_session')?.inputSchema).toMatchObject({ properties: { target: {}, config: {} } }); + expect(tools.find((tool) => tool.name === 'open_session')?.inputSchema).toMatchObject({ properties: { target: {}, config: {}, headed: { type: 'boolean' } } }); expect(client.getInstructions()).toContain('call {tool, args} runs any catalog tool'); expect(client.getInstructions()).toContain('Several sessions can be open at once'); @@ -481,6 +481,27 @@ describe('e2e mcp', { timeout: 120_000 }, () => { expect(closed.text).not.toContain('Cleanup:'); }); + it('opens sessions headed with --headed, and headless with the retired --headless', async () => { + for (const [flag, mode] of [['--headed', 'headed'], ['--headless', 'headless']] as const) { + const other = new Client({ name: 'e2e-mcp-flag-test', version: '0.0.0' }); + await other.connect( + new StdioClientTransport({ + command: process.execPath, + args: [CLI, 'mcp', flag, '--config', 'custom.config.ts', '--target', 'kiosk'], + cwd: project.dir, + env: { ...(process.env as Record), APP_URL: app.url, CI: '' }, + stderr: 'ignore', + }), + ); + try { + const opened = (await other.callTool({ name: 'open_session', arguments: {} })) as { content: { text?: string }[] }; + expect(opened.content[0]?.text, flag).toContain(`), ${mode};`); + } finally { + await other.close(); + } + } + }); + it('kept stdout for the protocol: the config\'s console.log landed on stderr', () => { expect(stderr).toContain('config loaded'); expect(stderr).toContain('e2e mcp: [info] e2e mcp'); diff --git a/packages/e2e/tests/integration/mcp-session-host.test.ts b/packages/e2e/tests/integration/mcp-session-host.test.ts index 3b721c7cb..362d34503 100644 --- a/packages/e2e/tests/integration/mcp-session-host.test.ts +++ b/packages/e2e/tests/integration/mcp-session-host.test.ts @@ -126,6 +126,20 @@ describe('SessionHost', { timeout: 60_000 }, () => { expect(fake.stats()).toMatchObject({ attemptsStarted: 2, attemptsEnded: 2, disposes: 2 }); }); + it("lets open_session's headed override the server's default either way", async () => { + const fake = createFakeEngine(); + const summaries: McpSessionSummary[] = []; + const onSessionEnd = (summary: McpSessionSummary): void => void summaries.push(summary); + const headless = host(() => fake, { headed: false, onSessionEnd }); + expect(await headless.open({ headed: true })).toContain('), headed;'); + await headless.close('done'); + const headed = host(() => fake, { headed: true, onSessionEnd }); + expect(await headed.open({ headed: false })).toContain('), headless;'); + await headed.close('done'); + expect(fake.inits.map((init) => init.headed)).toEqual([true, false]); + expect(summaries.map((summary) => summary.headed)).toEqual([true, false]); + }); + it('closes an idle session and disposes the engine', async () => { const fakes = engines(); const idle = host(fakes.next, { idleMs: 100 }); diff --git a/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts b/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts index a05e70a82..0e44d5586 100644 --- a/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts +++ b/packages/e2e/tests/integration/mcp-stdout-backpressure.test.ts @@ -33,7 +33,7 @@ describe('e2e mcp stdout backpressure', { timeout: 60_000 }, () => { beforeAll(() => { project = createProject({ 'e2e.config.ts': CONFIG }); - server = spawn(process.execPath, [CLI, 'mcp', '--headless'], { + server = spawn(process.execPath, [CLI, 'mcp'], { cwd: project.dir, env: { ...process.env, CI: '' }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/skills/e2e/references/mcp.md b/skills/e2e/references/mcp.md index 9875d27d6..84a9c9bd3 100644 --- a/skills/e2e/references/mcp.md +++ b/skills/e2e/references/mcp.md @@ -24,8 +24,9 @@ No model is needed: without the optional `ai` package, sessions open and the deterministic tools work. Flags: `--config ` names the default config file, `--target ` -fixes the target every session opens on, `--headless` hides the browser or -simulator (sessions are headed by default outside CI), `--max-sessions ` +fixes the target every session opens on, `--headed` shows the browser or +simulator when the engine supports it in sessions that do not set `headed` +(sessions are headless by default, as in `e2e run`), `--max-sessions ` sets how many sessions may be open at once (default 4, 1 through 16). ## Tools @@ -34,7 +35,7 @@ Four tools; everything a session can do is a catalog behind `call`. | Tool | Does | | --- | --- | -| `open_session` | Loads the config (`config` names another file; default the nearest `e2e.config.ts`), starts the declared app command if any, boots the engine, opens the app URL, and returns the session id, the catalog, and the first observation. `target` is required when the config declares several. Each session has its own browser or device. | +| `open_session` | Loads the config (`config` names another file; default the nearest `e2e.config.ts`), starts the declared app command if any, boots the engine, opens the app URL, and returns the session id, the catalog, and the first observation. `target` is required when the config declares several. `headed: true` shows the browser or simulator when the user wants to watch; without it the session follows the server's `--headed`, headless by default. Each session has its own browser or device. | | `tools` | The catalog: one line per tool with its argument names (`?` marks optional), the first sentence of its description, and `[read-only]` where it changes nothing. `tools {tool}` shows the full description and the JSON Schema of its arguments. | | `call` | Runs one catalog tool: `call {tool: "tap", args: {target: "n42"}}`. Arguments are checked against the tool's schema first; a wrong one fails with `INVALID_ARGUMENT` naming the field. | | `close_session` | Saves a recording still running, ends the attempt, disposes the engine, stops the app processes the session started once no other session uses them. | From 8bc7d850cb0082122100a20bb710f7f0c9691f25 Mon Sep 17 00:00:00 2001 From: Ray Tien Date: Mon, 5 Oct 2026 19:06:08 +0800 Subject: [PATCH 08/46] fix(cache): replay a recorded step that types an empty value (#789) (#834) The recorder writes a `type` or `typeText` action with value "" (clearing a field), but the reader rejected it as `invalid-entry`, so the step re-ran the model on every run and failed with REPLAY_STALE under --strict-cache. Co-authored-by: Claude Sonnet 5.5 --- .changeset/cache-empty-typed-value.md | 5 +++++ packages/e2e/src/cache/trace.ts | 9 +++++++-- .../e2e/tests/helpers/fixture-pages/forms.ts | 11 +++++++++++ packages/e2e/tests/helpers/trace-cache.ts | 16 ++++++++++++++++ packages/e2e/tests/unit/trace-cache.test.ts | 10 ++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 .changeset/cache-empty-typed-value.md diff --git a/.changeset/cache-empty-typed-value.md b/.changeset/cache-empty-typed-value.md new file mode 100644 index 000000000..a71198858 --- /dev/null +++ b/.changeset/cache-empty-typed-value.md @@ -0,0 +1,5 @@ +--- +"e2e": patch +--- + +A recorded step that types an empty value, which clears a field, replays instead of failing with `REPLAY_STALE` (`invalid-entry`). The cache wrote the entry but its reader rejected the empty `type` or `typeText` value, so such a step could never replay from a recording. diff --git a/packages/e2e/src/cache/trace.ts b/packages/e2e/src/cache/trace.ts index 6583e45a3..eabab07c5 100644 --- a/packages/e2e/src/cache/trace.ts +++ b/packages/e2e/src/cache/trace.ts @@ -529,7 +529,7 @@ function readRecordedAction(document: unknown): RecordedAction | undefined { switch (name) { case 'type': { const target = readDescriptor(raw['target']); - const value = readInputText(raw['value']); + const value = readTypedValue(raw['value']); if (target === undefined || value === undefined) return undefined; return { name: 'type', summary, target, value }; } @@ -620,7 +620,7 @@ function readRecordedAction(document: unknown): RecordedAction | undefined { case 'back': return { name: 'back', summary }; case 'typeText': { - const value = readInputText(raw['value']); + const value = readTypedValue(raw['value']); if (value === undefined || typeof raw['replace'] !== 'boolean') return undefined; return { name: 'typeText', summary, value, replace: raw['replace'] }; } @@ -909,3 +909,8 @@ function readInputText(value: unknown): string | undefined { } return value; } + +/** A typed value: `readInputText`, except that `''` is valid, since typing it clears a field. */ +function readTypedValue(value: unknown): string | undefined { + return value === '' ? value : readInputText(value); +} diff --git a/packages/e2e/tests/helpers/fixture-pages/forms.ts b/packages/e2e/tests/helpers/fixture-pages/forms.ts index 12467815e..578906efe 100644 --- a/packages/e2e/tests/helpers/fixture-pages/forms.ts +++ b/packages/e2e/tests/helpers/fixture-pages/forms.ts @@ -126,6 +126,17 @@ ${rows} } export const FORM_PAGES: Record = { + // Clearing a field is a typed value too: the status reads `cleared` only + // once the input emptied after holding text. + '/clear-field': constant(` + +Clear field + +

Clear field

+ + untouched + +`), // Repeated cross-sell rows: several buttons share role, name, and test id, so // every derived query is ambiguous and nothing in the query vocabulary can // separate them. Only the reference the model selected can. diff --git a/packages/e2e/tests/helpers/trace-cache.ts b/packages/e2e/tests/helpers/trace-cache.ts index 03a6f7d7e..d9569e50c 100644 --- a/packages/e2e/tests/helpers/trace-cache.ts +++ b/packages/e2e/tests/helpers/trace-cache.ts @@ -203,6 +203,22 @@ export const FLOWS: readonly Flow[] = [ }, actions: [1], }, + { + title: 'clears a field', + open: () => '/clear-field', + body: ` await agent.act('type a draft, then clear it'); + await expect(screen.getByRole('status', { name: 'Draft state' })).toHaveText('cleared');`, + script: { + 'type a draft, then clear it': { + act: (call) => { + const draft = nodeIdFor(call.prompt, /textbox "Draft"/u); + return [type(draft, 'draft'), type(draft, '')]; + }, + done: /"Draft state" text="cleared"/u, + }, + }, + actions: [2], + }, { title: 'mixes deterministic and agent steps', open: () => undefined, diff --git a/packages/e2e/tests/unit/trace-cache.test.ts b/packages/e2e/tests/unit/trace-cache.test.ts index af4db061e..f22d752be 100644 --- a/packages/e2e/tests/unit/trace-cache.test.ts +++ b/packages/e2e/tests/unit/trace-cache.test.ts @@ -69,6 +69,15 @@ describe('trace-1 entry', () => { expect(entry.payload.startPath).toBe('/settings'); }); + it('round-trips an empty typed value: clearing a field is a recordable action', () => { + const target = { role: 'textbox', name: 'Name' }; + const actions: RecordedAction[] = [ + { name: 'type', summary: 'clear textbox', target, value: '' }, + { name: 'typeText', summary: 'clear the focused field', value: '', replace: true }, + ]; + expect(entryOf(trace({ actions })).payload.actions).toEqual(actions); + }); + it('round-trips the postcondition: end path and end anchors', () => { const endAnchors = [ { role: 'status', name: 'Marker', text: 'saved' }, @@ -112,6 +121,7 @@ describe('trace-1 entry', () => { ['unknown action name', withPayload({ actions: [{ ...tap, name: 'click' }] })], ['tap without a target', withPayload({ actions: [{ name: 'tap', summary: 'tap' }] })], ['type without a value', withPayload({ actions: [{ ...tap, name: 'type' }] })], + ['select with an empty value', withPayload({ actions: [{ name: 'select', summary: 's', target: { role: 'x' }, value: '' }] })], [ 'oversized input value', withPayload({ From 260ef9507b9eb6626a42faaede064432c5f3208b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Kapu=C5=9Bciak?= <39658211+kacperkapusciak@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:22:43 +0200 Subject: [PATCH 09/46] docs(examples): add standalone Vite project (#854) * docs(examples): add standalone Vite project * docs(examples): clarify Vite setup behavior --- .fallowrc.json | 1 + .oxlintrc.json | 4 +- AGENTS.md | 8 + README.md | 5 + examples/.gitignore | 7 + examples/README.md | 18 ++ examples/with-vite/.gitignore | 14 ++ examples/with-vite/README.md | 33 ++++ examples/with-vite/e2e.config.ts | 25 +++ examples/with-vite/index.html | 13 ++ examples/with-vite/package.json | 31 ++++ .../with-vite/public/e2e-lockup-32-white.svg | 9 + examples/with-vite/public/favicon.png | Bin 0 -> 1012 bytes examples/with-vite/screenshot.png | Bin 0 -> 29240 bytes examples/with-vite/src/App.tsx | 65 +++++++ examples/with-vite/src/main.tsx | 13 ++ examples/with-vite/src/styles.css | 163 ++++++++++++++++++ examples/with-vite/tests/agent.e2e.ts | 25 +++ examples/with-vite/tests/greeting.e2e.ts | 26 +++ examples/with-vite/tsconfig.json | 15 ++ examples/with-vite/vite.config.ts | 8 + skills/e2e/SKILL.md | 3 +- 22 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 examples/.gitignore create mode 100644 examples/README.md create mode 100644 examples/with-vite/.gitignore create mode 100644 examples/with-vite/README.md create mode 100644 examples/with-vite/e2e.config.ts create mode 100644 examples/with-vite/index.html create mode 100644 examples/with-vite/package.json create mode 100644 examples/with-vite/public/e2e-lockup-32-white.svg create mode 100644 examples/with-vite/public/favicon.png create mode 100644 examples/with-vite/screenshot.png create mode 100644 examples/with-vite/src/App.tsx create mode 100644 examples/with-vite/src/main.tsx create mode 100644 examples/with-vite/src/styles.css create mode 100644 examples/with-vite/tests/agent.e2e.ts create mode 100644 examples/with-vite/tests/greeting.e2e.ts create mode 100644 examples/with-vite/tsconfig.json create mode 100644 examples/with-vite/vite.config.ts diff --git a/.fallowrc.json b/.fallowrc.json index 7df1d7782..92733e667 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -25,6 +25,7 @@ ], "ignorePatterns": [ "docs/**", + "examples/**", "**/next-env.d.ts", "**/.e2e/**", "packages/e2e/tests/tmp-projects/**" diff --git a/.oxlintrc.json b/.oxlintrc.json index 5369d67fd..82795af8c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -16,7 +16,9 @@ "packages/*/tests/fixtures/**", // Vendored conformance data, kept byte for byte. "packages/*/tests/conformance/*/**", - "packages/*/tests/tmp-projects/**" + "packages/*/tests/tmp-projects/**", + // Standalone user-facing projects with their own installs; nothing here builds them. + "examples/**" ], "rules": { // Sequential execution is the contract of a test runner, not a perf bug. diff --git a/AGENTS.md b/AGENTS.md index 14a0598c1..4a6de356d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,14 @@ suites that consume the built packages the way a user would. "Committed recordings" under Gotchas. Scenario files are copies: keep diffs against the source minimal, and name no company a scenario was distilled from. +- `examples/` — standalone user-facing projects, one per technology + (`with-vite`), each the same + one-screen greeter demo with deterministic and agent tests. They install + the published packages from npm, sit outside the pnpm workspace, commit no + lockfile, and run in no CI; oxlint and fallow ignore them. A change runs + the example's suite by hand and updates the "Last checked" line in its + README. A native example keeps its tests in an `e2e/` folder beside the + native project, as a user would. - `docs/` (the Mintlify docs site; pages are the `.mdx` files under `docs/`, navigation, theme, and redirects in `docs/docs.json`, extra CSS in `docs/style.css`; `docs/examples/` is typechecked and shown verbatim on diff --git a/README.md b/README.md index 59df5057f..5c50d2631 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ npx e2e init config and an example test. The [quickstart](https://e2e.tester.army/docs/quickstart) covers the rest. +To see a finished setup in your stack, open +[`examples/`](https://github.com/tester-army/e2e/tree/main/examples): +The Vite example is a standalone project with a passing +suite. + ## Packages | Package | What it does | diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 000000000..c9ec8b777 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,7 @@ +# The examples pin no lockfile here: each one installs the latest release in +# its ranges, the way a fresh project would. A copied example can commit its own. +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..244ce8c5b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,18 @@ +# Examples + +Standalone apps with three locator tests and two agent tests each. +Every app uses the same demo: enter a name and press Greet. + +| Example | Technology | Runs on | +| --- | --- | --- | +| [with-vite](with-vite) | Vite + React | Chromium | + +Each folder installs published packages from npm and works outside this +repository. See its README for run commands. Agent tests need +`AI_GATEWAY_API_KEY`; without it, they skip. + +## For contributors + +Run the suite locally after changes. Update the README's "Last checked" +versions. CI does not run these examples. Oxlint and fallow exclude them. +Do not commit lockfiles. Keep the demo and tests consistent across examples. diff --git a/examples/with-vite/.gitignore b/examples/with-vite/.gitignore new file mode 100644 index 000000000..6f8371de9 --- /dev/null +++ b/examples/with-vite/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ + +# What `npx e2e init` adds: the run output under .e2e/. +.e2e/artifacts/ +.e2e/cache/ +.e2e/sessions/ +.e2e/report.json +.e2e/ai-trace.json +.e2e/junit.xml +.e2e/summary.md +.e2e/failures/ +.e2e/logs/ +.e2e/videos/ diff --git a/examples/with-vite/README.md b/examples/with-vite/README.md new file mode 100644 index 000000000..cb7a323ea --- /dev/null +++ b/examples/with-vite/README.md @@ -0,0 +1,33 @@ +# e2e with Vite + +A Vite + React greeting app with three locator tests and two agent tests. + +![Vite example app with the greeting "Hello, Ada!"](screenshot.png) + +## Run + +Use Node 22.22.3+, 24.8+, or 26+. From this folder: + +```bash +npm install +npm run test:e2e +``` + +The runner starts the app at `http://localhost:5173`. +It downloads Chromium on the first run if needed. + +## Agent tests + +Set a [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) key: + +```bash +AI_GATEWAY_API_KEY="your-key" npm run test:e2e +``` + +Without the key, the agent tests skip. Add `-- --no-cache` to use fresh +model calls. Results are in `.e2e/report.json`. + +See [e2e.config.ts](e2e.config.ts) and [tests/](tests/) for the setup. +For your own app, follow the [Quickstart](https://e2e.tester.army/docs/quickstart). + +Last checked with e2e 0.15.2, @e2e-dev/web 0.11.1, Vite 8.3, and Node 22. diff --git a/examples/with-vite/e2e.config.ts b/examples/with-vite/e2e.config.ts new file mode 100644 index 000000000..6c476b10d --- /dev/null +++ b/examples/with-vite/e2e.config.ts @@ -0,0 +1,25 @@ +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { gateway } from 'ai'; + +export default { + // The Vercel AI Gateway serves the model and reads AI_GATEWAY_API_KEY. + // Only tests that use `agent` need it; tests/greeting.e2e.ts runs without one. + agents: { + default: { + model: gateway('openai/gpt-6-luna-fast'), + system: 'You are a thorough QA agent. Verify every outcome.', + }, + }, + targets: [ + { + engine: web(), + app: { + url: 'http://localhost:5173', + // The runner starts the Vite dev server and waits for the URL to answer. + // Outside CI, a server you already started on that port is reused. + command: { executable: 'npm', args: ['run', 'dev'], reuseExisting: true }, + }, + }, + ], +} satisfies E2EConfig; diff --git a/examples/with-vite/index.html b/examples/with-vite/index.html new file mode 100644 index 000000000..497874e71 --- /dev/null +++ b/examples/with-vite/index.html @@ -0,0 +1,13 @@ + + + + + + + e2e - with-vite + + +
+ + + diff --git a/examples/with-vite/package.json b/examples/with-vite/package.json new file mode 100644 index 000000000..8b7dd3167 --- /dev/null +++ b/examples/with-vite/package.json @@ -0,0 +1,31 @@ +{ + "name": "e2e-example-with-vite", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test:e2e": "e2e run" + }, + "dependencies": { + "@fontsource/dm-mono": "^5.3.0", + "@fontsource/inter": "^5.3.0", + "@fontsource/stack-sans-notch": "^5.3.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@e2e-dev/web": "^0.11.1", + "@types/node": "^26.6.4", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.4", + "@vitejs/plugin-react": "^6.1.1", + "ai": "^7.0.0", + "e2e": "^0.15.2", + "playwright": "^1.63.0", + "typescript": "^7.0.2", + "vite": "^8.3.2", + "zod": "^4.1.8" + } +} diff --git a/examples/with-vite/public/e2e-lockup-32-white.svg b/examples/with-vite/public/e2e-lockup-32-white.svg new file mode 100644 index 000000000..fedbdaa2b --- /dev/null +++ b/examples/with-vite/public/e2e-lockup-32-white.svg @@ -0,0 +1,9 @@ + +e2e + + + + + + + diff --git a/examples/with-vite/public/favicon.png b/examples/with-vite/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..5f7a4e64be8f7800142f4e7090e2eb275ea8428d GIT binary patch literal 1012 zcmV?+6401W;L788tRGDx6yq=FevG&dyG1Z*Qlf zq9W30G@cXC>2y?DTFOqG5dfB#ml=Y9z}VOrN28#ifV5hzX9OxME2RTh1Xfm77_+mp zG&wn`z{0`;+aMBd6R54Nb?D3yshyo2T3cI_=@8i3+F~7SP)9BaWMpJGeOqTyaYX<~ zOiXl4K&#bqsQ#A#oG-N|ydwZL0&>eOOXfzrw798N~2ngo_>FMdV1VTeY`A`53 zts!)laAbqloSU0ttgf!o=H@0t*kM@N5Xi~Nkqq5=TSt=+y6*07*^2Y?^D-L34mLqS zK}rI7d3irdAU;0+`v|nQwo*?|55uw`ySlmRpl9h z;o)JLn3$mR^K&jRwA$6x6Xe zLi6|c=brB9=%Bm1JLw$G#N5X(x&n~^spI2g>8GZqhC)I@NQHx7h_Kex)v*G=W&?l# zrkoJGy{O>&`g%rcYO2&D4bb`d`O)a;C|7)Sb+yztH#hTtf`fyT zi;GhLcfFIWI0Qrm3kZ4pU4_4CmTXkV|U^ z$;ru5k7Bg9UqC)TKkZ*edc9r}I5;@qDYm}8&Lpe}yuZIwOG^to1+ufV6@~cl@W3e* zdN@b^A^ZFLj;~|=UlNOpi)@V6!Cq%gz#AzqFL%C>==FNe;l;%T`C6{8ukCKiDtl{E zW@aXxot??n^M&;G_IkDzx3#tLioLtL>$P)4G&VNY^X~$)+01hQMR{{`qZl~u%H`!H z&k47;Vzm__BO~eP=!i@v6K!m4@cIoR_xJY-;Mwi%t#k~+2s>tp&%O<_va(z*r5M{7 iYF>YusecH+PT&s*wR?8={y!i90000fa0i literal 0 HcmV?d00001 diff --git a/examples/with-vite/screenshot.png b/examples/with-vite/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..0f28e4d0ca4905868d44b20bbe70206dca04f76d GIT binary patch literal 29240 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 W2{j!(1p@$uqN}N|@%Z4GtN#JmZ=0V0 literal 0 HcmV?d00001 diff --git a/examples/with-vite/src/App.tsx b/examples/with-vite/src/App.tsx new file mode 100644 index 000000000..0801fcb8f --- /dev/null +++ b/examples/with-vite/src/App.tsx @@ -0,0 +1,65 @@ +import { useState, type FormEvent } from 'react'; + +export function App() { + const [name, setName] = useState(''); + const [error, setError] = useState(false); + const [greeted, setGreeted] = useState(null); + + function greet(event: FormEvent) { + event.preventDefault(); + const value = name.trim(); + if (value === '') { + setError(true); + setGreeted(null); + return; + } + setError(false); + setGreeted(value); + setName(''); + } + + return ( +

+ ); +} diff --git a/examples/with-vite/src/main.tsx b/examples/with-vite/src/main.tsx new file mode 100644 index 000000000..a17fe3e84 --- /dev/null +++ b/examples/with-vite/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import '@fontsource/stack-sans-notch/400.css'; +import '@fontsource/inter/400.css'; +import '@fontsource/dm-mono/400.css'; +import './styles.css'; +import { App } from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/with-vite/src/styles.css b/examples/with-vite/src/styles.css new file mode 100644 index 000000000..8b2bb2fdf --- /dev/null +++ b/examples/with-vite/src/styles.css @@ -0,0 +1,163 @@ +/* e2e brand: dark canvas, square corners, hairlines instead of shadows, white text at falling alphas. */ +:root { + --canvas: #111111; + --panel: #161616; + --field-fill: #1a1a1a; + --hairline: #ffffff1f; + --field-stroke: #ffffff4d; + --button-fill: #f9f8f7; + --text-primary: #f9f8f7; + --text-secondary: #ffffffb3; + --text-faint: #ffffff80; + --text-on-light: #161616; + --accent: #ff8001; + --font-display: 'Stack Sans Notch', ui-sans-serif, system-ui, sans-serif; + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, monospace; +} + +* { + box-sizing: border-box; + margin: 0; +} + +body { + background: var(--canvas); + color: var(--text-primary); + font: 400 16px/24px var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +.page { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +.nav { + display: flex; + align-items: center; + height: 84px; + border-bottom: 1px solid var(--hairline); +} + +.hero { + display: flex; + flex: 1; + flex-direction: column; + gap: 16px; + padding: 64px 0; + max-width: 640px; +} + +.eyebrow { + color: var(--text-secondary); + font: 400 12px/23px var(--font-mono); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.title { + font: 400 38px/43px var(--font-display); +} + +.lede { + color: var(--text-secondary); +} + +.form { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 24px; +} + +.label { + color: var(--text-faint); + font: 400 11px/16px var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.row { + display: flex; + flex-direction: column; + gap: 12px; +} + +.field { + flex: 1 1 auto; + min-width: 0; + height: 40px; + padding: 0 12px; + border: 0; + border-radius: 0; + outline: none; + background: var(--field-fill); + box-shadow: inset 0 0 0 0.5px var(--field-stroke); + color: var(--text-primary); + font: 400 13px/20px var(--font-mono); +} + +.field::placeholder { + color: var(--text-faint); +} + +.field:focus-visible { + box-shadow: inset 0 0 0 1px var(--text-primary); +} + +.button { + height: 40px; + padding: 0 20px; + border: 0; + border-radius: 0; + background: var(--button-fill); + color: var(--text-on-light); + font: 400 13px/20px var(--font-mono); + text-transform: uppercase; + cursor: pointer; +} + +.button:hover, +.button:focus-visible { + background: #ffffff; +} + +.error { + color: var(--accent); + font-size: 14px; +} + +.status { + padding: 12px 16px; + background: var(--panel); + box-shadow: inset 0 0 0 0.5px var(--hairline); + font-size: 14px; +} + +.footer { + padding: 24px 0; + border-top: 1px solid var(--hairline); + color: var(--text-faint); + font: 400 12px/16px var(--font-mono); + letter-spacing: 0.1em; +} + +@media (min-width: 768px) { + .hero { + padding: 120px 0; + } + + .title { + font-size: 60px; + line-height: 68px; + } + + .row { + flex-direction: row; + } +} diff --git a/examples/with-vite/tests/agent.e2e.ts b/examples/with-vite/tests/agent.e2e.ts new file mode 100644 index 000000000..14bb4c864 --- /dev/null +++ b/examples/with-vite/tests/agent.e2e.ts @@ -0,0 +1,25 @@ +import { test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +// Agent steps call the model in e2e.config.ts. Without a key these tests are skipped. +const skip = process.env.AI_GATEWAY_API_KEY ? false : 'set AI_GATEWAY_API_KEY to run agent tests'; + +test('the agent gets a greeting', { skip }, async ({ app, agent, screen }) => { + await app.open('/'); + + // The agent works out which field and button to use. + await agent.act('get the app to greet {name}', { params: { name: 'Grace' } }); + + // Pair each agent step with a check that does not depend on the model. + await expect(screen.getByRole('status')).toHaveText('Hello, Grace!'); + await agent.assert('the app greets Grace by name'); +}); + +test('the agent finds the empty-name error', { skip }, async ({ app, agent, screen }) => { + await app.open('/'); + + await agent.act('press Greet without typing a name'); + + await expect(screen.getByRole('alert')).toHaveText('Enter a name first.'); + await agent.assert('the app asks for a name'); +}); diff --git a/examples/with-vite/tests/greeting.e2e.ts b/examples/with-vite/tests/greeting.e2e.ts new file mode 100644 index 000000000..dd8934bb1 --- /dev/null +++ b/examples/with-vite/tests/greeting.e2e.ts @@ -0,0 +1,26 @@ +import { test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +test('shows the greeting form', async ({ app, screen }) => { + await app.open('/'); + + await expect(screen.getByRole('heading', 'Say hello')).toBeVisible(); + await expect(screen.getByLabel('Name')).toBeVisible(); +}); + +test('asks for a name when the field is empty', async ({ app, screen }) => { + await app.open('/'); + + await screen.getByRole('button', 'Greet').click(); + + await expect(screen.getByRole('alert')).toHaveText('Enter a name first.'); +}); + +test('greets the name you type', async ({ app, screen }) => { + await app.open('/'); + + await screen.getByLabel('Name').fill('Ada'); + await screen.getByRole('button', 'Greet').click(); + + await expect(screen.getByRole('status')).toHaveText('Hello, Ada!'); +}); diff --git a/examples/with-vite/tsconfig.json b/examples/with-vite/tsconfig.json new file mode 100644 index 000000000..a38f5d14a --- /dev/null +++ b/examples/with-vite/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "tests", "e2e.config.ts", "vite.config.ts"] +} diff --git a/examples/with-vite/vite.config.ts b/examples/with-vite/vite.config.ts new file mode 100644 index 000000000..fe7efcc95 --- /dev/null +++ b/examples/with-vite/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + // A fixed port, so e2e.config.ts knows where the app is. + server: { port: 5173, strictPort: true }, +}); diff --git a/skills/e2e/SKILL.md b/skills/e2e/SKILL.md index 0067ff17e..006309967 100644 --- a/skills/e2e/SKILL.md +++ b/skills/e2e/SKILL.md @@ -61,7 +61,8 @@ one; the installed CLI prints the same text with `npx e2e guide ` (`e2e guide` alone prints this page). For anything the topics do not cover, the full documentation ships in the `docs/` directory of the installed `e2e` package (`node_modules/e2e/docs` in a single-package project); a link such as -`/reference/cli` is `docs/reference/cli.mdx`. +`/reference/cli` is `docs/reference/cli.mdx`. Complete projects for Vite, each with its config, scripts, and a passing +suite, are in https://github.com/tester-army/e2e/tree/main/examples. | Topic | File | Read it when | | --- | --- | --- | From 1cf4c7c761920ccafd5d9ad1669a3ae784d0f09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Kapu=C5=9Bciak?= <39658211+kacperkapusciak@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:22:43 +0200 Subject: [PATCH 10/46] docs(examples): add standalone Next.js project (#855) --- AGENTS.md | 2 +- README.md | 2 +- examples/README.md | 1 + examples/with-next/.gitignore | 15 ++ examples/with-next/README.md | 33 ++++ examples/with-next/app/globals.css | 161 ++++++++++++++++++ examples/with-next/app/greeting-form.tsx | 53 ++++++ examples/with-next/app/layout.tsx | 21 +++ examples/with-next/app/page.tsx | 20 +++ examples/with-next/e2e.config.ts | 25 +++ examples/with-next/package.json | 27 +++ .../with-next/public/e2e-lockup-32-white.svg | 9 + examples/with-next/public/favicon.png | Bin 0 -> 1012 bytes examples/with-next/screenshot.png | Bin 0 -> 30695 bytes examples/with-next/tests/agent.e2e.ts | 25 +++ examples/with-next/tests/greeting.e2e.ts | 27 +++ examples/with-next/tsconfig.json | 38 +++++ skills/e2e/SKILL.md | 2 +- 18 files changed, 458 insertions(+), 3 deletions(-) create mode 100644 examples/with-next/.gitignore create mode 100644 examples/with-next/README.md create mode 100644 examples/with-next/app/globals.css create mode 100644 examples/with-next/app/greeting-form.tsx create mode 100644 examples/with-next/app/layout.tsx create mode 100644 examples/with-next/app/page.tsx create mode 100644 examples/with-next/e2e.config.ts create mode 100644 examples/with-next/package.json create mode 100644 examples/with-next/public/e2e-lockup-32-white.svg create mode 100644 examples/with-next/public/favicon.png create mode 100644 examples/with-next/screenshot.png create mode 100644 examples/with-next/tests/agent.e2e.ts create mode 100644 examples/with-next/tests/greeting.e2e.ts create mode 100644 examples/with-next/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 4a6de356d..b6c6ec458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ suites that consume the built packages the way a user would. diffs against the source minimal, and name no company a scenario was distilled from. - `examples/` — standalone user-facing projects, one per technology - (`with-vite`), each the same + (`with-vite`, `with-next`), each the same one-screen greeter demo with deterministic and agent tests. They install the published packages from npm, sit outside the pnpm workspace, commit no lockfile, and run in no CI; oxlint and fallow ignore them. A change runs diff --git a/README.md b/README.md index 5c50d2631..3f72d744d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ config and an example test. The To see a finished setup in your stack, open [`examples/`](https://github.com/tester-army/e2e/tree/main/examples): -The Vite example is a standalone project with a passing +Vite and Next.js, each a standalone project with a passing suite. ## Packages diff --git a/examples/README.md b/examples/README.md index 244ce8c5b..e08d350f1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,7 @@ Every app uses the same demo: enter a name and press Greet. | Example | Technology | Runs on | | --- | --- | --- | | [with-vite](with-vite) | Vite + React | Chromium | +| [with-next](with-next) | Next.js App Router | Chromium | Each folder installs published packages from npm and works outside this repository. See its README for run commands. Agent tests need diff --git a/examples/with-next/.gitignore b/examples/with-next/.gitignore new file mode 100644 index 000000000..02f3396c3 --- /dev/null +++ b/examples/with-next/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +.next/ +next-env.d.ts + +# What `npx e2e init` adds: the run output under .e2e/. +.e2e/artifacts/ +.e2e/cache/ +.e2e/sessions/ +.e2e/report.json +.e2e/ai-trace.json +.e2e/junit.xml +.e2e/summary.md +.e2e/failures/ +.e2e/logs/ +.e2e/videos/ diff --git a/examples/with-next/README.md b/examples/with-next/README.md new file mode 100644 index 000000000..742ae998d --- /dev/null +++ b/examples/with-next/README.md @@ -0,0 +1,33 @@ +# e2e with Next.js + +A Next.js App Router greeting app with three locator tests and two agent tests. + +![Next.js example app with the greeting "Hello, Ada!"](screenshot.png) + +## Run + +Use Node 22.22.3+, 24.8+, or 26+. From this folder: + +```bash +npm install +npm run test:e2e +``` + +The runner starts the app at `http://localhost:3100`. +It downloads Chromium on the first run if needed. + +## Agent tests + +Set a [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) key: + +```bash +AI_GATEWAY_API_KEY="your-key" npm run test:e2e +``` + +Without the key, the agent tests skip. Add `-- --no-cache` to use fresh +model calls. Results are in `.e2e/report.json`. + +See [e2e.config.ts](e2e.config.ts) and [tests/](tests/) for the setup. +For your own app, follow the [Quickstart](https://e2e.tester.army/docs/quickstart). + +Last checked with e2e 0.15.2, @e2e-dev/web 0.11.1, Next.js 16.3, and Node 22. diff --git a/examples/with-next/app/globals.css b/examples/with-next/app/globals.css new file mode 100644 index 000000000..49b95e1be --- /dev/null +++ b/examples/with-next/app/globals.css @@ -0,0 +1,161 @@ +/* e2e brand: dark canvas, square corners, hairlines instead of shadows, white text at falling alphas. + next/font in app/layout.tsx sets --font-display, --font-sans, and --font-mono. */ +:root { + --canvas: #111111; + --panel: #161616; + --field-fill: #1a1a1a; + --hairline: #ffffff1f; + --field-stroke: #ffffff4d; + --button-fill: #f9f8f7; + --text-primary: #f9f8f7; + --text-secondary: #ffffffb3; + --text-faint: #ffffff80; + --text-on-light: #161616; + --accent: #ff8001; +} + +* { + box-sizing: border-box; + margin: 0; +} + +body { + background: var(--canvas); + color: var(--text-primary); + font: 400 16px/24px var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +.page { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +.nav { + display: flex; + align-items: center; + height: 84px; + border-bottom: 1px solid var(--hairline); +} + +.hero { + display: flex; + flex: 1; + flex-direction: column; + gap: 16px; + padding: 64px 0; + max-width: 640px; +} + +.eyebrow { + color: var(--text-secondary); + font: 400 12px/23px var(--font-mono); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.title { + font: 400 38px/43px var(--font-display); +} + +.lede { + color: var(--text-secondary); +} + +.form { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 24px; +} + +.label { + color: var(--text-faint); + font: 400 11px/16px var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.row { + display: flex; + flex-direction: column; + gap: 12px; +} + +.field { + flex: 1 1 auto; + min-width: 0; + height: 40px; + padding: 0 12px; + border: 0; + border-radius: 0; + outline: none; + background: var(--field-fill); + box-shadow: inset 0 0 0 0.5px var(--field-stroke); + color: var(--text-primary); + font: 400 13px/20px var(--font-mono); +} + +.field::placeholder { + color: var(--text-faint); +} + +.field:focus-visible { + box-shadow: inset 0 0 0 1px var(--text-primary); +} + +.button { + height: 40px; + padding: 0 20px; + border: 0; + border-radius: 0; + background: var(--button-fill); + color: var(--text-on-light); + font: 400 13px/20px var(--font-mono); + text-transform: uppercase; + cursor: pointer; +} + +.button:hover, +.button:focus-visible { + background: #ffffff; +} + +.error { + color: var(--accent); + font-size: 14px; +} + +.status { + padding: 12px 16px; + background: var(--panel); + box-shadow: inset 0 0 0 0.5px var(--hairline); + font-size: 14px; +} + +.footer { + padding: 24px 0; + border-top: 1px solid var(--hairline); + color: var(--text-faint); + font: 400 12px/16px var(--font-mono); + letter-spacing: 0.1em; +} + +@media (min-width: 768px) { + .hero { + padding: 120px 0; + } + + .title { + font-size: 60px; + line-height: 68px; + } + + .row { + flex-direction: row; + } +} diff --git a/examples/with-next/app/greeting-form.tsx b/examples/with-next/app/greeting-form.tsx new file mode 100644 index 000000000..a25545cd6 --- /dev/null +++ b/examples/with-next/app/greeting-form.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; + +export function GreetingForm() { + const [name, setName] = useState(''); + const [error, setError] = useState(false); + const [greeted, setGreeted] = useState(null); + + function greet(event: FormEvent) { + event.preventDefault(); + const value = name.trim(); + if (value === '') { + setError(true); + setGreeted(null); + return; + } + setError(false); + setGreeted(value); + setName(''); + } + + return ( +
+ +
+ setName(event.target.value)} + /> + +
+ {error ? ( +

+ Enter a name first. +

+ ) : null} + {greeted !== null ? ( +

+ Hello, {greeted}! +

+ ) : null} +
+ ); +} diff --git a/examples/with-next/app/layout.tsx b/examples/with-next/app/layout.tsx new file mode 100644 index 000000000..4d51342fe --- /dev/null +++ b/examples/with-next/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import { DM_Mono, Inter, Stack_Sans_Notch } from 'next/font/google'; +import './globals.css'; + +const display = Stack_Sans_Notch({ subsets: ['latin'], weight: '400', variable: '--font-display' }); +const sans = Inter({ subsets: ['latin'], weight: '400', variable: '--font-sans' }); +const mono = DM_Mono({ subsets: ['latin'], weight: '400', variable: '--font-mono' }); + +export const metadata: Metadata = { + title: 'e2e - with-next', + icons: { icon: '/favicon.png' }, +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/examples/with-next/app/page.tsx b/examples/with-next/app/page.tsx new file mode 100644 index 000000000..e5cc3559c --- /dev/null +++ b/examples/with-next/app/page.tsx @@ -0,0 +1,20 @@ +import { GreetingForm } from './greeting-form.tsx'; + +export default function Home() { + return ( +
+
+ e2e +
+ +
+

[01] with-next

+

Say hello

+

A demo screen for the e2e examples. Type a name and the app greets you.

+ +
+ +
Example app for e2e. The tests live in tests/.
+
+ ); +} diff --git a/examples/with-next/e2e.config.ts b/examples/with-next/e2e.config.ts new file mode 100644 index 000000000..712574664 --- /dev/null +++ b/examples/with-next/e2e.config.ts @@ -0,0 +1,25 @@ +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { gateway } from 'ai'; + +export default { + // The Vercel AI Gateway serves the model and reads AI_GATEWAY_API_KEY. + // Only tests that use `agent` need it; tests/greeting.e2e.ts runs without one. + agents: { + default: { + model: gateway('openai/gpt-6-luna-fast'), + system: 'You are a thorough QA agent. Verify every outcome.', + }, + }, + targets: [ + { + engine: web(), + app: { + url: 'http://localhost:3100', + // The runner starts the Next.js dev server and waits for the URL to answer. + // A server you already started on that port is reused. + command: { executable: 'npm', args: ['run', 'dev'], reuseExisting: true }, + }, + }, + ], +} satisfies E2EConfig; diff --git a/examples/with-next/package.json b/examples/with-next/package.json new file mode 100644 index 000000000..603b67243 --- /dev/null +++ b/examples/with-next/package.json @@ -0,0 +1,27 @@ +{ + "name": "e2e-example-with-next", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3100", + "build": "next build", + "start": "next start --port 3100", + "test:e2e": "e2e run" + }, + "dependencies": { + "next": "^16.3.8", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@e2e-dev/web": "^0.11.1", + "@types/node": "^26.6.4", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.4", + "ai": "^7.0.0", + "e2e": "^0.15.2", + "playwright": "^1.63.0", + "typescript": "^7.0.2", + "zod": "^4.1.8" + } +} diff --git a/examples/with-next/public/e2e-lockup-32-white.svg b/examples/with-next/public/e2e-lockup-32-white.svg new file mode 100644 index 000000000..fedbdaa2b --- /dev/null +++ b/examples/with-next/public/e2e-lockup-32-white.svg @@ -0,0 +1,9 @@ + +e2e + + + + + + + diff --git a/examples/with-next/public/favicon.png b/examples/with-next/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..5f7a4e64be8f7800142f4e7090e2eb275ea8428d GIT binary patch literal 1012 zcmV?+6401W;L788tRGDx6yq=FevG&dyG1Z*Qlf zq9W30G@cXC>2y?DTFOqG5dfB#ml=Y9z}VOrN28#ifV5hzX9OxME2RTh1Xfm77_+mp zG&wn`z{0`;+aMBd6R54Nb?D3yshyo2T3cI_=@8i3+F~7SP)9BaWMpJGeOqTyaYX<~ zOiXl4K&#bqsQ#A#oG-N|ydwZL0&>eOOXfzrw798N~2ngo_>FMdV1VTeY`A`53 zts!)laAbqloSU0ttgf!o=H@0t*kM@N5Xi~Nkqq5=TSt=+y6*07*^2Y?^D-L34mLqS zK}rI7d3irdAU;0+`v|nQwo*?|55uw`ySlmRpl9h z;o)JLn3$mR^K&jRwA$6x6Xe zLi6|c=brB9=%Bm1JLw$G#N5X(x&n~^spI2g>8GZqhC)I@NQHx7h_Kex)v*G=W&?l# zrkoJGy{O>&`g%rcYO2&D4bb`d`O)a;C|7)Sb+yztH#hTtf`fyT zi;GhLcfFIWI0Qrm3kZ4pU4_4CmTXkV|U^ z$;ru5k7Bg9UqC)TKkZ*edc9r}I5;@qDYm}8&Lpe}yuZIwOG^to1+ufV6@~cl@W3e* zdN@b^A^ZFLj;~|=UlNOpi)@V6!Cq%gz#AzqFL%C>==FNe;l;%T`C6{8ukCKiDtl{E zW@aXxot??n^M&;G_IkDzx3#tLioLtL>$P)4G&VNY^X~$)+01hQMR{{`qZl~u%H`!H z&k47;Vzm__BO~eP=!i@v6K!m4@cIoR_xJY-;Mwi%t#k~+2s>tp&%O<_va(z*r5M{7 iYF>YusecH+PT&s*wR?8={y!i90000fa0i literal 0 HcmV?d00001 diff --git a/examples/with-next/screenshot.png b/examples/with-next/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..26cdb0300c705a438372759dc3260739d52705d1 GIT binary patch literal 30695 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 literal 0 HcmV?d00001 diff --git a/examples/with-next/tests/agent.e2e.ts b/examples/with-next/tests/agent.e2e.ts new file mode 100644 index 000000000..71c4dc72c --- /dev/null +++ b/examples/with-next/tests/agent.e2e.ts @@ -0,0 +1,25 @@ +import { test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +// Agent steps call the model in e2e.config.ts. Without a key these tests are skipped. +const skip = process.env.AI_GATEWAY_API_KEY ? false : 'set AI_GATEWAY_API_KEY to run agent tests'; + +test('the agent gets a greeting', { skip }, async ({ app, agent, screen }) => { + await app.open('/'); + + // The agent works out which field and button to use. + await agent.act('get the app to greet {name}', { params: { name: 'Grace' } }); + + // Pair each agent step with a check that does not depend on the model. + await expect(screen.getByRole('status')).toHaveText('Hello, Grace!'); + await agent.assert('the app greets Grace by name'); +}); + +test('the agent finds the empty-name error', { skip }, async ({ app, agent, screen }) => { + await app.open('/'); + + await agent.act('press Greet without typing a name'); + + await expect(screen.getByRole('alert').filter({ hasText: 'Enter a name first.' })).toBeVisible(); + await agent.assert('the app asks for a name'); +}); diff --git a/examples/with-next/tests/greeting.e2e.ts b/examples/with-next/tests/greeting.e2e.ts new file mode 100644 index 000000000..5b9f5c07d --- /dev/null +++ b/examples/with-next/tests/greeting.e2e.ts @@ -0,0 +1,27 @@ +import { test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +test('shows the greeting form', async ({ app, screen }) => { + await app.open('/'); + + await expect(screen.getByRole('heading', 'Say hello')).toBeVisible(); + await expect(screen.getByLabel('Name')).toBeVisible(); +}); + +test('asks for a name when the field is empty', async ({ app, screen }) => { + await app.open('/'); + + await screen.getByRole('button', 'Greet').click(); + + // Next.js renders its own empty role=alert (the route announcer), so pick ours by its text. + await expect(screen.getByRole('alert').filter({ hasText: 'Enter a name first.' })).toBeVisible(); +}); + +test('greets the name you type', async ({ app, screen }) => { + await app.open('/'); + + await screen.getByLabel('Name').fill('Ada'); + await screen.getByRole('button', 'Greet').click(); + + await expect(screen.getByRole('status')).toHaveText('Hello, Ada!'); +}); diff --git a/examples/with-next/tsconfig.json b/examples/with-next/tsconfig.json new file mode 100644 index 000000000..84c88ea3f --- /dev/null +++ b/examples/with-next/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "resolveJsonModule": true, + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "allowJs": true, + "esModuleInterop": true + }, + "include": [ + "next-env.d.ts", + "app", + "tests", + "e2e.config.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/skills/e2e/SKILL.md b/skills/e2e/SKILL.md index 006309967..fc01fadeb 100644 --- a/skills/e2e/SKILL.md +++ b/skills/e2e/SKILL.md @@ -61,7 +61,7 @@ one; the installed CLI prints the same text with `npx e2e guide ` (`e2e guide` alone prints this page). For anything the topics do not cover, the full documentation ships in the `docs/` directory of the installed `e2e` package (`node_modules/e2e/docs` in a single-package project); a link such as -`/reference/cli` is `docs/reference/cli.mdx`. Complete projects for Vite, each with its config, scripts, and a passing +`/reference/cli` is `docs/reference/cli.mdx`. Complete projects for Vite and Next.js, each with its config, scripts, and a passing suite, are in https://github.com/tester-army/e2e/tree/main/examples. | Topic | File | Read it when | From 0edbd5fe81f6474973ebea52bd7e78bbad83f433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Kapu=C5=9Bciak?= <39658211+kacperkapusciak@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:22:44 +0200 Subject: [PATCH 11/46] docs(examples): add standalone Expo project (#856) * docs(examples): add standalone Expo project * fix(examples): correct Expo accessibility and adaptive icon --- AGENTS.md | 4 +- README.md | 2 +- examples/README.md | 1 + examples/with-expo/.gitignore | 51 ++++++++ examples/with-expo/App.tsx | 136 ++++++++++++++++++++ examples/with-expo/README.md | 41 ++++++ examples/with-expo/app.json | 27 ++++ examples/with-expo/assets/adaptive-icon.png | Bin 0 -> 4300 bytes examples/with-expo/assets/helmet-mark.png | Bin 0 -> 1516 bytes examples/with-expo/assets/icon.png | Bin 0 -> 8908 bytes examples/with-expo/e2e.config.ts | 34 +++++ examples/with-expo/index.ts | 5 + examples/with-expo/package.json | 40 ++++++ examples/with-expo/tests/agent.e2e.ts | 24 ++++ examples/with-expo/tests/greeting.e2e.ts | 25 ++++ examples/with-expo/tsconfig.json | 6 + skills/e2e/SKILL.md | 2 +- 17 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 examples/with-expo/.gitignore create mode 100644 examples/with-expo/App.tsx create mode 100644 examples/with-expo/README.md create mode 100644 examples/with-expo/app.json create mode 100644 examples/with-expo/assets/adaptive-icon.png create mode 100644 examples/with-expo/assets/helmet-mark.png create mode 100644 examples/with-expo/assets/icon.png create mode 100644 examples/with-expo/e2e.config.ts create mode 100644 examples/with-expo/index.ts create mode 100644 examples/with-expo/package.json create mode 100644 examples/with-expo/tests/agent.e2e.ts create mode 100644 examples/with-expo/tests/greeting.e2e.ts create mode 100644 examples/with-expo/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index b6c6ec458..aabe34ce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,12 +108,12 @@ suites that consume the built packages the way a user would. diffs against the source minimal, and name no company a scenario was distilled from. - `examples/` — standalone user-facing projects, one per technology - (`with-vite`, `with-next`), each the same + (`with-vite`, `with-next`, `with-expo`), each the same one-screen greeter demo with deterministic and agent tests. They install the published packages from npm, sit outside the pnpm workspace, commit no lockfile, and run in no CI; oxlint and fallow ignore them. A change runs the example's suite by hand and updates the "Last checked" line in its - README. A native example keeps its tests in an `e2e/` folder beside the + README. A SwiftUI example keeps its tests in an `e2e/` folder beside the native project, as a user would. - `docs/` (the Mintlify docs site; pages are the `.mdx` files under `docs/`, navigation, theme, and redirects in `docs/docs.json`, extra CSS in diff --git a/README.md b/README.md index 3f72d744d..6f7637889 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ config and an example test. The To see a finished setup in your stack, open [`examples/`](https://github.com/tester-army/e2e/tree/main/examples): -Vite and Next.js, each a standalone project with a passing +Vite, Next.js, and Expo, each a standalone project with a passing suite. ## Packages diff --git a/examples/README.md b/examples/README.md index e08d350f1..7acf40fae 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,6 +7,7 @@ Every app uses the same demo: enter a name and press Greet. | --- | --- | --- | | [with-vite](with-vite) | Vite + React | Chromium | | [with-next](with-next) | Next.js App Router | Chromium | +| [with-expo](with-expo) | Expo + React Native | Chromium, iOS simulator, Android emulator | Each folder installs published packages from npm and works outside this repository. See its README for run commands. Agent tests need diff --git a/examples/with-expo/.gitignore b/examples/with-expo/.gitignore new file mode 100644 index 000000000..089da1ec3 --- /dev/null +++ b/examples/with-expo/.gitignore @@ -0,0 +1,51 @@ +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android + +# What `npx e2e init` adds: the run output under .e2e/. +.e2e/artifacts/ +.e2e/cache/ +.e2e/sessions/ +.e2e/report.json +.e2e/ai-trace.json +.e2e/junit.xml +.e2e/summary.md +.e2e/failures/ +.e2e/logs/ +.e2e/videos/ diff --git a/examples/with-expo/App.tsx b/examples/with-expo/App.tsx new file mode 100644 index 000000000..2bd879a76 --- /dev/null +++ b/examples/with-expo/App.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { Image, Keyboard, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { useFonts } from 'expo-font'; +import { DMMono_400Regular } from '@expo-google-fonts/dm-mono/400Regular'; +import { Inter_400Regular } from '@expo-google-fonts/inter/400Regular'; +import { StackSansNotch_400Regular } from '@expo-google-fonts/stack-sans-notch/400Regular'; + +export default function App() { + const [fontsLoaded] = useFonts({ DMMono_400Regular, Inter_400Regular, StackSansNotch_400Regular }); + const [name, setName] = useState(''); + const [error, setError] = useState(false); + const [greeted, setGreeted] = useState(null); + + function greet() { + Keyboard.dismiss(); + const value = name.trim(); + if (value === '') { + setError(true); + setGreeted(null); + return; + } + setError(false); + setGreeted(value); + setName(''); + } + + if (!fontsLoaded) return ; + + return ( + + + + + + / + e2e + + + + [01] with-expo + + Say hello + + A demo screen for the e2e examples. Type a name and the app greets you. + + + Name + {/* testID is what getByTestId finds: the accessibility identifier on iOS, the resource id on Android. */} + + + Greet + + {error ? ( + + Enter a name first. + + ) : null} + {greeted !== null ? ( + + Hello, {greeted}! + + ) : null} + + + + ); +} + +// e2e brand: dark canvas, square corners, hairlines instead of shadows, white text at falling alphas. +const styles = StyleSheet.create({ + page: { flex: 1, backgroundColor: '#111111', paddingHorizontal: 24 }, + nav: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingTop: 72, + paddingBottom: 20, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#ffffff1f', + }, + mark: { width: 32, height: 32 }, + slash: { color: '#f9f8f7', fontFamily: 'DMMono_400Regular', fontSize: 26 }, + wordmark: { color: '#f9f8f7', fontFamily: 'StackSansNotch_400Regular', fontSize: 26, letterSpacing: -1.5 }, + hero: { gap: 16, paddingTop: 48 }, + eyebrow: { + color: '#ffffffb3', + fontFamily: 'DMMono_400Regular', + fontSize: 12, + letterSpacing: 0.6, + textTransform: 'uppercase', + }, + title: { color: '#f9f8f7', fontFamily: 'StackSansNotch_400Regular', fontSize: 38, lineHeight: 43 }, + lede: { color: '#ffffffb3', fontFamily: 'Inter_400Regular', fontSize: 16, lineHeight: 24 }, + form: { gap: 12, paddingTop: 24 }, + label: { + color: '#ffffff80', + fontFamily: 'DMMono_400Regular', + fontSize: 11, + letterSpacing: 1.1, + textTransform: 'uppercase', + }, + field: { + height: 44, + paddingHorizontal: 12, + backgroundColor: '#1a1a1a', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#ffffff4d', + color: '#f9f8f7', + fontFamily: 'DMMono_400Regular', + fontSize: 14, + }, + button: { height: 44, alignItems: 'center', justifyContent: 'center', backgroundColor: '#f9f8f7' }, + buttonLabel: { color: '#161616', fontFamily: 'DMMono_400Regular', fontSize: 13, textTransform: 'uppercase' }, + error: { color: '#ff8001', fontFamily: 'Inter_400Regular', fontSize: 14 }, + status: { + padding: 14, + backgroundColor: '#161616', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#ffffff1f', + color: '#f9f8f7', + fontFamily: 'Inter_400Regular', + fontSize: 14, + lineHeight: 20, + }, +}); diff --git a/examples/with-expo/README.md b/examples/with-expo/README.md new file mode 100644 index 000000000..4514aad2f --- /dev/null +++ b/examples/with-expo/README.md @@ -0,0 +1,41 @@ +# e2e with Expo + +An Expo greeting app with three locator tests and two agent tests per platform. + +## Run + +Use Node 22.22.3+, 24.8+, or 26+. From this folder: + +```bash +npm install +``` + +Choose one platform: + +| Platform | Build and install | Run tests | +| --- | --- | --- | +| Web | The runner starts Expo | `npm run test:e2e:web` | +| iOS | `npm run ios:release` | `npm run test:e2e:ios` | +| Android | `npm run android:release` | `npm run test:e2e:android` | + +For native builds, follow [Expo's setup guide](https://docs.expo.dev/get-started/set-up-your-environment/). +Start a simulator or emulator before building. Rebuild after app changes. +After config changes, run `npx expo prebuild` to update existing native projects. + +## Agent tests + +Set a [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) key: + +```bash +AI_GATEWAY_API_KEY="your-key" npm run test:e2e:web +``` + +Use `test:e2e:ios` or `test:e2e:android` for native tests. +Without the key, the agent tests skip. Add `-- --no-cache` to use fresh +model calls. Results are in `.e2e/report.json`. + +See [e2e.config.ts](e2e.config.ts) and [tests/](tests/) for the setup. +For your own app, follow the [Quickstart](https://e2e.tester.army/docs/quickstart). + +Last checked on 2026-10-05 with Expo SDK 57, e2e 0.16.0, +@e2e-dev/web 0.11.2 and @e2e-dev/mobile 0.9.2. diff --git a/examples/with-expo/app.json b/examples/with-expo/app.json new file mode 100644 index 000000000..4ff2131b0 --- /dev/null +++ b/examples/with-expo/app.json @@ -0,0 +1,27 @@ +{ + "expo": { + "name": "with-expo", + "slug": "with-expo", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "dark", + "backgroundColor": "#111111", + "ios": { + "bundleIdentifier": "dev.e2e.examples.expo", + "supportsTablet": false + }, + "android": { + "package": "dev.e2e.examples.expo", + "adaptiveIcon": { + "backgroundColor": "#111111", + "foregroundImage": "./assets/adaptive-icon.png" + }, + "predictiveBackGestureEnabled": false + }, + "plugins": [ + "expo-font", + ["expo-build-properties", { "ios": { "enableSceneSupport": true } }] + ] + } +} diff --git a/examples/with-expo/assets/adaptive-icon.png b/examples/with-expo/assets/adaptive-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..83f90df3dd4f2e7dbfd1cb2416ebb11af4742b9c GIT binary patch literal 4300 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 literal 0 HcmV?d00001 diff --git a/examples/with-expo/assets/helmet-mark.png b/examples/with-expo/assets/helmet-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..9c426fb621ec1eefd41092d668697f2b14450bd0 GIT binary patch literal 1516 zcmah}do+}382`Q*GloV(F2lH|<8HecxiiKXxusCiuIjXyDdn=5MEC}4#BxX%jO7~@ z%1FypE}2ZqqP6qcsK#YuD@G!6$r5(#{! z005M{_R#`lDA)`fR(3k~V)w{EA!c7l8~~uDn*rWS4u}K*Ot}}$Ehw>U`c+>(*ASoC zCw%;HqoXPyS=Frfcrad%8@(fnfBIT+#AOOxxA0-)vrg*ThB( z?w47^y#{-D7-rR{`EbOeb}^I1E%l8ANyrq@omkhPI(SymfdVs|vaa`?e-Ls4a!kHt3xfT(cs|Z2v_dB;uj(rbx->U-F|&Maw%>JiRKu!_ z2(zPzU{afk?aPr$u3PIEoGK(h*ps!nCdPsZ(E%dt79-|FE2p3kF^y!RpC zLMMIr8?m<+62VRLQ%Cy1@4M6$7+vE`K}&x5Iwg-l5KvxSCN`>n~75k5I<|H7MZq zA0m=D6R$aZy9E%TD}!sc&{ig`I_a?YekRAy@nv)_eucoKa4<;k)B}$J6TuM2KE%Y; z2OC8pkn_J-LVN3qvfz;d`xBv)L*3F*7i(p-gDJF*hgC@`>J%dO$9t27E8Tnpm*zCB zkJhB!Fqe9(!JndkIzGYl(9$U|DO|OW>yn~rv8wfFiS36#juX&gciDVZP4;apm~zr# zMyGemIhGUWa8*Qg3Vk|lrF(9g=-7X*db^ssgwekaj_Bm%e3&b-msEYuby} znLtP{`pJx)yR1eq>7h#N8~n{4mv_L*kbH4n{e@*+*R*igt#;-z9af-SdIcaMEap-6 z`jRMO>fna)?Y_p?_Jgq%s0D_R&_)_I3cz=gEZO*A8rF1&SyjO(@=lPA>@P|mBChR&5V zf5>p8!IVM=)72N zldsI8u^Qv!`}aoFCduJZa}`wOnoz>>)s9i+U=$hGggZ&ip!6oU7yUP};z>wFTmEoE U&ydZxkgadP%VR(74)sL(Utevk5&!@I literal 0 HcmV?d00001 diff --git a/examples/with-expo/assets/icon.png b/examples/with-expo/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a5756a506bb00fa7040b5d2052403e0bdfe6d0e5 GIT binary patch literal 8908 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 { + await app.open(); + + // The agent works out which field and button to use. + await agent.act('get the app to greet {name}', { params: { name: 'Grace' } }); + + // Pair each agent step with a check that does not depend on the model. + await expect(screen.getByTestId('greeting')).toHaveText('Hello, Grace!'); + await agent.assert('the app greets Grace by name'); +}); + +test('the agent finds the empty-name error', { skip }, async ({ app, agent, screen }) => { + await app.open(); + + await agent.act('press Greet without typing a name'); + + await expect(screen.getByTestId('error')).toHaveText('Enter a name first.'); + await agent.assert('the app asks for a name'); +}); diff --git a/examples/with-expo/tests/greeting.e2e.ts b/examples/with-expo/tests/greeting.e2e.ts new file mode 100644 index 000000000..227989437 --- /dev/null +++ b/examples/with-expo/tests/greeting.e2e.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'e2e'; + +test('shows the greeting form', async ({ app, screen }) => { + await app.open(); + + await expect(screen.getByText('Say hello')).toBeVisible(); + await expect(screen.getByTestId('name')).toBeVisible(); +}); + +test('asks for a name when the field is empty', async ({ app, screen }) => { + await app.open(); + + await screen.getByTestId('greet').tap(); + + await expect(screen.getByTestId('error')).toHaveText('Enter a name first.'); +}); + +test('greets the name you type', async ({ app, screen }) => { + await app.open(); + + await screen.getByTestId('name').fill('Ada'); + await screen.getByTestId('greet').tap(); + + await expect(screen.getByTestId('greeting')).toHaveText('Hello, Ada!'); +}); diff --git a/examples/with-expo/tsconfig.json b/examples/with-expo/tsconfig.json new file mode 100644 index 000000000..b9567f605 --- /dev/null +++ b/examples/with-expo/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +} diff --git a/skills/e2e/SKILL.md b/skills/e2e/SKILL.md index fc01fadeb..8939a2899 100644 --- a/skills/e2e/SKILL.md +++ b/skills/e2e/SKILL.md @@ -61,7 +61,7 @@ one; the installed CLI prints the same text with `npx e2e guide ` (`e2e guide` alone prints this page). For anything the topics do not cover, the full documentation ships in the `docs/` directory of the installed `e2e` package (`node_modules/e2e/docs` in a single-package project); a link such as -`/reference/cli` is `docs/reference/cli.mdx`. Complete projects for Vite and Next.js, each with its config, scripts, and a passing +`/reference/cli` is `docs/reference/cli.mdx`. Complete projects for Vite, Next.js, and Expo, each with its config, scripts, and a passing suite, are in https://github.com/tester-army/e2e/tree/main/examples. | Topic | File | Read it when | From 0277c9218ca4cb0c5a27e3f26a69e8fcd44cbd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Kapu=C5=9Bciak?= <39658211+kacperkapusciak@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:22:44 +0200 Subject: [PATCH 12/46] docs(examples): add standalone SwiftUI project (#857) * docs(examples): add standalone SwiftUI project * fix(examples): announce SwiftUI errors and correct font credit * docs(examples): record SwiftUI verification after review --- AGENTS.md | 2 +- README.md | 2 +- examples/README.md | 1 + examples/with-swiftui/.gitignore | 3 + .../HelloApp.xcodeproj/project.pbxproj | 240 ++++++++++++++++++ .../xcshareddata/xcschemes/HelloApp.xcscheme | 50 ++++ .../AppIcon.appiconset/Contents.json | 14 + .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 31753 bytes .../HelloApp/Assets.xcassets/Contents.json | 6 + .../HelmetMark.imageset/Contents.json | 12 + .../HelmetMark.imageset/helmet-mark.png | Bin 0 -> 1516 bytes .../with-swiftui/HelloApp/ContentView.swift | 147 +++++++++++ .../HelloApp/Fonts/DMMono_400Regular.ttf | Bin 0 -> 48852 bytes .../HelloApp/Fonts/Inter_400Regular.ttf | Bin 0 -> 342408 bytes examples/with-swiftui/HelloApp/Fonts/OFL.txt | 95 +++++++ .../Fonts/StackSansNotch_400Regular.ttf | Bin 0 -> 78128 bytes examples/with-swiftui/HelloApp/HelloApp.swift | 19 ++ examples/with-swiftui/README.md | 43 ++++ examples/with-swiftui/e2e/.gitignore | 13 + examples/with-swiftui/e2e/e2e.config.ts | 24 ++ examples/with-swiftui/e2e/package.json | 16 ++ examples/with-swiftui/e2e/tests/agent.e2e.ts | 25 ++ .../with-swiftui/e2e/tests/greeting.e2e.ts | 26 ++ examples/with-swiftui/e2e/tsconfig.json | 12 + examples/with-swiftui/screenshot-error.png | Bin 0 -> 91556 bytes examples/with-swiftui/screenshot-greeting.png | Bin 0 -> 90591 bytes skills/e2e/SKILL.md | 3 +- 27 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 examples/with-swiftui/.gitignore create mode 100644 examples/with-swiftui/HelloApp.xcodeproj/project.pbxproj create mode 100644 examples/with-swiftui/HelloApp.xcodeproj/xcshareddata/xcschemes/HelloApp.xcscheme create mode 100644 examples/with-swiftui/HelloApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 examples/with-swiftui/HelloApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 examples/with-swiftui/HelloApp/Assets.xcassets/Contents.json create mode 100644 examples/with-swiftui/HelloApp/Assets.xcassets/HelmetMark.imageset/Contents.json create mode 100644 examples/with-swiftui/HelloApp/Assets.xcassets/HelmetMark.imageset/helmet-mark.png create mode 100644 examples/with-swiftui/HelloApp/ContentView.swift create mode 100644 examples/with-swiftui/HelloApp/Fonts/DMMono_400Regular.ttf create mode 100644 examples/with-swiftui/HelloApp/Fonts/Inter_400Regular.ttf create mode 100644 examples/with-swiftui/HelloApp/Fonts/OFL.txt create mode 100644 examples/with-swiftui/HelloApp/Fonts/StackSansNotch_400Regular.ttf create mode 100644 examples/with-swiftui/HelloApp/HelloApp.swift create mode 100644 examples/with-swiftui/README.md create mode 100644 examples/with-swiftui/e2e/.gitignore create mode 100644 examples/with-swiftui/e2e/e2e.config.ts create mode 100644 examples/with-swiftui/e2e/package.json create mode 100644 examples/with-swiftui/e2e/tests/agent.e2e.ts create mode 100644 examples/with-swiftui/e2e/tests/greeting.e2e.ts create mode 100644 examples/with-swiftui/e2e/tsconfig.json create mode 100644 examples/with-swiftui/screenshot-error.png create mode 100644 examples/with-swiftui/screenshot-greeting.png diff --git a/AGENTS.md b/AGENTS.md index aabe34ce5..f64694d40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ suites that consume the built packages the way a user would. diffs against the source minimal, and name no company a scenario was distilled from. - `examples/` — standalone user-facing projects, one per technology - (`with-vite`, `with-next`, `with-expo`), each the same + (`with-vite`, `with-next`, `with-expo`, `with-swiftui`), each the same one-screen greeter demo with deterministic and agent tests. They install the published packages from npm, sit outside the pnpm workspace, commit no lockfile, and run in no CI; oxlint and fallow ignore them. A change runs diff --git a/README.md b/README.md index 6f7637889..2a1a902d7 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ config and an example test. The To see a finished setup in your stack, open [`examples/`](https://github.com/tester-army/e2e/tree/main/examples): -Vite, Next.js, and Expo, each a standalone project with a passing +Vite, Next.js, Expo, and SwiftUI, each a standalone project with a passing suite. ## Packages diff --git a/examples/README.md b/examples/README.md index 7acf40fae..e78e93854 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,6 +8,7 @@ Every app uses the same demo: enter a name and press Greet. | [with-vite](with-vite) | Vite + React | Chromium | | [with-next](with-next) | Next.js App Router | Chromium | | [with-expo](with-expo) | Expo + React Native | Chromium, iOS simulator, Android emulator | +| [with-swiftui](with-swiftui) | SwiftUI | iOS simulator | Each folder installs published packages from npm and works outside this repository. See its README for run commands. Agent tests need diff --git a/examples/with-swiftui/.gitignore b/examples/with-swiftui/.gitignore new file mode 100644 index 000000000..d7e1969fa --- /dev/null +++ b/examples/with-swiftui/.gitignore @@ -0,0 +1,3 @@ +build/ +xcuserdata/ +*.xcuserstate diff --git a/examples/with-swiftui/HelloApp.xcodeproj/project.pbxproj b/examples/with-swiftui/HelloApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..94c6341cc --- /dev/null +++ b/examples/with-swiftui/HelloApp.xcodeproj/project.pbxproj @@ -0,0 +1,240 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + A10000000000000000000003 /* HelloApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HelloApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000004 /* HelloApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HelloApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000000000000000000001 = { + isa = PBXGroup; + children = ( + A10000000000000000000004 /* HelloApp */, + A10000000000000000000002 /* Products */, + ); + sourceTree = ""; + }; + A10000000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + A10000000000000000000003 /* HelloApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000000000000000000006 /* HelloApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000010 /* Build configuration list for PBXNativeTarget "HelloApp" */; + buildPhases = ( + A10000000000000000000007 /* Sources */, + A10000000000000000000005 /* Frameworks */, + A10000000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000004 /* HelloApp */, + ); + name = HelloApp; + packageProductDependencies = ( + ); + productName = HelloApp; + productReference = A10000000000000000000003 /* HelloApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000000000000000000009 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + A10000000000000000000006 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = A1000000000000000000000D /* Build configuration list for PBXProject "HelloApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A10000000000000000000001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A10000000000000000000002 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000000000000000000006 /* HelloApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A10000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A10000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A1000000000000000000000B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A1000000000000000000000C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A1000000000000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "with-swiftui"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.e2e.examples.swiftui; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A1000000000000000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "with-swiftui"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.e2e.examples.swiftui; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A1000000000000000000000D /* Build configuration list for PBXProject "HelloApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000B /* Debug */, + A1000000000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A10000000000000000000010 /* Build configuration list for PBXNativeTarget "HelloApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000E /* Debug */, + A1000000000000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A10000000000000000000009 /* Project object */; +} diff --git a/examples/with-swiftui/HelloApp.xcodeproj/xcshareddata/xcschemes/HelloApp.xcscheme b/examples/with-swiftui/HelloApp.xcodeproj/xcshareddata/xcschemes/HelloApp.xcscheme new file mode 100644 index 000000000..d0c78a513 --- /dev/null +++ b/examples/with-swiftui/HelloApp.xcodeproj/xcshareddata/xcschemes/HelloApp.xcscheme @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + diff --git a/examples/with-swiftui/HelloApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/with-swiftui/HelloApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..27a4f3814 --- /dev/null +++ b/examples/with-swiftui/HelloApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/with-swiftui/HelloApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/examples/with-swiftui/HelloApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..3909bf3aac5290dc77f29b31e6f8503f75743f7a GIT binary patch literal 31753 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 literal 0 HcmV?d00001 diff --git a/examples/with-swiftui/HelloApp/Assets.xcassets/Contents.json b/examples/with-swiftui/HelloApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/examples/with-swiftui/HelloApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/with-swiftui/HelloApp/Assets.xcassets/HelmetMark.imageset/Contents.json b/examples/with-swiftui/HelloApp/Assets.xcassets/HelmetMark.imageset/Contents.json new file mode 100644 index 000000000..9f2851826 --- /dev/null +++ b/examples/with-swiftui/HelloApp/Assets.xcassets/HelmetMark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "helmet-mark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/with-swiftui/HelloApp/Assets.xcassets/HelmetMark.imageset/helmet-mark.png b/examples/with-swiftui/HelloApp/Assets.xcassets/HelmetMark.imageset/helmet-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..9c426fb621ec1eefd41092d668697f2b14450bd0 GIT binary patch literal 1516 zcmah}do+}382`Q*GloV(F2lH|<8HecxiiKXxusCiuIjXyDdn=5MEC}4#BxX%jO7~@ z%1FypE}2ZqqP6qcsK#YuD@G!6$r5(#{! z005M{_R#`lDA)`fR(3k~V)w{EA!c7l8~~uDn*rWS4u}K*Ot}}$Ehw>U`c+>(*ASoC zCw%;HqoXPyS=Frfcrad%8@(fnfBIT+#AOOxxA0-)vrg*ThB( z?w47^y#{-D7-rR{`EbOeb}^I1E%l8ANyrq@omkhPI(SymfdVs|vaa`?e-Ls4a!kHt3xfT(cs|Z2v_dB;uj(rbx->U-F|&Maw%>JiRKu!_ z2(zPzU{afk?aPr$u3PIEoGK(h*ps!nCdPsZ(E%dt79-|FE2p3kF^y!RpC zLMMIr8?m<+62VRLQ%Cy1@4M6$7+vE`K}&x5Iwg-l5KvxSCN`>n~75k5I<|H7MZq zA0m=D6R$aZy9E%TD}!sc&{ig`I_a?YekRAy@nv)_eucoKa4<;k)B}$J6TuM2KE%Y; z2OC8pkn_J-LVN3qvfz;d`xBv)L*3F*7i(p-gDJF*hgC@`>J%dO$9t27E8Tnpm*zCB zkJhB!Fqe9(!JndkIzGYl(9$U|DO|OW>yn~rv8wfFiS36#juX&gciDVZP4;apm~zr# zMyGemIhGUWa8*Qg3Vk|lrF(9g=-7X*db^ssgwekaj_Bm%e3&b-msEYuby} znLtP{`pJx)yR1eq>7h#N8~n{4mv_L*kbH4n{e@*+*R*igt#;-z9af-SdIcaMEap-6 z`jRMO>fna)?Y_p?_Jgq%s0D_R&_)_I3cz=gEZO*A8rF1&SyjO(@=lPA>@P|mBChR&5V zf5>p8!IVM=)72N zldsI8u^Qv!`}aoFCduJZa}`wOnoz>>)s9i+U=$hGggZ&ip!6oU7yUP};z>wFTmEoE U&ydZxkgadP%VR(74)sL(Utevk5&!@I literal 0 HcmV?d00001 diff --git a/examples/with-swiftui/HelloApp/ContentView.swift b/examples/with-swiftui/HelloApp/ContentView.swift new file mode 100644 index 000000000..57738bbfa --- /dev/null +++ b/examples/with-swiftui/HelloApp/ContentView.swift @@ -0,0 +1,147 @@ +import Accessibility +import SwiftUI + +struct ContentView: View { + @State private var name = "" + @State private var showError = false + @State private var greeted: String? + @FocusState private var nameFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Image("HelmetMark") + .resizable() + .frame(width: 32, height: 32) + .accessibilityLabel("e2e") + Text("/").font(Brand.mono(26)) + Text("e2e").font(Brand.display(26)).tracking(-1.5) + } + .foregroundStyle(Brand.textPrimary) + .padding(.vertical, 20) + + Rectangle().fill(Brand.hairline).frame(height: 0.5) + + VStack(alignment: .leading, spacing: 16) { + Text("[01] with-swiftui") + .font(Brand.mono(12)) + .tracking(0.6) + .textCase(.uppercase) + .foregroundStyle(Brand.textSecondary) + Text("Say hello") + .font(Brand.display(38)) + .foregroundStyle(Brand.textPrimary) + .accessibilityAddTraits(.isHeader) + Text("A demo screen for the e2e examples. Type a name and the app greets you.") + .font(Brand.sans(16)) + .lineSpacing(4) + .foregroundStyle(Brand.textSecondary) + + VStack(alignment: .leading, spacing: 12) { + Text("Name") + .font(Brand.mono(11)) + .tracking(1.1) + .textCase(.uppercase) + .foregroundStyle(Brand.textFaint) + + // accessibilityIdentifier is what getByTestId finds. + TextField("Name", text: $name, prompt: Text("Ada Lovelace").foregroundStyle(Brand.textFaint)) + .accessibilityIdentifier("name") + // The prompt is not a name; without this the field reads as an unnamed textbox. + .accessibilityLabel("Name") + .autocorrectionDisabled() + .focused($nameFocused) + .onSubmit(greet) + .font(Brand.mono(14)) + .foregroundStyle(Brand.textPrimary) + .padding(.horizontal, 12) + .frame(height: 44) + .background(Brand.fieldFill) + .overlay(Rectangle().stroke(Brand.fieldStroke, lineWidth: 0.5)) + + Button(action: greet) { + Text("Greet") + .font(Brand.mono(13)) + .textCase(.uppercase) + .foregroundStyle(Brand.textOnLight) + .frame(maxWidth: .infinity, minHeight: 44) + .background(Brand.buttonFill) + } + .accessibilityIdentifier("greet") + + if showError { + Text("Enter a name first.") + .font(Brand.sans(14)) + .foregroundStyle(Brand.accent) + .accessibilityIdentifier("error") + } + + if let greeted { + Text("Hello, \(greeted)!") + .font(Brand.sans(14)) + .foregroundStyle(Brand.textPrimary) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Brand.panel) + .overlay(Rectangle().stroke(Brand.hairline, lineWidth: 0.5)) + .accessibilityIdentifier("greeting") + } + } + .padding(.top, 24) + } + .padding(.top, 48) + + Spacer() + } + .padding(.horizontal, 24) + .background(Brand.canvas.ignoresSafeArea()) + .preferredColorScheme(.dark) + } + + private func greet() { + nameFocused = false + let value = name.trimmingCharacters(in: .whitespaces) + guard !value.isEmpty else { + showError = true + greeted = nil + AccessibilityNotification.Announcement("Enter a name first.").post() + return + } + showError = false + greeted = value + name = "" + } +} + +/// e2e brand: dark canvas, square corners, hairlines instead of shadows, white text at falling alphas. +private enum Brand { + static let canvas = Color(hex: 0x111111) + static let panel = Color(hex: 0x161616) + static let fieldFill = Color(hex: 0x1A1A1A) + static let buttonFill = Color(hex: 0xF9F8F7) + static let textPrimary = Color(hex: 0xF9F8F7) + static let textOnLight = Color(hex: 0x161616) + static let textSecondary = Color.white.opacity(0.7) + static let textFaint = Color.white.opacity(0.5) + static let hairline = Color.white.opacity(0.12) + static let fieldStroke = Color.white.opacity(0.3) + static let accent = Color(hex: 0xFF8001) + + static func display(_ size: CGFloat) -> Font { .custom("StackSansNotch-Regular", fixedSize: size) } + static func sans(_ size: CGFloat) -> Font { .custom("Inter-Regular", fixedSize: size) } + static func mono(_ size: CGFloat) -> Font { .custom("DMMono-Regular", fixedSize: size) } +} + +private extension Color { + init(hex: UInt32) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } +} + +#Preview { + ContentView() +} diff --git a/examples/with-swiftui/HelloApp/Fonts/DMMono_400Regular.ttf b/examples/with-swiftui/HelloApp/Fonts/DMMono_400Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7efe813da72c7b1c0294cae782e88f262ecb75be GIT binary patch literal 48852 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 IKfd7q0nomVmH+?% literal 0 HcmV?d00001 diff --git a/examples/with-swiftui/HelloApp/Fonts/Inter_400Regular.ttf b/examples/with-swiftui/HelloApp/Fonts/Inter_400Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9a53a64e570218c116ccc5712bd9df613411622e GIT binary patch literal 342408 zcmd?S2bdMbwy?dbdw19~B*{q_kPH%sjAVu!B!h@#hCITMqvR|(gCr3UNsdSokt`xf zLCtY(GXs z{Z1;a8Z@lXu<^lP3ks zikW$e80qdswr}08@PXzVxZaBE#UcqvJu*`Z&dYF~EwWp5ziaESeJ7IimdMMSyY^_? zI<&M!ny;+ne0#Un{h~t0$vxs{Bz{=;*4^57Y%rsNh}}@czaXkdujnIPTK+Doz(|py z)uMW~kBYP>jv{^{;yasF?5Op=Nt}v`jg_PvCA7PYltdCGjy_B9r_Tv-c0Nfik}CQ^ zn){I{SGx%!XfJo`yL?iy_=VBE-$+{KH!%ZoF7_ESMaST{@Iji#UAgyrPE9eqJ3JZ0 zRRZVq2A+`PVgxcPmBaKnAY zaZCDY;MVfh!mZ=0kK54K4Y!A{2kx7`p19FIa`*N14Z$7en~giy_df1g-v-=|eEV<@ z`Yz!9=(~b@)prB;mhTqsuf8XuEM*~&6>5dzCb5#>CbM3^O<|?NO=D%m&2DAK&12=k z&1V(BEo335Rm>`eTf!=h`-)W#x2jbYx4Kmwx3*Oqx2{zWw~5sRx0%%pw}aIYx2r{2 ztcBKM+!fY)qU_Xm8r&>)7ToN1dE82N72H>C%5FEdTjI8|-^7i!```|@N8paN$Kg)2 zC*v-$m*T!_>(sW{J8*Z}J8^f}yKz6aKga#j{u1|?{Wb1s`!wzq`-Yf~I6hHML#H8b zH)jm)I0wsdraDt`r#sVeS37%g_d5r0k2ptgk2%M1e{$~O-goZP=1do>a67u><(_fR z;GTDXB>X4$8vdK^P24Abtk$2{pIA(P8h={+>HQgSGx@`Cv-;7tKc^q7@E7uBMraMf8-_N-Z4}y= z7OGpWNGU)clFI(czG`2yf3~lqvkT-hF=SH6;|>l)|l8k2kpg*?j|X{5aIQ_!DQvKf1V{&e^k2mR^s4+{D-NP449 z(4P^1uw3dzNp1x7qcTY{BS$b!X2SJ|K)Fie(4Sp$C`Mi{og9Q`4f=CRa+NIT&n3y_Nzk8L3d^COzW^gsT)7G|KCKUi7m^TJ z5%d>+TED6YH83w29?tkbKIkt>I>UqhV&oMS^cN?c4ncnjNh?A7VeE1xA{br@f1LcK z@yF?^4F3GVIIrN3(_2~mnS$Zv@TU&?%OhJz&|d)`1Dy1bZqi!1OLd9n*i|}7TS{L7 z`Jy?72ag54W1(PpAt@d_FBUuo;??!ebP779Flj()1EjrFg5jz)@}q}N^=aUSK#{IVx&FZ^Mo@N7D|Or5ZjzbawE67}+= z?M8~a9y@^6VHi@>BImw;%BR*d`Dh(=;!a(Q?a^TvXuY;i5Ymf~ehJPykghJ{UrE-( zySIV7Ner!XT{7+OLHXOGooBCTU3EtX(O&Fm^3nGvjH64^Hdue!ngVs*i(2SJ>TSIm z`bTjp6Q>tabdxstIzE#}nB*d-9;DvOt0}F~fZnw2_T)-#ubpW5bovR)5JsERQt4Kt z+fgt1XE{C9thVfq5X~J;$R*CcpKn20-&zW7`_a@(FUG0@e6$_uI@0>@&XqvB)gjt~ z@)O#V^aCxU7ZOGUnhAR@g@D!Q@@fqPELPWF1NsSVHBU?OG-cf) z+j>%I8S)XUv3IvFgO)N(3L%*$EP-UtO0I2ROVe8VfVRfg)R=C230p*K&LXh6SG}0g z=p^jFsG;Ze+LPS7L4Mr!=(#@m>yX}_R23*iKqI|4*JX-}UmbtI9>ZwU)jhca){^i_ zT-mffbswnH(pJ-xo-xd;m$3L0^OH(knlB?qd(UdMK6?I1ui?mBkWV2g9ki;K86)&3 zoSwQZSK9D-nqJs*=_S0sK6P9hN+*c_eE!c$^E6%Edq&YW>fS2QUxzVgsXn6&^*t*^dkaL{Ya$Dt9 zHQ#zxeWN7&f1MAdjRp2KY2z3iZ@@jM9w32ye$TJtCzShd-tXLz@Pt@pB<49%vO`cN zKI~f=VEt>Nyg2(L#y-ov9RHFFk}{-_#Atj+{znv#0=H?Wh)fWL$FD{}oRD}qPue+JeCZFHEOED-Kolo&S{ z#|cIDTF_A1xKm&y%zy=i-3(x_h#jfp=`{XTTph>AL3wNGW9&gVVr-Pu9DgG`RA0k? z9B#oAxJ_6pm=z4K&haAWU16>fE{izEnB}Am;RnrO(#9N%j`zSBH~>fCr2v8SNcU9i z$iO`TIm|KA;=e}hgM{~4)IGZbZEWh^rtVp(x3P8I9|{l`*H=2Wj1OCgKc6b?e41~9 zNBmYR>f{}u!*)NE3O8s+e2Rry%#9sM3@z@QB zu`^+(2}%3hdBSV|xBWV==ZVYXN9;ETdRBV0SanO@K)}yYM!yIhj z=RlYaw|!48$Hie^aKPUpoeW_AvHAZpixTGVG?C^B@m-e4|3wy&cL*%XNTjv)ugk{{V5IwzM0!VGFwSa@|9iAEuE|fvJxOUSXZ%{vu@y)B zc2|xg;hL25j>NIINlM0wNDbqGcZ@s#({Yg0_w9mh=y#ip1;58u&cRveg)d@{JEfI~ zbiM1bAN#gxWFT-~?7F})5LexjL9oa?FN@Tz*!A!coP^UsHgUYI<8a&zm*5-tEr<>) z00)RuKbYQ7&M!mEQ*=JD>&a)3@rjhv*NuGv@P8ac-*Z{Z;%k=>ii2s?O61K+-9&J$ky zzwg(1Jhal*g%!Uix09AJNpO!sA&?;iOtC7*4q-&QgH)%%};_2VM1PS?;c)uNx! zj1C@aNeBFq)-;ZB*Z%65f_wjtZT}~6*e3{#N3r{Z$8{WsK@{WB0a%&fcmn@Pt~Cf8 z^|%%L8hi~gtT~GLcs9ZsH21T8Fn#7^%JM`a`EG%pGyJ=K&R@pIZX&IL^#3Ip*P=N7 zH{$zTB78e3!zjjr!SVQ+KjpFqN(FnFR50&K%lNW2ri>e52&}`VeUy7%kj=D_+{g^R zkKik=cM1CSc?{{JCu=DA<)sfA4f_M(zIQl&0vzobp!4X*{W*xw7~s239^kZ>+)h5u zr%5}W;p_3LfUgmAidJ4&q+JT#MR3n1;?qWaJ9u`>y3w0McaTiJAEdjPiQ{Lq%LP)4 z_8ZRqrS#g**-ATmRU()#hdWCppN{LT!;(sOJD245_MX0_U29GP_k^;$ZJ$Hl@!We< z=D4iut-~_McOv%Z_pnl-U zbLqhOUxr%+Rh4^tGT|%Etd+xKH*Wv&3QLq`lD>8?|LIwzpUx znO`jSrIGq>eyNYYKK>5ABpj1S2jgcs1+Cy>kbxWrnw?|{45Z!%aGo&EGuQaWOIG%& z`WY9QPpwk6kz00ZzqwYX_;^rDnOf;@Q)zs|R2<>>TQY7;g9Z50J|F*|#Whk>E=>nH zPMbMyj+Vt9Ev28jfh?(|9_RJc0qJ0lmkt(vuR~cAo~xTO+5AQ(8+GXS>Y$@*=x70H z;*Y0u;F@=i-%KIxpdMpNJE&*Xl(p~+91e1kqh>c8Pk_Gu9;|@>%01`J6?{?RhV=B< zOWhgLlYPV8RzqpWp2W&HXqWYUho!zX11{4yU6GcIxh>6iWw^OSDw$`bj+!Y=`My;v z-*M>1KW)uWO7R($?S^nmLa4H&r^CGhwZ?_ZWqJP9k0#D2HtGI0ny+oH8Biz(Qze zc9(Hx51GJm*8d;MpvFCkGLuOiGp*DzGDuA$M5-INrLCEmvZjVC@|wnYAkk(b^soo^ zNmbM0p5VEePWlr++DtE_)z2~t%JBWZVbB4Fz~ujtJWfhYd%x6lR!AND2afMbTe}O# zB#d8|yzsWp80>qqyk=kI{B4P5ep}N%jeiK?V$DUf!jdh2WFe}Ijju&-&kw?E8<>`w-MF)7r~ClE5As0Y!`D2$d-c&?6J%rH=+7w!VS8Mf|0?}IrI#Qtoi}s%vrZZ{ z*-b4SG)^Pt4~=B!pU%Ujd|bc9ap&{6k*rk`GEp|Vn*1_5o))sz)s#zsA-~(@DO(&Z zkt09XfinIShkvWAg{>~nmF%BbKV3$z0S?6-?GNM->y51%-}eDRGf7&HL{k2lV_f+E zD@R@a{|C`?U7P2qx_vTF_nv)u&ujEtx}CQt*q8N^w_j(P(pT>n8F~+geH)ti9NPS% zAS0QJ>ABfrgE?9pap(G&@L9(*{^Z{R}JNbE@Z`KgB&$`e)CF;+`SV$FY8o$BKJy z!*dlYj^HyDt@pU+pK-^8&o7?w$L$H~=K+_4Jhy+Z_tU-o@NKbsSjWxMYdQ8~XS1KX zH-P8Yd(z&%v##IZ9`H=P=Nq?YtM^$wp6;(c-Otqfo;n2UU@67FgF;`Xl+?0fm*_Lt&x@Z3?C*I15R*(jU0Pt6*CSnM9`b(Xf- ze=3LGpVM;iJqB%8djCc51L-{=&yKXMuxI1#d9l}{_j7c;@f@X{UPH&PpS6Bix9T;n zUeoG5CiWL}op|Zv?*pI5o$DiQXyfxRkKbtI39NVGt}(9<#LK}w&qEqkW0gA&=Ce~7 zk#o~dT37l`` z2+TLOXqX!u)BKHoWm1BEWzuu~%3}3I#+wJ9qR->d;p`R0gdC>b@_iR8x%AUV>d9ME zllL&@`fY+lbA34bX)*pZ^ee--Zw~z&{hnUe+cjyIDR|y|Sh_ovSkt6tEs~V@~NsuPEO9KF9|vX5iVm+pG)ZDeIVd{)NNSH$@F<}l%H489*=F64iBa!5sQ z|5Wdxdc^*r`(v-K*8QvQ+jT#x_lxNh+1rV+hD&PZ%Q1Q^aE4MR>?8SebN_2HMDP9T zeb11E?1|`ol(_qYeW>0aCC?b$KJ}igx9^&uy?X65*halRcpdqZ-!pq(!S?85ugi{x z3e?L~`k!U`k4J*-obL(>@PC+pRJ+JT5A0fKgRBf9^Vhv!}}8MKPKU!_oB#--s6bq?~9_x5vMLAY* z%Sr|PY*6pdvG3JFk2SQrhSry|k#)}y`X!+qrAMY}w5ze^a@P5aq$&1Zi$2NQ#~}Us z7VXRE%pM2fdf&hvCS?gfMLQksg|+A2ui3jfE4ei0JF!nFYg29C$a|i3Pd}|IY_}!t zp_K=I?06|-VRs{!6!!e&sUq25c$K&-WE%Gj_pV=%o`$Bfm;OE4%yA8QbTG0ZYXV5S zpE=vhubP+UPdqbyo=dFF%E#U%UI+HNQc{jN)=BF3s`O(G#(GTicsR!ca25{1&Y-^( z*D9jAT*2@soR`N>_#>7_@vOzKiGdYBT911I%eP0ZU>}SHu6toyId2>j&t=o;vF8m(uY#vzD-oup#}$v^4|AHvz7zXV5pkFL`L#3=>2 z@K=L=q>s)XH8+pS3EIGRxQE>}G_Er)tq<%GsGG6p^`3!ueq5Go{cFERFx-oy!@RnY zJi4BOdk!nfkFq|>VOEn~8vK;aE2ox0mlv5Ihd~`(*E%o8Ddr)M^Batd97*E|WqsTM zD7P1PvB|y#@}GyxghlK2M_MaD>u-Dj?>ccFr)Q3mD{hX$cS!PRkav5Ki^c%1vj+mr zykmLVSpw*9_IWzW$#@%JEdApUa}Ik-pmnFZ(B~spTz{a?ks}FvRbkA>Y~v((ERu<8 zDSNEtWQl4gh1Ey+lkmO6PZ>`?XD$(==U1_txVO3LA!+U8^wp*L-z~;fi|2!ajPzsY zbldqy?MAm1;+;T`i#>AFhlR)9GK-K7`#H3&mPXas*U@izeboBVwx(r6-s^A%F2W72 zX`89Veq>?x#tR!ZZSyJ|l4|G#eS3dj;eyHt;ZO^xJ2ePqz#7;Cm*5e1r-Xb^8CpOz zOoZjI9ZtY?c7J`45yGJsw1bhb0Ak<}Tw+8Zo-ZX}&%W}|7`nq~SOlBkFkFTw^em|% zA7CfeV$k0_KLF=}>o$4XWuOHNf(5W0&cI#%ySg9@0NSFn8n&{FRRn4ReUVE)<4)&) zyUD{}29VvqQzWDfa9;@bg>YZUE;t1b_@C@y!2hTxvY<7551c1X4B3HxCUGO^1|xvJ zE%7#>Hj*ev2Zf+IaDS5iz`Q=mD)-2lSQsG~5vh;~#dzUV_rl0J^{km=7Pq7w`i-6v>hT z@@HhF=n5lY9;}C>BH76|JNag(4zp8-*(rDSFX4Nj>^Z8!8_*Zt2J*=9 zDIi}CR(Kui$5q+@X*GC~NMTuoaHOHKrm4EP*X>46cggRgew} zL3L;iZ^2xk?0G4B-V5-DNWK>!H&lRT@Ft9dC9nmK!ByTZp&$#Cgjb;xyajWCcrSkr z7vK+({G^#bH&lRTK$`i-i4>r$1t@C)%2@!t6lek+VJM)hf+c~p3zBxhLn4K+*Fx2x z6<$xT&0k!6mpe9 zu2RTVYAx)8vv5zObP~t`WuY;2htaSIHo;-I3{OPLq=x(u0WF~qOokP(15UyX-T{J6 zU%@_KSpYF2WwG0`^p|DnFU!(jmYo4>V2?;Su9f3jIj)uCS~;$jTMk#4au$cWK;4w5 zZpzPwb+8}K!F`bmNr676LQfbEO98vAfL&I=E-PY}71KjuAfJllQ;~csqMwT7Q88Ae zQW_`#RpAXF?MkFw*@5;VRs4X>RlzQ*U>8-eqblQI2~f9Hj=@!t2nEE8AYKIVB09lP zmbyA%=sZO2L zNCG2ZK43RBzJQ-aYMPJ%iaP8!Wi0CF^sgdu?3&5^q~ayLHfa8E6QHL|WLueJ$nzb@h5~r~nA@I(7Uy_V7A({Q5fBuV2l>`+M?1WoQA>fLw1N z*Bi+7#tFcVTXBCY^xqo!TNAG}@ml{b(uTTfiyUpo0A*-Ly|m+=cGpGP`yeBPLoH|r zgJ1@be*3M!J?%-qg8}KGFd%;i%H3f!EP@y~1ef5ENXO)WE;}Mi$CiLDJ0f#O>ZT)g z)A6?OwOz;w)PE#46^WdY(_uT1XQ$#&7diqu=|mgp^gdu~onL_AfDSuvgoA)CJ3ruk zNAz)BkgE%I-Q{Dz{<{1q(lrlE73pR}CMX7!xf^BfMwz=U0Lsyga&)&K0~CU)uooC7 zyZco;^FmFre?~MR}uXE74rP@djRp&uYypCV8l-hke`VTiQ*l6GIp)0aH^Ql7q)r|&dC-+j?{U()P* zPo!TG$OfgLJ|Is&4t^gi%CkwGuQ%YY3ILU)6{gfV4+V z5gA<#S^>HptqzQqGy^cUCIM3)s!99dJ@)HgeBK?%Bva z8@XpA_iW^zjXvgJCv&iqInjV#=R6Xb+XArRc@Fdj>}KA(unoR~pV=BSAp;bF>VPcs zk$e6aK$iLBu^=T-4-2T51*ExvJQh$F3oZfqEW}n8ZWLLBju)ZhMdhFgP^Lwsv*;c8 zP2?TQ^bX~H=TrEV?J(?T@pO?Tq`w3?ms}TbYj0^rj^R)X+QA^00qA(?9ykMcMV4{j zGU6?32+=SDV&DYa6 zF>oAiiF}d-vOz^4txuxiy2y^~KpWoC0J^|iFdNYSj{QI#?4S-lO$;wVNq7}H0dYT_ z3mZgsqSu`}MLxqOKSRdPknuBQ{0td)VFSB}vkRNuZ2|3LcOj?_t)V|m1$4IiGx!c} zi|p}379h_(*ub9lFc7AT?9B+|y_Yoi4TCwb9uB~H_+4awGROtxfimxpf+;|}{YT-J z$bn=~5b8k`Oo5d^ItNJSbAir$)yW4v;BD9n-->*}eP4`*MIwhX12P^W&LQmJOZ5F^ z4(JLS;f~1RP(X)=i$G0y4SK;Qkt68s$ji_G+5&kWZ4BLkJdTpb(JK(ksq>Rd;HtF=;GARBB!yH(?5xvc@c^N{n#1G^L-QeMdU29oF%<;t%3G&ez?d56R7hG zcSU|c7e7!}KOono>Oj0pH${GQfIKc&fHi=;SBgO$xG3^dR+uAlm3*&$1gAx=O&9qY zJ^j2Iu+8h}@jCjsj$VH050v2s`ni!4iUYFUK+YQ-p)ZVq*&;VnLPKZ=(J&HbzzU!~ zZXO28e9M63fGoF`iQFdL+oXG&bZ?XHZPL9>y0=O9HtF7e54OM;fV{WS-|dGYzlK5v z$P1;RCcFk+U?5C@`S1aJ3di6F_)X-F1L=T!?@&K?u(Lbp@D8?fXB3d{9r8`cUCMPA zneOg}(;~m6fdW9;exq!^T^705P2@i1yU+diiSv7As0gI{`z+Wm@<%l|#Iq;ld2j(} zmk$-Z09l|ARD#LuYg2cRus8i2=n-i?qRt*20c_*33Do0b>dt%Su!AFQ=rMNmq$psE zPtgC9Lv1iVR-eRZWu0B=WBZGiU|s6 z;Jt(F+AEVc?wP##k8h(Wa~BZC8y}R9FkgF^3AcoIID&rjVGbZ)y9Ts??l25U*Io{r zfcG2Pr{Fp~;mwB0fcFqO)nNd94EjBWAKDGY>% zqLPqD63U*mAMjShWT}BS6DFGrCq=!mS5)$yqEa+~d4NA9WlPx7^X=c)`D;WaodDkJ*H*bP>TdNDJg zzZZE!W2Owy5q85*qB3(&=4iMlDhzqUW&^sr~*x(6AXk&@D6MM^3MJ>T!BAC<>1|%IbMVUPyr}Uj`q+O z#=(4`tn7EH9LSdA2S6V7JXOwgkQdNZ&brVVdci25EIH9>&Ta4|oP*nZc_4 zl|;`ayTM?X1PkEbP|@Rvija>!nea+QApsH5`OV}*inQWX1=sv>DuDi4cARTdzA zl@zc@R0Q@GQ5>oQZ6l&1VB-;E09%cC4?c$d@C{sn`=Y8kkPh;~D}WtU?E~0F)tzt( zC{MMdPy$*3bzBWOt341^oqDgn7m%%n4R62!Q8f#}t3bXrzY|sKO;NQQi>iaJ>%0Tl zZJkG=>Qd)*XTm4&L{vS}e|0q+7gZnG>K6d?(11E@P#SiKYREkek+0!nQH`3yyKqEQ zOw~t3bSDy?1yu3UsTJakQ2&56H#vzfU2TerG$Kdt+#3c(J&E~!*)Oqt(2Ktt#XBVmE44%BIfLa+?zk2`z?Ka1)}U)(VR z6am_H$F?vKro;QN8&1O=QIVnW5|oBl0li0}_eg9rl0GDoxRKa#r_4ZJok*|K$8ZF$ zi0VuporeJZXqQI7{axtq*^5+NJ_GLUa$8haKV*TDKt5gR)4CF`>s;6Z$Ka}{ZVJ)? zd3CD}t)V|mg;nqwdrTAx#OqGH9>nXB1xmuJ&dw)ZQ9&-7f^AAqy0Ty3ig5!c?G4 z11Zx$WF2@@)F1~a{~+QHBJQA$FcfCPIw1c+JE!+|{oH25E9-wa-&irCH?Q=Ne!0>JG6_D?6bU7k1 zAmfMvKt3ZT!XjV{8gWU~$d>^5M|J{qH}ZY>1dhNHQKQ;GPheaewG_4j_l~+IYP13A zp)gc~mcaNrnmk4?2jY%CDr$^^7a%uOfY$(7$1Db99fPc6Ux7x@1$Mv*QR9$l9Qqvh zo2c>VZ9MXgzbTODPXa;Y> zkD{h_gI`5Wqa4%vikhAaNMiNxu$_&^;hLxq4M-2f z{jfd|9#a=O!cah-n02rpkR|56sE;}T`rcF%sGpDP!0)0quYxUr+?%gJtf(z%ASaZA zCeQ=M0O@Y|0QLelwuQ27b%46rS`v_9EBf3z3P_v%P_^wXpj~apzdZ`Z!W>u$$n{Al zcqD4a+dvsVwctZhJF5V;{aI&GyU@!nYt`aDV< z9PJ77AO`lpNm0kRevIqKxPELLkmm6ekR2%R@hU)C$94D*qP}vW08qxS(9u_<^VL;R zCz1m7aN-RZ1C-|k_V;xd3;_HmeL#7?v4ApsgKod2{=V%E^aI~zgi(Opr(OZ>J4L>y zu!YmaJ58RacZoWKoZnXhbb6LHdKUehdmVO)I#2sJPdeu-!$|m26nmZO0x5mO1fRhTQNLt>YJd&@LV14q9v+IiLBDgO zF0_V$uuRlV^0`U<-^v8k>8({kws9n_+vVX+K)%~Efx5U&*>C?Q>eoWB8-4<0yhGeO z&7ceH7j-u=R0Zz8I}f(QSy8{0hPk5dWdr27M|-|E6+VOuqV6XJ>f-(oSPeJ%8KNA} zAAS+_$7_HL59$H-_kg?~js@zLJxTRwtEk7+!{gqfo)m_?{OV6FXbH>3kb+PV=D|6* z39(|Rq)-f60>5md4vAq{@CIxX!%PW1;S^klCt~=LK^PPU()5ug-<36Z3yI+)U5nq` zu-XFQ)*9FWN5!yx&;&ZcKv)Tz;UJJcdx3^Sx=u5g1_yv}=dKv84QU|{yb7f6_J%R= zJ&-T|*>3n#KuPEf@4+oGLJa5#lr6L=M8Ynh{D~r9f*6U3lbHJwN5OV6lDq_Ep&pP{ z668s;8^|YV4Ojulp7bmDQH*5BmMj;bk7UU6LKb)h8bc2l2dm*A5I;HPOisGVN5d-k z49z7JLFn#7LJ0$TuDLq(j%~1_QFEqwNY`Jyyef!^Ef@ zQLCW}>)JZHJ6|T|Hz+DqXc#6tYS*n5CY|fotsN$**|Cn5oZLZvVv0{JvBhBr+t064 zgi0bwEJ-A(B$F2;xulSkl19=>I!P}XB%{12nIyA>NfyZ}FG)7xS3XE9*T0=+>i>o` zQ~ixJeUw|5KgYjO{(oKi+^t)8jW&L7tKI8uTlZ>jTyER8Ta#%SE4Mo-*gMoZk{Mh)B&MlsxyM%K1{3KcYx;1*I3a0}CQ_bF6F9mfq#ll6aiY5RLN?W*SEueB zRI6US3Kmk0aSN+jxJ6V&+;CL_x2VdGTTErcEw0kzmQcxh^={Kkh4ktj)r+5=A~kt3 zpncCC!ZQ+VsI2Gmj0k^e&n<;LDXBp~2G7UyHPgZerZT*lsFpHyw8SA~u@^ zd(Dom<`$l5kp@rLNTaBf^zvy+P;Nrv{497gmq3EYF4UOiqP87gmQbo~gzE>3nMwmMK0Y zAWJR6cs3UQr{mQmjOSem{^;So{z3N+A%rV=8P_NIrGONcBKQq2v{|DEuWqVWt6q+B z$IGoRH>*sEG8xMlC9{?cDH&4YK#7$l#uh(Vyle5);b+1Rhp!DE5?%%}g%2t61kS)_ zmg;UR>x}*vhcNVQDkp%hV!ONXmvOiY0%P{95vp$tM;+m^{}D zhDPIMqqvCtv>GF2;oVOM;me2L6c=1Q}VS=o4Gj5d0zC+d)zqjD)* z4hZA5CpmLJu^nawJbTlwE~87S!X&Xd#vE&oGsl|~%!%eCbF%riImKP#E_Ii2-SmF@ zP0XRZtI2SOx^JPMr*|h2pE1^$U`%uu66)iZ=B5}^UFHk%>DS`zsV^DXjO<1ZBd5WC z#2I;vyhc9bWh1{)z$j=GG71|-jBul8RHeBtWnM=Z&WZ|a_6{n z-Fdo(_*FZewHoP+^zx#S(a0#7-8ybP%J4M5jP&fYWsb~cwlQB82yexecVw|!mHQ2u zr2kLLP2r|^rWC39<*VveGrNl2$DVAz$FHFsBSqILXVtSS+r8~c_6lb_GlHkN)L>M& zCO^w{`9*FppSdNswmfea~I#u5wqq@4Jlh)cRC&h6um%w?TaFaCiJOI?g6Zjhn_TVp8xK=Zznzs~`FQP@i$dxFMF?iFdIxM~$})t@m113%iaz)Shjx zcV;;!xawLFR%5%iJ;a`6uXCn2ho8HnruCX#!yaf)x7Ro`ov*w*Dp?Ke>h=J8n*F{r z$zcxuOdd6?=5|%PpFPE1<;-wcd5}kW>s33#?rXnouXH9jpFej;HLIyz(e7nWu$Md2 zoTJ_yWvn`O1-qv`-d^U6b@n}bhd;SLg+H191%Fb15`Rj6Dl}kN2dvLUtV7l@&f+Cf z=j9jIa&6b;rxH!q?}oUcZX!3ao77F_zThTtlVhJx+^TM@o61e?ro2CImo58Q#jP8qWCO5O+aKqdz?n`biH;0?m&FSX$n{FOAuba>BbMw0e+?U;g zZXvg@Tf{BlhP%bw;%-s5v|G|G<(6?@am%{p+zM`ax1w9gt?X8DBiveUZQ4~`x1Rf| zJIkFNXn*c}x4zrJZRj>~8@o;2W^Qx0iM!Cf=Dy~(a9g@>xUajd+}3Uzx1HP8ZSP+7 zTl`93q~F%H?k@5>+AiEKZdbqSc5}PCQEqRym)paQcKi7KZeO>b+utAJ4sr*(1Kfe` z5PzsU%zeup>QAKmA$p_X?ihE3JIWpHj&#SnW8HCowp(|CJJFrwPIjlbZ@W|7Y3_7) zhC9>UK(OM zeV{&4o7Bf@v)ZOURXf!_bx<8qN7XU)l{%r$sI%(4`c?g=Vh!5}H4?GxNJ5XCKfd>k zFsd5$j26c0Mn|K&(cc(Gzy3D;`E28TV~z2FvB8KjJ~DP1dyON;G2=Vq46XXA@w0KA z9^jsC{ibR9%*19g^93`Fnb*v3hMOhLy5{R$r z9C49xK#zcJ64-<#*n3+9jJ&*lxE z;j?@Te4Bh9Td!L!tv9SzR%>gjHPbq6ow2^R&ROTJi`Ea;C3}(mv3-hP!@OW$;@2>L z@$U{v9+D~~f5@ni(II2J-(g%6@^Q$PkgXw?Lav1T6mm7>=aB0mzl7WfxfQBH4Sx4A zU1<8y7kP_Rd@E)gsmAYRzVEDYK5*7L>zwt@24|!5p%deL zv%~q++39@d>~eNHdz`(_KK&b;&gafS=L_eM^QCi`U)wy&Z*3lTzH&}DUpptAZ=7%W zeCM2UPCI9u@11kb`RC5gI+vUuoeR!I=LgSs*xmU>i#^7%olqyOlg>%+WO80| z3Oejq#P`HAq$yv>*}m0o!|efdnrjM>f{XRb4k&wOWrv(Q=OyyGl! zmOgj3*jeGc=PYxUJMVhFl@2>EqGtsO_4WT9ZRy{i`VVIq|9FfxS>dx1{?p9E8?(c_ zF*}<#W@l$s*@wCC(^@PqFY!@{%wtUfRzgI$1C6J;sv-1B&?O^*&urc-`m`(m|Ezycf^NwBpyTApo+A&g) zw!W9&Cwr>B2!1~`u1z(wnp>|i`>Dd*qy}@KKFm-CG83B2JY_obq4(@n_8MkIadsZ; zA4o0CYpIoJqxGy;tp?1NDl?a<&YYkt1=XZ?Y7#Xqy*zgoBar?cS%eO^<3&pA*-731ruS9~o^h_9#R z@iq0dPm1r&na5QN&Yu=~GazqW^%rv&y%u;Sus-oDF2q|AgnBE2jA|ZhwmjbIC$G2q zsphSIYOvlK!`nq!bMbx_J>Rb5wcYw&+il>r-PgSJTMKXf*3z5RckpKQo$2w*$Y^az zGKN0Cl8nQiB6$B)9jlH^(3ZtJ>l;`Ncw=*8tFgSz+_wV1KwR0bEK{+z2$_b(RhQ{l zU2U1cytt>##Qu8o=B>VVUzv>^4v;z6;t-k3oO!&=!zw4qd@S>AS%7s;lZ9C5EZ$DE z%w8t%V5=*5`^`#rW|v^I@5@qqoxM)CU0%Ei}ihA6|C6%V@&`)JC?Oi zYKtceEZOqm#;ucC>FQtb4*ZHYck=u2>Y=H3aaiQAKj{7Pt%P%j-fz+OmB%F(aoF=` zSMj+kTzkl!CcOrGO4LII{IqFrofns@ULnyJ+2!pw?Q!-}uTRpem(o^kyPO@xuY)h4 zr(wT6Ue00i3O!IA{k!D!4fUzPhE^kLu$k4yYO8;n+-hgFw<4`h`WMQr&Q=$zyVWDX z{3*(M)9PvUvZAftRv)Xc)z9j04X_4UgRCLeP-~d=mNncOVU4s#S);8n)>vztHQpL* zO|T|fldQ><=Y>C)$NRnX5Np1*P(rPDtal`dwZvK~Nv-A9D&lXjHb@#~7N79@Sj^lc zou0W#2Ig)TEGB{!-nW2L@(=^Q{FsPkxX6 z9ddowT4}AaR#@-F&uFe&zc8!089%SNYyD>3v;E%u<`3%ubDT%?vE@79nOWCFEGWIKWS!%ZezYnkb^=h=UnszPPSzWuH{}O-c-m?#$G2xrbDwHQZ!7!fR2E%50zNyx9=5;zWkdndr#0>PiPs7-iG>q{E z&&c9qvBzSJ`>WnpYs2f4#OrmG*Xx*AG}~12t!$D!NT^#wS%_;VCGhLJbT6i_@f0uq z8c&CK?$gig&t8PL1I76ZMk9m ^h=5g~l@7O+Je#0AdzB7O1{nS^?-}rTz2j)Y5XH?75 z26W5S{R98RkE@IgxCS|nBZh9Lx}EbB5q~koaSYwcFZb?X~t< z`&rrOnfE`iOy=I1oiHbhla)DmHYdB2!^!CsaPm1XJNcblPHrcUlNUXPnCHxM*sX4t z%4?TC+Q>4oXcaN^F`KOIyxQfQbxu++>*o!ev_ijRVUC}MI@0}K6@PkmDlWMP-SzAf zv|yww%sz#%COORdXEAg4NT;Dwmi!Yjw&bx=TRz`i-$mbH-)7%p-(=q)U!<=-`5JA)b7*SyG@c0Fs}J}R8mvCF>ODcL0} zWeRm+up|0A+GHg4t?_(9e{72Bn-Ow8 zfu~ZwHX&={V;jC&yqQa}mdD4N7InSp*oH4$A~?eiLWVsJ4e1=xnX^2E>SG{oyfuY1 z42J0)J)T{Ptyf^dalr7#0gJgvE%DQrH>SsIYPDdG-xSAw^7JFa`45J`C>BWHw$I0X zq#d<-nSA5&w(LED5359F9ZYb=XRp>D$xrzD1mUJPN~b1tPJAfxS}Qy``Y;y?ezY7| z$kV)RyJ_6VljGY2*DQKC@8ij_J;4>9omGE4IhG~}*S&2B&-0WuK0Z{-u~N%Hza9Va z^r4?T#Fg1*4H5ikIc#mUan~#>Ch+m(xR&4wJ*fV8a_}XL_&ZF-KHjuQ&YR;ywH(Wl z!|MZdhY*adYjnA_ni~DY+K-gy8Pk$m_ZiJnc%=!Wl@?%s7yDFq)m`pX#=fV1((x>m z)%mx^C9bVzzTo-duk27ejDuX^8xi^#3^&vYVsVQElsmiPJGM4l5Fu)5lm;urg=y{;(;DvZ20?+uA;<*=j zC{-Ck@W;!c3giEsYdQFo5603vN0(^j$r+~^y0FBX_LJZ>&a~`2ht^jz#XsZ7$}^6N zMmx!AbTCFqV6NZUm}E?nF6_%~e~)$`)gX%%G>-EAtpVeu}-Z%eHpCeFxb;!zKl`zsW=WJmVv);DeHkz_mHP2{ft+mz~ZSC}SdZV4a z)!t^bw?DBzF*@3x+B=O%d!N0}=xiUbzc9MkhwZP89(Jr9YxH#rJ4KCtPAR91G03Ut z)G&rRwVnFLSf`Qmx-r>(;`bS|{i*#$j79!|{$<8?{{jCYo^Q|#smQ0F;OIe-xMHh1 zaUWhz;yo|*!Yx%S@L`WQ@SIo2_Ng%a!Aca?qyKdOkVqwBZWovpX-i|Y_S}U!XP!Sj z{RGCXzba}_^$DShSyudN^#2g|9&lC^>)vixRd=tc9tI4EiCJrgD4?jAP%$AQA|fWtia8-- z!mOAB*?^c36DABGW<|`38BtLYXGjid%*u8v+~@7Ldl|NSpL5UszH?`OPycIGS9Mpv zRn?)Yy4qYARG4n234QQ=86mI@qfT0|wI=l6cHn+~MY>#?`7rJWA6YJCJ7IyIr_9BD zJ1!T@4L%NLV9QCto%D;23@&3H(82UZ9F}gsC->r8a;0l$Z17W|6;Ct&jQ*KvW2%kW zZ?3Y1j_LXpnJ!JWk^PH*W3-g}%249tle=<138j8{IJTSliA%%Dm0ZJ#Ybp7?0p|xP z2~7&^(j#puM^$K_r9)I=G}HZ48@iGo8*qQx5}U0imf{W=n-rRejkq7iDzs7av`)On z0?+2MAMR*F`JGy>g0oa3_t*dA3{{YO>EClEEjbmL<%^```GPFzBTdU_1vzjDh=zqY zT@$k=4Ej+XYSL#PImdUSR7#m_ zL6t{W9#q+{vS($t$}W`~R2D0nS063I%y3&i%CRs08Es6cF{yX2`=lWTGhM(@A@RR(Vew-iaFZ1X6!Twl(h~L}y z^4t5(eMeuzS+oiLmMiExTR^Ye`xUQMJXbM=KIrlE*O$%ZC z3s35c;yLk5&iK>fsod*Mpyg?Jd_yG4u1{53md|@;jC~*IGtXp zN#UL0IG%DY3(pM)hsTD8@T}7-+&;Tu-^>Ev`F0P|%<<@g+xWrj{*}t<(>^z>2X7Wt=63>}a>;v{rd$YZk zC-qD0x%M=knU1sv@!Zs#dx&iqW6{yJvg_IA^!Zg-OPRLO^mI8bT~1AxQ&>8lHD!&c ztP_1cQwb%%^Kg^X-_x@2)3Wf>vhdR)4L>alKP}Sm&u8IzvQ6XD@{_5Ce?ANUd=`Fc z7Jh0Lergt;yZ1ES)GYkeEc}!#{FE&Glq@`F-8B4^Ec}!#{NybBDi+u~+WO^pm&O&B#ub;w6_@T8+m_?zb{4-pZfDzIM^TO| zC0VzU+gaA;`_1jl(PsM zl$7J_At;D5v7MI2qm)coedEDI29BnynZfEh!L3#V?Ob8qz$%qUE@`oy9MYo7-9Z^0>L3Syzsm+gbebxVfFhFOQqs znXQ%M=5`jpJZ^4h@zc2MHA}O&)Xw5kJBv%>ic9S*t~_pTXHr~_o7-9Z^0>L3#V?PW z+nHl5$Ib05etF#7&f=HH&Fzd;%5if$i(ei$x3l==aY=ugN7%6(H@CC+<#BU6i(ei$ zw=+KFxVfFhFOQqsS^P9EF|+-OOYJN!wX?W1uDI0B;>zRZc4n{TxVfFhFOQqsS^V<2 zxt&R8Ic{!e@yp}pb{4-pZf<9etQ%g@<+!<>#V?PW+gbc3^Hes`+{!qws~EL&hB?s;pyjF$W5K$b&Wz_-k8u<+V@$sb z8hFZ^70d{x)7B-=c{lUiehtqe=LV+*C-PK%5F>*677E<;O-+|mSi1CRRrBAIQ-3)U z@t5OJRewpP{FXGTPA<^1Bimt3?wV3|mNByYjWNRrQnY<}O`J%xtZ=bgj7Z zwfUB%MCWV8=CTzny}2u2i&fLLlCQd7ap_udXEBER71}|knn|Wr(20BgZ|ow*+|8sXX&Nnm57-HItR2qX{@IMyJDPia zzCyyd=`M`1E^?pGi$~@svy2{-+2&og^bGwbQkKrA91Ua?;UQ*U(<|so%iwpm!Opd_ zc#@cIpJ1flopziZX)og`VlXX_hwu#1i?P_7^8`_2TQF|WniaIgEij)kw(vDlIE9g9 z5OZfWbR~4`$%({IoAv}$CCcNO)s-O z`{-zDObhnF_atcvTR@M{`@w6$G-|@bv}cW{RdFQ!I~S&VZ=LSFQua8PhGM_b;&lx#hI||%Hwh- zEc@kgITM!s^0=G{Ge6FRWmg_o?s;?{atD*U^0;z$p?-3&kh}7@Qm1pjJTA2(^OGy4 zu9fRw?#kmz4bA=XxKbO{Pp$&FD~~H@=-e-lD>X0oleksbN>a5nb4ebkpX7qLWWTv9 zUn~1nKdIfhD_<-1SK7@gP>C&8oKE}C&-h8pi?sF7D_hws`d{mnrBD94|8cKuo@(hu zuL|Pq7-;(p=_QiZNF(jHOvTTVK8Qbtkk;04P#PnUUQ7C^u4H`4HMEn+XtFW%1zsQD zK)cDf_@?;g_?GzA_%=r7J@bccU@^XDl2G&i`adb%>jr-g>sAIb*7?u>L(`D${t|K) zIkD)E|I%|qTHE|3+$356r~UqKr1dty7Qb79G`cJJJud&J7g@s8{3Xnhv_0l++w{K? zqWG5(JJQO=cf0t1DP7<4FY9XpN2^~KuEr8u-V4(bm9`H%c^joUZLzXW+V)qE?_eC% zJ?V(O2N?hKQ2a3CpC-ps;>T#QdOUt2eliW4mPtGP--MTtPZIVqiTUR}N;O zCb|Fn$OB`y;jEdb&)N++!+*mJeU`MX7OZ7nGIw(aGLSQFS5t$%zGGCHyjgT7E&D@h zOFojji(a&9rlm@@o&EUPJux z*Y9ECau^Nu=ik4FEBqmxhWx9~?_s5^{PAB>|F3?(hxdO9ogeSt`Tia!`Ik7Fs=xRD zJ)Vp@`qzI+;lEp>aZU748qabTl(A?g9gAktF$na!(04)z!q{|lmXUUHd>8?)drM<$ z$+on-ul~2^3^`kj=X`OqoIQvqqs0Do#JP;plCk^g_;Nz!TiuXz(K_+Ee_KQTT;e%v z*~u>Yf3@{P7@^fa-kKnn=)2S6Gtv>a=P=gs{P+UKIu4C5rd9dU_=@+M(hdRten>wK1+8rUv9FIclKYv{Sa?&+4&Uje-qfAC}5k}u2{kmZhH zTT-)~8BL4zMZfQvQnDRmonE1J`cXU4-pbKm^?&bv&$OUVa|$DY#?yOz4QD0kfw`Z) zo|977eP!1@W!K$h*Ii}Ty=B+Lvg^*W>yEPP_Ofey*>zjlb!*v0pJkQ;-e1gI6Ur_| z=VgB5%B~yBF5ZaG*4|KdU0-%xS9Zy41$vZY%C6C6*Qm0K(V1De5oOo#vWs~LvbEQg zU00V~SCw5?mR(nrU6+?#mz7!f=#hVRT+#zMxnPt}*W!LFt*J)+fsb$yTvWxi>vh6V*3|F~kxtAzeY0WE~oUVzo z8sVfqxcjI#0pl8|kAIA1XaE0RD>x4n2Bx)QQQ0-W?3!10)t6lh%dQ1wS6$h~=(TKn zUzA;Q%C67LuFuM@Ps^^^W!K!Y>yxtUw_ zcD++}yD;%dRKNuE)!+sb$w=W!IFl>(R37k+SRIvWt6w%#xGJt_L&M zcK_Gjqk4#Evp>Cc_NVvE@TOi@M%T71*!X?!bhHoo-ROG8&aXq?@SO1GaCA5%+%WCi?N6VswbSi|w9DMg zopoQ+EtnVZUF7uI#g!}CpV6#xpA<&Brml4KN4Np*VPSh_t7+gUWWFf7 zgb{0&hasUxgq($vk)dTdJmffuQ7LVJ+^2WvPJK&8Np$4tsTi!|mJw#OTPh*l5<)I@ z->_WfzQ$*aYm{xe8O~GK=Hx|r9EPKI zvk%S>Y>RUPd!m*TnzKXPAoKgMbClXUPEblQCG3 zh2fvRq7={ZpOn%Tca~|5CR$?qO~n+{;`w?h&pf?gqC$%f(#PdoXIM zE6?`jI_TCU)JnHTYO&P`wcM?S&v4gFVz{QbN4hH9U%4hM8yq#3vhC7yQiTiowcJUa z9WE^c(aFB-a@m)q-p0#qy@ZrI{A9SLmd0P%v}RiBGiBosxxiZy z5{o)WthDx7YF{$aQu|`!OHH<1S@D_4as3i}eVm)IB6 zc(1S=!pvSn3@X;a(DpoX2vRJr|!b_8i>HE%lN8 zpTcsi9mI0Dm0CHWHZ3}!x+e((h?0Wbwv(%(yg!vVBgI$f~Uu?&?a_nV(WX*E(LmKvb zmLtt~EQgzKB_7YjeR!i|M@C-$;VNe4rXi?5@lrF7f88?$hI*f4f!U^ zKV2EkYpK7~o@FMjfrgq%ilk;X(8Bn~9+7eM4i2YJWGMZEXVAWVJS}*CspW>6o2)d{ z*2?DQS(fPxeaQ;*G}~KZq%My%Qs2iI>MCh}4EI$3 z58*S+Okz30P-i(l>MUt|A#m|h)=Wc};rmqJl41BiDaw8rqs#9G^LVOD|9W1aO zN1C$}*y3_?J3hnBZ7fHcTUp*<#^e7LIotPda+dQz<$C7&H0E__%rR-qYb6fv2T1%; zEJv7;_%xWYq+=x8r_RI;rR>Udi&vyulq>lPa~VEM%%!+TnoH8HUo2b9 z@|H6MTRl7V`NKKe9F5O%a}>*E=19VfF-PEDYzCwuIdhp~QrG%HLr}+icJI?VJDvBS z9_3zW0`1)YMQ)pY(*5>hxx(zpmX?@3aE~>8SdKIYu^eL##D6(&J8-@*a@P34^kzBA z?1ukR(;fFPvor2x^eT#_wSTIMX}c$%3BR8x{`+Fj;cSKR+x%W? z_R@SdhoK|b;-R9X;gIzAVsmh+4S#nXHr!267B)}qzZrX9Zo1$z+-#brWE0#Y&BnOD zGM!mAn2lJLufwK2zm}VJX^ghA{$I*kO}c&V70BP}G;}RKH%aRods!c!uXsO1V*W7) zo2TK|OvA4sYt8C}A8A&T@N&g5at<3oYy0`Z5L(nvWF-1wylJ-gznqDv|CP;yj|jbj z9)I#G_)xaQ3^Jsgk>2DL^U08w-~*OpDGTIM@E&WI269eW#=8~dRqzhW5dmiwQu-Fl z#gv(}mi_t6Lnhu(RuS$x++R@+ zC8pG^QQVzNOpZJlmin$uug&j0w*Te5dvHo>he5<$8JxuO25crak?ZA>;B=PD82cdB zI2HGD&T*2C<5-U6SwPZ3ogp1kx0VLfEo>rnYeXP*tAVElvB}A4iEZHdK}zuA;HDYw{nb+)^N=V(c13zlP;=}1ygg?lVlIk9PFngUPU zmYf@9?~dg#(jvK1z%6%Za$FJ1#gzKA{?n=~C2%QEr7ju8ma9cs-!jz(jv+lCFw>!0 zf1ULAVtFqzwZ0KMkdpu07SY4(!YnxX960y?J_`j&Nz_Q?dz32us|NF zC+E+fc%G=jN8TFyyMM_^39tXsXJ&&^+<-kzvS-4Kd&!~6sVvV(&SN<&;mwrf=HzCU zJO8N;Z*REWfC9@^5EXsUIyrHut%ifi}Ssq?_ zILqTIkK-sPtMrBNz5;JbKN%Ewi|uLN0-w&Ef6V*RuLcqCOTWpR-EY~qxHEr`H`*$A zqwRyhGg4(1aX;ey>4aV(?n3z&%gFZRt?TK;e^cIpBRp?e6C&+#r6d}9Y7Yyy3^!o} zew(lut`n{iCSmASG7kScx6CbKZmEx%JMRtV$9s<1^B!d`syp4SjL09!42hSx^BI*t z*qzAx$%ipAe{a{v^>jNjI=?ex^IN#Yu4F9DB1ZbW%b5J<7)$dgqjc_Ol#cd$46?^F z+UF3)y7Z$*s0U+ex-wcv#{9^r{Fp}LUl{k(XzCbQ^R}7BJYG{6^)k^+ppEEyM!sCd z9fRSj@Td86{!RbVr<7La)k({1Rk@$(PW)9*8CNw1vrJlzr9NlV6@K=dFXE?SxtqK_mV34&x`3sN&Sz<(^H`eb zT$Vw^T@i1^pB=>U<@AQ%6s?T+jn3lNW%O~6i++puiG~p3*XT_AuAt}p=IEdCe$g5D z{St8<;4OKs1H3KIb%3{e1~Y2sh{8WAxE4kODn8;@U37TGJIwC!M0{IxSjAgGL=XD- z=+MH73a*Y7fz0JsSmikrRv6EDufqCf`26ZQ6BhpIxq=r~%FNG&-#lkXMvx3*d4I&! zg*W(*X1kY0_r`li$Kkppni%gD9ZNbdrk|eo!Qy_=QT!T85C1LE-SM6gSDF+8g&-%&syj?#aw95&f1E8Q0Mx>WBLb#vKfgcaHYNeHx<>u8DVw z`r;l;kLy+O4xG^}{he}M85D2F^5nQ1%YpH>EKiEJVR>S_H5T~EbN(;<=;i!>LfjSi z51y+JJ*QjY{@!y%pdWQhmdD0huskN_Jz@G+H;Z|iD}3iG_;nQhyj@s#WV|VU-+HbV zg>SrEEslsc!Tq(DYsP?hW5OODcdj@FyB@}QV517TVjmiB$nuc56U&3+jw}y~JFq-3 z-hkx+aeJ2g$L)yQpIJG$eia%$SEa&o&-t{l%yTX&ES0&u3rlz<)fN_4tYXb#9dWV1FdmKhs$zQ%mE z#=p+hs53RXLBG$`=q}co8voDC)5t6;e>XBWhMgE6*aE9e`NLDP>`EdR_GjXJIP5zH&nv|z5u5JH$uE|@uU;lUWOs-j; zV^U_A-04qqOzI4iqyJZDn9S#w{2-lMQfHRT=au~Htdd8hb4m{U!<>>AWphf(9LE3O zXO!ISKg^;eGbkO)OiEH>WCkUfNl9i=`uB4uUGSeujg}SOTbJ2*?)uY6!zs~Yj7oeW zdNO({dYbnTpN*d53iE}`YWbgLfGN**xm;%{O5bFyDELCcyRS_ORlF71tgu>P^}-s3 zH4DuPYZY1))=p=|{GZH;8Kifn+++2f!#Ps!QW;q*=SzM&o<8Ka%vK)sm02_Ri$|)a zOF6IBD2zu8m=Pm|S&L-cUP|;9+FFSoMB6CQ`e<7veiv=0M5EC5N;CtNxy_mLoHxjW zpraDYj02sN=o?h#Wq^op^#&U$jOPy0H*h=SFUPjA5yr&!RtdbHnqB6rgFd{YJ`7I^70o_rF?m~CUkbB5eT+&tklCb|H!H<0KxGycaKEDGDUp=1^Ofjf^a3Ti6O}Rn(NE|EC6>ICI3VMq z>Dx_-TB3I;Q4zgci6)`!D97(R}nNC0>X= zt;9Q_&nVnE2f?#Sv>1I(iC;jUS7KQ|O^K(YvJRqgsQi8jq|CgWA;b!x8Yro-|uBe*!LAL$M}I_HbSL5&7z+6L_bn!OActwOyNE!pnWqX z`VO6~m>%e-Fo(F3Z(k_%xdglql@bR)- zj+3(E)<=I-=#LI)CrY7*ItYGNqVLfa3O(8Z-yYzj@x;9mm16^UE&7|ns0}{+uGrns zRSF|Jvd0{LbG*hV^tjPXqsW!qP$p8?p21#%v|X7OArA!G7o`jd={3XS z2D>MkC~^%kvTvZjGBC0~kn4$&eSqByZK|Z#gJ!sao=-luu6S%k8+(fFkFKG}x!K6^ zfE|cRT0zd!e2FE+o{W;mf}FGYYD)_Jwel^O6ghv(^-r*;pp+Fs&gJw1q}ah|ONI9C z^gAT<83dO4Zi))6;pulw4CTm<#a*M&RvwsI#gdPvl|t)JU|K779NI>abD?Rg&<7rv zc8Z+aOnb%Nhi;%q{WKjE`vlrik@{*nDfHg+VRJ?5uGCe5zWu;-R-_)AjTJi`-9(W( zO`l7Ooq~2zyd1}7ihT#&JVWw)3&l#@Ei+a_w^A6R5SXrt?}u)kA-3N}u?^_98Ilj( z6h=h^X1ff@lkF8oNbo^#g+83X?5Hr7B9MER6v>C~3L`B7IX9(<4SFby>Ilp(8Dfu~ z3S&O_ICqBFrkBEqkihiLkmK83aTREvjNQ;Z6vn0SG42eBx2M9W6+XzFA=}tXaZ--< z&KQF3qd3|Bz8M#z`zh`aw137W=>Cd36g?p0QuIK@9fls1F%p%!2kt~vwk3=Li4X21 zRN@Gu;c&%C9UPD$b>Rrbos1rtaVvV1!pNt9{!ryMRO&6bp{UeV@KWB7Q{08<@fi=J zCn$`^3XG&xcmyPU;BH1GO~Ruf=>T^NIw)f@dWzyCZG$tUoS&*VN!w`|kD;e4ZajKM z#@pzb3ZvfwBl#qJ4reKhxeMqKR_37RWPFRBtAqwUFXKD(e1*|}eBd|ZdsJ)zj1A<& zy$Z9*1x9QKR?4-M36N(6+M82+3smwRtd#f56u$v_x#G!tbA=-J_4IwG$TNhwO0j*= zs};EiH`gfX{rxc9;MYZmD^|*yq!0M69v{I~%J1lgX{6L4bJ$x7H3ouYWL^J5vG zqEi*O2z@+b1^R^IenX#Bd=Y(0@ipkv@C>%y8GTlXwnv{+qVDMPia!XQrug3I3yL3z zPKTG+j@bWY#h-@0s>rh-V`@_DQRwT6Z;if@A#N!jU_VA@C|-{Jt&APfw-whMeMbqJ zqVFo!q3 zKG>Qe_Wn$9Qtm#_5ZljD7z@jXTNQgbI#;0uGB95%wh3CN(61XXMk>Whn0bn{ahUmv ztw9$k!ANwWVy{P~d;ntz0`rx^_&q+{nz1^%SYZ?+A8yT%y0BDntwnDbUj7R(RO`>G1h_QTq$^LZmSjF7cDB%_8`xJDG#7Eir)y8IKn~DO7Wdh%AepV zOIB1a|_uqvBSfJ1Jg{sk`C_pgSwUE@%%$ z&g)iwgO}s(sYpAP-8JKQw3i~!qVmj~@+R6_k^3ntX$2?t?4!uNjNL>xWn24a9DsrsTOJ6MH7|BL7!Kin?+jGR8H8t{ zVs{9KpoeF?fr^cU>);5*iw%yA>r9{bv}6)!fHasY0B^jyV@Tgo&zvHgXL!|qnf0k{Lvp>Q#7vCSol6I)!W_-)b4 z6fb4r3dKv=zfy4*pjRnQ_J6hF#-i82Fw!ixlyU~{Lv)1VW}_n&_Y*owasNa|E1}r% zTE*{(O5TI-j$W_$Ug!;qm$EGT2Hyw0QSn{TaY})W%5_@sQjTs`Ldla`GHyeqJb{94wcDewDoH!^H6@;dzOKYlPv1}~H$`QCZxQ!P^lhbb3-leOvJ3jIQn?BG zo>JKXeP5~Ugnpn@c0^|?iG=@9Nj64jDM@$qBPEgWA1jI2;S(j1W1p?Wk~Xo|XXHga z`neJ>Lgm;X{sPU2WgAldAlVI-_>f3ls8fVP>8oe#YPZ!Ll-DXZ*-xO z?2O93A(1lnm6Gg=HYiC?bg`23K$pN$!pnY^!E)SU|3)Pedw;DYV$W~jXTnHcuTT=n z>wmy1{3%y1P-6Duj1sedXB9?j297{N%sySH#H2&osRc%S26CPi7$+J?8hi9AjdRTk>?U8wgc(k za$-9OB@b^>0y!?(2S~fPyG4=yZD|)1Ob7Hf#q5BNSHep4cEucvPEbP1gglp~&!%FB zyOdDudAA~M8*(2n$g`uHsDw>XSqJ75^gg(sG&e&ZP=e3V2Nij?bd!{DKlC9bJPmzV z2`@(F_#u?zll*{?^R%0+g!`gX;Bov1p|b8t+)@^vf~Rqd{hm?$JLt1YC~0{Po~JCm zi%wHQN!JS*+oIFqMYbbpdR5@|F^kKKFJ;H&oID zp`=&R0ilF_Pw}#y_u&K5`4Ku3W)Y?xD!(C=eSWNXv44gqoSUO~3Bx&7@E@Yzz_++( zqu(iVzw5qN> zQ*`}|&C!;M^b3X6iu5^$MMc`Y!xC^l^FvY2n_(N+3dkG5OL{0PDRS)XGbr2P2CyOQ z2yzS?gB+uz73A4Flr(Jyl;v}ENrULm9o~cAqw}vQw6nd88 zr=Vvmel&WH;(DV)6@MoxyjXl{5*RU`)m$^jgJVh+e1oiRkqilKvZ%Xiao%#>VK48B5S{ zikEHQr1;0sn-$Xny(Qxt^wx|n=xrI_q7xM9!w>Js_zo4@LGU0d$1MB zFQ^=YushtVM9ont-@+as6f1GxSAtE^4-{$N4QDDr7xY6# z+IquTO0Xj;`2o_V8-A<=SD~LM(ykNE&e#+ERPkct&lE4^`g6sLz2_+YR`d(Si|yws z{xE6X zpsN&5nTKN+o3{3+=AiXV*bti)|mY%Rp?&|a_`Zpv--9!lH^ z-BXF#XEpgP#FX3Wy_J~yRo!2SH%Iq}0|?U!w4g{oK(Rsz$b+I+q`khFC;_%FRw|a`EjCet z15nC?U^(7mQzbYMZKhcArMQ|B9E7f}NIQLT4J9Byi)$)&04m1`!9a8^McTWIEtKG7 zbZtf2zl-ZA!5~!Z0(J$U-m-Eh*BjT&z)ov1qL# z?Y_lUN^lR_T9LNZVjCrxh_+SiB($9p+>5qXr2VzHffC$_c2K09xJVflg8R@;ihUT} zPzh$B8!7flw6hYtfl{vo`#8FZV#MCmF+ti3i(M2Wwx_-c(r#GXTrrXlTPV_wSlm*P zF@MFa6luRJc2$hnZ)?RaL#dyF5nFDn*yU(9#fV+EQ>4wWxV>V;W;-a-ZdcqCin}Y) zo>%OnnElW_6eoGrSCMgN#XS`#`PNU7F=)lT6lu>Z?ybmpwBkOBlla}|21BJG96!xVEhdbr|_Mh7S| z-l!%C^8CtC+Fqaf&+)Jzg<4q9-Wsbo4|;#ugP% zQrsozK*dZzPgdNe=paSL92F&fAZ?aKNfXHUqoSk(r2Voe`v&tYD*FRzdn=xynCH+l z6={Pj4pGeW=vj)i$rUA^z)V9WZ$R4Visvfk1@t^c+U<(xD>BZec!A<(p%*G<26~Yq z?NG&`ij1`>UaYv!P_YG=cTqWhaG#@c%wXO_)>VQIs+SK@0RQMaFX!?^c4f(R&mt$1+g~)GD7shCyhQ%Z0u`m`eb%Ef1tKXf+6V3iu6|(Ur~ay&{q}dw=TY>1ZShKD|RUQh7x>? zzNtumVNvQH1fQU9Dbjyfd|NRGqEZjRbw=M+%t7dTirW~KItJ!o^aI6hg3eTAJaJL# z6}U~&S&ED;E`FrAE~umrWSntP(gbcZbhaYnj*FivZgccA#SB0{SKJop9K{@gexbN6 z(YcB_68%z<{-t7_Vva)V73p&-&Qpxko%xE}8eO26>F7d5`X!2s6eD@~l_Gr<#RkPl zelAv|zoNKAkui)#Id_2cT@7E{7#Ya zeZ}t;C)@i$F>>5LDo(cjlVZdMKPyi5vqF(EfW?0(()M5cMe&lS|5W_3=&y>GJpE1a z$Du0~FL}C3@y7>J4TDmF^Im_H^FpZ^9<9;UU@iP9&!u&s8vhH>8fcIICFlmQG5#EH zX%iqn!`o2uue1aHl!elcunV@`4($bdU|ZR5KiHq}PooDahI}hgjs??z9;z7ft#r6z z$hXn};FzS{q9ofo1^1fhU`5(fOQ$NHvROJ!2`@xXSG=T+bAcf3eWf!M{{=ckk@l_9 zS&E;FiY-9;K}+W-z7FNQBS$u0FZvf}G;8Ju3yoFox zSMmu0;*{P|l8exHmE>adJtdL2@54;CIT8I(@n51J!zcLPkIshA*`}mf@(hxpsN@qQ zQa&XPB$uIeKpm1cwvyxlcz%~8KIai>Q!FiluW(bxOAWv|!9Z!5V#HR|;S%*Vxg7mk zNhA-c@1-AzD{1)&e#R~PT%njj=sy%A<@6Ut`in~cRHVQqw|7s?oKTgbmiL1M3ooJgiv{NLNIj z)U*V)9g%-EB_;X*l{mGeAx2v%(GzHEB_iKzI0hk+bhTBYxoA5j!d5lym4suc*;t9c zMK^&>*}kNq3v5mrW};ibR=6n(HC>gYDY`XmgFks#v#pZIad%UqYtikL=q7Y~B@sLA zpd@0O9hF4vx|5PLL%S#$+x|gM8fU^`{6HnxW5v8gdVQM-=Tw* zSdRNtCEf@fwarYX@@ z=nG1;4f?r~$oA$a$&=_8FgFMaC!q~WBK!G9VfHi`aud`V>^}>2N%{U*HjY9YHbT8UWKl$#7)tnVuqn5#ZcC2InG*k9 zT#2z&?Etul^86J_eG(E3RC}4?Pe3n+EAX#CuU2C6uy(Xk5c`c$3YW@i2!-`g>P{_nryw?=+}2WF3oX${f$~>40)0#=NP4F#g`?2Nm4Y0T9M7|a zKLCASDM)_KR0@(`vy_75r<9K`34biQ02bmt8WmeWp)a~bDR5qDMLr2JdESbA65`e< z$1lj(lUC%h5R?C{R#)N@O8yDax9ECG+zOR#L0pYe7KE5`(~3GQMD=JdCHfKN_=V_e zl(e=w2KO=)TeiZEh4JW(O5qN4oKl#;t+-PPd!eCHpuDu^IE2F9DETK8`k`%=!hUEw zXpcYTs5NO63Y4wZLzTk5=#@&LKRQe)?2o>y6b?h*QwoO$K^tr*Bz;hhsm<|h^D6WS zAY9>O6#EMWw%wLvXnQB_?NG{y5K#`>PEiV*p_CJ$uocR7gu)i+lS*N0lyV}Z{*;%t zQf97@+3Y()ZDuu4-D@yABDsD*qU&9TB?&#}Ep$9rciQAxWDTQt* z$0`)IMHeZNr1xv3a6%AZ{*=P5D0$T`Cj9Ow@r6Q9^a!Oud1-fyQrHc}ZbD(3AZWj) zQrHAtTPbXc_E!o!qem-+-sn&uPb6R3lO7>yf>IWRLIM3rDTx2iN?|Q@g;Ge+e<+1! z=r2lv{dXWeLSeli=!i{)0(snt;}Z&;?>bR$I!Txv(UwZ#L=-y;h2w)@!}FDx^T37| zDTe)Qv`S$H%%Jl+N_2A&OuSu5&Iy8h2_yI+L2y6kU?Hgtg1SH{?1EarFSrrqIO{s$ zUV?52*vYm=$q&Ikh4xYG3n<4R*w;|bDT1AW?x|SLRdv+2x29_lWJD{3&GA8yVYb@#)Axc5gV!9%zuZ4WE)vFIa8JP>^pCKF~5N z1&*tJ8`uu_M09)TkNZ`Wys1A3H^*F0-qcfm3Xc= z#4R?t2rj`b$9t(#_!hlPDg1*O69)jtxDl% z^fs7C`2Em(VJ7A62lPXwvO78pJ|@iJDEZuo?JLC&VmqiH3mT~pjZ!vF41%9tR^qRM zVC5<$VGiPNH%*{uPM>0F%5O)#Zd>ZcVaG$s@+)wV8@SO0ra8Iv1)R?n!OS^Y`%SJjKFf2>|ryrsCRR46qotyQWnwJ&X4+N!ieX_wL- zrTt3>mJTi*S~|RRTlUdQdawL7BSG3_R_U$gz%?fbVsy8Tt{zi$6ahc!EP?G$%f zvs24XZ8kiA!;3ar)p?zXx8J+!{?2t=7wbCJ^{(5q?vT2}>Q1a1T6bmLn7Xlbchx;# z_jcW)x*zM~`lP;D{hIab)VHc{Q{Sgrm^EqE=uVAL|iF~1bg^T}G653G`wsPH>C;BLNhCA0?MoAdy?s9Y7H|`(q*KlY! z;ZG%DS4zU3l!X42gd?+(@Iv)l)gM;Ru5PGaQvGX@-nbGi+@;k@>y&Cr8Q(AjE(rrl$Cn0|hL)}>jV_HVO(@-0dbIRJ>9x|wrP-w~O5c@!s-d;5X5+LZ46Zq& zW^~Pjn#na!)jU`8d0G;9R;`WFlF*{INJ;2eE(!f=2h?6lNf=!_w)XzoM{6IeeI_dj zb88o5B_X0Dtp2BxaCEC1T02TY+j2>Gmy$4`%@dS_36z8>ZJ%iSOxx*gUuip|ToR&x zDG5U<2|s2fVU52n3AV0D-G+6$*Y&GAl#*~#-NkiR)m>Y6W8K|#Z`94H`<{~E>zmZC zUcYvIZCVmK)pxDmzJCAugVT~Qv!0?b?~Qq%HkwA?*d#3p)s5{a3B4Lmqa<9_IJ)uX z#@ib2ZTzrtcH@^>N#F|fuYYnM%h~1<_j%!>!i9x%+zfu5R5*e!#O)h%-Ic%JIJf`# zZ`Er*{j%)jWk)XCuVG#gG;j`V;C$D>`LAJE!!-?@r_u@1FnLyqbk4%o{mx#Ju72hAkYpfP19{U)DXi;Ggwv7H+ZNj0I0htOXy+ zeZqpJL9mGPHe0!H(RHGK{98P8apS^S3x8wnip5=J&AcTGXD|HZ%YzntvXHAp16PKH zo58sYPhZH@XyN#UTQBUku=~QR77kr_+`?lPv}6yfL&f}6^M9KE;QWW?kDAX_1Ru7x zVB`6-=D#=pZBo!-{s!}&oOfIOZFOtN+WfD6;FllNpIg6ceb4%&?yI^5bvM^l*RA*E z@-G+FRm|gRI=Aq}iF0F{=k*V`S-6UvqCu4;F6o0)CGwtYGr z*RHl*+jgsITAJV9{Ef9vT5I^)>#n_C5VU-0-N7xNL7!^52g_@MV6`@t@^$5aNy}fd zpM^~$p3s@^`V8jHowH7T5?$j_zmltHD8Cb1ndXnK`*+LA&;Gooa#0g5X+h9r9oPnXHR1jNoz`S}lOL*@ zRCTQCQnh7OH}R9dD#Ebrsw!^ztK#}vwKw$7ejlXz$7NL?*25qFs&=gEiEppmuc}Ax z60O>XuP$z1)uXECU*r8TR2p)Vzxw>^?<$V4s+jxdzpDDC@eivSkgrL9|4sUd-LLA9 zs>A*iF8ft=ej1Bq)lgj@f6XkuZ$%GA>&Nd#qoNryYjy1TR`%!2oIi)JX#d^Z)?cP` z=QlEYKC}M@HrOfHE!a0WJh(a-5!?~HY7*1TtZUks4Vj_&EOTM`%N1{!x6Dj4+kC_9 z{EtSr#}#oD&;B>x1E~9t-`YHZ~8Hig(#b{Dg9X%4aWESBjaf_%S4&%yr zZoF3XL%eg`BmUUk5U&@_j~B)}#cM~4;}_y*Cd4&Dh~_v@LBOi!~b_fI>U?$LLqhk3v}$QKSCGM|M7H^i=Hezl=pk55gt;42(^ z*c0rD_9S~8-;aKluPICo_u<;QzH8~K-8ydV=zF)WyU~qvH@RzFgWb(7^r8?&NQppPkleLo^3Ge z+r_4(UEf5^u&BN|G^N73IJnC*SlijUmio4A`=Ej@1-KXYr z$Ct*;x1llLh1Pr@Iy=Jd9Q5=pY!ANyGjZ)5^tOG1LxTPMy7oY3&N|muGdI>XQ7IS| zcCdE`vx7Iy+IF^`=lAlR%$BawoNjh;>-lzmA9J4D!*?_{xifuBznweB+~p56_n7$pWU{4KB zw5J8L>`TE%_GPoDYh(Jkw&pT-xVhX7Fi*R?%`@&EbFCX>#=DDsE%UhCZ6EL(*@Jv% zf2jG{t>g>xRl!O2bhDRhXZCjO%|31ev#;x5zHm$IBIn(C_H}=lKRoCh>}7X1t;|Gs zf?MS`_5*@l% z%iEBb;tW{{hq#?yMQ~)vHnDVlKarj=2_#PVc>W4-F+{=hwtON7D9hS zc)GpK58}%xbM0?#Z@-)0oo|vH?GNz>xby88zl%T8ALV;;FMDsip+6_?9B=8*^=JFD z;%(#YqEDmGDmRMWtlY42^UBRCJ6CR;v`;ojIwbAlIr03;O_MK^x}?5xi^?vQn^bnF z>{Qt?*(BLG>7H~(zl`hSdGVt7i};gdx1?868*dVC6?aQ|Cp#y*B)cX(lOD;u zWPY+~(j|H)>6CO%woSSv+a=p4J0v?MJH?Cn)*4>}<4bX)lHq*eW=t|VZcJ`S#wM+k zHfDRj(7*3L@H3-@rYin4UO*H1g5dn%(cm+)kngsE*@NfCIgDZk7!@d3aVIMOtXb2XY zrr|DO&u|BSk=fc`7V_=huw$^RKQG)UxIF9}ZeqHcZNg2%%}uA^S#zo%>M!<}_)EjB z!mj@EaO-f}aK~_`uzR?3*dyFE>=o{2TZZ$)1>wT5!Co9L4wr_@{Jmjg_B(=I!mHVc%#SKh{6xZ}j8*)8S^}*Z!vPhu{I;37HiB>TmYXm<__8?XSV6wmRry zi@}b(?Q@mAEZoyS>u>ST`CI+-{x&}?>=&(Tw)HQVZhpEs&FyN=@GqJ({Y!qlf7$)) zzUS*gKk&5oqrct1;xF*`_zC{iaKrE`^KlgTJN#>LyLf}RL%eakX}o#dHQt)NN|8XG&s;6793;`4-U2if`Pmfa5Cfn2Qk+F6nj=M*q$8>v!jEt_Lkrt z#^Fz7%>BKLufNmY9^A*c`5BCnf5Sd**07(OHSHYJ+;%S_2GH#N4=v~||B zbI!DPq3PmQGn={9&E{?mvxQsJ?B_a~{;reR-)(3Pa2uJkU0-vK+tXa(jxblcBh6Lr zD08(t+Fa*OG1t4n<_34F8S73nH@ef!?d}pY!Ch+ZaF>}o-R0(4H_<%j?lsT5`^+?V zzj?tuVBU1I%nbLDdB=Tb-gTdw_uMDueK*H^;Jz>)x;it<)tk9)srexc&5vPZehOpz zh`Ykgb<6BpcGUmF+*`m|RlNV>r{?4aX<#cMh-6dF0iufE*pf2 zUD(}$Edn-*9Vm+3`LVk@ko$kn%yaHNyR7~B{J#Iaud~x}o_U^`CuYt#_3HW9x!PAN zQr6LWD(h-YSx>W+^)*}RhaIFFXr{8Ex<=UujPk~ss%)ZZ%BE^onT1`Ot=OU2hTWL$ z*m>E3U6!-8LzOw&Vai->zVf2>kn)oDusTT}rcTzkQTNtk>OOj0-B(Yj`{_w_itelX z>m_Q9UaHpWWon&XuGZ@n>V5k4>izm6^#T0`^+A2H`jEaveOO5!kOkIo$1DY&TGb@&g;er&Kt&+&YOC*^OkYH^S1H4^RCfo%*5WQ zp~}|EHp&S15a&JjVD|*~SmO-iOyex$Y~vi|NaZNy4&_eeA@?x%aQ8^#D&uP78s|Um zeD?_VDB}U+LE|Ck9b<{H)Ogrfp?<1;v|p+lQUb&U1Ua>$pAKVz-bjGmm8#vrF08>>S*weyX_vzYD`% z*X`@B=dSPW>h^Ota5pq}Ft;`XbCfyS+}Rvs?qcp?jyETmW6j;n1nx3wg;dsoJCNq# zex!qOchaG_LF#DS7&RZaG3|+ag6hqg&Me#@v_Ebds>MA-8OU)B?k;Q?i?MPxm?haz zHh`6~Et#)n*yd~yi?ak9$hKxf*j8*C)}L*`Hf1GjGgiiiyLY&^L*{QZpEI8|vu2Tc zqI!ba!|Z7mn~#{Q%qPsd&3nxI%?Hc}&4@YcRe-X2B;&@asSlWxFu>KZjibPw@Y1zo2PET ztyD|Qx7_!=!2Q7e(EZ38X)Uy_u&%PMwyv?Rg|^;Rztq;#f6QqhDRYPT}sah(o_=3FU zsRe4GS_HYPhuTx^rS?|)sD0IS)OFSMAc^%;H&8beH|K1kZmMnu4sZ)dX9LuM>L7JX z@DoGSt<<5Ab%v?is4+FJ^1F3>wM02pEmh0Za%F&8p$=C^sN1UBsoSeNCki|W7J*LUDdJbZpxOBrZUQ-%46#8>Ns@|b-X%3-BaC5 zov2PyC#!p_`>6Y>`>9jZ{nZ+^R;^R()r>k-ou*D#8`J~T8ET_C6BOI5wy3kzR&f38 zYKJmdoejBit};ZOryi&tr2M2FtR4b6^Ds!7hpR`ZN2*7uN2~HapyRQ>WGmqE0u^`R zs;8)@s;8-^i#vkOQqNY;QO{M+!)J^Y7t^#*;je2b; z^(jNu#mbZF5_PG1qw*A&5pmz$&FU@ct;#RzZR+i~|LzX;PIb9@mwGpD!@F0#PrYA# zKz&esNPSpcp+16p8&|52K?Yu>KA}FTKBYdbJ_C99IrVw<1@%SsB}m4v;3mD-ASu0} zzN!8P_v*c^zN5aYzNfygexQD+ex!b^e!}N8Wot33eW`w>3{$^Wzfr%%J$&D*Kd3*d zKY>@=2KVy)s;*XlQ`e|jAlN&ainSo7>5x_#^gb595>AOjri3y^E6@sYn_sckL+h#a z(t6`=zrK)X*VWe3)`x7n0lsCCP^KwMAaQP_Jf}Pj$#P>zyPIm8X`5?XX#KSTxDjxW zwxu>$8=`Hc4b`^RhH2YqF)fa}0+X7rm1w0}8Ey`&(1vRxv~9KRaF5^)+KyVKR;5*I zDJ`u9+DL7bHd@;Wyl+z5SsMdcdRJ{MBw-(taEUe!T<3Ug0`%69MYKuUWNmNUO1Q65 zs_h3(;aBB7ZGWu>QbCW$4)NQyAav5%fyi-X*GUQr>yR~~T@7<@}4+-x9?LqA! z+!eV(dj!1EqqsBjG3{|kkE^sNAbmXr%Q%;lp4FZM*Mxf{wHLLQluIEszk(YcU&9Sm zZ$O6n5AIofTYCqx+Ix`JKF~hYKEl0=pJ@E*8O`x>`Geye?_eUJMXf7EcF zm6Fze(SFrdYro;f$gJ{(5^xz$X;#+g8h9z3$qGRdV3F7wH&$+=$Mm=|T2JUn-PcR>QrIcV^$Of(IYQY<-&WsF-(LAy-vReqR>FQ! zt*7*~vNP-$BlS`GXniN#e>z6rMc-8)tM8`oj=M1TzqA~}v>12Ix zeII>ar9){~X5-GxDf<3;4Xk2ydcBZar|HunJ0GCW&>QucxS6t9*#-A&&eB`;HdsqK z^x4X;`W)O>IS+E}LAZVM5dBbPtnw}_YV&c2@v6Hd0F~-=%*wq+o>}Kq4j5GFtls&=NQ<-G!WlS_CDU*%K#@@z0#=gdW z#uQ_Jqej`=s5R=0dLskv)}6*wW18}rG2Lh|4lrhLZZ{{Rw;F9izcL#dmbtLF9;obV z9Aq499AX^G<#**nNcBe;M=GBiM;S*O#~8;N#~H^PCm1Ih3yhPDlZ{i1Q;pM%)AO7% zm-vnIp$WJU*4vAXON>j6%Z$s7g~k=em6B^Vt~IVRt~VA5T>$5pZ_H@~ZZmF&9^ek+ zPGh-omvOgok8!VYpK-tBuQ_LJJYqa5bOw(btBfa%CzVf(r;MkKXN+f!=Zxo#7mOE; zmyDN&w<*>$3IO z`m7(@02<K=^z_({RupL_PSrdzh_Yj|ge%G1wPY32Evn$W_mf?DT?^p16h%wuaYnL*Sd@ zR^GQ^TX+|D1-{RvD)tfk`1fz=<@X6H4*MCOI{QUg$bNua|^RS?r9yU z6e~TI>&-#RMqE>(Y^*$J4l%bm_ssfciCGG*W4UsLQl%WBJOFD;p;@7vpqyw9H%BN7%x%r>%%u%H+L1XCYPP|5R&Jf zBtuOWQq;cC>r65CC+V`@%$QTnX#>l>j)z2cqL9f>hE#T{kju^>$?P2ST=P8heDebHLh~Zr!hDH&DWtW_ zA+KG*B{t-eKMe8_!)rX1&+Euan&R zNKSHH1*!H)F3+3KK<0hUeBOM)e9?T#eA#?O`A*zO`MUDG@&ok0Z$cORme2;jYrY3N z(g)^;xXJuu*o{`0pO~LQ$NRbYh504yU;k06&99V`&97mzctv?tc@5`2URU10cDQeq zHOZe3?xZ!NNJuohcOtfi3AZ?bN-Zn18)ZnJK;mRWaL zcUsG>yR5sdd#rn{`>gw|2doFJhpdOK71krxqt;66G3#+_mGy-6r1g~bwDpYjto5Ar zy!C?hqVsthcGC9k61&tc zv&-!Yd$>Kq-qzmE-rnB9-qEhKtL$n!WvA`H9%+xVN83BuJKJOIUF==$vG#8E?)Er) z4|}{l!QRu}%bsXYvM1Yn+xyu2+WXm4?EP(iD|4M)Z)fbOxqF#s*p1>w=4QLao@KY% zZFaleVb7MgGTR5*2Z?)`548^ycQPMgA88+DA8j9FA8Q|HA8(&vpJ*?zPqI(8Pq9z6 zPqR=*5q?3e9V>{spA?APr#>^JTI*l*cy z+wa)#+V9!#<5uMl?T_q_?N97a?a%Db?Jw*v?XT>w?QiUF?eFaG?H}wP?Vs$Q?O*I) z?bY^g_8L3uD30oAj_w!^b4E~TKq0?rh=o$89bjI|H3Tu&fSthB#X}L!GT*E8ND3K@XdN4%X+k zJ*UhmcPe1n8{us0Y{%_;u)I~m_EznrU{wp8k+8yzhNX08SmJhZc7?reH&|cCIeWmK zH^JG{*~^*eOoAE^*!caU>1Yz&7whlO?^=P2iBSY(ccHRgC&Vorn=<|J5PPH|3!4dry$NX~?P zE%G|hC38)(&?7^04L!EIsk<30Qd_wF-2v`EvUCoCU2-U_nZsb$ zjB!h)n{<7*#4UBp+;X?V9p2ey3EO8iteR=qHb=tZIU2Uko!v3+F3?zyb$4@jcgMMV zxZ~Xk?w;;m?nHNzJK5bkV&AE8Yu!4x-p#mE-D&Q0x4}KYo#8gRGuyLGhgNFVD*i9F>C%GrXhI%S2sHeMUxMvC* z>N)PY?s@L{?gj3J?nUm!?j`P}?q%-f?n3tp_e%FF_iFbV_geQl_j-4cdxN{!UE(fv zZ**^RZ+35SZ*^~TZ-*sCYZRl;}ZR~C0ZR&02ZSHO1_4fvN1HD1smau6L zfmM5`w>7tY!^Rzlr8@~*c!^i)m3ie}g*V(A;ce?}=WXxp;O*#DLO)&YrM$Ekcq6@0 z-e_+pZ)b0ew~M!{H`d$D+ua-I?ct60CU|>#dwCPRN#0~{Z*L!OUvEFD*Y;|?IHDeHG3`IEU(pT^V+=*Z?-qbo9kA#HZ)DEsq1LZc*5H}t+^>P z!>XKFQ`g$uWLDO;W@cxsN{a4iYKXNv6ipp1&vdYGm={2tS-`?ER+$IVJFfUEkbZQ&*R1YUg<|W}@qORr!kXa#?3G5U&!8 zmsgspx*A{zWtVT3j?s`2B-0Wy@pL@FQnfX$vLo>zVW$ohrKebFLbS9%G&h~^M_S^X zna;HnOUPG*ORt!a5KhPG)#HRp@i3nH6{mjDh^FO80;R%uLa}N>v1$ToHPu@kg0z&S z+dEpDtaM9TLt}GO0l5*Rqo#v64SHO5A&AqUQ&)mG4SF>pNwtKmA1{%kMbzZSX!KID z3-JmO21ud*;9W6B0uV& z92PH(R5dJaj}+rGEMAbWHO!qz@N`jg7!~-^2b_%!qYfv^y;1qzi5@571os5NI}!GY z#xYT8j0#`RuP&32k@H8)4BDSx#j1V0?M3L3Tws<(pjRZzVZRBr{I<- zDd2JS4Vl(VTLVp)NqVUyy;L~S$yp#CB>|)B%Z7?$K+;^cv{vCH>{hGPofDH4}m6N7{YXkE@_|o z;EO(hkW<<&9ioL~AgaKUT5awcY1l6@cg-~nN~a2anl5~rE+i1*X}^-~I=#7hhMsPk zChGO&tPJ4EF8OkHj`>(q`HbhM2@GB zs}i6Cp9tM2LicI*qZbH#nkeLe#geq<1jI)8a{dSSOO3I)d0wI_A_<*JXp~DND*KX6 zip48^ecbeB4&iu^uC{ld(%Q}k-5eJ|oRU&=Tpn=}#Hl6X1gH`MOi9=j0j5L%HW7~p z+PJxL4E<_z4^gqVM_1Ylxs6g9rc$cClsaBY=vgXS0}{$5K3+*vy<8h&iFl-XDM~Bl)6_+<6b5j1SF~pI2OYlR$J4SvBpbC$c`t|`uLg-Hi6@?IYBNd69|p#c6_1NInd2LT_9~kSHTal!fsG_cD4h zNq#^;N)KnN5CtS5A)Q`4kT0GHCx9{nOj+97D?d8sUSioW_Y&jg>=iCI0%g*YrH!YF zOHUJbo|asB%$H(D%nz#d>gf#vmhq~jHz{8~!I$2#JZ(-A?aAsx_9~!PA`(b|rY8{d zNUor(VhVKXV^Bi$RYG)CLiAKZ3ogMaNK-z7SCFo|K>W5wO&RBH`RrDZ(fxyPgh_ zzEXBMT`75k1o64?lw80P397#`oKvZtN{NbzSkkJISZvkgQ8k{fsB~-cGOh4NlVfpK zBRF_5?aK)_=F`OOhj^Yw<(d36;-^aV8t@b~B8q!L=7K;%D?EUb^Tl&MFw=q)0BEES zD~w|$RPk|R1?4kZXn0Th4rU$d`%)>ntB955_v2Q5bjmBC>4``=k%*i1xm5tINoq=y+@B_S zKOH9&i_6}EO_LN(C>O6TW%cu#r%gBNn~AST_+?gx=F*J72{)7PYlcWLlWQB`mE@5K z-K;IDL{?QqmeuqQ)db;cBEL|0O3LVuo^XicKrM4>rfJ%+ID-T{t<7r_PJ3(f zj7*X6=OG{lIUX21zl3FJ%$NqW@TNtEcUq?cv3MeBPRoNV9?&Z!;>NULZFMfE+(c;% zUWr5!gm@J>llYQLLKZRe!X<2|C}Gq2D|+I8qhJ1 zlZ3iSf^@Qk&EVr@&X8OUe~EZSrPDaGp3Hl0kFjlvbLK_S>A zA*Sc(F~#mL@8n7SkH0%g_zE0-2tW>Kd>s6d&Ra2^?vks8&vI@pQQG zNZmCEgwo~04+)8IPBOR%(|eI7BP|(R#M5*uxfC#@1ln}cYmP`x%_KRI8s%e2m{X{xVnY?Fc(BCD+SPVLeXQd!R0IY^B5a17$5NwYorngTJ= zOz0A#loFzqQTHm!^me(F^43CMDVQ!>R!32Zc&JK3mEqc3nqvw}Lj_X0jMzHxW?=Qq z4ly&b4ncREv|_}NC%6@Y=&(c3A?pxgUq@$Z?6k~Cp&%aWo~0fE%a0J-;0mz|;+}vt zT%wvX8iO(#gR(H5#=eYzl&17FrH3nloCQE#1jIA}F^KCO091!Sg%D8X;EHYeMj<#; zP(K%TrOQHo81I@(C6ylDo0M_rA?mR^IhnzV_iqLa%1P+0jMvKf^X;Ou!$*h%@<}7gKC3;;> za3=B654?H#+U3j;uJ>g2N>@~v^P++@AxS{ZhPop%Z~0PJgUL`J3FLJ-;rKz4%?n=_ zsX}vrRLPw95FNnEb6ck|57UitU`W^r$>70!7PAhNbAfrFczgFitnX1HS!jx>AanzFV8rCL~D&4uUFo?99xxsM$iQ zieS=^6cdmJD4^*jAWcwEDn~IOjZi?-O(6BC@qlcqLD*EKRJa6E>PEU$oCGAD2bEI6 z5|ET11l2YvCZwp9q=h<3l5#TTo8p}b=rr5=QoR6h%bCI_#gH!*Lr^mlw;V5nnz?RX zs{olVjV4eC3xGlkELVG9s*#{Lqal{Oc+8jPBDf*%EwRj(x&x?i!gyLl$$;xib{XX; zK9nFxFY8O59_ey^^NG#&rF8}I0_t!HsfY{lNr~c1i#Ga8{gI=9{!o21kNRXe_oaFa z`4g;0ShB_c2-iR+^gt>}z+p?RO+a#VK&Ib-FC=h=BO%fGiOKSs;Q`8RRj(l|h@^ptt6< z_JnSd4}d;L(nqp7!P>-h*H7_ycJuTDmgx;Po@w09s-xGena}F3_#f#JnF#{o zSOYQ_1jM-pWHtzhgAK@h5D+IDkg_|V{WO8pkYX+;VwUPEgbBJN2L!awCLj%LAhl=l zK+5TGDL-kfgP_b5bW2maM3I;;Q3Nhc(h{vP)zf4hS`^9h=u0$4egROpH0erq0byDq zBwAx>pn9a`9G1zjeTt{O7$xDPOFBbeDx6UtP0pmX^X2PfQK5FFMIZTSF$t}ZQu4xk zPMC#lTruUEva7^kXSdazQmo_ovqsd;{#t^3dhwPCYZm6xOytF|fAGI%y ze+W}Q<@5k}Q2P>b5vKCQ=lfD42#$x~CYf-khypEInGfj2#s46jYAeX>SxMIzO zD_#q3m>zadfDQ3txKOKHW7BfD5SHMG)8?yqRkeFyrnPyPkiKm&bX-vqx7w$J3c)H* zZSH_ri5R=BVXp8vO$}itQLufg$xFd$pVJJ-mPOmATB0m>8?g5P>AVJ{w_!(I6Zd2q zrcG~$#}YNRPc5YSWM4d4t7y}u?jUmk)hgmWS*7q7P%R>0*EM6sM0Teo)7sEn&zlp# zg-1epSRE9!yuP*!lnos2JOJfwrl~PAm3OO91X`(+1+@^;Ghvblgh@p_G$RA0r6^ZS z!7#aJzEWzlSEOc&>Y1-T%oJ&ZqIyRgqPRYtTBAs3`n;Cu0?0+=qXdg9R`G5wCfS&F!JzYH94?mDn>InuN!j+0oeE(9$>$kxqTX?1p*) zS)MJ-QiRPAt<7`8Dhg|$h>-eRl<{iX(3gg`8Ssnlgo6`yCk*$@_ceU|P93JmLfH(3 zdgQuJ5e1_EVP4t8FmLWnD58j7hk`xx??f@Zqc28ry&`W%Q9W|6ND+2h3-AdqEcEfQ z${2aE4YV9yN&J4LlbKtGl^;qLilGTd&Z5}RM`SK%?L<0WE-;C(amuWYhQ`KTQ52C; zMV)c7xC=t|=!$4zr4pz4D+r{Q^uU7x=`n_!qIuY=k*+(nVOmFPrXHIjIZdr;G&Qqfp(z7Y}jGBa~&o~%;l z%eO4TTr~|4CPkok=RQO;R7V3aIN<$6QFKWJ3TJhII`~fCR6IGQ3m~n+gqNYC6>dHw>0H42C zB3bIxaMW+iG>DZ4yK>TwpnvfK;S9h7MV;xY=UPPN<|(SM6FE6KT5|J*RM?4%ya)*u zb*7&lU5Tf-3*}&*pqYVp)9i>t9+3-QTq?`36(y$Q9R)%!K%t5+oS`#2M={f2MSysnj;-7yF3In3wI6_jw9D>L=@+I z(Ip%Sg<>s-M=pqbapo1!Qp&x>a#iJCk%tz01dHUdpHJW`IL{}!0C)-oI}m1Y<(e+) zm#aGW5~l!Pk>nY|?2*}r=ZnrhJltva;Srr@A5pbHJMJU26JCj=+$%=4R*othKB{o| zsKVi|0*8+(96qXW_^86+qY8(QDjYtlaQLXg;iC$Nk18AvPB?s2;qXy~!$%bkA5}Pf zRN?SZg~LY`4j)xGd{p7^QH8@twYDuB)!N*s)}ki9p-eJn$U>va<2h3%N3}LTswf(c zD&+}BwKg}Zxdb_?wc)6ub~&n)Avdb1KO9wblExDC<$R(uokkV?h^#66<+_Y&?OLOX zs%ccwo}5g0IhuO%@TqTVo++`O`y%k+K>;5bAHs)+2mvyqK0GL} zo_hp*av2CdJWTN6UI`!Wlkmx9mv1L>%eNC=iL)Ymeof}VDExTXBTiP7lI*R>miO{e zNEUYvmr0!BGoXYS_eEgFg92tUK7<($5zM%kV3x}xVa9_JW|0IAGaeK$;~oLCTn2&} z4-?F|SHg_@B+PQzCCo%_2{Yl9FcaDHFyp}}%y>A2nJ6UzBeLbaQJC>09u@*3OdPb( zNIgBmQf`Y&!4Y98dBvs11L3foWXa-EFh#tS%;F_62UD^jr0E5MNTt8c;{p=-A&x*g zqJUh~q~-j;Aa_X!>(Nt)zj%y)#ngs0eGMQSsR&2u1N zLHH8hIw}DER<69XddCR3F{zma(@PSw)XXBB1c(}7Ae|}rHB`f+yfp<5|L|VfE$NIfNx8`}8{4@Fv(_c=_%RKNKBDbp5#i*>fn<%G=0=+FRb{rklBHnVN_0E zCMd9;v{5pU*G9>3sAiHOS60edSTUdpnyM2Okl+cZ;QN)BU+ z`AmzcISk7ja#oeC%9)X-MT*MLPGPw|(OD{tbg(6lFRNI|uvLzH*k?yR?3g28I_C>= zzLBBN!{Nu!?~Ka%M(2Dx<$OEmd}DIHU2?u%bH1@T-)=eI?m6GMoNteuZ+y--A?MpO z=i4jio0#)W%K0XTz5DkDNDu|l5{0QQW;Iz3WT0JEF-X!%8+zSA;H|` z8kI;ZlcIz{_2dbIB65U5;Rs=bMRJ4@X2}x<#e{?rmdFtXMMMcBEJC_B3QEGDK#njd zOoS0;l~NtOJqdO))MF|%=Yk?BZ;y~hLimLG*RUnA(1T*_{_2E~8j8XI@E?MgOiPwV17Zs*rT8V0l#)ocM7%tbA~&>#wTW%7 z$)2Jm+5;QO>qlyV_>oTmKNtHB{ZSC7NI^glXBZH31eHlg0U1PlR~GFR*V7}-zDHMD$bvVi!@dt-?3)|ZN*Ge)+3LRGJ?-U{KUSTiOPCLuritf zcqxMtmvA^NKR`I&C2%RXS0|SSkc!Dlf)wZAaw(8XqV?gVR=&QNgnV)Ss8lpDKSr1u zWXnY3vi71(C&(4&4>3h*smc#K&MC=07U$3IL~?fOb0XT!gzP3)Q*%mc=+vz25)QoN zUke9VM1W4ehyYh^6QX06X0%gD*;MWz1|gzclEOf=fFFe?>}X-V5Tc@(>_^9du)nBz zt-gcs^MK}O6C(9hN1>kX)KWB?Tm-4BCDE>yhdt)`*DH-y z4dp>3CE5-)ob!~T{7&gGBd|0|j@U09Ny(EpvgHXCfhav=?{ruup2p7Td;nXUBLNAY zqTDX!NLqQc=hz6IFI66eP`RwHXx>^whWQ~1e&~dF6~+0TzL6^NRTQ2miJzbJj0E{{ zO%d#oC+K{-An8bu6Lmf)$XW(Dqr^{di9=SA>iMBN8O)tA59}d{FL~&y^M99!=$& z+C{2ZRq^qgXdpk>II$bSF+Yl9ew0JPcO4=Xq@!ih5xm5%@JKdVBxDZ)iRI$W#HTkR z8KX;|pUNW~dY4BkY{bgrIRbgqMj%>-aF3eJ<8lmptVN@pwswk`u6rUyOBl;2E(4fm9nj)LWX0xP}|5uX>T2!zJY7%X-U=1AMC!-#Q>Nz;MKywL=Iak zqC@6bKkRwVfD>lzCZ%f`QG2w#T$y}I*D^Wt&|2-}Q>aY8uy67>PPC!!lEP9lcUV@G zs*^<0ubU(S%=*GO)v3_$r%+1*r%1*ZiBc@@_?ZVXDK&@PzpShhyXTC|+xGjj%N!y2?(= zsaR;5ao$g8u8|-#(y=)0AjX==HL3{9`V#SKZS+nus=CY=145xdr8ai7F+M!06fgJf zJ+axg4c1p}(l~o&T}?}9CO~I|+8kV{FkHfwolMAsr(m3gnjH(UuZKYF+xZe1_+Fx zFi_xtJE4g@y^fKjk)?C>iSUambhax^pP)z^0Wt1Ef>O(-JWpV3A9C+-PZN$yod!z~z!l_*z*O1nxMFCD`DU zX%*F$q)S|Js#rw10yOzlpFYIlON$$ncoGsmA&gJo)$-*x8gL#<;<_(S>>w;5 zoHW_t$N+_ILF<+k809+(S_yMI$OCuRO?%jj6K3NqMLi96MEj zud49#Z(%c`TWX5x;s#DaQ5=2bB%pKR0UeQ{N2mk(P(dIM`(u_DuL@REfC6FB zGEUw}R7lbnwo+Ar-HOjJG=Z6u9&pgEh!MP@BfQ*3Nj|J8G9QdFGM_L4Nw2T~$&B1I zLltH)xA7*hVHaC~q`!R*Hlbu>si_T9+vgRCEI4wG4L(u3_#A@Fk6B0bJ0_=Th%91+;l}7x1J?6`C{In^vKj(o+yBEIn!Fx$QQgB#nAv5eBL+EiJUJJ(qtb?NRvKXNtX#a z(v_gk#wEf9o<1L!pzjJms1o%h=umNj4jCuH?+j!Cut!|(!`yl1uaR(##8jQ+Z4?*Df*CPiav{yqT`$?`k-WrK97>3 z4@;)#Gbt(hz+{R(my)6nO{VCxDJeQknWDp#DLPDpOBvKF^j@Z(y zO79cU$L#`ouYf)$6VUqw^x+C9061c!ydYN0(t?;#FV#m30Mr4Jpy;b$Nx6$KmLvuz zDYqvfTv^C@(VE)k*HEJawBBJ4`ZX%c26dJj72o1%lhDLUwz zBAz2fqF;)zGDTRQBEcXQPP_C0uLPZ-O%R)!ko}7#(p7yzRYg>9Qy|nXq!lW_>PO!( zX{_rTX78j*$yJh9y&!A1N|HjM9}$f~ag!^U*Yk86+0kvo9r^P4-gngX$aPrgY$Cbx z-Rr39nePZ?6Uzh$c34N9BUP{vO$$WLSZ{fzA`uW93wThb5ty(cv~D``g-{cloFS9) zj5w+W1`B~si4?#og@*cRk<>yNLCM8+;Hz3|Y8#sBJ9u3^`OYqR2S6?d-$?;Z0FCe* zJ@60Pg{9cWBcS^1s{P{%!+d881z@=Pg=a4iA*#G*ZuXjjG#nso@4;h`+EN3dljjd3 zpjl{b<`EwMY5|omXau{-*&Civ90mxUxiDoelqeI1Fl9=1N3@a)QQ_YFTS&am(`y>1 zQW0$BZJH*=ClcxLNXme%CN(wdpu-n&a0NAN<5o7c)ex=1bH$O9PrFuMp$kw%i9`RomK zQ#crV$e5`Oun5e<*F0OP4*8k7qLxf}eu9&>8!tiA`}3 zNWdut>0u&LhF~?1gkWNegzydBNC;NHNC@BckAz^2i-ho%EfL}qG}h`;V7;8N!l5b@ zI2(p+`odOa=ouS&CWM}v(31&0&7o(WOU3csvI22JnCQF+BZ8R%MyP2V;q4G#3gu2c znm39ucr*AJhiI73QWWmN(*^t~qL4?x04Pl`Jp|=&bfQ+huoC723 zXDFwNnpNd01sAC+s@h*MxT{=B?iJ)-L+&DSm*Rd|55QB z@&Cf@j=}#?Lyp7$U5Ads|H=LN|JwfL_+K^ba{M1vb`AbN?9{ z;ev03)c%Y5*W&-H{SQTZfX1OQ>2};A-DCS9b+#yP_3sz`DeA!EMEsybz8i4DfL;R& z27IyYXZ^n$&}+c#0W0{g|1Se>8CZqz`~k{{f9KQhCgI++<>mu_I_Bg--wnK} z)9g3i$p7L@m?s}_;dMt?78`*1xhvUdF}QuyPQX7=XaYJ ziOjwk2?%fYWA0?n?HVs)8?w)Kjh8KTjTN~?9oYrlrTvw-Y)5ywvJ1P5?HZR|+BK%T zaQ4Z3Y&OXI^S&&u1z!6vE26URtew(b%(BoqzFoTT1=*Lg zKgdvaTo{{kx6Jwef9lDOz&refzWvT@l$$kpJ>g%}AI3>HJ3pKcfddP&B|LV`M`4qTCHi4qG?%UW|<*IM$e{(E0I zOLNM|E|Ity;#=3+v*lf5PzLy$|LeYF_Q8A(>HD4el*4#_4&rOAA5l0&{atJSYvHh+ z?s8{C8VhN)yG&i;vQI|WvM!1LAPDMzGW(8vPmXup!L35l ze!NEVAYx|fF7H2IL%OT&uf+X6?fsR~kvzjA{=el7;rzD>{f{%RJzMYwb8F21xb;7- z{(sYpY=dm||7JA!njfXC?%|)O$v=kBze)Y~GvL2!9o$j~hkrg7WIv1aA;QyUUyj88 zE${D*$KR@iGG{;eyQThV>ubw^f4Ai@|9=}RLS8AX|Bu~&K5lg!Ot^X{w z>#X$05XmkL(>wD~|E%qQUh4n0^?bjx-{$>)?)z)w-gQL&yxzZ(Bb@vGO5Sia=teeM zt9Vz=n52;TJY&~4S@Zqcy;wUY-vlrJ&*z8i{`s2nzV2nReER=OUuS&iDxLq2dXo4b z)%!oIHl%f_O#nu$=-O2Fg03s=dD|Hg>stNGWy>~nu!h1ARC z^|f6~{C-N;oZW@vNkRK{u|5nB24cL z+gvO`Hw<^@3jPfV7z@nU{4d&z>{}w{c*H^@{(F30xF++G-B>Gfwt~OCB9pc@`-SMm zz5M_0|7AbVr)9s&`^mQsceAgq75_IQvKRi%0^Mf+cQXy%HD|6 z$kq2ZJ%44c?pkG*G|*k+WjM4r05YAdA>EHw_Rf6%?(6%175i7_`M=6>{qsKmSufX0+lzp3HBQ#SkQf2SU*MIC+=-(TdOh5yQM>?u-IkMC4c z-Lpju-+QIGd$<$$o}l54cv=x-nID>;n_pNzI)&~9?#+s7atn}!)IIU+gQphHbUZ0M z;H8JRqr72$WMU<@-?HCUn0umoieh;?c{?cuO5yMwhaWLK7(QnBxZy3s4<0^y_{8D+ z;a5NW0NxsVjlIs^uzs>1w^!LuA*L^C`q=!${M7u6*M_M!a&`j7h0>Po{h#;L1V6|2(Lv)4F=w!X8DlhihK4ssT1+dGds ztF&o~#+GI;WL)R0nIp3on!7+7rn3=>!m9C%M7(a^mVL*(9nUiGA-Z`PeAi%$*$qfD z4D`!N;wi(!ZCnPc%)ZNl>^JN*2;)p4YM~k#i+-t~71biZ)G*2TH9(sg)?T5~WruJ=GJG zw0feFW&`k);;Fzh9M57r%aydb0iLXqwzk8wJ)Rx#?1-ll&pt}p8>OUy7lRCkZO*o3 zm8_bj*v@P*TPksfy~{o|nc2_W**w;~%)HXP!Mx49-CSlRtukx4wVk!SwS%>zRcY;G z{ba3?c!oMLj^MvkKzfN7>vQ=igAYioKwqD+VNE8O2uzp0;< zU5N4>?E0+2KFN+?KV%o8t|b_iN3-W5|G9`=Vj7}_NVdOu<$bZIm%Faja3sL(5)DxI&&2q&?&l^|@&r&>(qRmy=5ghMP`v}xN zLUGs)*(Dh34vcjN#<~MNIu~QyfidpD7lqR>6G&?(^c!=3A@b{+69@ ztwAo2!xTBMMa~7tc`b4-K+XkdaS2+y7P%K7_qE8qK=mxn7^E&r|Ny57AFm?lNLV zOnKTEXN*&xF+MduRi0(*vGtVa*amDv<$1twfbt@Tq4E-ov$*mKE5%&$syWCUq`YPh zHU}%On?uc^${S{xS+2ZkPBbSfZ<&+L$;#X2zUIElJLYt=L3!7F#(Y6}-{Q;JCswo7 ztbA(CvSultS?yN4^0{@GHDCF{T4XI!zOoiui+nmdz5djFRU+=@2&5w zACw<#&-Rp`?IOFU@{8TaUPoDDZ*3=3)gEt;S1o%ld$MZV``S~~0{cMwaJAUJ%wDLj zYu{ksp!TzGv~N^5uOkir=OT5mbF*`cI>cG#EK`R%%bmN_t(|+Ed(>^5$DCDa%z4^*R!utJIp3+J zuHhPLnOg2QcKsz=5cB3g`6Xwg)aCwM|4{!Z{{;V3|7`z4gu}YF^*jBN2j|1dnp)cpe&Wn>zi?P}@`J#VI@^$~t?hwQ8D-<$q;{}c2otZ`R_8qv@3^nZWUGZ}F_&s4-s^}j;QSAJuOh8d#)4|o+Z zV+;ph^srJ7&gdm2qr9Rz%51d~^TyTcq3WT^8|pRcBg&iV%j(O@8ueB6OC_uRs%@JJnbgY73bR71Ft;Ey9oin&a%;IZ z-n!SiSDRoxXg#RyY3sJF?ZxLJZC`s`yPsBLZ*Fg?W$dIqLTj|E?3C7FkFs~y=JI(- zJHp=IuGfySo9zzmMEeN)1nqSDH2Vzg0{d+HLhWMv3VX43rG2w~v$oW}-M&M+(Z1Wh zTf4=+&%RH))qc=^NW0B`#9pZ_vp=>!)|NY)JDY2FIVDc1b~m53w0oVI&P?q-=S=4; z?SAJR=N#=p=ThfV?IAvEX%F*ROIzXG?>wMA>OAZ`tUczu<-DUk?tJKcs6FX?;(Ve# z<$UIRrakR^>3pd@<9zLWtv&1f==`WX=ltUQqCM}dc2;XII9VsFz3773)?V_ey=v`c zVBOJpz@sXa$|zvqcx4~#$)AE1YYbMbhm{MIM}U7fsl{p^<$HBKVBxPE3)RW$eDwr% zZ*c7AsZ;q%29Eto;N-!;$e-1v0wbRWMvhXy<7=DNkK?5_K&!!TpjL<9AdaKjmfEq} ziP|XbB<)mf7wvRyp|(3;`H+5%c9T{Q+KYxI%Y z5`AZVqV}`Cw_dMXe2vt5@-&}`sTpwcl6;Lv-M*+X6xtctM%XX3k--~`b8Ye z^(%}XMt}WE!#7IwJB=NT9rU|6#_M+*BaM;zJ;rEbw0^HK#u%gDXN)z*>i6>%SAW15 zZ_LymH0B%U=x=b^(7!hxGv0=*@~yGj*o3J}H3oBPF@~@rR%C3&X~r1JX~x)^uh7OY zzCs%@HiQi^;%q3}+DPzq+VEL98*Y?vx-!Z^SKAw$a`rREoBNy1#=e$n8OBUb8^$cq zMw8KMwOAcSyEVr;$T$#Gag1>oDB*hJc}mEAR%G8~-@^9dc+4hqJZAfHJZAfGJZ4ik9a6Fi$2y#TPCqsqm_2~a;p;Iwh~qaqnBzA))EViFWQRGEoJnjxaJ`uw?i}eH z$&PZ4c8+F8J7+j&v12&iv*Vrfob%WT&IQf|>_q2c=VG=1lyEsa$+^Y3g`LXjft|+b zft}9jft?9@Si#PAo^qaL=Q=MrFR=@q*PPebMa~<}8|-4x$-C?lPA6=k^QrSGyTbX( z`HEfXeCvG6u5!M2eq>iWzdFCN>-bSBcD<{+23zEU{bP$cg|H={j??f!h8I+^x3WZC zN4XA^aH!f#(7-%F14n@dURF;R*#9)Jf0X(a@P2=7d#x6~3Bcr|wLJx{&lR|Sh`{y3 z1+Jd~Tz^D6Q(*Zm0?U^PEWcA=`AUK1Z*eTwhX^b$7g$~;uzYuc<$DM$p8+iYMPDZH z`ze9n<90>n{r|e??&VtHwZMOZ`h@D`TksjWNuK>)!$I%k-a&3ZqhA&GFt~9PbSi zc)t^{8F&v?Yb1-&w{tppdhTHTYd7=;U%{FxyxtsO1_#E!VOhpqA@cZ`Rvb z#QL(n#topH4UNTuT5bllY-8LasAU3zdAE5t>uKI+-p6{G510?K-sVbkCF^TGVLrjuF<&!ZW9x!n zHrRTcO4&xB(qq}i*74Q}tin3YI*pC6&alp4+k#3TX4_ehSdX$v)>GC~Y;Wrs>lwBW zr&3Uz4w`HPO|E25+OONMn{oRi`x7(4sm?6nRA;6@bsLyzXJcnmbEMPX>2L1DY0uo5 z)1Eno)1JAjv!hdKj&-V?YIAo^hvqm=hvpuj!xnQqbSQ_J6P?4Iqs@JtQ=C)GTJX+i znRTGX%gyP|70$J0yK|Fst9c-3Z-sd%=bFuvIK7#ta(Xk*1iig$p2ca*yqMFNc?qX6 z^HR{*kLG2Zy3DIMb(vR#x=iyL_b2x!a}lR5^9FCUH`-k6P4MJ^vl)v1^L!a|7ELx@Ed&jR=*5hHTasri?_OG@GXPy9DLv46@yph zYFf4xeuJOo|9}5C)I^?_2fsP^y}_Rh{%Y_KgI5n;&2w-03s1rC;6MKj(T3PViiY&T zyKXvU;E;j5#E_vw5~Aent^RAjA^Qv|AF}^5XiUhj~7^0g1Cg@3<)`TPIa zI~VvUi>v=X*Uc^h_;>$ymM>i)~hzn(k0{Oh=z%2)54 ztLRid9e3M*z+F~8-gA+eY2Qv>7nU!K#LA(&=+>0)&_(8wMtb?x?a8BZS)`$Se&oi; zt&uw-iz7=S4@QXPBWJ6?g`Oa)w9r#RcUZ%Da~LDDP9gUwKh^ zh-?4yf#oC0hm=>A59gY#t)GRy2y#Q(gH@<+oKX%Sthwn9ItqAbh<_*aM`R&o|O(%G)%pJlQ?q;qvA5*%jcq z^3~XXS7Fy)9NF@9UFk`nlpH=FRDw zN^j$RY59_lcu)KFNIp)=yGCMg#XEMT z8%sYa-CAZ~DC{I}h|3*iJasCq<0M7dc@k1OqO}V?{PkSfRPX-CxUb{FuZt@UdDq}`xm;vi*^IIo z;Wb~!MIH&SkE|`56%#JHt;C(B* zCBi4TA_Dh}E4!V%R7D<)vkhlszKX^3rt4DSp8oDF2IJAWDWuYfStAbaAUJSh&dMmUpv^m@% zv>|wPxKC(v=(EtauoLbO?jPet1Z@GI <`puA>k3> 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
t~BE_hG(`#^9KB+x+p& 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∋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⋘$_~=4Au}%U9>56K}rqNj|XXfg{ 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 literal 0 HcmV?d00001 diff --git a/examples/with-swiftui/HelloApp/Fonts/OFL.txt b/examples/with-swiftui/HelloApp/Fonts/OFL.txt new file mode 100644 index 000000000..cc7f1713e --- /dev/null +++ b/examples/with-swiftui/HelloApp/Fonts/OFL.txt @@ -0,0 +1,95 @@ +Copyright 2025 The Stack Sans Project Authors (https://github.com/DylanYoungKoto/Stack-Sans) +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) +Copyright 2020 The DM Mono Project Authors (https://www.github.com/googlefonts/dm-mono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/with-swiftui/HelloApp/Fonts/StackSansNotch_400Regular.ttf b/examples/with-swiftui/HelloApp/Fonts/StackSansNotch_400Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ad4c38393f3ed4aab4aa00801495917d0f724a73 GIT binary patch literal 78128 zcmeFa2Y6h?)i*wKx2afNt5w&oR@zn97D=n#d$(mr9jR_~mzr>i70q9#z-CaGq8n3^Hv7h4p%7CVx1+`bbap@b3#V0Zr zkk{3d?-$u|)#%ymfwz7To@W4>6Au|fkFT(Sp)c^!63%CvU<+x8qIyVd98mhqVx7KSsE0@xy^W6N0p-TD1f z5f*Yj=zfhcP3g=@?%Op6)j>ZDT;grQlHZLce3rV3Stsd5fYQAzS5)OU3!2t z@WdZ~wLGl+Gp3Sh{`1tN5_(k;%)h#T8aV{4d|RJEdr=bKJz~2 z;N1$2INiY5skqqgfpvCuHn1m|@zyqj&I|Svv={w*?a6m91o+lI1lCNI9j<4$9FlN^8Ne+rs9VKWCms&`Z$s6JOq>QHsO+M>3rE7bGV{pu0*dFr34Z&%;1eoFn4`h881#;S2@ zsx)nyUd?%$pK5-txl8ki=2^|3H1BGS+8phv+Pk#x>QZ$}bSrh^x>IzI>3*;Kv+h0J zVck~&nE@36EddJx1_CY!xH90DfO`WT4|qP{^?<(z90~Y3P#YK*SQ^+EI4`g-aBbku zz$*i93A{J(@xbQ;Ul05|ND2xKiVv~|IfJT#P7S&%=;fgIgX4mi245QdTJXN$L&0Cp zQOyaP6FqEDO?hd^;^qSCL zgzgP}GW3Noby$4ZP}qjB9bspOT^e?M*e}Cg3s;4Qg`2}|;bq~C;d8^6hOY`A4?j8l zyztAzZw!Ah{H5?W!v7f|MTABqL|7x75tR`=5m!V!5%GtJzeM~a;z*=1GAFV)vOcmq za%tr1$jQh%BOi`Ridq>$m84=`Ye>rN2l2XZ?Hn!}_mcG%<-Wr7?{$^J138tcjV7xiseb zm|w;`5c71*%Q0^n5)4*@(@~TH#~26-SBtA5yRK9+SrKLq}a^Z zs@R^`HL<6}o*R34?2WNc#=a2y*Vuo=eiZw4TwmO^alecED&7#^5`Rhj-SI~g3KO~# zu1okwB2RQCb|jvbczxoZlH!w!leQ#1mh@(FcJf&AHOUVrA4oB$RHjU({3_)cW3aK_ z*kN2~Ty9)#+-TfpJj1xhc$x89;~mDwj4v7AH6Ar_Q>4jknrj+0U1j=<>66rq)PdA( zsZXSSY3Al2bCfyJoNmrDyUf+*7V|vs-*WSad8_$M^Uda`&0nNtrPZb_Nn4S&Hf=m@ zTiWSq=cZkfc5T{|X)mU|m-a!rDm^JZH@zagHN7W&dHO{9uJmiu?@WI({ZAIPrN~li zS!6lea=YaL%kM0Iw7hLiw=TEdVm+LZpRq0D*BOU0Q#1QBFUY(v^ZCrbWFE~*%Ccp( zW%Xr^XI+r>i>%*fy`1$$c2IUo_V(->vmeWTEBlL_!ki0pLvyXU)wzpvH{_m^dqeKS zxqr(2G%q@Dciyvkhx2>#_u4{i<+jzfDcjk$t8Mq&zO)C~W9(+T&0cR`WM6CFVZY4& zh<%^^u!B4FjsnMA$Dm`A<08ksj>jF(I9_tR?s&(s-*Lq8xx?+$Izyc?&Sa-W=sD7? zq@Sa4B&{6H)M^^%wn4vXSSibACF~T^TRe)pcqMO!rZ|OP$PY*fl1o}FJuAJfQmX<~ zb5xP4jL@oNQ}XWQYm*;KK9>BIG1?e!Og5$(Eyhfv%~*(7F zR8ybnic~2zEHyGUJ~cTtJGD8rKXv%lJs6o7w5<&#xSrj~?&mSQgxBzPz8ZP&mtsZU z&q#k&F_l&oguE@t+nBr)c|V%`N%EJ*C}XTK(P%{88OXat-Y1zZ5_w1X zw62{yY{`l_Y&UY z@9}*H08#Io-uLys&-OjGFMzRkpMUpZ9PfGe7w=y4?)G<=iMKiU&e-KVnHTX|{;gCg zHA@SnvoS{9EA5r(OM9iqrDvq)mAmKYN_tg#O@aj=y)OMtdPjcZJ4&zmu7N|X3rrtO zdP#Z(HQvsz=U4Ds`Ca@){x^OlKZEb$x9|t~)%;X`6~B#N!>{E}@JIRM{6_u;WMvKu zVNnNSMfl;8;m@YKh1aW z8~CsIef&=T0RKCGi+{u4<_M+;FH|V-{dz*T0YL5{Bl0P`}qp~b74!U zF{W!+APdHbnS>ED9^-rgB;C#&ECyp_2Ww$%7}MMN!>pUFWh>cgwuWtC^VuPG5!=Hq zz_@-PY=_I(l^8*9WH+&!+2gRt9$}9{TK~k}V1H(R!HE7>{s?=Eea=2%pR&&&QTw71 zBCBVwutxSOYhZtb-SQ@DWv{bN_BS?k- zoxod3tEmHV{h(T{Tythr{ZqMywWoSrxmK}S)uqa{n%PuC%C&~Yt2}hHEJu~Bzynx- zDn_{uWU11p%5^ZykX}%(LwG8`UAYcrMSN1Zj$?^@k$jE1&f)FKwGMV{ymB4Dq9HG$ z{5%kn`I>Sa1nGTVxkg@)KN)9^uP&&)x12l_J@s-0Ck#DxM7a)!^eQi|Y}zvgiEov3sBq)}uH1uxH=@ySzH^ z_7L8l0uBG`f1hVN-dKgQPBP!szkj0vy?qk0INNsC$stM%Q zsNhk)V|Z&48h8LW!+1ueGR~qf1jrO94*_Ew*OQ<`xINH??KpQS`TJ8QZu-iQ1FVJO z?FqoCltyUOJduokB_po*-tv@Y0As`eo{ZqQ0!PYwGw!JzM!+cF8t7!Yo&-;fqCI~+ zhi3D29ZEWhlqXthN=Vlj#)38lPPM9M)RrXqM(D^f!KYPTjDOdPxzMe{XqPGR{`d*` zV}t&-35k*W81Y>0Wyj0Z@sxrn3v;KMlDMD9yQ z=p-9bA-F_;M~BhwlfbS4Kd4G4%I* zzjpX;E9CptJ=e>@Oc=N6tWes+3_m2PehR!H=92!xQcjqz!ZS)AvRsi`aQ#El+n`$w z+`oeE_ob_uOV!9663!T#U6NHkvo~kH7CwSEXFkHY2>0(Gy^S=D^Z}pCOr9i-<2@wI z_m4|b19M>9a!Hq-ARR@Sen3($2HqszUxvC9{%&0Rr_-24I#Ie1c=J(z|8zF$`9l-h zPW9|}le7&qmwZp^VJ68h!ICT_o{0KW`%)WHyHT4^ojmoiqYghHsa^%%TIQ6Nvcwan zwSZH)59uVN-XEM|_y*KzHSoVL)gs^XS)PP5Y%wQnIVH_}#{M|-Ez$>o*>QdYXNL0^ zNdMx`0`Ie#-?49eNs0k2Bp(fNoyWJcJk=PpgFoL*6DZpcNa{4;5s&1Q$k#KwlIS{D z3dZvjrR|X2%`DbGp&a=?IAt)sD&V_GiUeQr>_mwlfvxD5NM68`JW>B2AP+)jeqb9@ z`-*l$Tlm+Ju1{1pf~m^U$5$f#4064PrSSbMTB<>Pmc#CBW^qzC;Qi>60f6=3jB76r z!8QSZ6L`J}?Xd~==_Uco!#PX2x6w5p!h10+gy*qPUc(}JJ`3h{7RB4apAf*C@w^#r z^8@kNrkRtZhgpd92-3SOQhE*NFL6H1qNLkE?+4QPp?H|%FiPYbro=C1IcUQi)c_+T;7+kAde)eTm_ol9VKpg?Hgu7qbwKc=oVzej0Z3v(ORq zasLkb6p|DOp5r~t)l8UMnfO^~yUS39K{*LHo{8~i8RiEMf=`>7MP*>|(*2^Pe)4stx7X zhJ0>B**ZaY2l@x}mFf;=)|4`{GzeVCg~}+-*~)!3?n!R4RY(K_ofPRBl;tv%4}Dq{ z$5KRMpnnqT`!|ebNc>*#9f@!m`I;C!2n;iEhIy@gorkkdNs%~*ArW4hU!pll3Q~rW zC=OykYDZf{!>bSl83;tO@->j%Ql=AW9_HEmSQy`kdDRw{#^caN{{kJ3H-orCN@Y@g zWBYtAjSNhVVs8E=3#wVtU}Ue<)yy}tJL=})_)1&N5*#~fnvLx2j;?ki8|rMYH?rz^ zT^+baXb4Z|&uhbxF%eyX5W*9$LgYz+jWuh2}uiUds6g4m~GRY1O4onQQzYh+s-@x7)8Xa5DUSByrFvwmQS+i<@ zJ%hN_2zyc-9~Q^G;&`Vx-Znb5ew^JnHa;}UuHJwfcKO7b(Ut6?i3yvXojEbJVuJ0Q znA$MGwoTI0Nl_k(ad4$eL;YMzP_rQ$kOggu4E6?DWJd0=Z9G=Vbcs{qm8(YPz`M;1u4rE65#mSzx z^WddAAAYI};jg+Fo~EC`V|4>O1wR+Qs$1Z-x(&Xi+u?g7zuRH>c0OW9;bT*C4cBrV zd@hCXK$h{pL2fCoM=4%8t1%*I@y>bZY0q-_9`Lsx$y3v4^tW^Px%@o7ho8?c;J-xe zC3q9z#pJic8$vNF8JBY-+Rb~%v@^$m%i-s36&N0hp-c_`o|6)=)kw|A{WVac7D_`K zQz>3YB8@@xVsMmzC446E%FTYl@xCwL%9+mY;eb`Awir&$uX~ zd?dZi;jyLmrz3*Z;wZ~I+6?i}GZ4X~2(y}1WB&dEe2<>ID9);ee{cvM!lMFX8RmKK zLk>L{9M34na3r}G(;d~4WL66})th446u+Q0;fx})aw~D*$naF<8No>s{bQm;?*Rpm zN}mR;@fIZM6?oVlhu8H+c+Sp6{9_ztSp+{wBXGhXJ5+*?&^FXdT)Y^uiBj~Ia$Z54 zf|UP~Y4`@d4Vqv+WLd+<_*TA*_wW{`<)eHHU&`kpl7a{eU(Yx5llWZTgzFLLjwR3? zjksRNrx2Cx;tjZ73vc!!-pT9n_8R!MdwB=1<5jp@4e$0sL`7DnD2^p?%RSKNR}uc& zfj9Cdv_uPU1?Lumvx~s>C1{bQXs@|!NEC<&43)}$P<|X*wUzW!VmP?}s%i~EbUmiISJF#K5W#Sk=;QMp1 zS9lucGppF2IK~o;2zNoYo#0CW=EY)Ufp0nsp8s#;@dcKe7-67KUW2y(Gh5AG7aCZ! zDfCaNN9y5oDo5*=u}4VySq0>9zZjLi!(mp>)}Sw}gC}e)WV9XLwDpLVG@wtpA(mq# z7pw`A!C@UYvkjcQMicOCY*gfA0X&hd>?>a@u{LTa^vb^=Vz7*2Owhwqd^sXL1AGPR z;DdaK53^3poVw6AR>K@-9=wjpx118XNvfp2gLVr4t|Y5a7= zP?oYo{0#OmqG-F>GJYmIAf(7&+Al;rgQ6M~-?$7hs-MDoxB`*4s}OUeXxnv&$lZWw z+t2w;hj8ccdih?&a%zQb z*~j`3g{nrx=L7yBqCST)%YGW8*Iq=g?uQm78{~0Bu*f!!fM!iX?CX~p7k-YY;*HR; zXTd(uLbF#PN`#0J^wndqEjkkdBkUa$Ec(k}?fe8b&H${P z8dyJ9BTjb(Vst;l2=xsAl7Gd&M$GVAK8+aR3=Ij)jIr!&*kB6NW0Q92L=a+ zM<=!Qg9E_AX?$#8QrjTzf*QQfbqxxc2APa*YIKdgp|+0BHhXcSwn@AZ+~lJ!KXN#N zntW8mTMnDLdBwnZaI+6rYH8KB$mMNOGHQ`C(zZ;l85tT5rhIG#H8!Ws9^C4CQO?9! zq-`Gp@G7<9dy&xHe3* zm9sXFCPSD^%J&6@o_lR=9!-R__427fJ~hgxZuvAF8xBDO~GU0SnN&e7?TrL9PowzWP|a<)#Fc0?AE5x*vP)@es& z=%^pGph!C=3&7ZM0x%{QL5ry?iS4-GD}^rYgn$N5%*xqeFVRl=VHP>%0^4h~Q*tI# z$7M35)N@LavMC{DQ?it8mh;%`%Oh~JSIRcaQnuCiP`lM%%1X4`#Op!Zj^jV1tWJ@# zI*(K-QdZ=-*A58T2_6sxlxtCXaUDM3%ToYC2Rx-xa78)9^&Ca;lpDF0;wET-s;ayo z>MO3b!!l^tFK<~K1w4FscHYCsYSrj1_59TP6EZ+#htW|o|N&(ZZkxy&o z)1){B%WW&J=ZL;q`M3CDkE?8)gj!koy5b?t;dx+SZ+HnEqg;_iMR=$SU$OY zVEN$kb<4Nrkpm?FLxgw|Fu8e5Tm(&E5E&Jhx^2VbV|kM+wSfBk~Xzlm}fXdC;YthkQkO2t3L|;K_N2OF0jLDDn_j;D>;( zo+C;vUrz+D1SREqjv%4jP^Wd+ifzi-uAIe?0l4VYI2S1JLgiefoL$OUj6A?Ak?&oC zp3PP(?s1lfAX}|M&!y0FDfCt;gyql=BH1LqcP~4VrLbuqXu8 z-PYi2R5whGkI9UvRVrF51`vnB5QnX%Mt)vXq8k_=AKN^&LEPI*T+kM{7v)eu#eo7e zG`4wEft1J)D!l?Jk|8U`CRfW>QyYdxLkHHQv=alPLvoj);yG=Cg*dwcg~AvdTfZVu zbYHq6a(GrN1|6rZE)Q-_1tvEkV9LE{GT_z(3Z3Tnwy3ksX;xHhMwI8-S~ z-l(XVa_cz$wn(`R_mV&Ud4Y0^xuzDeDkgo5xPS`2oM2edJy8Aj+Xa(X(gBeA7puB=jVxFYO9%3lfP%EiOOy3DRc0P#R;f&S z(^qT$I>EObn0WgJzSVsey(}kpV?X(`#ha>g)E41w(DC;%o1pcaGW<+0{0MAvAKZt3 z2(v;T{4_8Ae%S9mxDWpzW(+>~gctu~TB}F-&J;_#rANK+&-h)!?=F|;8ZZ1K_zJdq z%XzvNepIaa(^C0WYrPy%xLo7p<|y!lJ0r}*8@i@5Uoq-_eDlS8t^0BwJ33my|KU!$ zhPS%!59XQfzYxE;)Gx&o>jvD~0eY`o0|)-y|(@2R%4CbHs8^Da{E`K{gHW@*1V|x zh@6a!oXCE2Z>PD@CdKpYHfLvVTCKEg`b@M|%FI#eA?XFohFDFgWKOkM3JWSFrz6H- z&QqCFwOW02OpMb}iN&`M={n9C9J{JJchPC{&%Z8wN)w;gHC8(`r`1#2C=`x#pnVy)N9f_R7J56#L@F z%!aLo%ao|3TVA7`~)2{t)e?OrT>gy zEkMhP995*Lp$X99N8rgM&dYF;ZW(?UUPvFDT1ti=^1?%@je+M{r7r61SOXG!03YO&E#{+rQJ_Xt1a24m%+h+jl<5AUo>?|lo=K+l4Zx2IDMX*g zcwCI$qQIG3P!e$Tq7c#(j zMsAxTz;6{APo{GKJS2Sj8sKI>ItOR|Ea3Qlk+^h`0^>n*>QfA$Va zj}=9XeWE(mi~ZHip9CMEw|G4G5R4HcB3dhtRD}^dzPo4Pg2V(c<*oMizC!=f9 zPl~b>gC1#;ODRtQpX&$z0;?H4?Y_|4f^z$DD;(-(m23TaX^y+Z$Oq2hsptM#X}I6+ zWFX=qCnKwUGx?Xec=%~o^PF~}FLe7}) z80{NXboN2-TaeuWl{3m|j&hnjGFKLmTw z2d8mMhVMrl(+BtAAH?dC6Tv?~{J;mN9w*cJ7@lyFa+x2mfggHUe&6AU$%?fBu(=8g z@?esM!sbhsW^u*+iykSq$Nfs_g5vo2;svE;y(LLWCB3s4R4-lMF#T*!`*Me)uPrC1 ztKUs}NT?p`nN>;J34Q>(5Pek*&Y@MOA-4PoB+FvPel^Eb-$a`sVlZG0GWb z=47+%+O^BSqPV?z^qQ01FOKr%sI1H3aOwNA`sdGEkJ)DEPKn7;oq&n!iiB{W3R2NZ+VxkVMkB<_U0*dM`uU3t-G(U+xC+)=APE3 zdN;emRAf)J#6-pAc2t!16;-TjtZ6FAPs_v?usLnz10z)vV21T{7psi6_FZ@%)V5tYrrgO-PA9@`AF+_H$*2E>49IzpdpC`HuCNzslud^1> zGzOEnulw^`vMjBxvWKJGkB>^h563JWb>y@Um-I*3va<3bdcI7bzDatt|5&OfZ&6wK z+&q|Q5?RXl_D~+R#F$0)(y`IewQamG9B4V zA-9~zZ^I@OtKqA^Py97^&D#@l%R%R$-Bfog?MogbFJ_eT#tZk2{xWyym!pf6v;3yV z(}Vm41;N*iGGDoD%gu%#!a5bIw+tu#$@u%Z%MVVYs0=^I+b}B1^%(LpMLynEBpzR}T_`C<3z{1kW8HunQ^ zd8B$#{>_S9$aXi%c^v!@qW((x0jKiI`1_Sv?QHymh(7!9seNSpqcq#aXb-(AIZ;+M zBFD(U2^=`*TKD7ScD zl>20*+_K)3;iPe7xThz|aGGn$@B@fX(+sANU%>8#-c*sESD6b>j$R1@K+}HL zl9l1#we!Gz7i#o>&ujI6*r$VbqWb-}{U?eg{^RESa-RPuZJPfd+8mHn8f8^dHD+?z zjP-hE9(oJ;HHF)!5IUm}J`~w}Fv!+}R{xo+*IB*h)iV)(=++Tl9kol)|NM8rNqMXSmE^-egcVUf_-6{fmhWHdgZi)!V%?$-e!z?U@d_DQjMXdE z-aJ3!MXlw!M*^_48?5Pi@U2@6c| z92dEqp^`~vyvktkG2WonMveP$(L&rF%Z_wl`YPYG}Wj@s3TKHj$O$ zg#2_#N3g;v8Z{PmwOV~1!gS1fynbZGTh4o0mfEyZK*7?6j*XR-8{Iz{x!K-eu{79S zt!Zhk;Z^JE##=_p+t2J>cxGF;bYwbmo?~%o`4YQaK(h4`k!uZj3{5`l@yg5QE!^^$ z5`txlmE@9YR5g=by*rzlcJ{VU)~d!OW0j+<(_-l?E2uF_A2yHIfDgTAw%4tzG*@Og zi3!f^Mk{1f&IQtgRo${|zAM^Yz_EnU13!!vQX~(Fu<%eSFZ@%ic#`2%S|6Q5UVMUo z=*2&PwRpr+qF)T2hGOMcs=xfi7$UUV+3o-XSj=MuCp)T_#K$cvaJ8hTx43Lg>FG^> z7Ojlh71`X|^IR(m78jQ+DX=doFJ0`IN38Xf?+xh)%5jaT=Z8`e&T3JPv%TdIK4J2; z3PyilNkpLM`%80-*SQ}f!TUEwlJ0Hya3NErf$b||Kk^Jv&fAJME<|slSkG*Bs9Xje zk8K^Vt{!i#8BfUzt{&@NdC{WG#_f&y4OVMIer7{vW<$iKm6wJ$pE|euVxiYYo|JzP7?jk(<(x0 zc)TKgZ~3H&q)&k##)`lb;U78jN7qRy`Q4%%;&becf=?+BuTew$!wEv^ZcxP-j(Em3gFkY(>w!c|9#XJx4~=ji)W_ zJ*{cm`tiDzrEe84&bQ~L)D(1$mhj4z3l^>#T(W4eS&E!KBDhXv!Z?T>VMqrZ!S^yU z-vJjoNQNJV1?zi6%6*$Cmkf?AAo1hM+dDf;Arg~#7!p-(cq>E zt82v^ILazVLu2%qV=JSTa|B~cWNv3!(L(!3WPWx=b1rEbZWxWPF0xdA)@yY_Il2a$;3Rg-6Z&2l~1GpbPJuaoORTBB!V#JDA4i)5RAMLL$SjrO_2`|LW&20T$))Qp}I zf9T0qg3k})O3UAm2NYi$Gxm=34f2zrU%o-mZvlPX5BH1L2uksTgVJFy?&nVtT|1yR ziZMZ^a3Gm*-vIPFKMDs$831IjO4LR&?$`Gb?iYww-07uY;h=!>efC#+hPGvgrDNVV zzIuuBbj}p>OH`qNK7i*s_$BbA9+PH%2TD(YOQP>I!4QN8)9Nymoi{R`uqENSrP9^Y z0jip93EQx1BH=y`P9f^i;}`XKTm(H5!L{q#+m|Bf(T`F9mm5TFo<=_4or}sw6h8HX ze(|xeFG%c!#L+qqS>{}r%yP^RM@BAQ_2=zF?kiHbT1Z%Pr27`h=I-2##rL9Kq4X~5 z^(@tkA48;ytOvPONs7I#s*Hs6DAA_t0WoINtKgd5%aL_tp<>Kmfkp|w?H$54ODRchxGd~c7^oB(F0 zUKWwrSWwWJGZK=KVXm_Bc?r>ZEq(wJ))RC)zrNz>h~Ns!Le?*7On5mJ6VBJG(ZTH_ zb8=e?eZ3l;VQHd0#lJ__x!)(g$h64fd0FA(9|gYzTxcyBei+{w2z>a!gypT^AH$AK zJ~|jh1YFCHju3xoY*~bohLNw&&n^%#>Dl%GFO_XS`RMA*%Ib89qhf`~X66dn!jugo z%302XYVev;1N2ufob<5_KY}&xKDZD65PTj!I7VLue?KDjKDZD6AilIX5&Q#oiyxf& zx}5LFG!8&+XRjF{fL&qqcSjraH~$KumRR%2lP-J7w#=tRVzboi)=RDeb$xwsw2( zciZ~OU1+(VCc;5Ls5RVO~cZH z05Wb$Pc+LYA88q{TUT}5G{e20YEAtXy`NLRRiUP`CKq~5kLem|v#^lNxpigpbA;Bi z^u*U!WsMlyWNm`?sIQQxALE6X+rwJmi-azbp@;P(o4N4RArCL^u^`g;=p|TzP%R=N z!r1)&h=?2#IaAYFR9IuJYHP2u6tAwY7_MBUZgP~CS*qIGs;ng|n`+lpN%uG%nOX4( z;d3I*<+j|pcKgB-r#(L{Avq#A(o~jT*qP((b;&vdxl{S~qx=*%zu(iR`B6~=f@}F< zv0e&oLuK`1e~NF1lwRjYMavH<*rCT^AHz33RAUzyeiFtY@O&b|<2{Jh>XZd?w0w@@ zr|>U$FGVCZ!etxnYniOi$TSt1uuiV2DY+~^F|RGV$!ILgOX#f6D^3aKpVW^x+K1Zn zyP|aY^*P;r8GXSC`Q}`EWS=fN+gx31bQI~`Xk>}(M7|ilU138^K#rVZjn&A=Y3?Vu z9scU;m-5$^E(Mne^L83bxm9rKLwpHH^^OHU)>9Nffq#mfl4N)&QTD=*v9)+lV)+;y z!iCKSiUZ~dr^-MXW9 z@2Xp=W6FtUON9HWf-B-BJrn^2zEe5tC#RP2IXj1TMh)#8;<0;{P5B6T2!XpY9jgDd z%w1k6Nn$e`HPIA0T~3`9F_bTFo!smG;Ov66-3#|l3U)lV^y)94BMPDf;$75) zXUjFgSA8dfA7UH);50KQe8|J<6Ja03*O)#y&8TJUqy1D~+U?;r)b7Nw6EnZF%6)M~ z1K-%7ayM1-?UJoY@6KuB?>D&(O?q%mexJ&W`LWPVvTswsNjC{NbW;zNLB=MZfsB0s zkv$Xuk(2vR5IOk(Z=AqF2Go8cdk<~(A8$0t!T+2$`ahm4l3KEnYiOSQzu~$1F`gvh zC;0*Xg&#A+{{Ma?{eMjtswoA z2G7+cc$#1^B;aZD($QtB^6UBbf^Yj8(g$C*wr?_yT;mD)84EZZ`jnfV4}=i z(%jxu9L9InmoKc$&&VoBE3Rp)Xj)fZFq6u zCRoon)B>+ zsdKx__+^C!N%@J!$}AM3xUV>;%PyICUbmxkc}e+5V@8EJr68rC5PZU_BB?;(*c_}d zqSYZ-(}Xo8=0d)BWCS1OsHeZ>!5iJTmeR@+uI-m%raxFhc83YI0Z_~zGw`nG6A?1A zoxXAlcfLvoD@p{0N)~P`t=hbxWqa!em8HpPuQj%{6wiyQ)tXA?zT39GGk{OJ_wd&% zN1N)$s!>==MS4b!wYojmqOUB^cz|Ey9^}`+?vbUc7Th$Ex2;D06i4S5cr+upAj_?R zE0JvBspk)=B9d%r`R%#f7+N;pId4-{^_B(2!yP#npR;yHqd^+2$lP`2;6u-C>UV!q)nlp2tZt`0S;)Tp1f0NhX95!~7b3iRryh$^ z@5^i}q|X;d%1$cS_V%MK{k4(Bw;n=>O*h)?Knw~N z==F;Vae@DZi}d;h1-O|07P5f<&ayJD!oitaR`wn4ZcbJ~UW;4?|46HtM?&pbxhR*i zT;MiUc=Go%m;a=z#oj>7?S6)MTQt?LN+~i|kJUGf3f?~W`%V2kw5lh)HnXy`fiJQmq#CR-RS7wKEpqr``Ksm%CUa8 zy1!raFK8)=#-J3T6HllS^;RegC(mo`UDT4}uM)GH7A$DW&gV}|A8A|HUX+tlWcBI9 z;*O%6{K_RY80k360595*iyb=A*`Cn`P*p<2A5Li2QcUv^5B&EA&9r#en;>)uq8 zy{MiyF3CoJZacMoO4HkFuQjb6=a)t0TcHqBD>4;@*lyRPsGS|T=m5|$a4YoTUdcZFGS8oM?<|?E5A#ryrXQ^js~1m}Rze@{Xx*qvZ^9UD zYHxA%kUlK#e6ww|GjO&(thQ#=pHLrWA^#3=@<;2#r$v{|YtQ>0ec0WsblD%K4<+o~ z#3WjII0xQEXheJ&32ms-)5vlpt9`h%CTCuCy8BrPKWZ@k^4^~m%+DJC=@VF<*$=+k zP>V*?;(t#smYqN^wwUS)3Tu)Zy1H64tt~BW-Su_dKVB~$$K@ZP7k~XfsuyYW`x3i! zt%TP<$+K$9=c4xW-okwSwWHQ(taUhRQV?P`B+QwUU@#>n2H592oIQ3&j|2Z(lN`w@ zj-;ymyejBics+O!c8&Uu9feU%d>1;fV8MK{RIggGg7>*^PmDGO<*Sp5Hl=r3qaJ(g!`h;PCKM|= z!5FPi3JD6!PF?!)%P-fVqp<|!l?%?q(>I%&#d0ydRmg%TPhq?pbbf}zYf@BftR*%y z-&j%GP@P_5ZL2S;3f!9*8<&(68<+S^XsoS(N5|C{6f_yzYnBd|+Rrj1CB+yL;Qo<> zd@$5b_K#t1C5#;!Sh~tf%aStnKYRDx@dpcv6WhnSARFKZNoy9$Xuw#KFXWY`WWu}= za-o$CAOFb(){M!*0|~yVsxdw}F|N6^tT`?zCBCtS3K<_4mk<{h9~YAl8&HuIpBsc( zR#pYBvVwzhan&3b5g8jB84-7}Au`OMkBB7Y4qvid5}8|~FWc=}{oDUdVicAeMg;&e#$GEeDC`=J)2i zx6P;Vj(jQ54$|$QXq8Kk9s8DJ|2^^?cJMvIPe~Z~`1%AGT?$4dFkV3!tk~U-awHn1 zf<_m=Tc$DdrF8!ZY4mt#0OO4l(&$lWpchIn3mSqyA{R&>O4`G3_U3Y~z!2Oa4D7Qf zWAx+vA3aNJoBpF`NfQ2}XaA#T|A+M~b}o&?9-N~>&%&z6;brdM@zJHQcko1t@qhAx z8@Kc%8&7`Wdy*}kGJU%8&dixo%*@}3TCbpncaoPv!D9TK&kn$ zLGhMZp&`AXyjAU?g0~}Q=5w0idDk&ptcA3h1WXiI3}T_SJp5xYhJ&?a-r`hCL4hT8 z@w~{$d|P-yZf-%iEkE*(idEIk1G?^j{^sgc6}1^XwS9}V+Qoe}JsEX!8L`S!*=M-=4b6h!3bM@IH6N=+{;Oix|Zb4Oi9PmN5swkM;O zvg{A&)(udWTo#l36hUS6OI0{&Ub^Lu%;xIbImn`Ump3h(WBezh3?aE~t>wqqQ=8o#hd zkte{R>r9fBmrHK!KZ=-YkD1(PSfinLVml4bCh)i3Wz^Dt_ICG-Tt`XGzX)6Rhlw=CPI|4gNeiP+lcG{^FYY7DRbq2FF-ATv0 zw_SMQwhQ?(`SjI4<$tJ&1#e7y-l#F?%r2{09Qi1{ap8P%v@2~j@YD9VA=d}^vMW5XaDKc%{x_!GSgWOVR0fJ-lPzZb2Y}!$f z6CGByeS035O-2;i{L1=N#OsLy$)aT|{%uF7cROK#(v64@2hr@TSCBya!{V{Yt$X-K?g*)d>M6(6j-lU*-F`L~RH}V5M#UTt zRqN@SmnRZ)>^X@QuBs}{csb+`c z(*io0Qw!)@^Hl6byb|km57iCF#hHH= zcS&GM(7d3OprF7@;`qb6BO|xkY+EBEgYhv?@-^2aB_)@_$QEC>N~bDqCa>1O7(qKI z(Oi}zbx4lsmx}1{)auh$^HbNHzD6?j|Dj*9$X~dkKIH9e_sk1a=6BaWhjlra799Ts zJEHA+ZvAtzLs4+y`?zx=IBD6J@a-h6&eaLOpzvCfru8@oMGQ}@_0$zAu0aARTNIf; z>g;?F`wd)wqFuk9dP?3C>}l!9x3m(so?m?8?Z1@G!AM3EQJxxU1zI_M)=IhApBiCo z)zL~hWqsudzBbx`Z;?*vRF+uI`rrs3BBe@~PLH6UVIANozg1GfzeD@hnej_7*+Paq zU*E{JhbWLZ82skS1_k!Ewc28-vSw6S(yQ*O@2SWC9%sDC7#E*vx*pf~Uw;RtvKdwB zmZ}VEm8BAZUjlk>TC5?}9BVLNgqw!}1P3fLzm@Q@kl5`)d=p`nKXo~N5uN9|E3vPD z;6ruqjs|I5Gk<<}Ykud_WgWKittWMFsvB3O)Vhl6lkz*4E$y)Fd1whQUtL{Zm!EA+ zkBN-SZYe2R;woEJmsOl-$Vf=d!1e-}jm67XRjRVT{WGK@9zMb^p)(Y&$m@T_a%C7b z$8X7o21!RWh7y0!w+GauCO#iKv#oMCR&~1Cu`?S!(m7{(hVOFk;JZ9w#@RcxVehuX zJv~?SVA@RI_jbm>;&Q-?kBB<9)aJymiH)#m%moqOk)hiHa#C`p>OI9>4h#% zd#h1X+;q|)b{eTzkI!_@d3EY%?qeHysk@DT?hfLgf8FnDa}>u^gjm}udj@m*`@jA= zN#)+pf7#!EPk%o+??R4eB1iHri&0}3UyLAnZ=wKX1g*|>dRO|9g-QK!i$3OSuF0-S zbI%=pGONZs8g2M$?&w}V6&E*<+EnbmVlP!Qwb{j2xzCG@7n`Y&y;p*~QqamNSR)Is z_Gb9iXvZ00ydJlc27QR8!>8(EY2)_tRC`QvOyR1w#*xYiby-beg+3v==9E6{3~A=u zQqs(e@iXe>^*QDJMdj<7D%#5IDHT=CS#`@g>XHX|7DFEvGfS=fe-(zuM+b;=k`{L`fxlDRnaTdS4~_g4n%{c0tMEG-EL@ z<2Us2G+e>0LUWfoiSrwPDe$uS>+UQdfJOuRIsb=LtI*&&{3L^wpT(Efxo_szx^J%I zCrP#Ln{eXd2)rD23UXbEt1w!XM!Uwj{Ki<7-?b>qr->FzVnRkn0-Y1BR^)rFH31ii z30C(to-4UNZ-R2LQWk|9a(z%IDY$QveB(LnG++%1ER63p7(u<9%UgHux-EhM^unM! znV(B9fdg4=2X<^(Lw3{V!gTZojXsO-cgIV%9qZ56>>0W1Lc+2DYoUxaoDMWip+;Ir zD<<9XeE*KSF1%}GkLG;joXvW%##iP%*%&CR73f@}PtTF^r~k-3OB;W^bkFLc#XREX zWtWp&&w6hdo9(5eC=tj=S~C4d-&;#<6N`s<;>hL8M2(NjjozYt`qI(b(n)xWGHmR- zudk1rmK|}Q%1?tOnT=I}L6QS2ub#%YMLHZAzI}xxuy3RwejB{jSYP7#yjiRoz;|_| zB=e--#3ruDs!TS!ax%Ur@k*l2=%mXDLn#e!$%x`Sx3p`~kQA zEo==pkmWvB=>9Zo;1nLv&2`9=R`IU$&(pJ}SZ~%(UNOimSFG4=dh<;oqfpwk$0%Bs zR`_MYLWw0k?e|5H!HNd*3`Bl$G4Xl0-&exOZKrVm4}?p4 zax&&sVpS52fo6Q~e~z#PqA*hA)6RM|@R~ydd1VH}!*NTe=yPH3C^GAr)W6BSZIh;= z%VO!OsG#rEE7CJF(=8bpQs#+(U;@{d4OLVOmX!@wR1B3ZZz?KkY%D5ja_=}H82)W4 zv&MijlkSwu+{Sv3Rqk)z0iO4TNr=nd?1ti!JfVd5L{7n$hg#%0Tl{PIrs+K|uPv{y|w zq8laB(}dl?F-?S>gIu`7OEG|I6Czbj|Awj{O5~+KfKO`m^d*?piCwSv5akX~{(?__ z9wIU^iaK{mpUXAq;ouMYF!IR)**im|%IT*vOMMho8s^BvWQCb%b(us(xUS{`{;LaXxek@O(A{%E z7k-0JS9{@wweGLnU*aq%pyyLtf&%d&0_}(C0fK9@!Ln@n{;`DXTL=!7)ylHKv;kBeuGNr5UStTl?_-RbzG0RVbKLEi;7oY z)VuhiRj!;4dv;~&lKA+@l&Fl3k-FM-9T^Qfdu;=`W%}m+#rd6id7b%L^`XImnJ!J1 z!;zI;P|#MkW>0tLdFv`F)}Gx`yExZW=XmE0lL;GFm#vUON!Ily&juMZKFF%brB@7FWyqja zXHwqiv+g?_O%_YDv~i!vsL+`n&MO6YWuq`023N1oC?}x6@f^<|XE%!q;Ox zZrbYNHiIShdq45Yzcbx>+}B=f{XPjRu)U{_G{(j!gwBafEwklzJ8Zqmw_o8w5yrB7 zM`P4yx~#;6WcgP96iohHOnxP+IZ8<1QRy4hB?>eAhS}0b(scYDmoAUbN?z=tY{sJq zr;DH8xofDncxYE=$8I|B?kE{-%gSmSEGZdk&B|&W`cPWlUDj)}^_CTMew}0ya+3G%F2e@v$N%M)>kdd9g9oK7CUpBvQn~3OI#%}mRPhb zt?u>IponZG+SS3=gT7k_YlIT>cfoOM#vYEcwPgM;OF#dtxeF>>@rzcJ7TZ%ZGE(jT zb)ozC=JHBsX`tHLq9T=j+#B%rA6r6yC4_^e)wLc$iao_(C&654QJ> zrTtHf9{GP?!(dgVbOzSUe})cJeJ{un&Cmu#g+t@DYL*SrZdUMpq zjnao30S}Zm;djlZ1>EX1tXpxqOWJhSS$JZ_lYPn)1P}JD=ygfccp`z;CPbX*9m)^S zOh|iMdo#Em2SNfGDC9|*v_pWf9DLsQ*2vKEDTkD4iwYkf&!k`I)PY_t=n*w^47hq- zZx65TbabsA2C=nksWdui8?RO23>=gbGj`p`@air{CvdhYIFZsM)|-4S)-MWe0i{Q- zC*G)CSXt4l?b8*;r(k2P>c+m4I!7x^@rBaoG9}NvPK=dezZ4p5P74j|PRWi=$uLEP z&FN0gj!wu-m2QfP$SH_17@{L`3u6qipp+(EgnAqhZ35X#Oa_jlzoMH1AT2FVkl0R zykJXAnBdB)>g=>fmY0>d23w=``i#@dGqSRCYXU<<>DN)QYaDjGF;i@HLJqBpNf(*- zu4kjuVsWx_(z~egzyBSSKl+&aL>>_v0isW9{0@)~=NfuUofH z#@WQN+nGu<%zi$0H z`XU8z4aOYOtiZr{Nf4r_%fBP!`M`4B9{i4IBYc_k+r9LgzIduzp;RhO%a1g&$CZz$?^J4(M($-}13Qm2W( zWE^ekSaqa&j#{f`s;|{r)u;G7g1>{RzpLI-y)OPtBhokqgPx|+{_lw>d zy)k-?omWIJ;dx@ub z$LN>rPfJ*1e)Fi+bg$?BTgLO9H_dz|M{04;judl$&V2r8_aE7Py4{b3cS5>Oq5C@A z9SqtD`QX<52D>LSlI+~G=6=KOs#BeN!~8qgy}NL5eU&cSP+z<2?lF%LcY^sO2sd|G z=mw^jVc71pkQoN&R^h-J5`yy$3wf*E9}eBZq`QUbZqRNw|Lzudq3+&m(P`YDH2);s zy}#Ifa_Cl=Uq|+E*D67B>#mjdx{~WsY^;`C}v%%?X<#^h@cDDOXp<5w)8zrxazK*+nlvSVH zHFp0|;I^-;&0VZJ8^tP2vH9!Y25SfIlKSW;AB{MtwHYGPs~UE-#GVsOY8UM^Lz8@ zVfS8!Ns9UR3h&B$Py6~qi*>)bU$Cz)n9sX`+x#_4*SWhws=kE#O8a`3-Jc5Fgo$|F zz!*!2+Mxr_&R%z{olEG~x`>gCezc|U0`~CU z$8Pqky$YAgzS-&SGOv!lv+LN?yMVK!I~9p{on)<24|?nk?y*O-XY1KB+YT&(>n@&s zxo!!sKIG;Xk)GV=nJoO)?wW7n*n>v*ckSNa?xXGgBfC%M&Z1zh`HuIEbtL;r_l1Td z!RKXnbqi-N=JlU@c6TkE-Q2ln2iMZs3!0pNCehP%?iq3Co!;s5&U&wfvxOAW&x22T z$ZQ)Z>>@Y7BhKc8IroA2n3dy}W6ik8$M$uy`D>SIM=MEoO*(PJW8$u4dYirluiXt_ zeZ%`1S{jk+7S6+wVmtTReP?7Rdup72u8ZWHb!M?YRYqiP z3ui`ThMiL*6T`3LA|sgxp>qh=!1K<-r1?iX|26V^JAcFdSCJ<;AB{W^#tk4e-G3ap zyYak}k?N-NHr5W+Iga#N%JKQRV;2!}6HBiN(vPF_S>@Jw!~JWx9}{mm@h&!n-DSHw zFBc2ixrl|yHU9+j=_em2yWV-v$j7vobNY(`>%hWvH}^sd^K0`-3hw4Jkai=ry}AG0 zLSAh5>&$#!&F41zdYk!wYCf-;`#E#}#O}YhFm$>A zvxnV#+MNjl_KH7+Z8pW;s;K1cBM=lZlmd0VVIM;xIK`@6YjDh%f| z+L@j&F7^0!(62Su8UKJ^qutN^3cp%DxM)#xKk3sl=6OnYjwCc_f`5wCPq98|t$)Nn z9&|5A{C_;s#GBt2)W^ zkB7b!$Z4IQh>#{c1uk^OyP~ z{D}(f5A?qz+-QSX>8}TpVAT;~g@s+>udr|P{d{{)@+I4zPfv$ERT{_q&HfIRq(2*4 z&G6NWal*c?q^ys^L$&zV@LWN-fnhG|m>tdmcTLw5ezb$V?;Lbo<*)WL{I&itay?Eo z#-9VsYK%T{gsCPZ9N=%Imn?`IaL?`lSL1udX`n{$?B{b*7Ai-7XN<3%MHIvdxgE-A@Bai1W+kXBRt zxBOX9uNDjPLF}yA(BxQXQ9m8viju}UCx(^0x%Qx;k3qpY|6gT+a8Ba;5&Jd;DPK=% z90B5F_+U*lh%qZA2GOj@Mu^9uP91Rqh|_==aPJiV3n1=5dmr>aqLx{OeH7!Kag8{d z9QbPlO$WSKPJIGL;ttvgf*7 z1mVbCmQ+x34hScZz+^88e%%c;pHj4MlzotDSy0;sHXtargXh~r`5+y*7hF}}2X+2> z$y z>FGngboW>HvDe;BcAt8^-9op-yVfmpncj_Vg?7nn4-nZO(x52yB<+(iXHdp8h z*%vB8h`zMBFL1ZJ+xfq)AJ8uY?yFvB_nP|?&|i12dsnzW^C#$qZXo}EUhD?BL0&sj z{figx2D`ze`lfr+yT}c3Ls(_vukNq-4|l`84(@-sw`fzp?cV0EvJq~Cm*U=W?|7HG zciE3H)xGE5^OD?1dO2IU_uc!rN4ZhBN4wF49K)Y}sr>IZ4hSE(@%;1mA%CzrH^EH+ z{zNy?>+U`#&pq4}H^pn?K5?IT-P}~>a`$wfLWQEWIlLnx6-X7Ob(PvH%cYDU2c~bXY@*Qd)!{!^c8!(+hXLRgf zbPOoi(37zGoJf(b^!M5AD`Q82}57lmpcK|4|HQlr{saLQz_qtULl z(e7fS-4#Z=HqdS%xm^SWFEk2XWE6~pf=l2GQLu|qu$%GMB}Tz?qu>Qb!8D^_E2CYi z@meR^h3l-xbU^Dht*5qrw)NAkA8*~i^*yccYTc*x)#yz-q#aMoNSl_{ zE3Hjxed-sfFQz_|vLa0awXHEBcAsHEXZe@MDDu{?1`;^4%`6K_fEolu^zCSh8_eenz9?~Kch zo6jijlZ@Ej8P|r<&%x2(M=zvrvydLjFB#9A7I`*uOQcWaGFL<{|LC4@KX#W<_UWM{ zKIqsz#y#d9gL9uiDw8SuiIlFm@+N5dDbhF1P4{kbGvUNrq2OBY`)&i0_z?V+=l#s( zBZ-f|QwO}qiQU)V;rjWr+(3Ve8|=?_L;US-xS!+RW=8!xTqFH_H;Q^SjxUGYSU=m1 z^H;lKW_~0R=QzHH@y)?^t()p^gj2iowFelz>MwM!`TxfM5dTKVGD7A7d6OIA*8+7v zQ1`phxX1cah&|bTfO|aG1i#8n@oRuKnKa(>%L!fM-sd@rXDswo+VyRKyA!zEz;-im zbHRAKdl&phg6k-*(fEzweXPHRG}e$t4QU*46S)d}$zorBR}2aq+n4z)sl0E67Fj@! z(PAmkGl9Mo=$Sy@1N1#W&jflVc=YuPpyCGiy8k<3?;>_CvA2@n#o)Gs*z1U$N9=XY za9c<0b>ww7df;t;0HK}*qgVY8-Jkv4?oHWTxGHcR02=eR__hw8B7ADlN`Izo-sC%W zGWg7Qxmee&NPh$Ae?g2jq(2SFSwLPGVqQeZA|$adp>Fp}z*-RNh_yb%I-6J-hVi@j zjfBFZxJDCh4DT`ekM~O{sWK>ekQ{8Gv@*b`uRjNegzI2(1v~e(fDTI`zO+> zNu z3AwteuiW` zM%>3K^Cw7g0M}DozXG2>`^SJQ85Z0P!2Jvg%z~!#py_&OnngPENGGe2hBJ|d+0bJi z^jHWz7x~3*2{OF`_^YriE+XY+V855tXF|C=_YheAjM8|RTs%TKKg#uU{2znA{sk_1 zoE$zum;qc*66PtMPxJgG;eO>Wf=>Bhx(`ez1KKj6{db_f+>DP(;iL8NQ7L@1o>X2Y zl_ox#N$zHm|Jh(QhkVba6z3t^^MSblS}%mhSK(eu*>nP%!VsHPU{lrtHo0z)Kh+JR zL}!BeEMm?E`#GdAm+OLh@*7a_L+dmEt&$U$Fn-w*l@kfPQPx}F{~GGr+j;8;eFp+%Fj5oLU75t3LCnR(tRQ9uRH-tmOeE#0Xbi1j zqWVJ=$tP4m@LD8 z2cOVK+O}D~He0+PZ+i?{5ci;Il|^W%3`#nam^oI;Ss}db$jSh)_=G&nM4G-pW6tB5 zfi7GQCV5cg04>CXvqCigf+W*g8%`z8obP%!>0xt~l~eZseCXshX@Hk0sQP`>n8AYt3k z!_^^w9YPO_$Cko1`OsvY%Oc)#q;3UQ4t%%@PF`b_-v_^yp{oy)pFFthuq!4_@j#ST zsz1^yS%z6dy*;GjR#E|x|626P_; z1qTCZ1RC&Nq+leRJc?^H?_+o$i^PxP`heVxhr6fvD}cBk-gtwUYB7%`Umv0e=#>WN zDm2C$z#jzsiP(D?;f43qPMPO{+a_?^0dAWp^IeqrF3LQQ+J6N!-VZ)I!C?>S zu$$O_DfTB|9>zvFdv5|zrt@2c^U>zANf9^kFnBXJMvPE{AW6E*DF-0_&rKl3Pj1tje6*8LQpmZTT`!F@> zBjoK-THU{(Z_457!2K7D%SPQpv>Seg6+|ff9%ENceW@nzk5C4$`@eus)*^>nq1AFq zQ<`x#{E`99%Hfx-q2Ygq75p&QBV3PS;r<-|$NaJI##G8`GPY+W8nhA~S%K|Y37@QhPc~47B|u*X ze!ECvDLA6F$gec*r_}S);l7|In+J~L;p2Va`3X3V1(V@m@*%vskv@eRtxut&SLzjc z`TPN~ojpU>Fw0^Yv$$-VwVV5N zYzum<=>1igBiym%xeuq8KZ4ef)mv)53Y}v%!g7PS)*!xx?WMBJJ>b-nSS(W7Y>ff=OQFdvA#XKPbKHV($rNtf`kUIHK4Raz`6S63*D@PAYxw z*e~>ij-!9KR{h!5%Ne1!wcdJeeeTEct@TgR^H{`K5hI%ZJo+UI0?_o7}J#F9htsXoyM?t;DOBm5Q>Tj}M_KmpM z?Ur$i6^v$OYCeR&mprZ`?qTJd_Xfh$0dX&Bry9$#tR z&IMqx9$c3~nWIoYN(~anzf$aB7(y)uMhWwS+7W9PspgZ5)|7RjVe>*bMiWQbkQ(vC z8xiZdhL^x)BmPO?e3UOyderM}oF*aY>!vJiWQH^}2GYQ{MDWn84ZZJF*#-wZbpkk5 z^vf6H&L#(xtat-DS2Tt?M(iWhj}iLY84b4C8YjqO0ckT@qFExrh;kUi#bP!-9rUWV z+&Jn@zKfnvAdY!Ua1igOV!h~;X+2!RH(d0r8?RKtC3WIqFwl`extEAa$nGwPU#&ta2LQlp$TR$2pB zgB{0(-nW6ym>V?piGk)&YJ`!5M8KucC^019ON%3!C?Tbjd<{p3h@(;XI5NIF!qjM70d-_OF|!DIDs zPBrsYBsWJW^I$|=qnJ8Q;a>;U4nb|zDZWNC$({1L%^zSpn`=n@14TfJ8qnz!5p9epez_YEPIq`r>-!TwXTg3i8H0vXXYc=9A?#!77 zu51Dhq+HkrbUqR2ZG1)L2EHZX5{)H;HW$F&6BLtJG5t(w@E#A&npIFkPSe2o0^U`7 z7nybrbh_#QbU)NP34CfFs4h-8WBRX>93F!bN)2fwSEZErR??{l@^Q)?jmDSdq$aL8 zK|CLhtm1ykxZn&j6309v=s=C3dT^&u)d6>@Bz923`;nM68rLRXHN1gVHMcNfmKr`g zkU-;4ll)^)Tk|kBlU^?VTpZGBqD(Z@I?`dL1Hokzmox&lBe>_Qgsfc9h4}0hb%`r{ zD#=#`bk?lPfX06BLpDCU^slqvZLnQSAsEB$`#5-l%}`X~c^0 zMiTD*njPf-iSXiFjnGR*q$wR`R?a)t8YQv~oMj_b;iHnQq@EInCk*Eb)5XHN9?eUg zNsMDiebh=z^eKXNhrt;)`PmEB2S{5pSic0yNvJ?w2QQ=|CGCk@PM!`@S|`xZDa0zE zJWH)UD<*B#DeqaD^~mx(YPw^<+(ej-Xrp=PqgkfY)@sHS^UY?=9!Q3rJRa`c`!kJ$+}cOxe(i#=$%5z>M>z!jSF{F1PV{GF2K-2zN$8tLOG zm=r-R&CfBe!he#@hWTmC^1@$|E^V%7384d>ITQN&V8|}0l-gLhPgX>2U=M;}9pQxA zabi^x;siMtCPAG-9S|JkXd`&2Zq9~dmzu6CChS*)ZL;g?$q6&V@vBBAZB{jyFM$d^ z6fCnkTcZ=Cu{4|A(?9RV&9Y9Jfq{P_vwQ-*k%o z-e`pj4pYuezaY_eboDxgdZQjK*41XutJI32$uT(Na;&7Ay*8AXVkGf*n)F7R#f^vG zu@FQZ?)$CQOfbI)Fw0220UMB(2B8w5-+T*=%?6%F{Ezuk1}!;(hGs=aC`Lpsd`roN z=Ak4?y2M|WCO5ccKm1t@H?Kt^GHt$NH8^hp=UU~CGlleyQ4&Zw*nzJo3J2y|ow9%w zRHu9_ZqPwWSjxmd1BD#0WEsd}+>KjYRmJ-mcnh5e<&ksCv$#Y$ssZ_?y+sX?h<22X zB;Js1c!Utx4&+8~D!`q2^Ju<30S6(2*m=-39olq|b|zjCVWd$UIXnPVS*wC}+^ol} zfC~t-jsA59{3@t>2peV_*zS;40lF}u-wS%cLs%Q4a~Yvepx^fe_7s-lE=nMieC80d z)ane;;tTNH25-iRkI30lO3W<5fKr6e47^V8MNjVzN^XzM^xr|fcgXN(=DLL}M;c}u zeSRxh0@fCCQOa`;_h1&EZ2k&lYzjU#njMUrerE9m_-%vcWrT;ujcI3_tI+NyN9MBn zk~ot?8%Cbz95E}BP>0X?)cD#|I}u8J8FDvIc*EY+W|w{Gn?#8J)t1K2Cj?r7ckwG< z6AmcyT zT;eoY=%R2zkb5|VnSZ7=qJ)d0M=SAB+i5R2$kxi_dlD4hgEb%v#13?;+JTzOhs8~< zb`mC^6yP%6o3~TS;YH2i+X2*a_$NyMfUiD;Ltq$dr_@-xwUUscinN|j>hznC_A09f zwO)$)05-$x6{ODiE_I!Bt?UieN$=oahus{7N0w0sHTd5qN85m;_S|V^jIA;1Ofd`M zJ8+cR=JiIA$Lx=VTqMBPzaPN+zJHg0hd%+0%luB$*{ZE*liPU`NL(%-+xYJ*6zSGfR z4YbvcB4t%T5KkO4UOF7uL*)7xlzM}53%KbRcv74Ai;&m0;L*kVE!4s<%Vw#*ABi*K70#bYqr%X_&uMTEJvrU zk)E@%DQ>P2pofp{Sx70A5{BLtLLEn&(_au`8jsYLS{twySWqv}Bcv{yQRTP^zcrS+ z>M=NiyDYNPT=ZxdoCK_=Q}C9oGy34LZmX=8IYB-G`!nDY^?#IEil@lu!B7TwAaO9FcRD9{d{DyYRfE$I}~){F}gCJ$T-z+o-VwZpb%vj@kSwHXT6&TQRiW>uan`_>OsvX9 z%qx~$W;16uigb@>=9KdGhLx49-y?)!mWA=w6v{4}zhtVgHb)?bA)ZKOIZ~BiC0RzP zz0g=gss_MfAaoTLQ*Y|R+w3iyHu)Scl`<_OHTry%3+iJ1`$g&d_lrZtzjjBT86QQY zAM`+tL~8e=Z~)^*^h89GXr28>)T8eVY4wlKe?mtA#c-=-&`1^xbC4TK^Bq zx(;kn^*=KrU@{o=#|j#)cd$@9C9p;JLBUMijPsi`;pB}JPSGN zfWk;HKI+XlhOBRg7E^c^eQM!fwez+U`ViqZ<5pkTCSq)XU-f6|VkmQfG}NLQgL@}X z)cz;|)&@%W1e#OYHE7XDBgUykz*<|L16Wyfx4Z1@#)Lt-tIP zbxSU(p2B|EExJ+)xo}+(I!Uef?WA`MIN5w@@LvVb?Sw^|N&PeUQ$5`?sL|`_ODn`` z-4Cv+;SPh>X+nRAyB3(cNOdH4*;94!)jrd=^T>MxtK+74SMy~gHS5PnTom6uK(53# zDFdgi#IPPGbh{++40y@jI1S87AO$T#QA+kkC81}kOu%k@jCXjeBYaS6VxI+N=0A^r zrtyWe(jS696|6R%9xK%_WG1Nwmcu6jpIEB060*Y(fxS^fSx&%4MsGAfNd2Vgf}>V~ z^-y6E@63;t(SFYN zD&t7;+93TYp%Q{C^bhn@#czI`HLBF?16=-ov)8}dNx7*da0veUiaR|`q!9GH>_k_| z!c#pY4f2Bhhq{(}dJeHlVmMo0pw*qhznwtZbv9Z^_j_miG`(4WvAxO`YWZl+Yc{e; zuRPjInxzh!sS_I1RSUrMs!Fd_s`7@qYl62jQ z0FGIorYQn`=1YiSFs4SFMJmyj58Bz`A#K(25z|omYGtZ2+l(eY1&)%y2FgLB2L<4p zOKIgB-)tlFa>7hADyvUOWhM?%F2xn6sB_e!JBW+62jz~Y3=VitJ=Ck|+uTh`%dBps z#Z9fYff7_du}<|su0UG$5*r#QH{gJc1QyhP_FpuA9Rsr!_G==VDjB{?L(`?8ue1YM z2dwT*d`YvCNCW0haVT-sLnfO%wCd24jJg6`5B zX8%YdnIUfke>h^FFm4wdF$Aqj%r;YOPhF zuQXHwQaOP&LXy#7+3H`XmYbmOv>+1AVoVGFvrSeyXcxs|bWdS8%r3%zz5lXa{V}?q za{%?MgVtPN*ZQNN?geLkvlo+E`V9C=zl#p_e#H(r4XuGL&}<2jvnAjXwKJ&a$kAe8=80n9 zw+T*|!?%URq*o(MbtK`KiNu}Sm@qS9I#2piEkSfu4I?bqGM;i9)Y?dCt)?}e0$&7u z1(qYyi!e6UK=_}AZG`n$M>~1nOvqq1$!4%A<*k7-()tn_-P}PON9%_<2$oZyR2PT6 zShIj|+fHbKjxbM}RIq?CyCX3z%M!+n#Mt}EHAJLCWt#B>&ZC}EC$~7A) zN=KGRh40&~MDq+^^)X_Pa73veWJxEQev}*gLI?4da;NPPzAv_3P*KP12}$bNhj4A_ zfPNcW^8Z`?;NlaMX${X3dsd;DWi4wC0Gfek9(Vby6Mve`rV-gn)i`G#F)eYnFL5^c zS?BuwJ@1sK#u&<`DW&upUwKw43am=Dvi_ z^hccS^Y^`hX8}D#t9WYsu7Vci`^FR5U@tyk{DAuk@^#EwhSJRH2Plo<+^o(u*@NQ? z&&EkH&h}|~YqInMj%)hT#k0f+Min(LJhlnjPnJe$&Thb*oXL=}VI<&{V;Dph2J?GqIrbe(DgdxRYtU z4~4;~TsU)ZsrB%Q{>&vohaQLXvqC6MI2X!?8nU0B|GA^Fzs_S+^J|Xg^@-Wu<$)Cx z1h>{-SfA1FWUiP7LsrAO0=y29!$ynjYm#d8Cg#^@!Nwr}B?o1!`5ViSuxhY84GkCY zwh}ugAKX@1%Sv`#7H{JBowPMpp&zSac@;hZzXp~NIi-Hk5wrY)x0dhCa{k?xLjY%2 z(-$l6YPDHnhiN&rUO4D;jM+iKJ3ZRw(6@55Ls%a2i{T&p9z0`KGaMemJp23K`Ubr; z=SXu0cB7NkkH6At-BakmkHLnu;<#(x>u_tgsGYLH!cPq`3$+iS3gyGM{o$7x;WIN> z(Jpv9`yTe&OwmLvZ_*}Rpni2YTpwxx|JTl&k`JMJymB3qil zI2fd=);f^Tjy$(>G5*{X>U>wIFE@q#qb0yMT96iNgvp@zWiLEb7i<4FVRml)7yE); zXLV@o{qKE3Tjqj-A?*h4vg8XG>DMgMW6(-%s{+z+u z0YX^!sDDBSCATbaoqvz@lM$+!?mQ=4*d~;(ajNF4h@VdxM=*B`>yFs?p0HYx-f7+_ zfB0K8R{gD!16o4oK(=q^`dK=aG40pV*Z;a7Wv?0etW6a55T2pru^^!N5u1q=%-j=4 zy_gb5>!nCz~Uhs!-SE#f4`J3T_R&42@P4RY1{(q|< z<-CVldW(8l!DcTlswHL}72uwa&o--1#1S*B{UU9x_1W_LQRk)^)LQ5I51gHv^L$K0>yL;slcZ$&D>PRoE~ux$q)Doig%_40^-lAH zlBDI|^3hboe_f44Jl7OgdKk?1z!z2Ev;ZCvKgC;LYy~c4UaHZ?6v6`*o(ot$#?Zx7PT}m z@}>h0H_J>lrMQD1Fn- z=8J|ad8^LJMqf{%gfs_ID{|Ya-a+li*T(sxVL$)eu_y@=wvf*aDSIJynC+=~9ovw+Dtcu3p8{M@TL0D_*`6+X{u7m+ixMLs>S%Xu;u0cRAjy6jTU zc~Ji(ZL2J^iWv1(SPOpyEqDa|F*C$Sb!5<{GRq7-L@lht|84keh{R&7g4RpeJ*$xV3D364~OBES|nG0Z8KjW?8nfbijLlGt(U4lJ7*bQ z6S*(JkDfnVP4k~q$e(6n9j33IHYU8aTr>mz2FkcpbtTd6h?SG?&bW~t$)`Iy#QLOJdSpElHQT64!^oGZ{}d*~(4 zK-$$4dXkcvW&bNJ1y^c7Y6-OtDRb`Dt_E-YF<&pM@9aaIO{lO(rs*5l!YwTyOR;jkE(@h@zsXe zWG?cEyEvvL{0Vy3@%0u*leQ4_mXD^A2NG#BlR$qI+D@0qLIU-I{a1{(j9OUQvV=TE zD6NK|BSm4DkI>5An%_FwOG0xmxxb#ue6xI745a64j^-%=9WC|(GY}`B+j6-NK~t~e zeJC9C7e?bIFeeEuOPl*I%;TkJ-+zsnhHL!6#N$mzQ=2*%D}^`Ajt;(BhzDA>q3qk7 z#&GAvYV?PTo4KtmFBwC(>AwPc3JlIJd=9{|yYN4V?R)~O(pgV$Cf_Lys}WC8N6JDy z<W<;|QF%nm4s3WiRN- zpOl=3(25P#URn#6MVKq+Z1(^4%#*v89j0#fu4BKjZ!@#)JKk%&z0TSs|L$dRF7j4* z*)B-)L~w7#!< zMC-4*pR+P)4lA0h<@|+K6?IQ&%~kg#*n9*wY6+!qUJKu9wqF_}wd@?Mm1J1QwiC1W zv{RO2AGS-lU&?9+5oUe&#(fp%b?nS`6Yg(uI&yNWcQHBXhx-oB>)EmG2e`GrR)qbv z?&Gd~+q6T?1706?Z+j4*hrCDdd6fNX(!D1*9s9XGP0HGdE6IDF^9JvC-V5OMBIk|X zOHin__X_mt488tL_<`)!b)okcZ!jawZ*oRh(Paqkp&qMidH;*E4XeCpEtI!8+psg- zJGkHFY~#Jh8OJIxBdIgrXFZsx_W^r~b?_#5%%ArrbEdLyoLXR0Ia9q)Iiu_d_8IQa zIXke++%*0bobJtmc5^x7y?LCs+rD%0-U7}`SZ`(_G+V?O?=9xMlQLbxtjDFC@s#;8 z?=CNs^Ped76@<@bm71$4`<3X0RjiF0@2%&&o4tXype43?JHTNlXR5c0GwS8BFIg&U z+~nid?qsRnUe2iPAJp3`B<644i1?OF$9oks)rnPI$+GlNCd)DM(w>VZW>gYP+?&Lb*?(Eoc-F0P0 zqBiUGrl(a!*@l zx;-l&c1H(4wC-ePMd-(zbfKWJ+uf7^ZLKIf}eo9<$3CI8UYO74P; zE@eGzt(Dx@)=K_?tv3A)TPyinwpMZ%TPyiyTPyi$)tNBXa*VZb^QkN&d|y`5jI2zh#o&#w5QRlK(pC{+Tll`5#Dr z-{6d(2?mkwUpOyC9}H&K$R>@@!FKd>Chgr#+S?-SA3&|~oL3+q30-)r)mj6Cl_(-k14-Azh+Af-j#!)W{il$B()z109|UKO&MX0jS>CaYacR)5h< zRxdPJO*L6fMpnDBN|faAD&(*i>xD@I)0#=(qb7krF$w$^lfa*w1m0~D*xMxVag)IQ zCV|)5I^}&$0{fW+USJaVQ|}CyxyfNC!7aBbbYex-CghOdP~<{UAuQ_=(4d(PM2X_UhLAZOYbgT=k1-RcK&1M=R5zZ z^WB|$cfPQ5a_6{Cm7TVBTG454rx~55b{f$!x8sVA(>jjt_*%yoJ3im>*^bwBY}cW< z!^94wIy~FqvJRa(v}s@2eqH+&?dP`tto>{4Uu^$O`yaQztNqRGFKd5s`!4OxWz4-1@rK7q{+`UYWi#eGRL&Pe~t_J~I6e=})Hj zPw$<6S$ZVxcv?wXe%g=I`lY^{`kT~8Qy)nEe(H^>7pHbeO<)S{$&@cs=BNB7<UgWYt#-EhW2-;3 zdal(KNwrA}lSU*xpLAc+jY$cK>k>ap9F_Q7;=_seCEk^IUqWTV!i0$l;}S+D{CmQq z2@fP(9AC|z;&a(|doa6&_mBU6{B7~Q4UvX1xrpdKJ3#TCAh%v2t#}Lb@6INOs<>XwdJX zH}61Keh*FAA1!%5n(-kt;$z;wpb@1FpF#`%l3hig^?rjE{2kiwC03_<#rqR_?hW*t zES)#8aE4;jyp1*UE;h_a^w}8f7g;YKv9jIA*e{c@SU$y4`5ZkqofYq9viI#Qb{L$C z4qJ%!%D@s?V)`o+{k7b*mvq-Ebk-WQ)Oz&OS6Ccdy=~YUyRj|`&`+`;O1y)ts&@$c zp~9;~E7f>)-cj#_cN$F;b#d(5+KSzC)6hb)4mx8GbaNNDi`}KtKWLvTuv)KjSGzv$ z1~kv@XdJD$_c|J8Aevw zVUgd(s#neS&2r0;*lgr=C32eU)*z>{efJ=p#qI!hpQC*q;kt#hU=U#BypjR>J3PHE)%M%k_+iM!oOR4>65)$5*rr*#(o4gGXt_45e>IYjl1@ z&)GC^E+OR#+Sq4U-LVa;K6Yb;$DXX;dO0g7UQfSyA{gu=g-Sw{k;+cK?jjfa2~hzy zXINn~!Wx%7S)uWA(oONSgfkmX9(s9eiiDQVRZcN@O@iC<s=V1Z)0&}E$0p(M`T+K7A z5!#b=I0Fi?{v}_O+g*G;$k%;9$y6MmefM>uYkZGiQIdT&v-&iA;c7K zI{ELzDxEjr7G_0YR!Uk~UVp}_S$W=DM6b+ZVk`rzOmy3FS`q7^HoeQn{uWc48b zig!J|Id_0{f68Gfnj!{cJrc)yU*updIhaGNPb~*2#N0_OkZ37BKu?iT3hV;yC^vp&|bseRePq~*; z?whULUy;tj5^Yb25^A6Jgevo%;(D6vd2;p=rT(v6%4QDmwF>`qfE z5pvju+_fb}59H-4O8I7LXUSMl;x*v34{k1pH&T(eYvAaP;P@TN<081a8@N6It{dRV zP^&_{^gW&+@O#o7qdm)&dQRzVS<qStW-hK(5FU zI5hx;l@7os9}6xXdy87p?s4R{1j|d*&ZlgaL)U@Obui^Uj*`0`N>qBoz+@!7WA77o zG--VRXN>3ii1&$H`tNEA&uQ@gT*57&4EpY7_R;YKjLbo_34m~LTxByFB za&!T2tcD5PILaj|sUyW{P=ooPP@xN7!bVE+M1^h?Q-OLBP~(9b544Mb z5KlezP3qn6aox}LDCrHLJ^4JB`o2e!2ho2TvFGAmK<*Zi;|%h%l=n<@d^R~;g*%sP zHP;%hwOs4C3b`adrO=}S+*`qW>GZJlqAyo@d5yd%KYykNX(0U_gS{J&=RV}?2IRR9 zdAotU-9X-MXqLAt$y*olp#7N!BTXaF3ZuESM^kcRj^~rRrBFaQ*1pK(8XV%`#v*V~ zpJpH0&kKmLn7-G6Q0#5*Qc~_t%Iq4*b2Qf&t_ke&x1aJ=&DsuGXH`3>)DalVp~Xt* zQ%r5x%QP$Nx}Z6;z)ic*Mmsp$x^ucCiyB=Loi`-O- z@8OsE;M`m;CCizlRR9d?S^KK}2mAu2Gp(~*0vU8uTSu6Eup(V)%i@cD#U zK#oqRzYr2N@%!$9!+~sJ2#iSt(21aOy z3f^Q<#8Qo*iC4idtDtY7{op@%v$-`8MPsiaTn^!KEPo%uWzx@qU9lCr%clg_K*_yO zQrdp*Sr8&_80E{l#iTwR{+YpE3Bzb_$bT{ZvgvwYOWaH?(;t0z1G@Bf_<0z-JQFTG zfZdVAmlCLX7|Y`nAr29ujt~`upx=RZp7sV>Mj72eNe?4e2f$Fa?_}&E(5Ah*t? za*ueU!}mnFys=!LaLwRL7}CN;$@3C#IQI5&?5lX*vUppu%yRM~1v*(q7E+7)AE#ka(q5PYdH1Z60C8KQk_#GEc;2O~EE^k4@c?y=gkZ>s@GL zUWiS6kw4vCjGT98ub>`q>!q}+zDXR};M&_`CY-p!9}1nek;?((MEx+s*$rkQ60x6J z)WP%d+$Duk@Jyoo%HUA7OFzeUVkI)(wExN}r0~He89{cjfsif9N<7k02bE6pwm3|= z9;i#v!dg`(-O_%N^k2d@RyxV#NsyPJHF|K}Ot_`?r5hz(%NOl>QWJj7K##}1_MuG> zhCoJNW%rr4{a1*2H#8i;UOl5}PpCg2XodAhV*2>~iE$_L&_F&)*nQ)_xuyOHs8VS> zybjEFf>{kV#WrMbJM_~IUe$!wJ~?~fwH<`s1zd-gV%2GE(Ms=DuA!7}4%YXp$o*L3 z&T{RIM9AyN_4VN2hg$GP^!-hYNyYLZYQq!A@o8vxuJ17PNE&gw61Nv|FQU%xM$O*Z z>cM4<*ez!?ZWVfpmH@j+eS?(EqEI=Yi>8yR+Oyf-52=&x0gr2ecO5pwO+f3*`$)4C zQn3u~CRA%+rBd^zI#vN9#s$EmTGWa3WXD6N0H1FFp)2vzu!e3SUK;VzEcZjO z4q|x7`br^GGFT@QDw$BpK<*0UbRdi4ZnAMlLAwe2W+W*OA-zar*@}Bu^UpAdQAjis zXRzu#5Y*Zi_p~9`^stbR<>W1gl!l=%#Fb}hZs1kJ3)Q4hP6*T9q$tfTK8?m)KtCjS z`2W)SH=4A1sL@GVw!HW_5s2bebUPd<+dWaKTRu$FqmTL^`D&G`1t+L#8 z-k5|>3EF;5M7 zv$O5^$Xk(@BTq+eja(h+73m&Hk0eDR?sR0Dt96BuT9+3+>9(?0Z7%)nOWb_UFBn-H zM^F7Q`tOIiKe%5p>*OKV-`xYgKjBh4x-(-V8X1X=iv*)0-?6cfC$Q2q?(w{hcf8`g z%6P|6MmIEep%IHkXqu+c3XM@{bYd%ZPZ3sYsf|U@BkZD1vlBYIp6*Jgy_GeV&@_&4 z_9((s8zsmAy9L%NasLiqPGxVF|V#lIM~7rO;b%eYkE4ebgUGp9lRs>ao^( z?|KhnDV$9_SH=I$D`P^C;DKp_x*W$EMzlWw_O& z5%y%zCh)NIJZS47J43mD6}}aar0`HHh4^5mS}pc98uNj350K(?!q78?&hK` zWHgc%(2Aou)bh+ALxYVV!O>h}cys78rT-G?GqQkQ=*g=>yvx-X3Jz;WD8bMSD`BHK dSii%i8qrDvy4D%}p@aD=+$~(M0EvCx{x`8C-{SxP literal 0 HcmV?d00001 diff --git a/examples/with-swiftui/HelloApp/HelloApp.swift b/examples/with-swiftui/HelloApp/HelloApp.swift new file mode 100644 index 000000000..8e0ff34b6 --- /dev/null +++ b/examples/with-swiftui/HelloApp/HelloApp.swift @@ -0,0 +1,19 @@ +import CoreText +import SwiftUI + +@main +struct HelloApp: App { + init() { + // The brand fonts ship in Fonts/ (SIL OFL, see Fonts/OFL.txt). Registering + // them here saves an Info.plist entry. + for url in Bundle.main.urls(forResourcesWithExtension: "ttf", subdirectory: nil) ?? [] { + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/examples/with-swiftui/README.md b/examples/with-swiftui/README.md new file mode 100644 index 000000000..92bab12b0 --- /dev/null +++ b/examples/with-swiftui/README.md @@ -0,0 +1,43 @@ +# e2e with SwiftUI + +A SwiftUI greeting app with three locator tests and two agent tests. +The test project is in `e2e/`, next to the Xcode project. + +SwiftUI app with the greeting Hello, Ada! SwiftUI app with the error Enter a name first. + +## Run + +Open `HelloApp.xcodeproj` in Xcode. Select the **HelloApp** scheme and an +iPhone simulator. Press **Run** to build and install the app. Stop the +Xcode run before you start the tests. + +Use Node 22.22.3+, 24.8+, or 26+ for the tests. From this folder: + +```bash +cd e2e +npm install +npx agent-device doctor +npm run test:e2e +``` + +After app changes, run the app in Xcode again before you run the tests. + +## Agent tests + +Set a [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) key. +From `e2e/`: + +```bash +AI_GATEWAY_API_KEY="your-key" npm run test:e2e +``` + +Without the key, the agent tests skip. Add `-- --no-cache` to use fresh +model calls. Results are in `e2e/.e2e/report.json`. + +See [e2e/e2e.config.ts](e2e/e2e.config.ts) and [e2e/tests/](e2e/tests/) for the setup. +For device setup and CI, see the [Mobile guide](https://e2e.tester.army/docs/mobile). + +The bundled fonts use the [SIL Open Font License](HelloApp/Fonts/OFL.txt). + +Last checked on 2026-10-05 with e2e 0.15.2, @e2e-dev/mobile 0.9.2, and Xcode +27 on an iOS 27 simulator. Three locator tests passed; the agent tests skipped without a key. diff --git a/examples/with-swiftui/e2e/.gitignore b/examples/with-swiftui/e2e/.gitignore new file mode 100644 index 000000000..9ea98bc1d --- /dev/null +++ b/examples/with-swiftui/e2e/.gitignore @@ -0,0 +1,13 @@ +node_modules/ + +# What `npx e2e init` adds: the run output under .e2e/. +.e2e/artifacts/ +.e2e/cache/ +.e2e/sessions/ +.e2e/report.json +.e2e/ai-trace.json +.e2e/junit.xml +.e2e/summary.md +.e2e/failures/ +.e2e/logs/ +.e2e/videos/ diff --git a/examples/with-swiftui/e2e/e2e.config.ts b/examples/with-swiftui/e2e/e2e.config.ts new file mode 100644 index 000000000..a56689792 --- /dev/null +++ b/examples/with-swiftui/e2e/e2e.config.ts @@ -0,0 +1,24 @@ +import type { E2EConfig } from 'e2e'; +import { mobile } from '@e2e-dev/mobile'; +import { gateway } from 'ai'; + +export default { + // The Vercel AI Gateway serves the model and reads AI_GATEWAY_API_KEY. + // Only tests that use `agent` need it; tests/greeting.e2e.ts runs without one. + agents: { + default: { + model: gateway('openai/gpt-6-luna-fast'), + system: 'You are a thorough QA agent. Verify every outcome.', + }, + }, + targets: [ + { + name: 'ios', + engine: mobile({ platform: 'ios' }), + // PRODUCT_BUNDLE_IDENTIFIER in the Xcode project. Build and install the app + // first with Xcode's Run command; the tests open the installed app. + app: { bundleId: 'dev.e2e.examples.swiftui' }, + }, + ], + workers: 1, +} satisfies E2EConfig; diff --git a/examples/with-swiftui/e2e/package.json b/examples/with-swiftui/e2e/package.json new file mode 100644 index 000000000..760667a32 --- /dev/null +++ b/examples/with-swiftui/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "e2e-example-with-swiftui", + "private": true, + "type": "module", + "scripts": { + "test:e2e": "e2e run" + }, + "devDependencies": { + "@e2e-dev/mobile": "^0.9.2", + "@types/node": "^26.6.4", + "ai": "^7.0.0", + "e2e": "^0.15.2", + "typescript": "^7.0.2", + "zod": "^4.1.8" + } +} diff --git a/examples/with-swiftui/e2e/tests/agent.e2e.ts b/examples/with-swiftui/e2e/tests/agent.e2e.ts new file mode 100644 index 000000000..8fe930beb --- /dev/null +++ b/examples/with-swiftui/e2e/tests/agent.e2e.ts @@ -0,0 +1,25 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +// Agent steps call the model in e2e.config.ts. Without a key these tests are skipped. +const skip = process.env.AI_GATEWAY_API_KEY ? false : 'set AI_GATEWAY_API_KEY to run agent tests'; + +test('the agent gets a greeting', { skip }, async ({ app, agent, screen }) => { + await app.open(); + + // The agent works out which field and button to use. + await agent.act('get the app to greet {name}', { params: { name: 'Grace' } }); + + // Pair each agent step with a check that does not depend on the model. + await expect(screen.getByTestId('greeting')).toHaveText('Hello, Grace!'); + await agent.assert('the app greets Grace by name'); +}); + +test('the agent finds the empty-name error', { skip }, async ({ app, agent, screen }) => { + await app.open(); + + await agent.act('press Greet without typing a name'); + + await expect(screen.getByTestId('error')).toHaveText('Enter a name first.'); + await agent.assert('the app asks for a name'); +}); diff --git a/examples/with-swiftui/e2e/tests/greeting.e2e.ts b/examples/with-swiftui/e2e/tests/greeting.e2e.ts new file mode 100644 index 000000000..54f408edc --- /dev/null +++ b/examples/with-swiftui/e2e/tests/greeting.e2e.ts @@ -0,0 +1,26 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('shows the greeting form', async ({ app, screen }) => { + await app.open(); + + await expect(screen.getByText('Say hello')).toBeVisible(); + await expect(screen.getByTestId('name')).toBeVisible(); +}); + +test('asks for a name when the field is empty', async ({ app, screen }) => { + await app.open(); + + await screen.getByTestId('greet').tap(); + + await expect(screen.getByTestId('error')).toHaveText('Enter a name first.'); +}); + +test('greets the name you type', async ({ app, screen }) => { + await app.open(); + + await screen.getByTestId('name').fill('Ada'); + await screen.getByTestId('greet').tap(); + + await expect(screen.getByTestId('greeting')).toHaveText('Hello, Ada!'); +}); diff --git a/examples/with-swiftui/e2e/tsconfig.json b/examples/with-swiftui/e2e/tsconfig.json new file mode 100644 index 000000000..5bb0043c0 --- /dev/null +++ b/examples/with-swiftui/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["tests", "e2e.config.ts"] +} diff --git a/examples/with-swiftui/screenshot-error.png b/examples/with-swiftui/screenshot-error.png new file mode 100644 index 0000000000000000000000000000000000000000..12cd466c52853d5abbef358236a18af5a2d82caf GIT binary patch literal 91556 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 j2RwmG778u_c@QCHy`>ToR{U(?KoJH{S3j3^P6` (`e2e guide` alone prints this page). For anything the topics do not cover, the full documentation ships in the `docs/` directory of the installed `e2e` package (`node_modules/e2e/docs` in a single-package project); a link such as -`/reference/cli` is `docs/reference/cli.mdx`. Complete projects for Vite, Next.js, and Expo, each with its config, scripts, and a passing +`/reference/cli` is `docs/reference/cli.mdx`. Complete projects for Vite, +Next.js, Expo, and SwiftUI, each with its config, scripts, and a passing suite, are in https://github.com/tester-army/e2e/tree/main/examples. | Topic | File | Read it when | From 7149587674be7c4a7abaf04f55a55423bcc99597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Kapu=C5=9Bciak?= <39658211+kacperkapusciak@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:22:44 +0200 Subject: [PATCH 13/46] docs: link standalone examples from technology cards (#853) --- docs/docs.json | 12 +++ docs/example-projects.mdx | 31 ++++++++ docs/images/examples/README.md | 9 +++ docs/images/examples/nextjs.svg | 1 + docs/images/examples/swiftui.png | Bin 0 -> 100676 bytes docs/images/examples/vite.svg | 130 +++++++++++++++++++++++++++++++ docs/index.mdx | 3 + docs/style.css | 11 +++ 8 files changed, 197 insertions(+) create mode 100644 docs/example-projects.mdx create mode 100644 docs/images/examples/README.md create mode 100644 docs/images/examples/nextjs.svg create mode 100644 docs/images/examples/swiftui.png create mode 100644 docs/images/examples/vite.svg diff --git a/docs/docs.json b/docs/docs.json index 4d8774b6d..6d0366ef6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -283,6 +283,18 @@ ] } ] + }, + { + "tab": "Examples", + "icon": "folder-open", + "groups": [ + { + "group": "Example projects", + "pages": [ + "example-projects" + ] + } + ] } ] }, diff --git a/docs/example-projects.mdx b/docs/example-projects.mdx new file mode 100644 index 000000000..1c4579113 --- /dev/null +++ b/docs/example-projects.mdx @@ -0,0 +1,31 @@ +--- +title: Example projects +description: Run standalone e2e examples with Vite, Next.js, Expo, and SwiftUI. +--- + +Each example is a standalone greeting app with three locator tests and two +agent tests. Enter a name, press Greet, and check the result. The projects +install published e2e packages from npm and work outside this repository. + + + + React with Vite. Run locator and agent tests in Chromium. + + + Next.js App Router. The runner starts the app and tests it in Chromium. + + + React Native with Expo. Run the same tests on web, an iOS simulator, or an Android emulator. + + + A native iOS app with an Xcode project. Run tests on an iPhone simulator. + + + +Follow each project's README for installation, builds, and run commands. +The config and tests live beside the app. The SwiftUI project keeps them +in its `e2e/` folder. + +Locator tests need no model. Set `AI_GATEWAY_API_KEY` to run the agent +tests; without it, those tests skip. See [Models](/models) for model setup +and the [Quickstart](/quickstart) to add e2e to your own app. diff --git a/docs/images/examples/README.md b/docs/images/examples/README.md new file mode 100644 index 000000000..2a741d6da --- /dev/null +++ b/docs/images/examples/README.md @@ -0,0 +1,9 @@ +# Technology logos + +These assets identify the technologies used by the example projects. + +- `vite.svg`: https://vite.dev/logo-without-border.svg +- `nextjs.svg`: https://github.com/vercel/next.js/blob/canary/packages/create-next-app/templates/app/ts/public/next.svg +- `swiftui.png`: https://developer.apple.com/assets/elements/icons/swiftui/swiftui-96x96_2x.png + +The Expo card reuses `../integrations/expo.svg`. diff --git a/docs/images/examples/nextjs.svg b/docs/images/examples/nextjs.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/docs/images/examples/nextjs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/images/examples/swiftui.png b/docs/images/examples/swiftui.png new file mode 100644 index 0000000000000000000000000000000000000000..342ccb1467eefb28342f57eae6d21aedfe0795c1 GIT binary patch literal 100676 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 literal 0 HcmV?d00001 diff --git a/docs/images/examples/vite.svg b/docs/images/examples/vite.svg new file mode 100644 index 000000000..0f2bdacd0 --- /dev/null +++ b/docs/images/examples/vite.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/index.mdx b/docs/index.mdx index 0a810e814..20295dc59 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -70,6 +70,9 @@ you shipped. One prompt for your coding agent, or set up by hand. + + Run a standalone app with Vite, Next.js, Expo, or SwiftUI. + Write test steps as goals in natural language. diff --git a/docs/style.css b/docs/style.css index 5453d1a3d..7e35b164a 100644 --- a/docs/style.css +++ b/docs/style.css @@ -665,3 +665,14 @@ html.dark body .code-block-fade-overlay { .dark img[src*='/images/integrations/expo.svg'] { filter: invert(1); } + +/* The Next.js wordmark needs the same contrast in dark mode. */ +.dark img[src*='/images/examples/nextjs.svg'] { + filter: invert(1); +} + +/* Keep the wide Next.js wordmark legible alongside the square marks. */ +#content [data-component-part='card-icon']:has(> img[src*='/images/examples/nextjs.svg']), +#content img[src*='/images/examples/nextjs.svg'] { + width: 80px; +} From 432e9bff7c7d6f97b37412e1f4d1f3053057fb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwa=C5=9Bniewski?= Date: Mon, 5 Oct 2026 13:24:24 +0200 Subject: [PATCH 14/46] fix(web): re-resolve a locator that turns ambiguous before its action runs (#859) * fix(web): re-resolve a locator that turns ambiguous before its action runs * fix(web): read the dispatch split from call log lines only --- .changeset/strict-mode-reresolve.md | 5 +++ packages/web/src/actions.ts | 15 ++++++-- packages/web/src/support.ts | 21 ++++++----- packages/web/src/surface.ts | 2 +- .../web/tests/integration/lifecycle.test.ts | 36 +++++++++++++++++++ packages/web/tests/unit/actions.test.ts | 4 ++- 6 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 .changeset/strict-mode-reresolve.md diff --git a/.changeset/strict-mode-reresolve.md b/.changeset/strict-mode-reresolve.md new file mode 100644 index 000000000..69907e501 --- /dev/null +++ b/.changeset/strict-mode-reresolve.md @@ -0,0 +1,5 @@ +--- +'@e2e-dev/web': patch +--- + +An action whose locator gains a second match just before it runs, such as a screen transition that briefly shows the old and new page together, no longer fails with `ENGINE_FAILURE` and a Playwright strict mode violation. The locator resolves again: two matches still fail with `LOCATOR_AMBIGUOUS`, and a duplicate that went away lets the action run. A drag whose drop target gains a second match after the press fails with `ACTION_FAILED` and is not repeated. diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 9ecac2adb..9c568e3a5 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -238,14 +238,25 @@ function actionabilitySummary(text: string): string { /** * Classifies a failed action onto the error contract (see the table above * `POST_DISPATCH_PATTERN` in support.ts): stale, not actionable, possibly - * committed, or an engine fault. + * committed, or an engine fault. A strict mode violation is a located match + * that turned ambiguous: stale like a detached one before any input, so the + * runner locates again and reports the ambiguity or acts on the one match + * left; possibly committed once input went out, as the second side of a drag + * resolves after the press. */ export function classifyActionError(rawCause: unknown, action: LocatorAction): Error { if (isClassified(rawCause)) return rawCause; const text = redactSensitive(message(rawCause), action); const cause = isSensitive(action) ? undefined : rawCause; if (/strict mode violation/i.test(text)) { - return new EngineError('ENGINE_FAILURE', text, { retryable: false, cause }); + if (POST_DISPATCH_PATTERN.test(text)) { + return new EngineError( + 'ACTION_MAY_HAVE_COMMITTED', + `${action.kind} matched more than one element after its input was dispatched: ${text}`, + { retryable: false, cause }, + ); + } + return new EngineError('NODE_STALE', text, { retryable: true, cause }); } if (DETACHED_PATTERN.test(text)) { return new EngineError('NODE_STALE', text, { retryable: true, cause }); diff --git a/packages/web/src/support.ts b/packages/web/src/support.ts index cec212ed8..93c3e5dc8 100644 --- a/packages/web/src/support.ts +++ b/packages/web/src/support.ts @@ -309,20 +309,23 @@ export function isTestErrorCode(cause: unknown, code: string): cause is Error & * | `TimeoutError` on an action, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED | * | element detached / not attached / no element / resolved hidden | NODE_STALE (retryable) | * | execution context destroyed / frame detached by a navigation | NODE_STALE (retryable) | + * | strict mode violation, log ends before the input dispatch | NODE_STALE (retryable) | + * | strict mode violation, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED | * | not an input / not editable / not checkable | NOT_ACTIONABLE | - * | strict mode violation, anything else | ENGINE_FAILURE | + * | anything else | ENGINE_FAILURE | * * The action split is read from the call log Playwright appends to a timeout - * message: `performing action`, ` action done`, and `waiting for - * scheduled navigations to finish` are only logged once the input is being (or - * has been) dispatched, so a timeout whose log reaches them is uncertain and - * the harness must not blindly repeat it. Every earlier line (`waiting for - * element to be visible, enabled and stable`, `scrolling into view if - * needed`, `retrying action`) precedes dispatch and is a plain - * actionability miss. + * or strict mode message: `performing action`, ` action done`, and + * `waiting for scheduled navigations to finish` are only logged once the input + * is being (or has been) dispatched, so a failure whose log reaches them is + * uncertain and the harness must not blindly repeat it. Every earlier line + * (`waiting for element to be visible, enabled and stable`, `scrolling into + * view if needed`, `retrying action`) precedes dispatch and is a plain + * actionability miss. Only whole `- ` log lines count, so a locator or element + * text quoting those words never does. */ export const POST_DISPATCH_PATTERN = - /performing \w+ action|\w+ action done|waiting for scheduled navigations to finish/i; + /^\s*- (performing \w+ action|[\w ]+ action done|waiting for scheduled navigations to finish)\s*$/im; /** Translates an unexpected Playwright error at the contract boundary. */ export function translatePwError(cause: unknown, operation: string): Error { diff --git a/packages/web/src/surface.ts b/packages/web/src/surface.ts index 8dab6709c..84b919a8c 100644 --- a/packages/web/src/surface.ts +++ b/packages/web/src/surface.ts @@ -756,7 +756,7 @@ export class PlaywrightSurface { return matches.map(({ raw, index }) => { const pinned = handles?.[index]; // A single match keeps the strict locator, so a ref that turns - // ambiguous between locate and perform fails loud instead of acting + // ambiguous between locate and perform is stale instead of acting // on whichever element is first. const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index); const id = refs.storeLocated( diff --git a/packages/web/tests/integration/lifecycle.test.ts b/packages/web/tests/integration/lifecycle.test.ts index fc36f5aa3..fa5bda088 100644 --- a/packages/web/tests/integration/lifecycle.test.ts +++ b/packages/web/tests/integration/lifecycle.test.ts @@ -828,6 +828,42 @@ describe('web engine lifecycle', () => { }); }); + it('reports a located ref stale once a second element matches it, and acts on neither', async () => { + const engine = web(); + await withAttempt(engine, app, artifactsDir, 'amb1', async () => { + await engine.session!.open!(`${app.url}/`, operation('amb1')); + const page = surfaceOf(engine)!.page(); + await page.setContent(''); + const [go] = await engine.locate!(byRole('button'), operation('amb1')); + await page.evaluate(() => document.body.insertAdjacentHTML('beforeend', '')); + + await expect(engine.perform!(go!.ref, { kind: 'tap' }, operation('amb1'))).rejects.toMatchObject({ + code: 'NODE_STALE', + retryable: true, + }); + expect(await page.evaluate(() => (window as unknown as { taps: number }).taps)).toBe(0); + }); + }); + + it('reports a drag whose drop target turns ambiguous after the press as possibly committed', async () => { + const engine = web(); + await withAttempt(engine, app, artifactsDir, 'amb2', async () => { + await engine.session!.open!(`${app.url}/`, operation('amb2')); + const page = surfaceOf(engine)!.page(); + await page.setContent(` + + +
Drop
`); + const [card] = await engine.locate!(byRole('button'), operation('amb2')); + const [zone] = await engine.locate!(byRole('region'), operation('amb2')); + + await expect( + engine.perform!(card!.ref, { kind: 'dragTo', target: zone!.ref }, operation('amb2')), + ).rejects.toMatchObject({ code: 'ACTION_MAY_HAVE_COMMITTED', retryable: false }); + expect(await page.evaluate(() => (window as unknown as { downs: number }).downs)).toBe(1); + }); + }); + it('mints one root id that survives navigation, and swipes the viewport when it is the target', async () => { const engine = web(); await withAttempt(engine, app, artifactsDir, 'r1', async () => { diff --git a/packages/web/tests/unit/actions.test.ts b/packages/web/tests/unit/actions.test.ts index b0d2125d8..202e9ec36 100644 --- a/packages/web/tests/unit/actions.test.ts +++ b/packages/web/tests/unit/actions.test.ts @@ -48,7 +48,9 @@ describe('classifyActionError', () => { }); it.each([ - ['strict mode violation: 2 elements', 'ENGINE_FAILURE', false], + ['strict mode violation: 2 elements', 'NODE_STALE', true], + ['strict mode violation: 2 elements\nCall log:\n - move and down action done', 'ACTION_MAY_HAVE_COMMITTED', false], + ["strict mode violation: getByText('performing click action') resolved to 2 elements:\n 1)

click action done

\nCall log:\n - waiting for getByText('performing click action')", 'NODE_STALE', true], ['element is detached from the DOM', 'NODE_STALE', true], ['Element is not attached to the DOM', 'NODE_STALE', true], ['Element is not an ,