From d0df17c54125e63b27b381f55eec3885d64d2c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 1 Oct 2026 14:19:35 +0200 Subject: [PATCH 1/9] test: migrate Maestro e2e to the e2e runner (stable) - replace .maestro YAML flows 1:1 with TypeScript tests in e2e/ on the public stable packages (e2e 0.15.1, @e2e-dev/mobile 0.8.1); no agentic flows, no NPM_TOKEN - engine installs/launches nothing on stable: freshAppBeforeEach() in every file installs E2E_*_APP_PATH and relaunches via device.openApp relaunch - add @e2e-dev/github PR reporter; run suites in ios.yml/android.yml after the release build, upload .e2e report artifacts - sync .agents/skills/e2e to the skill shipped with stable, drop the maestro skill --- .agents/skills/e2e/SKILL.md | 149 ++++++ .agents/skills/e2e/references/agent.md | 308 +++++++++++++ .agents/skills/e2e/references/bug-bash.md | 236 ++++++++++ .agents/skills/e2e/references/debugging.md | 111 +++++ .agents/skills/e2e/references/explore.md | 128 +++++ .agents/skills/e2e/references/mcp.md | 114 +++++ .agents/skills/e2e/references/running.md | 209 +++++++++ .agents/skills/e2e/references/setup.md | 320 +++++++++++++ .../skills/e2e/references/writing-tests.md | 389 ++++++++++++++++ .../skills/maestro-mobile-testing/SKILL.md | 436 ------------------ .github/workflows/android.yml | 25 +- .github/workflows/ios.yml | 26 ++ .gitignore | 2 +- .maestro/README.md | 30 -- .maestro/flows/basic-pager/ensure-ltr.yaml | 22 - .maestro/flows/basic-pager/ensure-rtl.yaml | 22 - .maestro/flows/basic-pager/open.yaml | 12 - .../flows/basic-pager/verify-controls.yaml | 52 --- .../verify-horizontal-ltr-swipe.yaml | 29 -- .../verify-horizontal-rtl-swipe.yaml | 29 -- .../basic-pager/verify-vertical-swipe.yaml | 27 -- .../issue_1083_modal_set_page_repro.yaml | 58 --- .../issues/issue_1098_nested_pager_repro.yaml | 47 -- ...issue_1083_modal_set_page_repro_setup.yaml | 20 - .../issue_1098_nested_pager_repro_setup.yaml | 20 - .../setup/material_top_bar_example_setup.yaml | 11 - .../setup/nested_pagerView_example_setup.yaml | 7 - .../setup/on_page_selected_example_setup.yaml | 13 - .../scrollable_pagerView_example_setup.yaml | 15 - ...view_inside_scroll_view_example_setup.yaml | 12 - .maestro/smoke-test.yaml | 50 -- .maestro/tests/material_top_bar_example.yaml | 69 --- .maestro/tests/nested_pagerView_example.yaml | 36 -- .maestro/tests/on_page_selected_example.yaml | 61 --- .maestro/tests/pager_basic_example.yaml | 12 - .maestro/tests/pager_rtl_example.yaml | 12 - .../tests/pager_vertical_basic_example.yaml | 12 - .../tests/scrollable_pagerView_example.yaml | 56 --- .../tab_view_inside_scroll_view_example.yaml | 44 -- e2e/README.md | 121 +++++ e2e/bun.lock | 363 +++++++++++++++ e2e/e2e.config.ts | 14 + e2e/package.json | 20 + e2e/targets.ts | 41 ++ e2e/tests/basic-pager.e2e.ts | 38 ++ .../issues/issue-1083-modal-set-page.e2e.ts | 36 ++ .../issues/issue-1098-nested-pager.e2e.ts | 34 ++ e2e/tests/material-top-bar.e2e.ts | 52 +++ e2e/tests/nested-pager-view.e2e.ts | 35 ++ e2e/tests/on-page-selected.e2e.ts | 34 ++ e2e/tests/scrollable-pager-view.e2e.ts | 31 ++ e2e/tests/smoke.e2e.ts | 20 + e2e/tests/support/app.ts | 69 +++ e2e/tests/support/basic-pager.ts | 62 +++ e2e/tests/support/test.ts | 40 ++ e2e/tests/tab-view-inside-scroll-view.e2e.ts | 37 ++ e2e/tsconfig.json | 17 + package.json | 14 +- scripts/run-maestro-tests.sh | 262 ----------- skills-lock.json | 8 +- tsconfig.json | 2 +- 61 files changed, 3091 insertions(+), 1490 deletions(-) create mode 100644 .agents/skills/e2e/SKILL.md create mode 100644 .agents/skills/e2e/references/agent.md create mode 100644 .agents/skills/e2e/references/bug-bash.md create mode 100644 .agents/skills/e2e/references/debugging.md create mode 100644 .agents/skills/e2e/references/explore.md create mode 100644 .agents/skills/e2e/references/mcp.md create mode 100644 .agents/skills/e2e/references/running.md create mode 100644 .agents/skills/e2e/references/setup.md create mode 100644 .agents/skills/e2e/references/writing-tests.md delete mode 100644 .agents/skills/maestro-mobile-testing/SKILL.md delete mode 100644 .maestro/README.md delete mode 100644 .maestro/flows/basic-pager/ensure-ltr.yaml delete mode 100644 .maestro/flows/basic-pager/ensure-rtl.yaml delete mode 100644 .maestro/flows/basic-pager/open.yaml delete mode 100644 .maestro/flows/basic-pager/verify-controls.yaml delete mode 100644 .maestro/flows/basic-pager/verify-horizontal-ltr-swipe.yaml delete mode 100644 .maestro/flows/basic-pager/verify-horizontal-rtl-swipe.yaml delete mode 100644 .maestro/flows/basic-pager/verify-vertical-swipe.yaml delete mode 100644 .maestro/issues/issue_1083_modal_set_page_repro.yaml delete mode 100644 .maestro/issues/issue_1098_nested_pager_repro.yaml delete mode 100644 .maestro/setup/issue_1083_modal_set_page_repro_setup.yaml delete mode 100644 .maestro/setup/issue_1098_nested_pager_repro_setup.yaml delete mode 100644 .maestro/setup/material_top_bar_example_setup.yaml delete mode 100644 .maestro/setup/nested_pagerView_example_setup.yaml delete mode 100644 .maestro/setup/on_page_selected_example_setup.yaml delete mode 100644 .maestro/setup/scrollable_pagerView_example_setup.yaml delete mode 100644 .maestro/setup/tab_view_inside_scroll_view_example_setup.yaml delete mode 100644 .maestro/smoke-test.yaml delete mode 100644 .maestro/tests/material_top_bar_example.yaml delete mode 100644 .maestro/tests/nested_pagerView_example.yaml delete mode 100644 .maestro/tests/on_page_selected_example.yaml delete mode 100644 .maestro/tests/pager_basic_example.yaml delete mode 100644 .maestro/tests/pager_rtl_example.yaml delete mode 100644 .maestro/tests/pager_vertical_basic_example.yaml delete mode 100644 .maestro/tests/scrollable_pagerView_example.yaml delete mode 100644 .maestro/tests/tab_view_inside_scroll_view_example.yaml create mode 100644 e2e/README.md create mode 100644 e2e/bun.lock create mode 100644 e2e/e2e.config.ts create mode 100644 e2e/package.json create mode 100644 e2e/targets.ts create mode 100644 e2e/tests/basic-pager.e2e.ts create mode 100644 e2e/tests/issues/issue-1083-modal-set-page.e2e.ts create mode 100644 e2e/tests/issues/issue-1098-nested-pager.e2e.ts create mode 100644 e2e/tests/material-top-bar.e2e.ts create mode 100644 e2e/tests/nested-pager-view.e2e.ts create mode 100644 e2e/tests/on-page-selected.e2e.ts create mode 100644 e2e/tests/scrollable-pager-view.e2e.ts create mode 100644 e2e/tests/smoke.e2e.ts create mode 100644 e2e/tests/support/app.ts create mode 100644 e2e/tests/support/basic-pager.ts create mode 100644 e2e/tests/support/test.ts create mode 100644 e2e/tests/tab-view-inside-scroll-view.e2e.ts create mode 100644 e2e/tsconfig.json delete mode 100644 scripts/run-maestro-tests.sh diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md new file mode 100644 index 00000000..9e79aed1 --- /dev/null +++ b/.agents/skills/e2e/SKILL.md @@ -0,0 +1,149 @@ +--- +name: e2e +description: Agentic end-to-end tests with e2e, the e2e runner. Covers scaffolding e2e.config.ts, picking the Playwright browser engine or the agent-device mobile engine, starting the app under test from the config, driving flows with agent.act, judging with agent.assert, agent.waitFor, and agent.extract, pinning values with screen, app, browser, and expect, shaping the agent (context, system prompt, tools, personas), the replay cache, the e2e CLI, reading .e2e/report.json, and bug bashes (parallel explore runs proven with repro tests). Use when a project depends on e2e, when asked for end-to-end, browser, mobile, or agentic UI tests, to bug bash or hunt for bugs, or when an e2e run fails. +--- + +# e2e: agentic end-to-end tests in TypeScript + +e2e runs UI tests with agent goals and exact assertions. `agent.act` drives +one goal; `agent.assert`, `agent.waitFor`, and `agent.extract` judge the +screen. `screen`, `app`, `browser`, and `expect` make exact interactions and +checks. The replay cache reruns verified actions and checks their recorded end +state without a model call; agent judgments still run live. UI targets use +`@e2e-dev/web` for browsers or `@e2e-dev/mobile` for iOS simulators and +Android emulators. A test that takes only `app` can check an API with `fetch` +and `expect` (topic `writing-tests`). Model sign-in commands are in +[setup](references/setup.md#subscriptions-and-api-keys). + +```ts +// e2e.config.ts +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { gateway } from 'ai'; + +export default { + targets: [ + { + engine: web(), + app: { + url: 'http://127.0.0.1:3000', + command: { executable: 'pnpm', args: ['dev'], log: '.e2e/logs/app.log' }, + }, + }, + ], + // The model behind every agent.* step: an AI SDK instance; gateway() from 'ai' reads AI_GATEWAY_API_KEY or a Vercel OIDC token. + agents: { + default: { + model: gateway('openai/gpt-6-luna-fast'), + system: 'You are a thorough QA agent. Verify every outcome on screen.', + }, + }, +} satisfies E2EConfig; +``` + +```ts +// tests/billing.e2e.ts +import { test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +test('a member upgrades to Pro', async ({ app, agent, screen, browser }) => { + await app.open('/settings/billing'); + await agent.act('upgrade the workspace to the Pro plan'); + await expect(screen.getByRole('status')).toContainText('Pro'); + await expect(browser).toHaveURL('/settings/billing'); +}); +``` + +## Topics + +Read the topic for the job before writing code. The files sit next to this +one; the installed CLI prints the same text with `npx e2e guide ` +(`e2e guide` alone prints this page). + +| Topic | File | Read it when | +| --- | --- | --- | +| `setup` | [references/setup.md](references/setup.md) | Adding e2e to a project, writing `e2e.config.ts`, starting the app from the config, mobile targets | +| `writing-tests` | [references/writing-tests.md](references/writing-tests.md) | Writing or fixing tests: fixtures, locators, actions, matchers, sign-in sessions, the `browser` fixture | +| `agent` | [references/agent.md](references/agent.md) | Adding `agent.*` steps, picking a model, cost and budgets, the replay cache | +| `running` | [references/running.md](references/running.md) | CLI flags, reporters, `.e2e/report.json`, exit codes, CI | +| `explore` | [references/explore.md](references/explore.md) | Exploring an app toward a goal without a test file: `e2e explore`, its budgets, verdict, and `run.explore` | +| `debugging` | [references/debugging.md](references/debugging.md) | A run failed: error codes and their fixes, `--headed`, `--debug`, `--ai-trace` | +| `mcp` | [references/mcp.md](references/mcp.md) | Driving the live app from a coding agent over MCP: `e2e mcp`, its tools, and the explore-then-write loop | +| `bug-bash` | [references/bug-bash.md](references/bug-bash.md) | Asked to bug bash, QA, or hunt for bugs across an app or a branch: parallel `e2e explore` charters, merging findings, proving each with a repro test | + +## Workflow + +1. Look at what exists: `e2e.config.ts` or `e2e.config.mts`, the `tests` glob + (default `tests/**/*.e2e.ts`), `e2e` in `package.json`. Nothing there: + follow `setup`. +2. Learn the screens before writing a test: routes, labels, roles, button + text. Semantic locators need the accessible names the app renders, so read + the components, open the page with `--headed`, or drive the live app over + the registered `e2e mcp` server (topic `mcp`): `open_session`, `observe`, + and `locate` show exact names and check a locator before you write it. +3. Write `tests/.e2e.ts`. Drive the flow with `agent.act`, one goal + per call, and pin each outcome right after with `expect` or `agent.assert`. + Exact values go through `screen`: a sign-in form in a setup test, a field + that must receive one specific string, a count that must be one number. +4. Run one file: `npx e2e run tests/.e2e.ts`. Agent steps need a + model in the config and that provider's authentication (a saved + subscription login, an API key); a local endpoint may need none. Tests + without agent steps need no model. +5. Read the failure: the reporter prints the error code, message, and a code + frame; `.e2e/report.json` has every step and artifact path. Fix the + locator, the expectation, or the app. Never add a sleep. + +## Rules + +- Run the CLI as `npx e2e ...` (or `pnpm exec e2e ...`). +- The config is `export default { ... } satisfies E2EConfig` with + `import type { E2EConfig } from 'e2e'`. `targets` is required; a UI target + names an engine and declares the app beside it: `{ engine: web(), app: { url, command } }`. + A tools-only target can omit the engine and set `platform`. +- Import `test`, `describe`, the hooks, `expect`, `credentials`, and + `secrets` from `e2e`. A test that uses the `browser` fixture imports `test`, + `describe`, and the hooks from `@e2e-dev/web`: the same runtime functions, + typed with `browser`. +- Config and tests are ES modules whatever `package.json` sets as `type`. +- Locators resolve when used. Actions wait for readiness and `expect` retries + assertions. Reads such as `textContent()` fail at once on zero matches and + `count()` answers from the current screen; nothing waits for a value to + change, so use a matcher when a value has to settle. +- A locator that matches two nodes fails with `LOCATOR_AMBIGUOUS`; narrow it + (topic `writing-tests`). +- Secrets never appear in test code. Declare accounts under `credentials` and + every other sensitive value under `secrets` in the config; resolve with + `credentials.user(name).password` or `secrets.get(name)` (separate + namespaces: `secrets.get` never returns a password), and hand the opaque + `Secret` only to `fill()` or `agent.act` params. +- Agent instructions: one goal per `act`, the wording on screen, real values + in params. Judge meaning, not phrasing: `toContain('Pro')`, not an exact + sentence a model produced. +- Check each agent goal's outcome. A passing `act` with a recorded check can + be cached and replayed without model calls (topic `agent`). +- Shape the agent for this app: `context` for vocabulary the screens use, + `system` for how it works, tools for a test API, named personas under + `agents`. When a step fails, tighten the goal first, then the context, then + the agent. +- `.e2e/` is output (`report.json`, `artifacts/`, `cache/`, `logs/`; the + config's `output` moves the report and artifacts, never `cache/` or the + app's log). Read it, never edit it. + +## Feedback + +When e2e itself gets in your way, tell the e2e team: a command or API that +broke (`bug`), docs or this skill that misled you (`docs`), or a capability +you needed and did not find (`feature`). Send it once per problem, after you +worked around it or gave up, never for failures of the app under test. + +```bash +npx e2e feedback --type bug -m "" \ + --task "" --expected "<...>" --actual "" \ + --approach "" --command "" --agent "" +``` + +Describe e2e's behavior only: never paste app content, page text, test files, +URLs of private apps, or credentials. Secret-named environment variables and +common token shapes are redacted, but do not rely on it. `--dry-run` prints +what would be sent. Tell the user you sent it and give them the reference id +it prints. diff --git a/.agents/skills/e2e/references/agent.md b/.agents/skills/e2e/references/agent.md new file mode 100644 index 00000000..a18b935b --- /dev/null +++ b/.agents/skills/e2e/references/agent.md @@ -0,0 +1,308 @@ +# Agent steps + +`agent` is a fixture like `screen` and the main way a test drives the app. +Each call is one bounded invocation: fresh redacted observation, deadline, +model-call budget, no shared transcript. No agent step, no model calls. + +## Configure a model + +Put an AI SDK model under `agents.default`. Vercel AI Gateway reads +`AI_GATEWAY_API_KEY` or, without it, a Vercel OIDC token: + +```ts +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { gateway } from 'ai'; + +export default { + targets: [{ engine: web(), app: { url: 'http://127.0.0.1:3000' } }], + agents: { default: { model: gateway('openai/gpt-6-luna-fast') } }, +} satisfies E2EConfig; +``` + +Subscription logins and local models: +[setup](setup.md#subscriptions-and-api-keys). Keep `ai@^7` installed with +any provider. + +- Pass a model instance, not a string (`INVALID_CONFIG`). +- An agents entry is one plain object of `model`, `judge`, `system`, + `context`, `tools`, `maxSteps`, `maxModelCalls`, `judgmentTimeout`, + `maxObservationBytes`, `maxInputTokens`, `providerOptions`, or `executor` + for a custom brain; none inherits `model` or `context` from `default`. +- `model` drives `agent.act`. Judgments use `judge` when set, else `model`. +- A missing model for the built-in agent raises one run-level + `MODEL_UNAVAILABLE` when the first test acquires `agent`, exit 2; auth + failures surface on the first model call as `MODEL_PROVIDER_FAILED`. +- `context` is what the app calls things, sent to every model call, judges + included; `system` is how the acting agent works, read only by the act + loop. Both live on the agents entry; `agentContext` on a test or group + adds more. + +### Choose an agent + +Tests use `agents.default` unless selected otherwise: + +- `e2e run --agent buyer` picks another configured agent. +- `{ agent: 'buyer' }` on a test or group pins that agent; a list such as + `{ agent: ['buyer', 'admin'] }` runs each test once per agent. +- `--agent buyer,admin` runs unpinned tests for both, narrows a pinned list + to matching names, never replacing a pin it does not name. +- `{ agent: 'name' }` on an `agent.*` call overrides the test's choice. + +For signed-in personas, pair an agent with a `session` in a describe block +repeated per persona; the cache records per agent step, so a specialised +agent replays too. + +## act: one goal + +```ts +import { credentials } from 'e2e'; + +await agent.act('add a todo named "Buy milk" and mark it done'); +await agent.act('invite {email} as an editor', { params: { email: 'ada@example.test' } }); + +const member = credentials.user('member'); +await agent.act('sign in with the given credentials', { + params: { + username: member.username, + password: member.password, // a Secret: the model sees its name, the runner fills the field + }, +}); +``` + +`act(instruction, options?)` runs a multi-action flow to a verdict. Passed +resolves with `summary`, `modelCalls`, `actions`, and `cache` (the replay +cache's part). Failed or blocked throws an `AgentError` whose `code` says +why: `ACTION_FAILED` (product failure); `STEP_BUDGET_EXHAUSTED`, +`STEP_TIMEOUT`, `CONTEXT_OVERFLOW` (out of room); +`AUTH_CREDENTIAL_UNAVAILABLE`, `AUTH_CREDENTIAL_INVALID`, +`SECRET_UNAVAILABLE`, `ENVIRONMENT_UNAVAILABLE`, `SEED_DATA_MISSING`, +`TEST_SETUP_FAILED`, `AUTOMATION_UNSUPPORTED`, `POLICY_DENIED` (blocked +from outside); +`MODEL_OUTPUT_INVALID` (unusable answer). Full list: topic debugging. + +Options: + +- `params`: values the instruction names; the runner fills a `Secret`, + and a run-unique `unique(\`E2E ${Date.now()}\`)` keeps the cache working + across runs. +- `timeout`: the config `timeout`. +- `maxSteps` (default 25 actions), `maxModelCalls` (default 25): may only + lower the agent's limits, else `INVALID_ARGUMENT`. +- No `schema` (use `extract({ schema })`) and no `vision`: the model decides + when it needs pixels. + +The tools, one per engine action. Name the target as the screen names it +(files in `params`); the agent picks the verb: + +- `observe`: re-read the screen after waiting on work in progress. +- `tap`: button, link, menu item, tab, checkbox, row, field. +- `double_tap`/`long_press`/`right_click`: second-click item, long-press + menu, context menu only. +- `hover`: menus, flyouts, tooltips that open on the pointer. +- `type`: one input; on browser/device no target means focus; `replace` clears. +- `press`: one key to a node or, on browser/device, to focus; `times` up to 20. +- `select`: one option by visible label. +- `check`: set a checkbox, switch, or radio to a state, not flip it. +- `scroll`: viewport or one scrollable node, a screen or a few. +- `scroll_to`: a listed node into view, or by `text` page a list to a row. +- `drag`: one node onto another. +- `upload`: project-root files to a file input; outside it or hidden (`.env`) + is `POLICY_DENIED`. +- `navigate`: a URL or app-relative path. +- `back`: browser history or in-app back. +- `type_secret`: a declared secret by name; plaintext never reaches the model. + Offered only when the step declares secrets and the engine can fill them. +- `screenshot`: attach viewport pixels; every later result then carries one. +- `tap_at`/`hover_at`/`press_at`/`select_at`/`type_at`: a screenshot point. +- `dismiss_keyboard` (device): hide the on-screen keyboard. + +The focused field's selected text is listed as `selection="..."` (never for +a secret), so a repeated `Shift+ArrowLeft` selects one word with visible +feedback. Point tools serve a canvas shape, map pin, image region, or system +sheet control, hit-testing the tree first so a listed control underneath is +acted on by id; `type_at` on nothing listed taps, then types. A screen with +nothing to tap by id opens with a screenshot attached. A device has no +`right_click`, `select`, `upload`, or `scroll_to` by node id. Pixels are +masked and withheld after a secret fill (topic writing-tests): act on pixels +before signing in, or in a test of its own. A result says when an action +closed an on-screen keyboard; on a touch screen that tap was often spent +closing it, so act again. + +## assert, waitFor, extract: one question + +```ts +import { z } from 'zod'; + +await agent.assert('the dashboard shows a trial badge'); // one look, one judgment + +await agent.waitFor('the export finished and a download link appeared', { // polls + interval: 500, + timeout: 120_000, +}); + +const data = await agent.extract('every todo title and how many remain', { // structured output + schema: z.object({ titles: z.array(z.string()), remaining: z.number().int() }), +}); +expect(data.titles).toContain('Buy milk'); +``` + +- `assert` does not poll. False is `ASSERTION_FAILED` with the model's + explanation and a screenshot in the report; too little on screen to + decide is `ASSERTION_INCONCLUSIVE`, also a failure, so reach the right + screen first and ask about what is visible. Judged from the tree alone, + it adds `pass vision: true when the answer is in pixels`, as does + `waitFor`'s timeout after an inconclusive round. +- Judgments see the assertion and the current screen only, never prior + steps or the act loop's summaries; malformed output gets one repair + round, then `MODEL_OUTPUT_INVALID`. +- `waitFor` observes every `interval` (default 3 s), judges only when the + screen changed, and is `STEP_TIMEOUT` after `timeout` (default 30 s). +- `extract` takes any Standard Schema validator (zod works); the model sees + the schema's shape, never its value rules (`min`, `max`, lengths, + patterns), which check what it read. Data the screen does not show is + `ASSERTION_INCONCLUSIVE` naming what was missing, never `""` or `0`; to + accept absence, ask for it (`'the phone, or null when none is shown'` + with `.nullable()`). + +`vision` on a judgment picks the evidence: `false` (default) the tree; +`true` the tree plus a masked screenshot; `'only'` the screenshot alone, for +what the screen presents (an overlay, a broken layout, a chart) where the +tree would answer first. `'only'` never falls back to the tree: unprovably +masked pixels fail with `POLICY_DENIED`. + +## Write instructions the model can execute + +- One goal per `act`; goal order is the test's, the path the model's. +- Use the words on screen: `'open the Billing tab'`, not `'upgrade'`. +- Values go in `params`, never expanded: `act('rename to {name}', { params })`. +- Do not describe mechanics the runner handles: waiting, scrolling, retries. +- Pin every `act` outcome right after it; that check lets the cache record: + +```ts +await agent.act('create a workspace named "Atlas" on the Pro plan'); +await expect(screen.getByRole('status')).toHaveText('Created "Atlas" on the Pro plan'); +``` + +The model gets the instruction verbatim plus the params as a separate +block; the check also makes the test model-portable. Off-screen state (a +database row) can land after `act` returns; `expect.poll` the read instead +of sleeping. + +## What the model sees + +A redacted snapshot of the screen (roles, names, text, states), prior-step +summaries, and your context; never raw HTML, cookies, headers, environment +values, or a `Secret`'s value; password fields masked. The first +screen of a step arrives whole; later action results report what changed, +keyed by node ids stable while an element exists, or the whole screen when +most changed. Pixels arrive through `vision` on a judgment or the act loop's +`screenshot` and point tools, masked and withheld after a secret fill. When +the browser engine's tree capture times out, the model gets a screenshot and +a warning, a judgment needs `vision: true`, and no control may be inferred +absent nor old node ids reused. Nothing the model returns runs as code or +selectors: the runner validates and authorizes every tool call first. + +## Budgets and cost + +| Call | Model calls | Default timeout | +| --- | ---: | --- | +| `act` | up to `agents..maxModelCalls` (25) | the config `timeout`, 120 s | +| `assert` | 2 | 30 s | +| `extract` | 2 | 30 s | +| `waitFor` | up to `agents..maxModelCalls` (25) | 30 s | + +- Slow model calls: raise the step or test `timeout` for `act`, the agent's + `judgmentTimeout` for judgments, `actionTimeout` for slow UI. + `STEP_TIMEOUT` and `STEP_BUDGET_EXHAUSTED` fail the test; smaller goals + help. +- `--debug` prints phase timings and a per-step table (duration, model + calls, tokens, cache share, cost) to stderr and saves step transcripts as + artifacts. + +## The replay cache + +A passing `agent.act` saves its actions once a later check verifies the +outcome; the next run replays them without model calls, and the live agent +continues from the current screen when the app or final state no longer +matches. Misses and hand-offs use the model; `agent.assert`, +`agent.waitFor`, and `agent.extract` are never cached. + +- On by default (`read-write`), `read-only` in CI, off via `cache: 'off'` + or `--no-cache`. Entries live in `.e2e/cache/`; deleting the directory + only slows the next run. +- An entry is written only after a later verification passes (a locator or + engine `expect` matcher, `locator.waitFor`, `browser.waitForURL`, + `agent.assert`, `agent.waitFor`), so an unchecked `act` never replays; a + plain-value `expect`, `expect.poll`, `agent.extract`, another `act`, or + the attempt passing confirms nothing. +- A replay needs the app on the recorded path (unless the recording opens + with a navigation), re-finds each control by role, name, test id, + placeholder, and input purpose, and passes alone only when the recorded + end path and the controls seen during the step are back; otherwise the + agent takes over mid-step. `step.cache.reason` says why: `no-entry`, + `wrong-context`, `target-not-found`, `target-ambiguous`, `end-mismatch`, + and so on. +- A step recording no actions creates no entry; one whose `unique()` value + equals, is spelled inside, or is the encoded form of another param's value + is not recorded either (`step.cache.notRecorded`: `param-collision`). +- `e2e init` gitignores `.e2e/cache/`; remove that line to commit entries + and share replays with CI and teammates (CI stays `read-only` unless + `cache: 'read-write'` is set). +- A failing run evicts the entries it implicates; `--no-cache` rules the + cache out of a failure. +- With committed recordings, `--strict-cache` in CI fails a recording that + no longer replays with `REPLAY_STALE` instead of quietly spending model + calls every run; re-record locally and commit. Unrecorded steps still run + live. + +## Inspect what the model did + +```bash +npx e2e run tests/checkout.e2e.ts --debug # step table, transcripts as artifacts +npx e2e run tests/checkout.e2e.ts --ai-trace # writes /ai-trace.json (.e2e/ai-trace.json by default) +npx unbox-ai runs .e2e/ai-trace.json # one line per agent step +npx unbox-ai summary .e2e/ai-trace.json --run 0 # turns, tokens, tool calls of one step +``` + +Never read the trace directly: megabytes of resent context, images replaced +by byte counts. `--no-cache` traces the whole flow, since replayed steps +make no model calls. + +## Make the agent yours + +1. **The goal.** A failed step usually named something the screen does not; + reword it with on-screen labels, and `--debug` shows what the model saw + and tried. +2. **`context`.** Vocabulary every step needs (plan names, what a + "workspace" is, which tab holds billing), once on `agents..context` + or per test via `agentContext`. +3. **`system` on the agent.** How carefully it verifies, what it never + does, how it treats a modal; a UX reviewer, a cautious QA persona, and a + fast smoke agent are three `system` prompts on one model. +4. **Tools.** A test API the agent may call mid-flow (seed a cart, mint a + coupon) via `tools`; see below. +5. **The model and its options.** `providerOptions` for reasoning effort, + or another model for one persona. `--agent ` runs the suite as any + configured agent, comparing candidates on the same tests; every result + records which agent ran it. + +## Beyond the built-in agent + +- `tools: { seedCart }` on an agents entry adds AI SDK tools wrapped with + `defineTool(tool({ ... }), { mutates: true })` from `e2e/agent` for a + test API a flow calls mid-step. +- `createToolLoopExecutor` keeps the loop and replaces prompt and tool + vocabulary. +- Any `StepExecutor` (`{ name, version?, cache?, runStep(ctx) }`) goes under + `executor`; the runner still owns observations, actions, budgets, and the + report, and `system` or `tools` beside `executor` is `INVALID_CONFIG`. + +Reference: https://e2e.tester.army/docs/agents + +## In CI + +Run the suite, agent steps included, on every pull request, passing the key +the config's model reads (`env: { AI_GATEWAY_API_KEY }` for `gateway()`) +from secrets. Sharing `.e2e/cache/` lets CI replay verified action steps +without model calls; CI defaults (`read-only` cache, retries): topic running. diff --git a/.agents/skills/e2e/references/bug-bash.md b/.agents/skills/e2e/references/bug-bash.md new file mode 100644 index 00000000..f774c483 --- /dev/null +++ b/.agents/skills/e2e/references/bug-bash.md @@ -0,0 +1,236 @@ +# Running a bug bash + +A bug bash is many `e2e explore` runs at once, one charter each, then a +verification pass that turns every claimed bug into a repro test that fails +for the reason reported. Plan the charters, fan them out, merge the findings, +prove each bug. Report confirmed bugs only, each with its failing test. + +## 1. Prepare + +- A config with a target and an agent that holds a model (topic `setup`), + and authentication for its provider. +- The app must serve several explorers at once: start it once and set + `reuseExisting: true` on the target's `app.command`, or declare the URL with + port `0` so each run starts its own app on a free port. `reuseExisting` is + ignored when `CI` is set, as in many agent sandboxes: use port `0` or + unset `CI`. A fixed port that is not reused fails every run after the first. +- When the target's `app.command` brings up a stack of its own (a database + it starts and removes on exit), the first explorer to finish removes the + database under the rest. Start the stack once yourself with the config's + command and environment, and explore with a bug-bash config that declares + no command (below). +- Explore a production build when the project has one. A dev server that + compiles a route on first visit reads to the explorer as a dead link. +- Seed one disposable workspace or user per charter with the project's own + fixtures or seed scripts and declare each as a credential (`bb-`). + Explorers that share an account report each other's edits as bugs. +- Start signed-in charters from a saved session: `e2e explore --session + ` runs the setup test that saves it and explores signed in (topic + `explore`). A setup that types a password withholds screenshots from then + on; sign in by cookie or API call to keep them (topic `writing-tests`). + Each charter with its own account needs its own setup and session. +- Put what the local app cannot do in the agent's `context`: integrations + without keys, what is seed data, what must never be clicked (paid runs, + real accounts). Add the explorer's own blind spots from step 5's artifact + bucket (the config below carries the sentence); without them those + families dominate the findings. +- A bash against a deployed site others use is read-only: no signups, + sign-ins, or submissions, nothing injection-shaped in URLs, no request + loops. A WAF block is the firewall working, not a finding, and it can + follow the runner's IP into every later charter. +- Each exploration step needs 40 or more `maxSteps` and `maxModelCalls` on + the agent; a config tuned for test steps (`maxSteps: 15`) starves it. That + is per step, separate from `--max-steps`, which counts a charter's steps. +- On a mobile target, declare one target per device and give each explorer + and verifier its own (topic `setup`). +- Skip `--headed`. On an engine that records, `--video` gives each run a + replay for any confirmed bug. + +A bug-bash config, left untracked, spreads the project's config and +overrides what a bug bash needs. Typing the import as `E2EConfig` keeps +`shared.tests` and `shared.credentials` compiling when the base declares +neither: + +```ts +// e2e.bugbash.config.ts +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { gateway } from 'ai'; +import base from './e2e.config.ts'; + +const shared: E2EConfig = base; + +// What the local app cannot do, plus the explorer's blind spots (step 5's artifact bucket). +const context = + 'Sign in with the credential the goal names. The local app sends no email and has no AI key. Never start a paid run or connect an integration. ' + + 'Not bugs: a link that opens a new tab leaves this one unchanged; accessible text splits around inline links, so judge copy by the rendered screen when a screenshot is available and never report split text alone as broken copy; an infinite-scroll "Loading more" sentinel loads when scrolled into view; images lazy-load, so scroll and wait before calling one blank.'; +const persona = { model: gateway('openai/gpt-6-luna-fast'), maxSteps: 40, maxModelCalls: 40, context }; + +export default { + ...shared, + // The project's tests, so a repro can use its setup tests' sessions, plus the repro tests from step 6. + tests: [shared.tests ?? 'tests/**/*.e2e.ts', 'tests/bugbash/**/*.e2e.ts'].flat(), + // The app already runs: no command. + targets: [{ name: 'web', engine: web(), app: { url: 'http://127.0.0.1:3000' } }], + retries: 0, + reporters: ['list'], + credentials: { + ...shared.credentials, + // The seed script's password, from the environment rather than the file. + 'bb-cart': { username: 'bb-cart@example.test', password: process.env.BUGBASH_PASSWORD ?? '' }, + 'bb-account': { username: 'bb-account@example.test', password: process.env.BUGBASH_PASSWORD ?? '' }, + }, + // The postures from step 2 as personas: same model and budgets, a different stance; each charter picks one with --agent (step 3). + agents: { + default: persona, + skeptic: { ...persona, system: 'Distrust every number, date, count, and claim on screen; cross-check each against every other place it appears.' }, + fuzzer: { ...persona, system: "At every input, run the goal's input matrix before anything else, judging each entry before the next. Never take the happy path." }, + }, +} satisfies E2EConfig; +``` + +Set `BUGBASH_PASSWORD` to 6 or more characters first, or the config fails to +load. Pass `--config e2e.bugbash.config.ts` to every command below, and +`open_session {config: "e2e.bugbash.config.ts"}` over MCP. + +## 2. Plan charters + +A charter is one `e2e explore` goal: one area, one posture, one sentence, +naming the start route and, when needed, the credential (`Sign in as +credential bb-cart. Starting at /cart, ...`). Read the routes, navigation, +and forms first; for a branch, `git diff --stat` against the base. + +| Posture | Charter shape | +| --- | --- | +| First-time user | `Starting at /signup, sign up and complete onboarding like a first-time user; report anything confusing, broken, or inconsistent` | +| Numbers and copy | `Starting at /cart, change quantities and apply a coupon; check every price, total, and label against the rest of the page` | +| Edge input | `Starting at /settings/profile, submit each field empty, too long, with unicode and with leading spaces; report validation that is missing or wrong` | +| State | `Starting at /projects, create, rename, and delete a project, reloading and going back after each; report state that is lost or stale` | +| Error paths | `Starting at /login, try a wrong password, an unknown account, and a locked account; report errors that are missing, misleading, or leak detail` | + +Aim for five to ten charters, each with its own slug. Overlap is fine; +duplicates merge in step 4. Give each posture its persona (config above), +picked per charter in step 3: a generic agent walks past a stat that +contradicts the same stat on another page, the skeptic catches it. An +edge-input charter names its exact matrix (empty, a 300-character string, +unicode, leading spaces, literal special characters) or it spends the whole +time budget before judging a single result. + +## 3. Fan out + +One `e2e explore` per charter with its own output directory: +`--output .e2e/bugbash/` writes `report.json` and `artifacts/` there, +and `--reporter list,markdown` adds `summary.md`. `e2e init` gitignores +specific `.e2e/` paths, not `.e2e/bugbash/`: add it to `.gitignore` or +delete it when done, and delete it before a new bug bash. `charters.txt` and +the logs under it are yours; the rest is the runner's output, read only. + +Run them as background shell jobs, four at a time, not as subagents. One +line per charter, `slug|target|agent|charter`, the agent naming its persona, +then (`xargs -0 -P` is a GNU and BSD extension, present on macOS and Linux): + +```bash +mkdir -p .e2e/bugbash +cat > .e2e/bugbash/charters.txt <<'CHARTERS' +cart|web|skeptic|Starting at /cart, change quantities and apply a coupon; check every price, total, and label against the rest of the page +account|web|fuzzer|Starting at /settings/profile, submit each field empty, a 300-character value, unicode, and leading spaces; report validation that is missing or wrong +CHARTERS +while IFS='|' read -r slug target agent charter; do + [ -n "$slug" ] && printf '%s\0%s\0%s\0%s\0' "$slug" "$target" "$agent" "$charter" +done < .e2e/bugbash/charters.txt | xargs -0 -n 4 -P 4 sh -c \ + 'npx e2e explore "$4" --config e2e.bugbash.config.ts --target "$2" --agent "$3" --output ".e2e/bugbash/$1" --max-steps 6 --video --reporter list,markdown < /dev/null > ".e2e/bugbash/$1.log" 2>&1' _ +``` + +The log's summary prints `AI` (cost) and `Duration`. Exit code `1` means +issues were reported: read the log either way. Exit codes `2` and `3` are +setup and environment problems; fix them and rerun that charter alone. + +## 4. Merge + +Read each log's `Findings` section (topic `explore`); `summary.md` holds the +same, and `report.json` has the record under `run.explore`. The video is in +the attempt's `video/` directory under `.e2e/bugbash//artifacts/`. + +Merge findings that describe one defect: same path, same broken behavior. +Keep the clearest reproduction and every charter that hit it. Keep warnings +in a separate list unless the user asked for polish. + +A charter that filled a password, or whose session's setup did, prints no +`evidence` line after that point. Its video is not masked: check it for +secrets before sharing (topic `writing-tests`). + +## 5. Triage + +Sort every finding before writing any test. Read the source to sort them. + +| Bucket | Sign | Outcome | +| --- | --- | --- | +| Explorer artifact | A "dead" `target="_blank"` link whose destination opens when clicked with popup capture or navigated to directly, a broken sentence the screenshot renders whole, a "Loading more" sentinel nothing scrolled to, a lazy-loading image or embed | Rejected with the check that settled it; settle this bucket first. A new-tab link whose destination never opens stays a candidate | +| Environment | Fails on a key, a service, or a limit only the local stack lacks (an email provider, an AI key, a billing plan) | Rejected, naming the variable or service; note separately when the app handles the failure badly in a way production users would see, such as showing the raw error | +| Design | The code, its tests, or its copy say the behavior is intended | Rejected, citing where | +| Fixture | The seed data lacks a field real records always have | Rejected, naming the field | +| Candidate | None of the above | Verify it (step 6) | + +## 6. Verify + +A finding is a model's claim; prove each candidate before reporting it. When +your client can start subagents (Claude Code's Agent or Task tool, for one), +start one per area with its three to five candidates as the log printed +them and these steps, up to four at a time, the `e2e mcp` server's default +session limit. Each reports back, per finding: confirmed or rejected, the +root cause as `file:line` when it may read the source, the repro test path, +and the failure it saw. Without subagents, verify one area after another. + +1. Read `actual` against the screenshot, or the video when there is none. + A finding the evidence contradicts is rejected here. Settle the artifact + bucket first: a "dead" link's destination must actually open (a valid + href with a prevented default is still dead), a copy claim must show in + the rendered screenshot. +2. Write a repro test that follows the reproduction and asserts the + expected behavior, so it fails today and passes once the bug is fixed. + Put it under `bugbash/` inside the directory the config's `tests` glob + covers (`tests/bugbash/.e2e.ts` for the default): a file the glob + does not match is never selected, whatever path you pass to `e2e run`. + Build on the project's fixtures (a `test.extend` fixture, a setup test's + session). Prefer `screen` actions and `expect` with exact values; + `agent.act` for a step that varies, `agent.assert` for an outcome only + judgment can check (topic `writing-tests`). Tag it `{ tags: ['bugbash'] }`. +3. Get exact locators from the live app: with the `e2e mcp` server + registered, `open_session` with the bug-bash config, pass its session id + to every call, walk the reproduction, and `locate` each locator before + writing it (topic `mcp`). Close your session when done. +4. Run the file alone: `npx e2e run tests/bugbash/.e2e.ts`. Confirmed + only when it fails with `ASSERTION_FAILED` on the assertion that encodes + the bug. Any other failure (`LOCATOR_NOT_FOUND`, a timeout, a setup + error) means the test is wrong: fix it and rerun. A passing test means + the bug did not reproduce: reject the finding, say so, and move the test + out of `tests/bugbash/` (to `.e2e/bugbash/extra-tests/`, or offer it as + a regression test). Only failing repro tests stay there. + +## 7. Report + +Lead with the confirmed bugs, most severe first. For each: title, path, one +line of expected against actual, root cause when known, steps, screenshot +and video paths, repro test, and the charters that found it. Then the +environment candidates that are production risks, marked unverified. Then +the rejected findings grouped by reason (environment, design, fixture, did +not reproduce), and the warnings. End with the charters run, their cost, and +the areas no charter reached. + +The repro tests fail until the bugs are fixed: where the project's `tests` +glob covers `tests/bugbash/`, its gating run leaves them out +(`npx e2e run --exclude-tag bugbash`) or they stay uncommitted. Offer to fix +each bug: the repro test turning green is the proof, and it stays as the +regression test with its `bugbash` tag removed. + +## Rules + +- Never report an unverified finding as a bug. "The explorer reported" is + not "confirmed". +- One charter, one area. A charter that spans the whole app ends at its + step budget having skimmed everything. +- Seeded data and test accounts the app ships for development are not + bugs; say so in the agent's `context`. +- Leave the project's files as you found them: the bug-bash config, seed + script, and repro tests stay untracked until the user asks otherwise, and + stop the stack you started when the user is done. diff --git a/.agents/skills/e2e/references/debugging.md b/.agents/skills/e2e/references/debugging.md new file mode 100644 index 00000000..abb9176b --- /dev/null +++ b/.agents/skills/e2e/references/debugging.md @@ -0,0 +1,111 @@ +# Debugging a failing run + +Paths below sit under the configured `output` directory; `.e2e` is the +default. + +## Read the failure + +1. Run with `--reporter list,markdown`: `list` ends with a `Failed Tests` + section, the markdown reporter's `Failures` line names `.e2e/failures/`. +2. Open the failed test's page under `.e2e/failures/` and grep `Expected:` + / `Observed:` (an `expect`), `Asked for:` and `Waited:` (a locator), + `Look at:` (the line it unwound through), whether every attempt + failed alike (a bug, not a flake), the steps, the last model turns of a + failed agent step, and the accessibility tree at failure, one node per + line. Fix from what was there. +3. `.e2e/report.json` backs the pages: + +```bash +jq '.run | {status, exitCode, errors}' .e2e/report.json +jq '.run.results[] | select(.selected and .status != "passed") | {titlePath, file, status}' .e2e/report.json +jq '.run.results[] | select(.selected and .status != "passed") | .attempts[-1] + | {status, error, failure, steps: [.steps[] | select(.status != "passed") | {api, label, source, status, error}], artifacts}' .e2e/report.json +``` + + `error.details` holds the facts, `error.source` the line, `failure` the + `url`, `screen` and `screenshot` artifact ids, and `candidates`; a + failed agent step has `turns`; `selected` drops filtered-out tests + (recorded as `skipped`). +4. Artifacts, under `.e2e/artifacts/`: `failure/screen.txt` + and the engine's screenshot per failed attempt; a Playwright trace per + traced attempt (`npx playwright show-trace `); downloads; with + `--video` the recording (`video/video.webm` in a local browser, each + later page `video/video-part.webm` with its own `startedAt`; + `video/video.mp4` on a device; a provider's file or link); with + `--debug` every agent step's transcript. + +## Error codes + +| Code | Usual cause | Fix | +| --- | --- | --- | +| `CONFIG_LOAD_FAILED` | The config throws while loading (a refused engine option is `INVALID_CONFIG` instead) or imports a missing package, subpath, or removed export such as `defineConfig` | Install the dependency, or fix the import or line quoted | +| `INVALID_CONFIG`, `INVALID_GLOB` | Unknown or foreign key (`app`, `webServer`, `use`, `projects`, `baseURL`), `json` with `list`; a `tests` glob with braces, classes, an absolute path, or no wildcard | The message names the key or glob to write; the app is declared in the target's `app` | +| `CONFIG_NOT_FOUND`, `CONFIG_AMBIGUOUS` | Wrong `--config` path; both `.ts` and `.mts` present | Fix the path; keep one | +| `NO_TESTS` | The glob, a positional, or a filter matched nothing; the message names each empty positional and each undeclared `--tag` with the nearest declared one | Check the config `tests`, the `.e2e.ts` suffix, the tag names | +| `NO_LAST_RUN` | `--last-failed` found no `.e2e/report.json` | Run once without it | +| `COLLECTION_ERROR` | `async` describe body, `test.setup` inside `describe`, an option forbidden in a serial group, registration outside collection | Restructure per `writing-tests` | +| `HOOK_FAILED` | `beforeAll` or `afterAll` threw; its scope's tests skip | Fix the hook; the report carries its error | +| `UNSUPPORTED_ARTIFACT` | A test's or target's `trace` or `video` on an engine that cannot record | Drop it there, or set it at the config root or CLI (such targets skip with a notice) | +| `BROWSER_INSTALL_FAILED`, `LAUNCH_TIMEOUT` | Browser download failed; engine init or attempt start exceeded `launchTimeout` | Run the quoted `npx playwright install ` (`--with-deps` on bare Linux); raise the root `launchTimeout` (60 s default) | +| `APP_UNREACHABLE` | `app.command` never answered `readyUrl` within `startupTimeout`; on a device, a message naming the iOS automation runner: the runner failed, not the app | Read the quoted log lines; check the port, `app.url`, `app.command.env`, `app.command.startupTimeout`. Runner: rerun, else `npx agent-device daemon stop` and reboot the simulator | +| `APP_ALREADY_RUNNING` | Something already serves `url` when `command` should start | Stop it, or `reuseExisting: true` locally | +| `APP_URL_REQUIRED`, `APP_NOT_OPEN` | A navigation on a target without `app.url`; a `screen` call before `app.open()` | Add `app.url` to the target; open the app first | +| `INVALID_ARGUMENT`, `INVALID_LOCATOR` | A step argument failed validation; a locator got a bad option or filter key | Fix the call the code frame names | +| `LOCATOR_NOT_FOUND` | Wrong role or name, inexact text, element off screen or in an iframe, page not open | Read the markup for the accessible name; `exact: false` or a RegExp; `browser.frameLocator` for iframes; `app.open()` first; `--headed` | +| `LOCATOR_AMBIGUOUS` | Two matches (hidden duplicate, repeated label) | `{ name }`, a container scope, `filter`, `first()`, or `{ visible: true }` | +| `ASSERTION_FAILED` | Wrong expectation, or the state settles later than 5 s; for `agent.assert`, a false judgment (explained in the report) | Compare with the report's actual text or the screenshot; `{ timeout }` on the matcher; rewrite the question | +| `ASSERTION_INCONCLUSIVE` | `agent.assert` or `agent.extract` asked about what the screen does not show (another page, still loading, only in pixels); a failure, never a pass | `app.open()` or `agent.waitFor` the right screen first; ask about what is shown; `vision: true` when the answer is in pixels (the message says so) | +| `MODEL_OUTPUT_INVALID` | An `extract` or `assert` answer failed the schema after one repair round | Simplify the schema or question; pick a stronger model | +| `ACTION_FAILED` | Element not actionable (covered, disabled, detached), or an operation timed out | `expect` the condition first; close overlays; check `actionTimeout` | +| `TEST_TIMEOUT` | The attempt exceeded `timeout` (120 s) | Split the test, or raise `timeout` | +| `STEP_NOT_AWAITED` | The body returned while a step still ran: a call without `await` | `await` the call the code frame names (every `app`, `agent`, `screen`, `expect` call) | +| `MODEL_UNAVAILABLE` | `agents..model` holds no AI SDK instance: reported once under `run.errors` at the first `agent` fixture; the run stops. A model-less custom executor fails only `waitFor` and `extract` this way | Construct one, e.g. `gateway('openai/gpt-6-luna-fast')` from `ai`, and export its key (`AI_GATEWAY_API_KEY`) | +| `MODEL_PROVIDER_FAILED` | Network, 5xx, rate limit, no credits, or no response in 120 s, after the transport retries | Check the credential (the key; for keyless `gateway()` the Vercel CLI login and `.vercel/project.json`) and quota; retry (exit 3) | +| `REPLAY_STALE` | `--strict-cache` or `cache.strict`, and a committed recording no longer replays; `step.cache.reason` says why | Rerun read-write with the knob the message names off; commit the changed entry under the directory it names (a custom `cache.store` is written by that run) | +| `AUTOMATION_UNSUPPORTED` | The step needs an interaction the engine's toolset lacks (drag on a device, say); blocked, exit 1 | Do that step with `screen` actions, or run the test on a target that supports it | +| `STEP_TIMEOUT`, `STEP_BUDGET_EXHAUSTED` | Goal too big or ambiguous, or a slow provider | Split the goal, use on-screen wording, add vocabulary via `agents..context` or `agentContext`; raise `maxSteps` or `maxModelCalls` (budget), the config `timeout` (`act`), `judgmentTimeout` (judgments); `--debug` | +| `CONTEXT_OVERFLOW` | Screen plus step history did not fit the model's context window (`act` after one shrink-and-retry; a judgment on the first overflow) | Lower `agents..maxObservationBytes`, split the step, or pick a larger-window model | +| `POLICY_DENIED` | A `file:`, `data:`, or `javascript:` URL; a password `Secret` into a non-password sink; reading a secure field, `toHaveValue`, `toHaveText`, `toContainText`, `toHaveAttribute` included; `app.screenshot()` after a secret fill | http(s) only; passwords into password inputs only; assert the outcome, not the value; screenshot before filling secrets | +| `UNSUPPORTED_CAPABILITY` | A fixture the engine lacks (`browser` on a device), `schema` or `vision` on `act`, an action the surface lacks | Declare `requires: ['browser']`; drop the option or action | +| `SESSION_UNAVAILABLE`, `SESSION_CONTRACT` | `session: 'x'` with no setup saving `x`; a setup that skipped a declared name. `SESSION_MISMATCH`, `SESSION_EXPIRED`, `SESSION_INVALID`: the stored session is another run's or app's, expired, or corrupt | Add or fix the `test.setup`; rerun it | +| `ONLY_IN_CI` | `test.only` reached CI | Remove it | +| `AUTH_CREDENTIAL_UNAVAILABLE`, `AUTH_CREDENTIAL_INVALID` | `credentials.user('x')` for an undeclared name; the app rejected a configured credential | Add it to `config.credentials`; fix the stored value | +| `SECRET_UNAVAILABLE` | `secrets.get('x')` for an undeclared name | Add it to `config.secrets`; `E2E_SECRET_X` only overrides a declared one | + +## Tools + +| Do | When | +| --- | --- | +| `--headed` | Watch the failing step | +| `--workers 1 --retries 0` | Take parallelism and retries out of the picture | +| `--no-cache` | Rule out a stale `agent.act` replay | +| `--debug` | Each agent step's duration, model calls, cost, transcript | +| `--ai-trace`, then `npx unbox-ai runs .e2e/ai-trace.json` | What the model saw and called | +| `--video`, `--video=retain-on-failure` | Watch the failed attempt; `step.startedAt` minus the segment's `startedAt` is the step's offset into it | +| `command.log: '.e2e/logs/app.log'` | The app's output when it never gets ready or errors mid-test | +| `await app.screenshot('before-submit')` | Evidence before a secret is filled; later calls fail with `POLICY_DENIED` | +| `CI=1 npx e2e run` | Reproduce CI-only behaviour (defaults: topic running) | + +## Flaky tests + +- A read (`textContent()`, `count()`) caught a value mid-update: use a + matcher. +- Shared data: unique names per run, `afterEach` cleanup, or a `serial` + group. +- App not ready: assert on the element you are about to use, not the + previous page. +- An agent judgment asserts exact phrasing: judge the fact, add a + deterministic `expect` beside it. +- Load timing: `retries` masks it; `--workers 1 --headed` shows it. +- Measure a fix: `npx e2e run --repeat-each 10 --retries 0` + (`--no-cache` for agent steps). `Repeats` reads `0 of 1 test passed all + 10 runs` over `3/10 passed · repeat 0 ASSERTION_FAILED · ...` (0-based) + before, `1 of 1 test passed all 10 runs` after. + +## Is it the app? + +A deterministic step failing every run at the same place with the same code +is a product bug or a changed screen, not a flake: reproduce once with +`--headed`, then fix the app or update locator and expectation together. A +blocked agent step (`AUTH_CREDENTIAL_UNAVAILABLE`, `ENVIRONMENT_UNAVAILABLE`, +`SEED_DATA_MISSING`) exits 2 or 3 on purpose: fix the environment. diff --git a/.agents/skills/e2e/references/explore.md b/.agents/skills/e2e/references/explore.md new file mode 100644 index 00000000..b3b709c5 --- /dev/null +++ b/.agents/skills/e2e/references/explore.md @@ -0,0 +1,128 @@ +# Exploring without a test + +`e2e explore` runs the agent against the app with a goal instead of a test +file: see what it can do with an app before tests exist, hunt for regressions +on a branch, or find what is worth turning into a test. It needs a config with +a target and an agent that holds a model, nothing else. + +```bash +npx e2e explore # goal: "Explore the app and find bugs" +npx e2e explore 'Explore checkout like a first-time buyer and report anything off' +npx e2e explore --target web --max-steps 4 --headed +npx e2e explore 'Hunt for broken forms' --video +npx e2e explore --session admin 'Explore the admin settings' +``` + +## What a run does + +1. Opens the app on the target's URL. +2. Plans one step: a structured model call reads the goal, the steps and + findings so far, and the current screen, and answers with a title and a + concrete charter for one flow, or decides the goal is covered. +3. Runs the charter as an `agent.act()` step with the project's tools plus + `report_finding`. The agent reports each defect the moment it has evidence: + title, `issue` or `warning`, severity 1 to 5, expected, actual, reproduction + steps. The runner adds the path and a redacted screenshot. +4. Repeats until the planner finishes, the step limit, the clock, or three + failed or blocked steps in a row that reported nothing; then asks for a + closing assessment. + +A failed step does not end the run: it is recorded, and only reported issues +fail the product verdict. A run whose charters were all blocked stays blocked. +A step that hits its action or time budget ended at its limit and counts as +neither. Configured `credentials` reach the explorer as step secrets: the +planner knows the account names and usernames, and the agent fills passwords +with `type_secret` by name. That tool is offered only when the engine can fill +secrets; otherwise the agent skips signing in and says so in the step summary. + +## Start signed in + +`--session ` starts from a session a setup test saves +(`test.setup('...', { sessions: ['admin'] }, ...)` and `session.save('admin')`, +topic `writing-tests`). The run collects the config's test files, runs exactly +the setup that declares the session, then restores it into the exploration +and, on a target with a URL, opens the app, as a test with +`{ session: 'admin' }` does. No other test runs. The setup runs as for +`e2e run`, with the configured agents, cache, and retries; only the +exploration runs as the explorer. The planner and the agent are told they +start signed in, so no charter is spent signing in again. A name no setup +declares fails before any app process starts with `COLLECTION_ERROR`, naming +the declared sessions. + +A setup that filled a secret taints the restored session, so every finding +goes without a screenshot; one that signed in without a fill (a `browser.setCookies` +session cookie, say) keeps them where the engine captures pixels, and only +configured secrets are redacted (topic `writing-tests`). + +## Flags + +| Flag | Default | Effect | +| --- | --- | --- | +| `[goal]` | `Explore the app and find bugs` | One quoted sentence: the area and the posture. | +| `--target ` | first configured target | The one target to explore. | +| `--agent ` | `default` | Build the explorer from another configured agent (`agents.`). | +| `--session ` | none | Run the setup that saves this session, then explore with it restored. | +| `--max-steps ` | 8 (1 to 12) | Exploration steps at most. | +| `--timeout ` | 600000 (180000 to 900000) | Wall clock; the last minute is for the assessment. | +| `--headed`, `--reporter`, `--output`, `--debug`, `--ai-trace`, `--trace [mode]`, `--video [mode]` | as `run` | Same meaning as for `e2e run`. One attempt, so a retry mode (`on-first-retry`, `on-all-retries`, the CI trace default) records nothing (`CI=1 e2e explore` needs `--trace on`; the run's notice says so); put the goal before a bare `--trace` or `--video`. | + +Per-step action and model-call budgets default to 40 each; +`agents..maxSteps` and `agents..maxModelCalls` in the config +override them. The replay cache is off and retries are zero for the +exploration. + +## Reading the result + +Exit code `0`: steps ran, no `issue` was reported, and not every charter was +blocked; warnings are allowed. Exit code `1`: at least one `issue`, or no step +ran and nothing was found. If every charter was blocked and no `issue` was +reported, the run is `blocked` even with warnings, and the exit code is the +first blocker's own, as for `run`: 1 for an automation limit, 2 for a missing +model, credentials, seed data, or test setup, 3 for an unavailable +environment. When a charter passes, fails, or exhausts its budget, reported +issues decide the verdict. Other errors use `2` and `3` as for `run`. + +The terminal shows each step by its title with its duration, actions, and +findings, and each finding the moment it is reported as +`⚑ high issue Title (/path)`. At the end a `Findings` section lists every +finding, issues first and the most severe first, each with where it was seen, +its screenshot path, expected against actual, and the steps that reach it; +then the `Assessment` and the `run` summary with `Findings` and `Steps` in +place of `Test Files` and `Tests`. Severity words: critical 5, high 4, +medium 3, low 2, trivial 1. `.e2e/report.json` has the record under +`run.explore`: + +```json +{ + "goal": "...", + "budgets": { "maxSteps": 8, "timeoutMs": 600000 }, + "ended": "finished | step-limit | time | stuck | aborted", + "summary": "the closing assessment", + "steps": [{ "index": 1, "title": "...", "instruction": "...", "status": "passed | failed | blocked | exhausted", "summary": "...", "errorCode": "...", "startedAt": "...", "durationMs": 0 }], + "findings": [{ "id": "", "index": 0, "step": 1, "kind": "issue", "severity": 4, "title": "...", "expected": "...", "actual": "...", "reproduction": ["..."], "path": "/cart", "observationRevision": "...", "artifactId": ":artifact:2", "reportedAt": "..." }] +} +``` + +A step carries `errorCode` only when it did not pass; a finding carries +`step`, `path`, `observationRevision`, and `artifactId` only when known. +`artifactId` names the evidence screenshot among the attempt's `artifacts` in +the result whose `file` is `explore`, where its path, size, and digest are. +With `--session`, `run.results` also holds the setup's result and the +project's other tests as skipped (`filtered`). The attempt directory is +`.e2e/artifacts//explore--//attempt-0/`: the slug +is the goal's first words in lowercase ASCII, capped, and the digest keeps +distinct goals apart while the same goal always maps to the same directory. +For example +`.e2e/artifacts/web/explore-check-the-cart-totals-1a2b3c4d5e6f7a8b/default/attempt-0/finding-1.png`. + +Turn a finding into a test: its `reproduction` steps are the `agent.act()` +instructions or `screen.*` actions, and `expected` is the assertion. + +## When it does not fit + +The agents entry's `tools` and `system` carry over to the explorer; a +hand-rolled `StepExecutor` under `executor` is replaced by the built-in agent +for the run, with a notice on stderr, and needs a `model` on the entry (or the +executor's) or explore fails before it starts. Findings are the model's claims +plus evidence, not verified reproductions: read `actual` against the +screenshot before filing a bug. diff --git a/.agents/skills/e2e/references/mcp.md b/.agents/skills/e2e/references/mcp.md new file mode 100644 index 00000000..4ce194a0 --- /dev/null +++ b/.agents/skills/e2e/references/mcp.md @@ -0,0 +1,114 @@ +# Driving the app over MCP + +`e2e mcp` serves a project's live app to a coding agent over MCP (stdio). The +coding agent drives the app the way the testing agent does: look at a screen +before writing a test, check a locator before committing to it. Running tests +and reading a failed run stay on the CLI (topics `running` and `debugging`). + +## Setup + +The server ships with `e2e`. `e2e init` offers to register it; by hand: + +```bash +claude mcp add e2e -- npx e2e mcp # Claude Code +``` + +Or declare it in the client's project config (`.mcp.json` for Claude Code, +`.cursor/mcp.json` for Cursor, `.vscode/mcp.json` for VS Code): + +```json +{ "mcpServers": { "e2e": { "command": "npx", "args": ["e2e", "mcp"] } } } +``` + +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 ` +sets how many sessions may be open at once (default 4, 1 through 16). + +## Tools + +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. | +| `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. | + +The catalog, per session: + +| Catalog tool | Does | +| --- | --- | +| `observe` | A fresh observation: one node per line as `#id role "name" ...`, plus the current path. On the web engine a link's `href` is its path on the app URL's origin, else origin and path (`mailto:` and `tel:` keep their scheme, `blob:` its inner origin and path); `?…` and `#…` mark a dropped query or fragment, `data:…` and `javascript:…` an inline payload, a trailing `…` a target cut at 256 characters. | +| `tap`, `double_tap`, `long_press`, `right_click`, `hover`, `type`, `press`, `select`, `check`, `scroll`, `scroll_to`, `drag`, `upload`, `navigate`, `back`, `dismiss_keyboard` | The grammar verbs, as the testing agent gets them: `check` takes `checked`, `drag` a `to` node, `upload` project-relative `files`; `dismiss_keyboard` comes with device engines. Each reports what changed on screen; `observe` shows the whole screen. A verb the engine cannot honor is not listed and fails with `UNSUPPORTED_CAPABILITY`. | +| `type_secret` | Fills a configured secret by name: a credential's password into a password field, a `secrets` entry into any editable input; the plaintext never reaches the agent. Listed when the config declares `credentials` or `secrets` and the engine can fill secrets. | +| `locate` | Tries a semantic locator with exactly one of `role`, `text`, `label`, `placeholder`, `testId` (`name` only with `role`; `exact` optional; anything else is `INVALID_ARGUMENT`) and returns how many nodes match, which, and the `screen.*` call to write. | +| `screenshot` | The masked pixels as an image. | +| `tap_at` | Taps a point (`x`, `y` in the latest screenshot's pixels): a listed control under it by id, otherwise the bare point, which an engine without a bare-point tap cannot do. Listed when the engine declares `tap` or a bare-point tap. | +| `hover_at` | Hovers a point the same way: a listed control under it by id, otherwise the bare point. Listed when the engine declares `hover` or a bare-point hover. | +| `type_at` | Types `value` into the field at a point: a listed input under it is filled by id; with a keyboard, anything else is tapped to focus it and typed into, at the caret unless `replace` is set. Without a keyboard a point on nothing listed fails. Listed when the engine declares `type` or a keyboard. | +| `press_at` | Sends one `key` (`Enter`, `Escape`, `Tab`) to the control at a point: a listed control gets it by id; with a keyboard, anything else is tapped to focus it and the key goes through the keyboard. Listed when the engine declares `press` or a keyboard. | +| `select_at` | Picks the option whose visible label is `value` in the select-like control at a point; the point must land on a listed select. Listed when the engine declares `select`. | +| `start_recording` | Starts a video of the app (`name` optional, for the file name). Listed when the engine records video. | +| `stop_recording` | Stops it and returns the absolute path of each video file, under `/videos//` (`.e2e` by default), or the URL of a provider's own recording. | +| Project tools | Every `defineTool` in the agent's `tools` that applies to the target's platform, under its own name; an engine pack such as `mobileTools` adds `open_app`, `swipe`, `alert`. | + +Once a secret is filled in the session, `screenshot` and the point tools +(`tap_at`, `hover_at`, `type_at`, `press_at`, `select_at`) answer with a +`PIXEL_TAINTED` line for the rest of it; act on listed nodes by id (topic +`writing-tests`). Before the session's first `screenshot`, a point tool only +says to take one. + +Resources: `e2e://guide` and `e2e://guide/` hold this skill. + +## Workflow + +1. `open_session`, then `call {tool: "observe"}` and act until the screen you + want to test is in front of you. Node ids are valid only for the newest + observation; an action reports what changed, so observe again before + using new ids. +2. `call {tool: "locate", args: {...}}` for each locator you intend to write. + One match: use the printed `screen.getByRole(...)` call. Zero or several: + adjust before writing the test; the same failure would hit the test as + `LOCATOR_NOT_FOUND` or `LOCATOR_AMBIGUOUS`. +3. Write `tests/.e2e.ts` (topic `writing-tests`). Deterministic steps + where you saw exact names; `agent.act` where the flow varies. +4. Run it from the shell: `npx e2e run tests/.e2e.ts`, read the + failure (topic `debugging`), fix, repeat. +5. `close_session` when you are done; an idle session closes on its own after + 30 minutes and never outlives 4 hours. When the client exits, every session + closes and the app commands stop. To look at another project or config, + `open_session {config: "path/to/e2e.config.ts"}`; no restart needed. + +## Rules + +- Record a demo or a bug for a pull request with `start_recording` once the + screen is set up, and `stop_recording` when the part worth watching is + over; `close_session` saves one still running. Videos are not masked: + keep secrets off screen while recording. +- A failed action is an error result that leads with the tool, its target, + and the code (`tap #n9 failed: LOCATOR_NOT_FOUND: ...`) and still shows + the screen it re-observed: re-aim from that screen. +- Nothing a session does is recorded as a test or into the replay cache. A + session is for looking and trying; the test is what you write afterwards. +- A run from the shell and a live session share the app only if the target's + app `command` sets `reuseExisting` (ignored in CI); otherwise close the + session before running. +- Parallel agents (subagents) share one server: each opens its own session + and passes its session id to every `tools`, `call`, and `close_session`. + A call may leave `session` out only while one session is open. Sessions + open at once share one config. Sessions on the same app command share its + process, which stops when the last of them closes. On mobile, two sessions + on one simulator fight over it: one target per device (topic `setup`), each + session on its own target. +- `TARGET_REQUIRED`: pass `target` to `open_session` or start with `--target`. + `UNKNOWN_TARGET`: no target of that name; the message lists the declared + ones. `NO_SESSION`: call `open_session` first, or the session named has + ended (the message says why). `SESSION_REQUIRED`: several sessions are open; + pass `session`. `SESSION_OPEN`: every session slot is taken (close one, or + raise `--max-sessions`). `CONFIG_IN_USE`: open sessions use another config; + open on theirs, or close them first. `ENGINE_IN_USE`: the target's engine + comes from a package every session shares; create it in the config or a + file it imports by path. `UNKNOWN_TOOL`: the name is not in this session's + catalog; the message lists what is. diff --git a/.agents/skills/e2e/references/running.md b/.agents/skills/e2e/references/running.md new file mode 100644 index 00000000..e7c788d2 --- /dev/null +++ b/.agents/skills/e2e/references/running.md @@ -0,0 +1,209 @@ +# Running tests + +## Commands + +```bash +npx e2e run [files...] [options] # run tests +npx e2e explore [goal] [options] # explore toward a goal without a test file (topic explore) +npx e2e list [files...] [options] # print what run would select +npx e2e init [directory] [--yes] # scaffold a project, refresh the agent skill +npx e2e guide [topic] # print this skill; topics: setup, writing-tests, agent, + # running, explore, debugging, mcp, bug-bash +npx e2e cache ls|clear|stats # inspect or empty the replay cache +npx e2e login|logout|models [provider] # e2e/oauth subscription logins: openai, + # github-copilot, spacexai +npx e2e mcp [--target ] # MCP server for a coding agent (topic mcp) +npx e2e feedback -m [opts] # report a problem with e2e itself +npx e2e telemetry [disable|enable] # anonymous usage telemetry: status or switch +``` + +`run` flags: + +| Flag | Effect | +| --- | --- | +| `[files...]` | Files, directories, quoted globs relative to the project root, or a bare name (`signup`, `signup.e2e.ts`, `agent/signup.e2e.ts` all select `tests/agent/signup.e2e.ts`); `file:line` is the test whose `test(` opens on that line. They narrow the config `tests` glob, never bypass it. | +| `--config ` | Config file; default `e2e.config.ts` or `.mts`, found upward. | +| `--target ` | Target names, comma-separated or repeated; only these start their app commands. Unknown names fail before startup. | +| `--tag ` | Any of the tags, comma-separated or repeated; all of them with `--tag-mode all`. An empty `--target`, `--tag`, or `--agent` value is a usage error, exit 2. | +| `--exclude-tag ` | Drop tests carrying any of these tags, however selected. | +| `--grep `, `--grep-invert ` | Keep, or drop, tests whose title (describe titles and test title joined by spaces, `checkout pays`; not file or tags) matches a regular expression. Bare pattern or `'/pattern/i'`; repeat for alternatives. | +| `--last-failed` | The tests the previous run (`/report.json`) did not pass, plus every test in a failed `beforeAll` or `afterAll` scope. No report is `NO_LAST_RUN`, exit 2. | +| `--shard ` | One contiguous slice of the selected tests (`--shard 2/3`), cut after every other filter; serial groups stay whole, each shard brings its own setup tests. | +| `--headed` | Visible browser or simulator when the engine supports it. | +| `--agent ` | Run unpinned tests as these `agents.` entries (default `agents.default`), comma-separated or repeated; several names run each such test once per agent. | +| `--workers `, `--retries ` | Override the resolved values; retries 0-10, workers 1-1024. | +| `--max-failures ` | Stop after n failures: the rest skip (cause `failure-limit`), running tests end `interrupted`; exit 1. | +| `--repeat-each ` | Run every selected test n times, each run its own result (`repeat` 0 through n-1); add `--no-cache` or later runs replay the first's recording. The `Repeats` summary row names each unstable test's failed runs. | +| `--reporter ` | `list`, `json`, `junit`, `markdown`, comma-separated; `json` cannot combine with `list`. | +| `--output ` | Results directory, over the config's `output` (default `.e2e`). | +| `--no-cache` | Replay cache off for this run. | +| `--strict-cache` | Fail a step whose committed recording no longer replays (`REPLAY_STALE`, exit 2) instead of handing it to the agent. | +| `--pass-with-no-tests` | Exit 0, not `NO_TESTS`, when nothing matches. | +| `--debug` | Phase timings and an agent step table on stderr; transcripts as artifacts. | +| `--ai-trace` | Every model call, to `/ai-trace.json`. | +| `--trace [mode]`, `--video [mode]` | Which attempts record a trace, or a video (WebM on browsers, MP4 on devices), over the config and every target: bare is `on`; `--trace off` skips the cost; `retain-on-failure` (video) keeps only failed attempts; `on-first-retry` records first retries, `on-all-retries` every retry. A test's own `trace` or `video` still wins; targets whose engine cannot record are skipped with a notice. Both are greedy: write `--video=` or put test files first. The failure recap names the video. | + +```bash +npx e2e run tests/signup.e2e.ts +npx e2e run signup.e2e.ts:12 # by bare name, the test declared at line 12 +npx e2e run tests/agent --tag smoke --exclude-tag slow --grep checkout +npx e2e run --last-failed # the loop after a red run +npx e2e run --shard 2/3 # one CI job of three +npx e2e run 'tests/**/*.smoke.e2e.ts' --target chromium --workers 1 --retries 0 +CI=1 npx e2e run # the CI defaults, locally +``` + +`list` takes the same files and selection flags (`--config`, `--target`, +`--tag`, `--tag-mode`, `--exclude-tag`, `--grep`, `--grep-invert`, +`--last-failed`, `--shard`, `--pass-with-no-tests`), prints one line per +test-target pair, `file › title [target] #tag`, skipped pairs ending in +` (skipped: )`, and starts no app, engine, or worker. +`--reporter json` prints `{ "pairs": [...] }`. + +```bash +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`. + +## The replay cache + +Entries live under `.e2e/cache/`, one file per key, named by key digest. +`cache` commands read the same config as `run` (`--config`, `cache.dir`); +with a custom `cache.store` they refuse (exit 2), inspect that store with +its own tools. + +| Command | Prints | +| --- | --- | +| `e2e cache ls` | One row per entry: test, target, instruction digest, age, action count. | +| `e2e cache stats` | Directory, entry count, total size. | +| `e2e cache clear` | Deletes the entries and the directory; files the runner never wrote stay. | + +## Output + +`` (`.e2e` by default) holds `report.json`, `junit.xml`, +`summary.md`, `failures/`, `ai-trace.json`, `sessions/`, and `artifacts/` +(screenshots, Playwright traces, videos, `--debug` transcripts, downloads). +`artifacts/` is cleared once a run's tests start; a run stopping before +leaves the last run's files. The report records every artifact path, a +hosted service's video by URL. + +- `list` (default): setup steps, one line per file and target, a `Failed + Tests` section (error, code, failing line, code frame), then the + summary rows `Test Files`, `Tests`, `AI`, `Cache` (when the replay cache + ran), `Repeats` (with `--repeat-each`), `Errors`, `Start at`, `Duration`, + `Report`, `AI trace` (with `--ai-trace`). Past a minute `Duration` + repeats as minutes and seconds, setup time split out as `startup` in the + same parenthetical (`682.97s (11m 23s, startup 43.00s)`). +- `report.json`, written whatever the reporters, holds `run.status`, + `run.exitCode`, `run.errors[]` (run-level, such as `APP_UNREACHABLE`), + and `run.results[]`, one per test and target: `titlePath`, `file`, + `source`, `tags` (`[]` when none), `agent`, `repeat` (0 unless + `--repeat-each`), `selected`, `status`, `attempts[]` of `steps[]`, + `artifacts[]`, `error`. +- `junit`: `junit.xml` for CI summaries; `--reporter list,junit` keeps the + terminal output. +- `markdown` (`--reporter list,markdown`): `summary.md` plus one page per + failed or flaky test under `failures/`. The summary holds counts and + spend, a block per failed test (error, facts, failing step, whether every + attempt failed alike, last model turns, screen location and closest + nodes, the line to look at, evidence paths), the flaky tests folded + alike, and every test as one folded table, a row per file (or an + exploration's findings and assessment); a failure page adds every step, + every kept turn, and the screen at failure inline. Read the page first; + paste the summary into a pull request or handoff. +- `json`: the report on stdout. +- Custom reporters get step progress with `identity` (`attemptId`, + `attemptIndex`, `stepId`, `stepIndex`: report IDs, zero-based indexes; + retries change the attempt, serial members share the group's attempt with + distinct step IDs) and an `end` phase carrying the redacted error and the + `blocked` and `cancelled` statuses. Older streams may lack `identity`. +- `github()` from `@e2e-dev/github`: on GitHub Actions, one pull request + comment per run (edited on rerun) plus the job summary; needs + `pull-requests: write` and `GITHUB_TOKEN` (or `GH_TOKEN`) in the step's + env. + +## Exit codes + +| Code | Meaning | +| ---: | --- | +| 0 | Every selected test passed, was flaky, or was skipped | +| 1 | A test or setup test failed or timed out | +| 2 | CLI, config, collection, credential, model-config, or policy error | +| 3 | Engine, app process, model provider, artifact, or cleanup failure | +| 4 | Internal runner error | +| 130 | Interrupted by an external signal | + +The highest code present wins (`130 > 4 > 3 > 2 > 1 > 0`). 130 needs an +external signal: a `--max-failures` stop or a run-level error that +interrupted workers keeps the failures' or the error's code. Exit 2 is +deterministic, never retry it; only exit 3 is worth a job-level retry. + +The first SIGINT or SIGTERM interrupts and writes the report if any test +had started; the second forces engine teardown; the third kills the app +process groups and exits 130 at once. + +## Continuous integration + +CI mode is on when `CI` is set (not `0` or `false`): `retries` 1, +`workers` 1, `trace` `on-first-retry`, `test.only` rejected with +`ONLY_IN_CI`, the replay cache `read-only` unless the config sets a mode explicitly +(`cache: 'read-write'` or `cache.mode`), `reuseExisting` ignored. + +```yaml +# .github/workflows/e2e.yml +# Omits the github() reporter: it needs pull-requests: write and +# GITHUB_TOKEN in the run step's env. +name: e2e +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26 # any Node >= 22.12 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: npx playwright install chromium --with-deps + - run: npx e2e run --reporter list,junit + env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + E2E_USER_ADMIN_USERNAME: ${{ secrets.E2E_USER_ADMIN_USERNAME }} + E2E_USER_ADMIN_PASSWORD: ${{ secrets.E2E_USER_ADMIN_PASSWORD }} + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-report + path: | + .e2e/report.json + .e2e/junit.xml + if-no-files-found: warn + - if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-artifacts + path: .e2e/artifacts + if-no-files-found: warn + retention-days: 7 +``` + +- Install browsers as their own step so the download stays out of the + launch timeout. +- Upload artifacts unless cancelled, so a test that failed then passed on + retry keeps its evidence. +- Start the app through the target's `app.command`; the runner tears it down + on every exit path. +- Agent steps run in the same job: pass the key the config's model reads + (`AI_GATEWAY_API_KEY` for `gateway()` from `ai`) as a secret in the run + step's `env`, and commit `.e2e/cache/` so recorded steps replay with no + model call. diff --git a/.agents/skills/e2e/references/setup.md b/.agents/skills/e2e/references/setup.md new file mode 100644 index 00000000..b64c7e8b --- /dev/null +++ b/.agents/skills/e2e/references/setup.md @@ -0,0 +1,320 @@ +# Setting up e2e + +## Requirements + +- Node.js 22.12 or newer. +- ES modules: `.ts` config, tests, helpers, and workspace packages exporting + `.ts` source load as ESM regardless of the nearest `package.json` `type` + (CommonJS packages need no change); never `require` or `module.exports`. +- Browser tests: `@e2e-dev/web` plus `playwright` (`>=1.63.0 <2`), a peer the + engine does not install: an existing Playwright keeps its version and + browser cache, one out of range fails install as an unmet peer (npm's + `ERESOLVE`): upgrade `playwright` within the range. Missing + browsers download on first boot; in CI run `npx playwright install chromium + --with-deps`. Mobile tests: `@e2e-dev/mobile`, pinning `agent-device` + exactly; the pin moves with each engine release. + +## Scaffold + +Fresh project: + +```bash +npx e2e init # npm +pnpm dlx e2e init # pnpm +``` + +With `e2e` installed, run the installed version: `npx e2e init` or `pnpm exec +e2e init`; `npx e2e init my-app` scaffolds into a new directory. + +The wizard picks an engine and a model provider (None for tests without AI) +and offers to install this skill, register the MCP server for your coding +agent, and install dependencies. `--yes` picks Playwright and Vercel AI +Gateway, installs the skill in `.agents/skills/` (`.claude/skills/e2e` +symlinks to it), registers MCP in `.mcp.json` and `.cursor/mcp.json`, skips +installing dependencies. + +Init adds dependencies and a `test:e2e` script to `package.json`, writes +`e2e.config.ts` and `tests/example.e2e.ts`, updates `.gitignore`, leaves +existing configs and tests alone. Re-run after upgrading to refresh skill and +MCP entries. + +Without the wizard (`ai`, Vercel AI SDK v7, only for `agent.*` steps): + +```bash +npm install --save-dev e2e @e2e-dev/web playwright ai@^7 +``` + +## Subscriptions and API keys + +`e2e init` writes model config and dependencies for a subscription, an API +key, or a local endpoint. Authenticate: + +| Choice | Setup | +| --- | --- | +| ChatGPT Plus or Pro | `npx e2e login openai` | +| GitHub Copilot | `npx e2e login github-copilot` (GitHub CLI signed in, or your own `--client-id`) | +| SuperGrok or X Premium+ | `npx e2e login spacexai` | +| Vercel AI Gateway | Set `AI_GATEWAY_API_KEY`, or sign in to the Vercel CLI and `npx vercel link`; without the key `gateway()` uses a Vercel OIDC token | +| OpenRouter | Set `OPENROUTER_API_KEY` | +| Local or self-hosted endpoint | Set the endpoint URL and a model it serves, plus a key if required | + +Switching an existing config to ChatGPT: install `ai` and `@ai-sdk/openai`, +set `model: chatgpt('gpt-6-luna')` from `e2e/oauth/chatgpt`, run `npx e2e +login openai`. `npx e2e models` lists the ids each login serves. Use API keys +in CI. + +## The config + +`e2e.config.ts` sits at the project root and default-exports an object literal +ending in `satisfies E2EConfig`; unknown keys are `INVALID_CONFIG` at load. + +```ts +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { gateway } from 'ai'; + +export default { + tests: 'tests/**/*.e2e.ts', + targets: [ + { + engine: web(), + app: { + url: 'http://127.0.0.1:3000', + command: { executable: 'pnpm', args: ['dev'], log: '.e2e/logs/app.log' }, + }, + }, + ], + // Model behind every agent.* step. + agents: { + default: { + model: gateway('openai/gpt-6-luna-fast'), + system: 'You are a thorough QA agent. Verify every outcome on screen.', + }, + }, + credentials: { + admin: { username: 'admin@example.test', password: process.env.ADMIN_PASSWORD ?? '' }, + }, +} satisfies E2EConfig; +``` + +A string `password` is checked at load (6+ code points): set `ADMIN_PASSWORD` +or `E2E_USER_ADMIN_PASSWORD` first, or defer to fill time with +`() => process.env.ADMIN_PASSWORD ?? ''`. + +| Key | Default | Notes | +| --- | --- | --- | +| `targets` | required | Non-empty; `--target` takes `name`. UI targets set `engine` and `app` (`platform` and `name` default to the engine's platform); tools-only targets may omit `engine` and must set `platform`. | +| `tests` | `'tests/**/*.e2e.ts'` | Globs relative to the project root (optional `./`): `*`, `?`, whole `**` segments, leading `!` excludes (`'!tests/wip/**'`; only exclusions is `INVALID_CONFIG`). Braces, character classes, extglobs, `..`, absolute paths, and a wildcard-free directory entry (`'!tests/wip'`) are `INVALID_GLOB`. | +| `timeout` | `120000` | Per test attempt, ms; also the default `agent.act` deadline. | +| `launchTimeout` | `60000` | Engine init and attempt start, ms. | +| `actionTimeout` | `30000` | Each locator action and engine operation, agent-step observations included. | +| `assertionTimeout` | `5000` | `expect` polling window. | +| `cleanupTimeout` | `30000` | Each `afterEach` hook, fixture teardown, engine cleanup, ms. | +| `retries` | `0`, `1` in CI | 0 to 10. | +| `workers` | half the cores, `1` in CI | Parallel test files, capped by the `workers` the engine declares per target. | +| `reporters` | `['list']` | `list`, `json`, `junit`, `markdown`, or `{ name, onEvent?, onRunFinished? }` objects; `json` excludes `list`, `--reporter` keeps the objects. | +| `cache` | `'read-write'` | `'read-write'`, `'read-only'`, `'off'`, or `{ mode, store, dir, strict }`; CI demotes only a defaulted mode to `'read-only'`. `strict` (`--strict-cache`) fails a step whose recording no longer replays (`REPLAY_STALE`) instead of handing it to the agent. | +| `agents` | `{ default: built-in }` | Tests run with `default`, `e2e run --agent ` picks another, entries never inherit from `default`. Options: topic `agent`. | +| `credentials` | `{}` | Named `{ username, password }`; `password` is a 6+ code point string or a function returning it. | +| `secrets` | `{}` | Named values the model never sees (API keys, tokens), same value rule. A separate namespace: a credential's password is `credentials.user(name).password` (named `.password`), never `secrets.get()`, so a name may be both. | +| `output` | `'.e2e'` | Results directory; `--output ` for one run. Inside the project root, not the root, not a tests glob's directory, never the cache dir (`cache.dir` stays `.e2e/cache`). | +| `artifacts` | none | `{ store }`: artifacts go to the host `ArtifactStore` (`{ put(artifact), putLink?(link) }`); `putLink` gets provider-hosted video links (never a passed `retain-on-failure` attempt's). Failure screenshots are always captured when the engine can. | +| `trace` | `'on'`, `'on-first-retry'` in CI | Attempts that record a Playwright trace: `'off'`, `'on'`, `'retain-on-failure'`, `'on-first-retry'`, `'on-all-retries'`; precedence and capability rule as `video`. | +| `video` | `'off'` | Same modes; `'retain-on-failure'` records all, keeps those that did not pass. Precedence: the test's `video`, `--video [mode]`, the target's (`{ engine, video }`), the config's. | +| `projectId` | the package name | Report and cache identity. | + +- `tests` discovery enters only directories a glob can match; symlinks are + not followed. +- `trace` and `video`: a config or flag mode skips targets whose engine + cannot record (one notice), a target or test mode requires it + (`UNSUPPORTED_ARTIFACT`); a retry mode with `retries: 0` prints a notice; + neither invalidates the replay cache. + +## The app under test + +The target declares the app; the engine only drives it. `web({ url })`, +`mobile({ app })`, and the other old app options are unknown keys. The +target's `app`: + +| Key | Meaning | +| --- | --- | +| `url` | Base URL for `app.open()` and relative navigation; required on a `web()` target, not supported on a mobile target yet. Missing scheme: `https://`, `http://` for loopback; port `0` on `127.0.0.1` or `[::1]` takes a free port. | +| `bundleId` | Device targets: the bundle id, package name, or display name (`Settings`) `app.open()` launches. | +| `appPath` | Device targets: the `.app` or `.apk` under test. A device target needs `bundleId` or `appPath`. | +| `launchArguments`, `permissions` | Device targets: arguments and permission states (`grant`, `deny`, `reset`) every fresh launch gets. | +| `command` | The process serving `url`: `{ executable, args, cwd, env, startupTimeout, shutdownTimeout, log, reuseExisting }`; `{port}` in `args` and `env` expands to `url`'s port. Targets declaring the same command share one process. | +| `readyUrl` | Readiness probe when it differs from `url`; `{port}` expands too. | +| `environment` | `'test'`, `'staging'`, `'production'`; inferred from the host, labels the report and cache key. | +| `identity` | Stable identity for cache and session keys when the origin changes per deploy (preview URLs). Defaults to the URL's origin and path, else `bundleId`, else `appPath`. | + +There is no `services` key in this version: it is an unknown key wherever it +appears. Start dependency processes before the run, or have `app.command` +start a script that brings them up and serves the app. + +`web()` options: + +| Option | Meaning | +| --- | --- | +| `browser` | `'chromium'` (default), `'firefox'`, `'webkit'`, or a `BrowserProvider` leasing hosted browsers over CDP (`kernel()` from `@e2e-dev/kernel`, or your own), which implies chromium and excludes `connect`. Scope `'worker'` (default): one browser per worker slot from `prepare` to `finish`; `'attempt'`: one per attempt, with `reconnectEndpoint`'s limits. | +| `viewport` | `{ width, height }`, default 1280x720; `null` follows the browser window (hosted live view, headed run). On a headed hosted browser (Kernel) use `null` and size the service's screen; a fixed size gives a smaller, unmaximized window. | +| `connect` | `{ cdpEndpoint }` attaches to a remote Chromium over CDP; both it and `reconnectEndpoint` are resolvers `(signal) => url`, not strings. With `reconnectEndpoint` it rides one persistent default context and reconnects only to the original browser and page. | +| `headers` | Sent to the app's site only (Vercel's `x-vercel-protection-bypass`, ngrok's `ngrok-skip-browser-warning`), `agent.act` included; disables the browser HTTP cache and service workers. | +| `basicAuth` | `{ username, password }` for a `401` challenge; `password` may be `secrets.get('name')`, resolved per attempt and redacted like any secret, the base64 `Authorization` credential too. | +| `userAgent` | The `User-Agent` every attempt sends and `navigator.userAgent` reports. | +| `testIdAttribute` | What `getByTestId` reads; default `data-testid`. | +| `screencast` | `{ size?, quality? }` for the engine's own video: frame size (default the viewport's), JPEG quality 0 to 100. | + +- `basicAuth` via `secrets.get()` masks text, not screenshots or model pixels + showing the password. An undeclared name is `INVALID_CONFIG` at load, as is + the handle (a reference, not the value) in `app.command.env`, a template + literal, or `context`; read those from `process.env`. +- `reconnectEndpoint` or an attempt-scoped provider rides one persistent + context without `headers`, `basicAuth`, `userAgent`, `app.clearState()`, or + session state. Recovery never repeats a dispatched operation; exhausting + the budget is `OPERATION_TIMEOUT`. Without `reconnectEndpoint` a dropped + connection is reacquired at the next attempt. + +Two browsers, one app declaration: + +```ts +const app = { url: 'http://127.0.0.1:3000' }; +export default { + targets: [ + { name: 'chromium', engine: web(), app }, + { name: 'mobile-webkit', engine: web({ browser: 'webkit', viewport: { width: 390, height: 844 } }), app }, + ], +} satisfies E2EConfig; +``` + +### Let the runner start the app + +Prefer `app.command` to a hand-started dev server: self-contained locally +and in CI. + +```ts +engine: web(), +app: { + url: 'http://127.0.0.1:3000', + command: { + executable: 'pnpm', + args: ['dev'], + env: { PORT: '3000', DATABASE_URL: process.env.DATABASE_URL ?? '' }, + startupTimeout: 120_000, + log: '.e2e/logs/app.log', + }, +}, +``` + +- The runner spawns `command`, polls `readyUrl` (default `url`) for a 200 to + 499 status within `startupTimeout` (default 60 s), and stops it when the run + ends, fails, or is interrupted (`shutdownTimeout`, default 10 s). Never + ready is `APP_UNREACHABLE`; `.e2e/report.json` is still written. +- The child gets only `PATH`, `HOME`, the temp-directory variables, + `SystemRoot` and `COMSPEC` on Windows, and `command.env`; pass the rest + through `env`. Model keys and `E2E_USER_*` values are never inherited. +- Output is discarded unless `log` names a file under an ignored directory + such as `.e2e/logs/`; without it a server dying on boot is invisible. +- A `url` answering before the spawn is `APP_ALREADY_RUNNING`; + `command.reuseExisting: true` attaches to a running dev server (CI ignores + it). +- `executable` resolves on `PATH`, never through a shell; name the server + itself, not a wrapper script. +- `url: 'http://127.0.0.1:0'` (or `[::1]:0`, never `localhost:0`) takes a + free port; the command receives it as + `{port}` in `args` or `env` (`args: ['dev', '--port', '{port}']`), which + also expands in `readyUrl`. Tests read the URL from `app.baseUrl`; the + cache identity keeps `:0`. A port-0 `url` with no `command`, and + `reuseExisting` beside one, are `INVALID_CONFIG`. + A port grabbed between allocation and spawn fails the start with + `APP_UNREACHABLE`; rerun. + +For an app started elsewhere, point `app.url` at it, literally or via +`process.env.APP_URL ?? 'http://localhost:3000'`; the runner reads no +`APP_URL` and loads no `.env`, so put `process.loadEnvFile('.env')` atop +`e2e.config.ts` (workers re-import it). + +## Environment variables the runner reads + +| Variable | Effect | +| --- | --- | +| `AI_GATEWAY_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, ... | Read by provider packages, not the runner. | +| `E2E_USER__USERNAME`, `E2E_USER__PASSWORD` | Override `credentials.`; `` is the name uppercased, other characters `_`. | +| `E2E_SECRET_` | Overrides `secrets.`, same rule. | +| `CI` | CI defaults; list in topic `running`. | +| `E2E_TELEMETRY_DISABLED`, `DO_NOT_TRACK` | Disable anonymous telemetry, as does `e2e telemetry disable`; `E2E_TELEMETRY_DEBUG=1` prints events instead of sending. | + +## Mobile targets + +`@e2e-dev/mobile` drives iOS simulators and Android emulators through +[agent-device](https://github.com/callstack/agent-device); needs Xcode with a +simulator runtime or the Android SDK with an emulator; run +`npx agent-device doctor` once. + +```ts +import type { E2EConfig } from 'e2e'; +import { mobile } from '@e2e-dev/mobile'; +import { mobileTools } from '@e2e-dev/mobile/tools'; +import { gateway } from 'ai'; + +const iphone = mobile({ platform: 'ios' }); + +export default { + targets: [{ engine: iphone, app: { bundleId: 'com.example.app' } }], + workers: 1, + agents: { + default: { + model: gateway('openai/gpt-6-luna-fast'), + tools: mobileTools(iphone), + }, + }, +} satisfies E2EConfig; +``` + +- `app.bundleId`: bundle id, package name, or display name `app.open()` + launches fresh (an attempt launches nothing itself; without it, the + installed build). `app.appPath`: the `.app` or `.apk` under test; the + engine installs nothing, so a fixture every test takes calls + `device.installApp()` once per device (no path installs `app.appPath`). + `app.launchArguments` and `app.permissions` ride every fresh launch: + arguments reach the app process (iOS) or `am start` (Android), permissions + are set first. +- One worker per device. No `device`: every booted simulator or emulator of + the platform is the pool, up to `workers` (none booted: agent-device boots + one); one `device`: one worker whatever `workers` says; a list (`device: + ['iPhone 17', 'iPhone 17 Pro']`): an explicit pool. Devices boot in + `prepare`, before the run's clock. Two sessions on one device fight over + it: for parallel MCP sessions or bug-bash explorers, declare one target per + device, each naming its `device`. +- `device` can be a `DeviceProvider` leasing hosted devices, one per worker + slot: `easSimulators({ projectId, buildId })` from `@e2e-dev/eas` reads + `EXPO_TOKEN`, needs no Xcode or Android SDK, and with `buildId` EAS installs + the app (omit `app.appPath`). A run must fit one session: `maxDurationMinutes`, + absent, is the account's cap (40 on a standard plan). `videoTouches: false` + on the engine for video there. +- Only a control that appeared or moved with the previous action waits out + `transition` (default 500 ms); agent actions settle `settle` ms (default + 150) before the next observation, `settle: false` skips it. +- `screen`, `expect`, `app`, `agent` work unchanged; `test` from + `@e2e-dev/mobile` types the `device` fixture (`installApp`, `openLink`, + `setPermission`, `setNetwork`, `setAppearance`, `clearKeychain`, `locator`, + more). Portable suites declare `requires: ['device']`. +- No `state` capability: `test.setup` and `session` are unavailable; sign in + per test with `screen` actions or `agent.act`, both fill a `Secret` (topic + `writing-tests`, Sign-in sessions). +- A deterministic check naming a platform label runs there only: + `test('...', { platforms: ['ios'] }, ...)`. +- `selectOption`, `setInputFiles`, `scrollIntoView`, `secondaryTap`, and + `modifiers` on `tap` or `doubleTap` are `UNSUPPORTED_CAPABILITY` on a + device. +- React Native on iOS: checkbox and radio role and state come off the + accessibility value (`getByRole`, `check()`, `toBeChecked` work); a tab is + `other` with `selected`, query by test id or label; a plain `View` is a + leaf, scope to the `ScrollView` or give children test ids. + +## Done when + +- `npx e2e run tests/example.e2e.ts` passes against the app. +- `package.json` has a script such as `"test:e2e": "e2e run"`. +- `.gitignore` lists the `.e2e/` outputs (init adds them); drop the + `.e2e/cache/` line to commit replays. +- CI runs the whole suite, agent steps included, on PRs; see `running`. diff --git a/.agents/skills/e2e/references/writing-tests.md b/.agents/skills/e2e/references/writing-tests.md new file mode 100644 index 00000000..f7601c2e --- /dev/null +++ b/.agents/skills/e2e/references/writing-tests.md @@ -0,0 +1,389 @@ +# Writing tests + +## A complete file + +```ts +// tests/todos.e2e.ts +import { beforeEach, describe, test } from '@e2e-dev/web'; +import { expect } from 'e2e'; + +describe('todos', { tags: ['todos'] }, () => { + beforeEach(async ({ app }) => { + await app.open('/todos'); + }); + + test('adds and completes a todo', async ({ agent, screen, browser }) => { + await agent.act('add a todo named {title}', { params: { title: 'Write the release notes' } }); + await expect(screen.getByRole('listitem')).toHaveCount(1); + await expect(screen.getByRole('status', 'Remaining')).toHaveText('1 remaining'); + + await agent.act('mark the todo as done'); + await expect(screen.getByRole('status', 'Remaining')).toHaveText('0 remaining'); + await expect(browser).toHaveURL('/todos'); + }); + + test('ignores an empty submission', async ({ screen }) => { + // An exact interaction: the empty submit is the point of the test. + await screen.getByRole('button', 'Add').tap(); + await expect(screen.getByRole('listitem')).toHaveCount(0); + }); +}); +``` + +The agent does the flow; `expect` pins what must be true after each goal, +and that check lets the replay cache rerun the step later. `screen` actions +are for exact interactions and values. Files match the config `tests` glob, +default `tests/**/*.e2e.ts`. In a browser every test starts from a fresh +context with no page open, so it calls `app.open()` first; on a device a test +that skips `app.open()` starts where the previous test left the app. + +## Registration + +`test` registers everything, and `describe`, `beforeEach`, `afterEach`, +`beforeAll`, and `afterAll` are also top-level imports of the same functions +(`import { describe, beforeEach } from 'e2e'`; `@e2e-dev/web` and +`@e2e-dev/mobile` export `test`, `describe`, and the hooks typed with their fixture; a +`test.extend()` chain registers hooks that see its fixtures through +`test.beforeEach`). Everything registers at import, so a `describe` body is +synchronous (`async` is a `COLLECTION_ERROR`). + +```ts +test('title', async ({ app, screen }) => {}); +test('title', { tags: ['smoke'], retries: 2, timeout: 60_000 }, async ({ app }) => {}); +describe('group', { tags: ['billing'] }, () => { /* tests and hooks */ }); +describe('checkout flow', { serial: true }, () => { /* ordered, shared app state */ }); +beforeEach(async ({ app }) => {}); // per attempt, with test fixtures +afterEach(async ({ screen }) => {}); // runs after failures too, with its own cleanup budget +beforeAll(async ({ platform }) => {}); // per suite realm, no app fixtures +afterAll(async () => {}); +test.skip('later', async () => {}); +test.only('focus', async () => {}); // local only: CI fails with ONLY_IN_CI +test('conditional', async () => { test.skip(await onlyOneOrg(), 'nothing to switch to'); }); // throws: the body stops here, reported skipped; call it before the first step +test('later', async () => { test.skip('waiting on the API'); }); // bare skip from the body +test.setup('sign in', { sessions: ['admin'] }, async ({ app, screen, session }) => {}); // see Sign-in sessions +const wsTest = test.extend<{ ws: Ws }>({ ws: async ({ browser }, use) => { await use(await seed()); await drop(); } }); +wsTest('uses the workspace', async ({ ws }) => {}); // code after use() is teardown, runs after failures too +``` + +A setup test cannot skip from its body (`INVALID_ARGUMENT`). + +| Option | Default | Notes | +| --- | --- | --- | +| `timeout` | `config.timeout`, 120 s | Covers `beforeEach` and the body. | +| `retries` | `config.retries` | 0 to 10; a serial group's value applies to its members. | +| `tags` | `[]` | Distinct, non-blank, no comma or edge whitespace (`'Login Form'` is fine); union across layers. `--tag smoke` selects, `--tag-mode all` requires every tag. | +| `skip` | unset | `true` or a reason string. | +| `platforms` | unset | Only targets with these platforms, e.g. `['ios']`. | +| `requires` | `[]` | Engine capabilities, e.g. `['browser']`; missing ones skip the test at selection rather than failing it with `UNSUPPORTED_CAPABILITY`. | +| `session` | unset | Restore state saved by a setup test. | +| `agentContext` | unset | Extra context for `agent.*` calls in this test or group. | +| `agent` | the run's agent | A configured name (`agents.`) or a list run once per agent; `--agent` narrows the list, a setup test takes one name. Innermost wins; `agent.act(..., { agent })` names another for one call. | +| `trace`, `video` | the target's | `'off'`, `'on'`, `'retain-on-failure'`, `'on-first-retry'`, `'on-all-retries'`. Innermost wins over `--trace` / `--video`, the target, and the config; recording where the engine cannot is `UNSUPPORTED_ARTIFACT` for the run. | +| `serial` | `false` | Groups only. Members share app state, run in order on one worker, retry as a whole, and take the group's `trace` and `video`. | + +Serial members cannot set `retries`, `trace`, `video`, `session`, +`platforms`, `requires`, `skip`, or `only`, nor can a nested `describe` set +`trace` or `video`; nesting serial groups is a `COLLECTION_ERROR`. + +Hooks nest: outer `beforeEach` first, inner `afterEach` first. `beforeAll` +reruns per retry and per serial group (each a fresh module realm) on the +test timeout; `afterAll` runs on `config.cleanupTimeout`. A failing +`beforeAll` is a `HOOK_FAILED` run error skipping every test in its scope. + +## Fixtures + +Built-in fixtures are lazy; destructure them. Your own `test.extend` +fixtures set up for every test registered through that `test`, destructured +or not, so a state-changing fixture belongs on its own `test`. + +`app` (`App`) and `screen` (`Screen`): always. `agent` (`Agent`): needs a +configured model, else `MODEL_UNAVAILABLE` (topic `agent`). `platform` +(`string`): always, hooks included; `web`, `ios`, `android`, or an engine's +label. `browser` (`Browser`): browser targets, import `test` from `@e2e-dev/web`. +`device` (`Device`): device targets, import `test` from `@e2e-dev/mobile`. +`session` (`SetupSession`): only in `test.setup`. + +### app + +- `baseUrl` (`string | undefined`): the target's app URL with the run's + port; `undefined` when the target declares no `app.url`. +- `open(path?)`: opens the target's `app.url`, a relative path, or an absolute + http(s) URL. On a device it takes no path and relaunches the pinned app. +- `back()`: one history step back. +- `restart()`: recreates the context keeping persisted state (a restored + session included), then reopens the base URL. +- `clearState()`: clears cookies and storage, recreates the context, reopens + the base URL. +- `screenshot(label?)`: saves a redacted screenshot artifact and returns its + path. Denied after a secret fill (see Sign-in sessions). + +`clearState()` is `UNSUPPORTED_CAPABILITY` with `connect.reconnectEndpoint` +or an attempt-scoped browser provider; on a device with no pinned `app`, both +methods are. + +## Locators + +`screen.getBy*` builds a lazy query, resolved only by an action, read, or +assertion. Every query also exists on a locator, scoped to its subtree. + +| Query | Matches | +| --- | --- | +| `getByRole(role, name?, { exact?, checked?, disabled?, selected?, expanded?, pressed?, level?, visible? })` | Semantic role, optionally by accessible name (`getByRole('button', 'Save')`; the object form `{ name }` works too) and state (`level`: heading level 1 to 6). First choice. | +| `getByLabel(text, { exact?, visible? })` | Form controls by label. | +| `getByPlaceholder(text, { exact?, visible? })` | Inputs by placeholder. | +| `getByText(text, { exact?, visible? })` | Visible text. | +| `getByDisplayValue(value, { exact?, visible? })` | Inputs by current value; on the web it cannot scope child queries or be a `filter({ has })` target. | +| `getByTestId(id, { visible? })` | `data-testid` on the web (or `web({ testIdAttribute })`), accessibility identifier or resource id on a device; a string matches the whole id, a RegExp tests it. Last resort. | + +Roles: `button`, `link`, `textbox`, `searchbox`, `combobox`, `listbox`, +`option`, `checkbox`, `radio`, `radiogroup`, `switch`, `slider`, `spinbutton`, +`progressbar`, `meter`, `image`, `heading`, `tab`, `tablist`, `tabpanel`, +`menu`, `menubar`, `menuitem`, `menuitemcheckbox`, `menuitemradio`, `toolbar`, +`tooltip`, `tree`, `treeitem`, `list`, `listitem`, `table`, `grid`, `row`, +`rowgroup`, `rowheader`, `cell`, `gridcell`, `columnheader`, `separator`, +`group`, `article`, `figure`, `form`, `status`, `alert`, `dialog`, +`alertdialog`, `main`, `navigation`, `banner`, `contentinfo`, `complementary`, +`region`. The union is closed (anything else is a type error); `img` aliases +`image`; a role the platform lacks (`tooltip` on a phone) matches nothing. +On the web a `contenteditable` host is a `textbox` for the agent and takes +`fill`; from a test use `getByLabel`, `getByTestId`, or `role="textbox"` on +the host. + +Text matching is exact after whitespace normalization (whole string, +case-sensitive) for a `getByRole` name, `getByLabel`, `getByPlaceholder`, and +`getByText`: `name: 'Save'` misses `Save changes` (unlike Playwright). A +missed query makes a negated assertion pass, so check it positively. +`getByText` and `getByLabel` return the innermost match, so a container +echoing its child does not count twice. `exact: false` is a case-insensitive +substring; a `RegExp` matches as written. + +- Exactly one match per action, read, or assertion. Two fail at once with + `LOCATOR_AMBIGUOUS`; at zero, actions and assertions poll until the + timeout then `LOCATOR_NOT_FOUND`, a read fails at once. Exceptions: + `toHaveCount`, `toBeVisible`, `toBeHidden`, `toBeAttached`, list-form + `toHaveText` and `toContainText`, `isVisible()` (false at zero), + `isHidden()` (true), `count()`, `all()`, `allTextContents()`. +- Narrow with `filter({ hasText })` (a case-insensitive substring, unlike a + query), `filter({ has: locator })`, `first()`, `last()`, `nth(i)`, or + scoping under another locator. Any other `filter` key (`hasNot`, + `hasNotText`) or an empty `filter({})` is `INVALID_LOCATOR`. +- `visible: true` drops nodes the page hides (a closed drawer) before the + exactly-one rule. +- On the web, queries reach open shadow roots and closed roots attached with + `attachShadow`, not declarative closed roots. `browser.locator(css)`, + `frameLocator`, and `filter({ hasText })` stop at a closed root; query the + text inside or filter with `has`. +- `screen.scrollUntilVisible(locator, { direction?, momentum?, timeout? })` + scrolls the viewport (`down`, `slow` by default) until the locator resolves + visibly, else `LOCATOR_NOT_FOUND`; on a locator it scrolls that node. + +### Actions + +Each action resolves one node, waits up to `config.actionTimeout` (30 s, or +`{ timeout }`) for it to be actionable, and does one thing: `tap()` (alias +`click()`), `doubleTap()`, `secondaryTap()` (each takes +`{ modifiers: ['Shift'] }`), `longPress({ duration? })` +(100 to 10000 ms; default 500 on the web, 1000 on a device), +`fill(value | Secret)`, `pressSequentially(text, { delay? })`, `clear()`, +`press(key)`, `check()`, `uncheck()`, +`selectOption(label | { label } | { value } | { index })` (one option; an +array is `INVALID_ARGUMENT`), `focus()`, `hover()`, `setInputFiles(paths)` +(relative to the project root), `dragTo(locator)`, `scrollIntoView()`, +`swipe({ direction, momentum? })`. Web only, `UNSUPPORTED_CAPABILITY` on a +device: `secondaryTap`, `selectOption`, `setInputFiles`, `scrollIntoView`, +and `modifiers`. + +`fill` sets the value with no key events; when the app reacts to keystrokes +(autocomplete, a masked input) use `pressSequentially`, which focuses the +field and types one character per `delay`, plain string only (a `Secret` is +`INVALID_ARGUMENT` and goes through `fill`). + +Coordinates are CSS pixels, for what the tree does not list: `tap({ position: +{ x, y } })` offsets from the node's top-left corner (with `modifiers` it is +`INVALID_ARGUMENT`), `screen.tapAt({ x, y })` taps a viewport point, +`screen.swipe({ from, to })` swipes along a path, +`screen.swipe({ direction, momentum? })` swipes the viewport. Prefer a +locator; a point moves with the layout. + +### Reads + +Reads resolve once, no retry: `textContent()`, `inputValue()`, +`getAttribute(name)`, `isVisible()`, `isHidden()`, `isEnabled()`, +`isDisabled()`, `isChecked()`, `boundingBox()`, `count()`. `all()` gives one +`nth(i)` locator per current match and `allTextContents()` every match's +text, both `[]` at zero. Text is the rendered text, whitespace collapsed: on +the web what `innerText` reads (`text-transform` applies, `display: none` +drops out, `
` is a space); `toHaveText` reads the same. `isChecked()` is +`false`, not an error, on a node with no checked state, so query checkable +controls by role. `waitFor({ state?: 'visible' | 'hidden', timeout? })` waits +within `actionTimeout`, else `LOCATOR_NOT_FOUND`. For a value that has to +settle use `expect`, not a read. Reading a password field's value or +attributes is `POLICY_DENIED`, as is `toHaveAttribute` on one, negated too. + +## expect + +`expect(locator)` polls up to `config.assertionTimeout` (5 s) or +`{ timeout }`; `.not` inverts and passes once the negation has held 1 s +continuously, so it never returns in under a second. `expect(value, +message?)` is synchronous. +`expect.poll(read, { timeout?, interval?, message? })` re-reads until a value +matcher passes (`assertionTimeout` and 100 ms by default, stopping with the +attempt); a throwing read keeps polling, and it is not a report step. +`expect.soft(x)` keeps a failure instead of throwing; the attempt fails +after the body with every soft failure listed. +`expect.any(Class)`, `expect.anything()`, `expect.objectContaining(obj)`, +`expect.arrayContaining(arr)`, `expect.stringContaining(s)`, and +`expect.stringMatching(s | RegExp)` stand in for values inside `toEqual`, +`toMatchObject`, `toContain`, and `toHaveProperty`. + +```ts +await expect(screen.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); +await expect(browser).toHaveURL('/dashboard'); // relative to the base URL, or a RegExp +expect(order).toMatchObject({ id: expect.any(Number), lines: [{ sku: 'a' }] }); +expect.soft(await screen.getByTestId('tax').textContent()).toBe('$8.00'); // kept, body runs on +const users = expect(await response.json()).toMatchSchema(z.array(User)); // any Standard Schema; typed output +``` + +`toMatchSchema(schema)` takes a synchronous Standard Schema (Zod, Valibot, +ArkType), fails listing every issue by path, and returns the parsed value +typed; prefer it when the app already has a schema for the response. + +| Locator matchers | Browser matchers | Value matchers | +| --- | --- | --- | +| `toBeVisible`, `toBeHidden`, `toBeAttached`, `toBeEnabled`, `toBeDisabled`, `toBeChecked`, `toBeSelected`, `toBeExpanded`, `toBeFocused`, `toHaveText`, `toContainText`, `toHaveValue`, `toHaveAttribute`, `toHaveCount`, `toHaveAccessibleName` | `toHaveURL`, `toHaveTitle`, `toHaveClass(locator, expected)` | `toBe`, `toEqual`, `toMatchObject`, `toBeTruthy`, `toBeFalsy`, `toBeNull`, `toBeUndefined`, `toBeDefined`, `toHaveLength`, `toHaveProperty`, `toContain`, `toMatch`, `toBeGreaterThan`, `toBeGreaterThanOrEqual`, `toBeLessThan`, `toBeLessThanOrEqual`, `toBeCloseTo`, `toMatchSchema` | + +`toHaveText` compares the whole normalized text, `toContainText` a substring +or RegExp, `toHaveValue` a form control's value as is, whitespace included +(it fails on a node with none); on a password field all three are +`POLICY_DENIED`, never a comparison against `''`. List forms: +`toHaveText(['Alpha', /^Beta/])` needs exactly two matches with those texts +in order; `toContainText(['Alpha', 'Beta'])` needs each entry in a distinct +match, in order, extra matches allowed. `toHaveAttribute(name)` checks +presence, `toHaveAttribute(name, value)` the value; `toBeAttached` waits for +a match, hidden or not; `toHaveClass` compares the whole normalized class +list or tests a RegExp. A failed matcher is `ASSERTION_FAILED`, exit code 1. + +## Sign-in sessions + +Sign in once in a setup test, save the state under a name, and let other +tests declare it. Selecting a dependent test alone still runs its setup. + +```ts +// tests/auth.setup.e2e.ts +import { test } from '@e2e-dev/web'; +import { expect, credentials } from 'e2e'; + +test.setup('authenticate as admin', { sessions: ['admin'] }, async ({ app, screen, session, browser }) => { + const admin = credentials.user('admin'); + await app.open('/login'); + await screen.getByLabel('Email').fill(admin.username); + await screen.getByLabel('Password').fill(admin.password); + await screen.getByRole('button', 'Sign in').tap(); + await expect(browser).toHaveURL('/dashboard'); // prove the sign-in worked before saving + await session.save('admin'); +}); +``` + +```ts +// tests/dashboard.e2e.ts +import { test, expect } from 'e2e'; + +test('the dashboard opens directly', { session: 'admin' }, async ({ app, screen }) => { + await app.open('/dashboard'); + await expect(screen.getByRole('heading', 'Dashboard')).toBeVisible(); +}); +``` + +- Setup tests are top-level; exactly one setup saves a given name and the + body saves every declared name once (`SESSION_CONTRACT` otherwise). Names + match `[A-Za-z0-9_.-]{1,128}`. +- A session holds cookies, local storage, and IndexedDB for one run, + encrypted and deleted at cleanup; server state is not part of it. +- Once a secret is filled, model pixels and assertion screenshots are + withheld for the rest of that session (later serial members included) and + `app.screenshot()` is `POLICY_DENIED`. A restored session keeps its setup's + taint; a setup that signs in without a fill (`browser.setCookies`, say) leaves + screenshots available. +- Credentials live in the config, values in the environment: + +```ts +credentials: { + admin: { username: 'admin@example.test', password: process.env.ADMIN_PASSWORD ?? '' }, +}, +``` + +- A static password or secret needs 6 or more code points, else + `INVALID_CONFIG` at config load, so an unset variable fails every command. + A function is read at fill time and redacted only from that fill on. +- `E2E_USER__USERNAME` and `E2E_USER__PASSWORD` override either + field per run, even over a function; `` is the credential name + uppercased, every character outside `[A-Z0-9]` as `_`. +- `credentials.user('admin').password` is a `Secret` with no plaintext + accessor, named `admin.password` to the agent and in reports; `secrets.get()` + never returns it (separate namespaces). Only `fill()` and `agent.act` params accept it, stringifying it + is `INVALID_CONFIG`, and `credentials.user()` outside a run throws + `AUTH_CREDENTIAL_UNAVAILABLE`. +- Any other sensitive value (an API key) is a `secrets` entry, + `secrets: { 'stripe-key': process.env.STRIPE_KEY ?? '' }`, overridable with + `E2E_SECRET_STRIPE_KEY`; `secrets.get('stripe-key')` is the same kind of + handle, fills any editable input, and is redacted by name everywhere the + runner writes. + +## The browser fixture (browser only) + +Prefer `app` and `screen`; `browser` is for what only a browser has, and a +portable suite declares `requires: ['browser']`. Page methods act on the one +active tab; cookies, routes, and `onDialog` cover the whole browser. A tab +the app opens itself (`target="_blank"`, `window.open`) is not followed: +`browser.goto` its URL instead. + +- `goto(url, { waitUntil?, timeout? })`, `reload({ timeout? })`, + `back({ timeout? })`, `forward({ timeout? })`: navigation on the test + timeout by default; `goto` takes a base-relative path. +- `url()`, `title()`, `waitForURL(url | RegExp, { timeout? })`: reads and a + URL wait; `waitForURL` matches like `toHaveURL`. +- `locator(css)`: raw CSS or XPath; not portable, a last resort. +- `frameLocator(css)`: a `Screen` scoped to one iframe + (`browser.frameLocator('#payment').getByLabel('Card number')`); keeps + `locator(css)` and `frameLocator(css)` for nesting. +- `evaluate(fn | source, arg?)`: runs a function or source string in the + page, JSON in and out, no closures; a throw in the page is + `EVALUATE_FAILED`. +- `route(pattern, handler)`, `unroute(pattern)`: intercept requests; + `route.request` has `url`, `method`, `headers`, `postData`. The handler + calls exactly one of `fulfill({ status?, headers?, json | body })`, + `continue()`, or `abort()`; none or two fails the next step with + `ACTION_FAILED`. +- `waitForResponse(pattern, { timeout? })`: resolves with + `{ url, status, headers, json(), text() }`; `text()` and `json()` reject + with `ACTION_FAILED` when the body could not be read. +- `cookies()`, `setCookies([...])`: a target is an http(s) URL or a domain. +- `setViewport({ width, height })`: resize. +- `onDialog('accept' | 'dismiss' | handler)`: awaited; resolves to an async + unsubscribe. Register it before the tap that opens the dialog. A handler + gets `{ message, accept(text?), dismiss() }`, `accept` taking the prompt + text; no handler, or one that neither accepts nor dismisses, fails the + next step with `INVALID_STATE`. +- `waitForDownload(() => trigger, { timeout? })`: returns + `{ path, suggestedFilename }`. +- `keyboard.press(key)`, `keyboard.type(text)`, `mouse.*`: unfocused input; + prefer `locator.press` and `locator.fill`. + +A `route`, `unroute`, or `waitForResponse` pattern is a glob string or +`RegExp` matched against the full URL (`*` stays within one path segment, +`**` crosses `/`, `?` is one character); a predicate function is +`INVALID_ARGUMENT`. + +## Habits + +- Selectors come from the source (labels, roles, text); add an `aria-label` + or heading where the app has no accessible name rather than fall back to + `browser.locator('.btn-primary')`. +- Test data gets a run-unique name (`Invoice ${Date.now()}`) and `afterEach` + cleanup, so replays and retries never trip over leftovers. +- APIs live in the same suite: `fetch(new URL('/api/users', app.baseUrl))` + plus value matchers, in a `test.extend` fixture that reads `browser.cookies()` + when the API needs the session. +- No sleeps or polling loops; a matcher with a longer `timeout` instead. +- `await` every step call, else `STEP_NOT_AWAITED` at the line of the call. +- Assert the fact a model produced with `toContain`, not its exact sentence. diff --git a/.agents/skills/maestro-mobile-testing/SKILL.md b/.agents/skills/maestro-mobile-testing/SKILL.md deleted file mode 100644 index fe4aa546..00000000 --- a/.agents/skills/maestro-mobile-testing/SKILL.md +++ /dev/null @@ -1,436 +0,0 @@ ---- -name: maestro-mobile-testing -description: Maestro mobile E2E testing for React Native and Expo apps. Use when Codex needs to write, review, debug, or run Maestro YAML flows; add stable testID selectors; handle mobile auth state, OTP or magic-link flows, optimistic updates, native dialogs, platform-specific behavior, CI execution, Maestro Cloud, or Maestro MCP/device-tool workflows. ---- - -# Maestro Mobile E2E Testing - -Use this skill to produce reliable Maestro tests and to iterate on failing mobile E2E flows. Prefer project conventions first: inspect existing `.maestro/` flows, app IDs, scripts, testID naming, auth patterns, and package scripts before adding new structure. - -## Codex Workflow - -1. Inspect the project: - - Find flows with `rg --files -g '*.yaml' -g '*.yml' .maestro`. - - Find selectors with `rg 'testID=|accessibilityIdentifier|accessibilityLabel'`. - - Check app identifiers in `app.json`, `app.config.*`, Android manifests, iOS plist files, and existing Maestro headers. -2. Choose selectors: - - Use `id:` selectors when the app is localized, text changes often, or multiple elements share copy. - - Use text selectors for stable, user-visible copy in single-language apps. - - Use native dialog text for system alerts because test IDs are not available there. -3. Write or patch the smallest useful flow: - - Add test IDs in app code only when no stable selector exists. - - Reuse sub-flows for repeated login, setup, and verification sequences. - - Prefer `extendedWaitUntil` over sleeps. -4. Run and iterate when tools are available: - - Use `maestro test .maestro/.yaml`. - - Use `maestro test --debug .maestro/.yaml` for step-through debugging. - - Use `maestro studio` or `maestro hierarchy` to inspect selectors. - - If a Maestro MCP server or device-control tool is available, use it to launch, tap, screenshot, run flows, inspect failures, and revise the YAML. -5. Report what changed and how it was verified. If no simulator/emulator/app is available, say exactly what could not be run. - -## Install And Run - -```bash -curl -Ls "https://get.maestro.mobile.dev" | bash -brew install openjdk@17 -export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home - -maestro test .maestro/smoke-test.yaml -maestro test --debug .maestro/smoke-test.yaml -maestro studio -maestro hierarchy -``` - -Minimal flow: - -```yaml -appId: com.myapp ---- -- launchApp -- tapOn: - id: "my-button" -- assertVisible: "Expected Text" -``` - -## Selector Strategy - -When using ID-based selectors in React Native, add `testID` props: - -```tsx - - {t('submit')} - -``` - -Use predictable IDs: - -```text -{screen-or-component}-{role-or-action}[-{variant}] - -auth-prompt-login-button -product-card-42 -otp-input-0 -tab-home -dashboard-loading -``` - -Avoid index-based selectors when possible. Use relative selectors to disambiguate repeated UI: - -```yaml -- tapOn: - text: "Add to Basket" - below: - text: "Awesome Shoes" - -- tapOn: - text: "Delete" - childOf: - id: "item-card-42" -``` - -Useful state properties: - -```yaml -- assertVisible: - id: "terms-checkbox" - checked: true - -- tapOn: - id: "submit-button" - enabled: true - -- extendedWaitUntil: - visible: - id: "email-input" - focused: true - timeout: 3000 -``` - -## Auth State - -Do not assume launch state. On iOS, `clearState: true` does not clear Keychain-backed auth tokens such as `expo-secure-store`. - -Add an auth-loaded marker in the app root or tab layout after auth resolution: - -```tsx -{!isLoading && } -``` - -Start tests with an auth pre-flight: - -```yaml -- launchApp - -# Prevent cold-boot XCTest accessibility races on iOS. -- swipe: - direction: DOWN - duration: 100 - -- extendedWaitUntil: - visible: - id: "auth-loaded" - timeout: 15000 -``` - -Handle both guest and signed-in states with conditional flows: - -```yaml -- runFlow: - when: - visible: "Sign In" - file: flows/sign-in.yaml - -- runFlow: - when: - visible: - id: "tab-home" - file: flows/authenticated-action.yaml -``` - -Rules: - -- Never rely on `clearState` to force guest state on iOS. -- Use adaptive flows for tests that can start authenticated or unauthenticated. -- Only assert guest-only UI after a flow explicitly signs out or clears the relevant storage. -- For tab bars that differ by auth state, assert shared tabs or branch by `when:`. - -## OTP And Magic Links - -Use a test email capture service such as Mailpit, MailHog, or Ethereal. Maestro JavaScript runs on GraalJS: do not use `async`, `await`, `fetch`, `const`, or `let`. Use `var`, `http.get`, `http.post`, `json`, and `output`. - -```javascript -var emailServiceUrl = typeof EMAIL_SERVICE_URL !== "undefined" - ? EMAIL_SERVICE_URL - : "http://localhost:8025"; - -var response = http.get(emailServiceUrl + "/api/v1/messages"); -if (!response.ok) { - throw new Error("Failed to fetch emails: " + response.status); -} - -var data = json(response.body); -var body = data.messages[0].Content.Body; -var match = body.match(/(\d{6})/); -if (!match) { - throw new Error("OTP code not found"); -} - -output.OTP_CODE = match[1]; -``` - -For OTP fields with auto-focus, tap each digit input before entering text: - -```yaml -- runScript: - file: scripts/split-otp.js - env: - OTP_CODE: ${output.OTP_CODE} - -- tapOn: - id: "otp-input-0" -- inputText: ${output.OTP_0} - -- tapOn: - id: "otp-input-1" -- inputText: ${output.OTP_1} -``` - -## Optimistic Updates - -Use short waits to prove UI changes happen before a server round trip: - -```yaml -- tapOn: - id: "action-button" - -- extendedWaitUntil: - visible: - id: "undo-button" - timeout: 3000 - -- extendedWaitUntil: - visible: - id: "user-indicator" - timeout: 5000 -``` - -Guidelines: - -- Mutation-triggered local state should appear in 3 seconds or less. -- List additions/removals should appear in 5 seconds or less. -- Verify a derived state or repeat action so the test catches no-op taps. - -## Native Dialogs And Permissions - -React Native `Alert.alert()` and OS permission prompts block the UI. Dismiss them with optional text taps after verifying the expected state change when possible. - -```yaml -- tapOn: - id: "action-button" - -- extendedWaitUntil: - visible: - id: "new-state-element" - timeout: 5000 - -- tapOn: - text: "OK" - optional: true -``` - -Android permission examples: - -```yaml -- tapOn: - text: "Allow" - optional: true - -- tapOn: - text: "While using the app" - optional: true -``` - -Use `optional: true` only for genuinely optional UI. Overusing it can hide failed interactions. - -## Deep Links - -For Expo, use the configured scheme from `app.json` or `app.config.*`, not the bundle ID: - -```yaml -# Wrong -- openLink: "com.myapp://profile/settings" - -# Correct -- openLink: "myapp://profile/settings" -``` - -Confirm the route is registered in the app's deep-link handler. Unregistered routes can fail silently. - -## Platform Differences - -```yaml -- runFlow: - when: - platform: ios - file: flows/ios-specific.yaml - -- runFlow: - when: - platform: android - file: flows/android-specific.yaml -``` - -Important differences: - -| Area | iOS | Android | -| --- | --- | --- | -| Local device support | Simulator | Emulator or physical device | -| `clearState` | Does not clear Keychain | Clears app data | -| Cold boot | Can hit XCTest accessibility race | No equivalent common issue | -| Permissions | System alerts | Android dialog text varies by API level | -| CI | macOS local or Maestro Cloud | Local, Docker with emulator, or Maestro Cloud | - -## API Dependencies - -If screens call backend APIs, start the real backend or a deterministic mock before running tests. Without it, screens often stay on loading or empty states. - -```bash -npx tsx scripts/mock-api-server.ts -maestro test .maestro/my-test.yaml -``` - -Keep mock responses close to the app's API contract and seed state before each suite when tests depend on specific data. - -## Flow Structure - -Prefer this layout when no project convention exists: - -```text -.maestro/ - config.yaml - flows/ - sign-in.yaml - complete-action.yaml - verify-result.yaml - scripts/ - fetch-otp.js - split-otp.js - smoke-test.yaml - auth-signin.yaml - feature-action.yaml -scripts/ - mock-api-server.ts - run-e2e.sh -``` - -Name main tests as `{feature}-{action}.yaml`, sub-flows as `{action}-{context}.yaml`, and scripts as `{verb}-{noun}.js`. - -Template: - -```yaml -# {Feature} {Action} Test -# Validates: {expected behavior} -# Requires: simulator/emulator with app installed; backend or mock API if needed - -appId: com.myapp -env: - TEST_EMAIL: maestro-{feature}@example.com - EMAIL_SERVICE_URL: http://localhost:8025 ---- -- launchApp -- swipe: - direction: DOWN - duration: 100 -- extendedWaitUntil: - visible: - id: "auth-loaded" - timeout: 15000 - -- takeScreenshot: 01-initial-state - -- tapOn: - id: "target-element" - -- extendedWaitUntil: - visible: - id: "expected-result" - timeout: 5000 - -- takeScreenshot: 02-final-state -``` - -## CI And Maestro Cloud - -Use tags to separate smoke, CI, and work-in-progress flows: - -```yaml -appId: com.myapp -tags: - - ci - - smoke ---- -- launchApp -``` - -```bash -maestro test --include-tags ci .maestro/ -maestro test --exclude-tags wip .maestro/ -``` - -For CI, build the app artifact first, then run local Maestro or upload to Maestro Cloud: - -```yaml -- uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - -- name: Build Android APK - run: | - cd apps/mobile - npx expo prebuild --platform android --no-install - cd android && ./gradlew assembleRelease - -- name: Run Maestro Cloud Tests - uses: mobile-dev-inc/action-maestro-cloud@v2 - with: - api-key: ${{ secrets.MAESTRO_API_KEY }} - app-file: apps/mobile/android/app/build/outputs/apk/release/app-release.apk - workspace: .maestro - include-tags: ci -``` - -Maestro Cloud is useful when local simulators are unavailable, for real-device coverage, and for iOS CI outside a local macOS setup. - -## Common Failures - -| Symptom | Likely cause | Fix | -| --- | --- | --- | -| Java runtime missing | `JAVA_HOME` not set | Install OpenJDK 17 and export `JAVA_HOME` | -| Element not found after tap | Native dialog blocks UI | Add optional dialog dismissal | -| OTP digits do not enter | Auto-focus moved input | Tap each `otp-input-N` before typing | -| Test passes but action did not happen | `optional: true` hides failure | Remove optional from required actions | -| Visibility assertion fails | Element not rendered or wrong selector | Inspect hierarchy and wait for stable marker | -| Script output empty | Used browser JS APIs | Use GraalJS `http.get` and `json` | -| Auth state inconsistent | iOS Keychain persists | Use auth-loaded and adaptive flows | -| iOS kAX error on launch | Cold boot accessibility race | Add the post-launch short swipe | -| Loading or empty screens | Backend is unavailable | Start mock or real API server | - -## New Test Checklist - -```text -[ ] Existing project flow/style inspected -[ ] App ID confirmed -[ ] Selector strategy chosen -[ ] Required testIDs added or existing text selectors reused -[ ] Auth pre-flight included when auth exists -[ ] iOS post-launch swipe included for cold boot flows -[ ] Both relevant auth states handled -[ ] Native dialogs or permissions handled -[ ] Optimistic updates use short waits -[ ] Backend or mock API requirement documented -[ ] Reusable sub-flows used for repeated setup -[ ] Screenshots added at useful checkpoints -[ ] Tags added for CI filtering when applicable -[ ] Flow was run, or unavailable runtime was reported -``` diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7f53d801..5a4547c1 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,10 +8,15 @@ on: - '.github/workflows/android.yml' - 'android/**' - 'example/android/**' + - 'e2e/**' push: branches: - master +permissions: + contents: read + pull-requests: write + jobs: android-build: runs-on: ubuntu-latest @@ -72,8 +77,14 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." - - name: Run build + - name: Install e2e dependencies + run: bun install + working-directory: e2e + + - name: Build and run e2e tests uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # v2.34.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: api-level: 35 arch: x86_64 @@ -85,3 +96,15 @@ jobs: disable-animations: false script: | bun example:android:release + bun run e2e:test:android + + - name: Upload e2e report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-report-android + path: | + e2e/.e2e/report.json + e2e/.e2e/artifacts + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index ae2597c0..8b1ee3c4 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -8,10 +8,15 @@ on: - '.github/workflows/ios.yml' - 'ios/**' - 'example/ios/**' + - 'e2e/**' push: branches: - master +permissions: + contents: read + pull-requests: write + jobs: ios-build: runs-on: macos-26 @@ -51,3 +56,24 @@ jobs: - name: Build iOS App run: | bun example:ios:release + + - name: Install e2e dependencies + run: bun install + working-directory: e2e + + - name: Run e2e tests + run: bun run test:ios + working-directory: e2e + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload e2e report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-report-ios + path: | + e2e/.e2e/report.json + e2e/.e2e/artifacts + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index b9296349..290766f0 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,7 @@ lib/ #e2e test-butler-app.apk example/vendor -.maestro/debug-output/ +e2e/.e2e/ #Example example/ios/Pods diff --git a/.maestro/README.md b/.maestro/README.md deleted file mode 100644 index 8660c71d..00000000 --- a/.maestro/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Maestro - -Run E2E tests from the repository root: - -```bash -bun run e2e:ios -bun run e2e:android -``` - -These commands build and install the release example app, then run the Maestro flows for the selected platform. - -If the example app is already installed on a running device or simulator, run Maestro directly: - -```bash -bun run maestro:test:ios -bun run maestro:test:android -``` - -Use `bun run maestro:smoke` to run only the smoke flow. -`maestro:debug` writes failure artifacts to `.maestro/debug-output`. - -The smoke flow targets the example app ID `com.pagerviewexample` and verifies the horizontal pager using stable `testID` selectors. - -Basic PagerView regression coverage is split into three deterministic flows: - -- `tests/pager_basic_example.yaml` verifies horizontal paging in LTR. -- `tests/pager_vertical_basic_example.yaml` verifies vertical paging in LTR. -- `tests/pager_rtl_example.yaml` switches to RTL before verifying the reversed horizontal gesture. - -Shared setup and assertions live in `flows/basic-pager`. Each flow resets the app to the required layout direction, so a failed RTL run cannot affect the next test. diff --git a/.maestro/flows/basic-pager/ensure-ltr.yaml b/.maestro/flows/basic-pager/ensure-ltr.yaml deleted file mode 100644 index a60a4670..00000000 --- a/.maestro/flows/basic-pager/ensure-ltr.yaml +++ /dev/null @@ -1,22 +0,0 @@ -appId: com.pagerviewexample ---- -- launchApp: - stopApp: true - -- extendedWaitUntil: - visible: - text: 'PagerView Example' - timeout: 15000 - -- runFlow: - when: - visible: - id: 'layout-direction-rtl' - commands: - - tapOn: - id: 'layout-direction-rtl' - -- extendedWaitUntil: - visible: - id: 'layout-direction-ltr' - timeout: 15000 diff --git a/.maestro/flows/basic-pager/ensure-rtl.yaml b/.maestro/flows/basic-pager/ensure-rtl.yaml deleted file mode 100644 index 7b02ea68..00000000 --- a/.maestro/flows/basic-pager/ensure-rtl.yaml +++ /dev/null @@ -1,22 +0,0 @@ -appId: com.pagerviewexample ---- -- launchApp: - stopApp: true - -- extendedWaitUntil: - visible: - text: 'PagerView Example' - timeout: 15000 - -- runFlow: - when: - visible: - id: 'layout-direction-ltr' - commands: - - tapOn: - id: 'layout-direction-ltr' - -- extendedWaitUntil: - visible: - id: 'layout-direction-rtl' - timeout: 15000 diff --git a/.maestro/flows/basic-pager/open.yaml b/.maestro/flows/basic-pager/open.yaml deleted file mode 100644 index da9d4350..00000000 --- a/.maestro/flows/basic-pager/open.yaml +++ /dev/null @@ -1,12 +0,0 @@ -appId: com.pagerviewexample ---- -- tapOn: - id: ${EXAMPLE_ID} - -- extendedWaitUntil: - visible: - id: ${PAGER_ID} - timeout: 10000 - -- assertVisible: - id: 'pageNumber0' diff --git a/.maestro/flows/basic-pager/verify-controls.yaml b/.maestro/flows/basic-pager/verify-controls.yaml deleted file mode 100644 index 1fcad56e..00000000 --- a/.maestro/flows/basic-pager/verify-controls.yaml +++ /dev/null @@ -1,52 +0,0 @@ -appId: com.pagerviewexample ---- -- tapOn: - id: 'next-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber2' - timeout: 5000 - -- tapOn: - id: 'prev-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber1' - timeout: 5000 - -- tapOn: - id: 'start-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber0' - timeout: 5000 - -- tapOn: - id: 'last-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber9' - timeout: 5000 - -- tapOn: - id: 'remove-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber8' - timeout: 5000 - -- tapOn: - id: 'add-page-button' - -- tapOn: - id: 'next-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber9' - timeout: 5000 diff --git a/.maestro/flows/basic-pager/verify-horizontal-ltr-swipe.yaml b/.maestro/flows/basic-pager/verify-horizontal-ltr-swipe.yaml deleted file mode 100644 index 22fc19da..00000000 --- a/.maestro/flows/basic-pager/verify-horizontal-ltr-swipe.yaml +++ /dev/null @@ -1,29 +0,0 @@ -appId: com.pagerviewexample ---- -- tapOn: - id: 'scroll-enabled-button' - -- swipe: - from: - id: 'pager-view-horizontal' - start: 90%, 50% - end: 10%, 50% - duration: 100 - -- assertVisible: - id: 'pageNumber0' - -- tapOn: - id: 'scroll-enabled-button' - -- swipe: - from: - id: 'pager-view-horizontal' - start: 90%, 50% - end: 10%, 50% - duration: 100 - -- extendedWaitUntil: - visible: - id: 'pageNumber1' - timeout: 5000 diff --git a/.maestro/flows/basic-pager/verify-horizontal-rtl-swipe.yaml b/.maestro/flows/basic-pager/verify-horizontal-rtl-swipe.yaml deleted file mode 100644 index fd8aa15d..00000000 --- a/.maestro/flows/basic-pager/verify-horizontal-rtl-swipe.yaml +++ /dev/null @@ -1,29 +0,0 @@ -appId: com.pagerviewexample ---- -- tapOn: - id: 'scroll-enabled-button' - -- swipe: - from: - id: 'pager-view-horizontal' - start: 10%, 50% - end: 90%, 50% - duration: 100 - -- assertVisible: - id: 'pageNumber0' - -- tapOn: - id: 'scroll-enabled-button' - -- swipe: - from: - id: 'pager-view-horizontal' - start: 10%, 50% - end: 90%, 50% - duration: 100 - -- extendedWaitUntil: - visible: - id: 'pageNumber1' - timeout: 5000 diff --git a/.maestro/flows/basic-pager/verify-vertical-swipe.yaml b/.maestro/flows/basic-pager/verify-vertical-swipe.yaml deleted file mode 100644 index a160d576..00000000 --- a/.maestro/flows/basic-pager/verify-vertical-swipe.yaml +++ /dev/null @@ -1,27 +0,0 @@ -appId: com.pagerviewexample ---- -- tapOn: - id: 'scroll-enabled-button' - -- swipe: - from: - id: 'pager-view-vertical' - direction: UP - duration: 100 - -- assertVisible: - id: 'pageNumber0' - -- tapOn: - id: 'scroll-enabled-button' - -- swipe: - from: - id: 'pager-view-vertical' - direction: UP - duration: 100 - -- extendedWaitUntil: - visible: - id: 'pageNumber1' - timeout: 5000 diff --git a/.maestro/issues/issue_1083_modal_set_page_repro.yaml b/.maestro/issues/issue_1083_modal_set_page_repro.yaml deleted file mode 100644 index 314ca461..00000000 --- a/.maestro/issues/issue_1083_modal_set_page_repro.yaml +++ /dev/null @@ -1,58 +0,0 @@ -appId: com.pagerviewexample -tags: - - ios - - regression ---- -- runFlow: ../setup/issue_1083_modal_set_page_repro_setup.yaml - -# This deliberately invokes setPage while the pager is obscured by a native -# stack modal. Before the fix, React state became 0 while SwiftUI showed Page 1. -- tapOn: - id: 'issue-1083-open-modal' - -- extendedWaitUntil: - visible: 'Modal screen' - timeout: 10000 - -- tapOn: - id: 'issue-1083-submit' - -- extendedWaitUntil: - visible: 'Last requested page: 1' - timeout: 10000 - -- extendedWaitUntil: - visible: - id: 'issue-1083-page-1' - timeout: 10000 - -- tapOn: - id: 'issue-1083-advance-directly' - -- extendedWaitUntil: - visible: 'Last requested page: 2' - timeout: 10000 - -- extendedWaitUntil: - visible: - id: 'issue-1083-page-2' - timeout: 10000 - -- tapOn: - id: 'issue-1083-open-modal' - -- extendedWaitUntil: - visible: 'Modal screen' - timeout: 10000 - -- tapOn: - id: 'issue-1083-submit' - -- extendedWaitUntil: - visible: 'Last requested page: 0' - timeout: 10000 - -- extendedWaitUntil: - visible: - id: 'issue-1083-page-0' - timeout: 10000 diff --git a/.maestro/issues/issue_1098_nested_pager_repro.yaml b/.maestro/issues/issue_1098_nested_pager_repro.yaml deleted file mode 100644 index 52966488..00000000 --- a/.maestro/issues/issue_1098_nested_pager_repro.yaml +++ /dev/null @@ -1,47 +0,0 @@ -appId: com.pagerviewexample -tags: - - ios - - regression ---- -- runFlow: ../setup/issue_1098_nested_pager_repro_setup.yaml - -# Changing the outer page count remounts its SwiftUI page controllers while the -# nested pager remains mounted. The app must remain responsive after each change. -- tapOn: - id: 'issue-1098-refresh-outer-pages' - -- extendedWaitUntil: - visible: 'Refreshes: 1' - timeout: 5000 - -- swipe: - from: - id: 'issue-1098-outer-pager' - # Percentages are screen-relative in Maestro. 40% is the outer page title, - # above the nested pager, so this gesture reaches the outer pager. - start: 90%, 40% - end: 10%, 40% - duration: 500 - -- extendedWaitUntil: - visible: 'Outer page 2' - timeout: 5000 - -- swipe: - from: - id: 'issue-1098-outer-pager' - start: 10%, 40% - end: 90%, 40% - duration: 500 - -- extendedWaitUntil: - visible: - id: 'issue-1098-inner-pager' - timeout: 5000 - -- tapOn: - id: 'issue-1098-refresh-outer-pages' - -- extendedWaitUntil: - visible: 'Refreshes: 2' - timeout: 5000 diff --git a/.maestro/setup/issue_1083_modal_set_page_repro_setup.yaml b/.maestro/setup/issue_1083_modal_set_page_repro_setup.yaml deleted file mode 100644 index fb1afa10..00000000 --- a/.maestro/setup/issue_1083_modal_set_page_repro_setup.yaml +++ /dev/null @@ -1,20 +0,0 @@ -appId: com.pagerviewexample ---- -- launchApp - -- scrollUntilVisible: - element: - id: 'Issue #1083 Modal SetPage Repro' - direction: DOWN - -- tapOn: - id: 'Issue #1083 Modal SetPage Repro' - -- extendedWaitUntil: - visible: - id: 'issue-1083-requested-page' - timeout: 10000 - -- assertVisible: 'Last requested page: 0' -- assertVisible: - id: 'issue-1083-page-0' diff --git a/.maestro/setup/issue_1098_nested_pager_repro_setup.yaml b/.maestro/setup/issue_1098_nested_pager_repro_setup.yaml deleted file mode 100644 index 9deeae23..00000000 --- a/.maestro/setup/issue_1098_nested_pager_repro_setup.yaml +++ /dev/null @@ -1,20 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp - -# The issue examples are below the fundamental examples on the home screen. -- scrollUntilVisible: - element: - id: 'Issue #1098 Nested Pager Repro' - direction: DOWN - -- tapOn: - id: 'Issue #1098 Nested Pager Repro' - -- extendedWaitUntil: - visible: - id: 'issue-1098-outer-pager' - timeout: 10000 - -- assertVisible: - id: 'issue-1098-refresh-outer-pages' diff --git a/.maestro/setup/material_top_bar_example_setup.yaml b/.maestro/setup/material_top_bar_example_setup.yaml deleted file mode 100644 index fa0e46f6..00000000 --- a/.maestro/setup/material_top_bar_example_setup.yaml +++ /dev/null @@ -1,11 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp -- assertVisible: 'PagerView Example' -- tapOn: 'MaterialTopBarExample' -- extendedWaitUntil: - visible: - id: 'material-top-bar-pre-auth-screen' - timeout: 10000 -- assertVisible: - id: 'material-top-bar-login-button' diff --git a/.maestro/setup/nested_pagerView_example_setup.yaml b/.maestro/setup/nested_pagerView_example_setup.yaml deleted file mode 100644 index dcde82c6..00000000 --- a/.maestro/setup/nested_pagerView_example_setup.yaml +++ /dev/null @@ -1,7 +0,0 @@ -appId: ${APP_ID} -# Nest PagerView Example tab is active and accessible ---- -- launchApp -- assertVisible: 'PagerView Example' -- tapOn: 'Nested PagerView Example' -- assertVisible: '7 likes' diff --git a/.maestro/setup/on_page_selected_example_setup.yaml b/.maestro/setup/on_page_selected_example_setup.yaml deleted file mode 100644 index d2ff6d7e..00000000 --- a/.maestro/setup/on_page_selected_example_setup.yaml +++ /dev/null @@ -1,13 +0,0 @@ -appId: ${APP_ID} -# Nest PagerView Example tab is active and accessible ---- -- launchApp -- assertVisible: 'PagerView Example' -- tapOn: 'OnPageSelected Example' -- extendedWaitUntil: - visible: - text: 'You are on 1 page' - timeout: 5000 - -- assertVisible: - text: 'Hey' diff --git a/.maestro/setup/scrollable_pagerView_example_setup.yaml b/.maestro/setup/scrollable_pagerView_example_setup.yaml deleted file mode 100644 index 31cc183c..00000000 --- a/.maestro/setup/scrollable_pagerView_example_setup.yaml +++ /dev/null @@ -1,15 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp -- assertVisible: 'PagerView Example' -- tapOn: 'Scrollable PagerView Example' -- extendedWaitUntil: - visible: - id: 'pager-view' - timeout: 10000 -- assertVisible: - id: 'scroll-view' -- assertVisible: - id: 'pageNumber0' -- assertVisible: - text: 'page number 0' diff --git a/.maestro/setup/tab_view_inside_scroll_view_example_setup.yaml b/.maestro/setup/tab_view_inside_scroll_view_example_setup.yaml deleted file mode 100644 index 4c8fce54..00000000 --- a/.maestro/setup/tab_view_inside_scroll_view_example_setup.yaml +++ /dev/null @@ -1,12 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp -- assertVisible: 'PagerView Example' -- tapOn: 'TabView inside ScrollView Example' -- extendedWaitUntil: - visible: - id: 'tab-view-scroll-view' - timeout: 10000 -- assertVisible: 'First' -- assertVisible: 'Second' -- assertVisible: 'First Route' diff --git a/.maestro/smoke-test.yaml b/.maestro/smoke-test.yaml deleted file mode 100644 index 62cb0f67..00000000 --- a/.maestro/smoke-test.yaml +++ /dev/null @@ -1,50 +0,0 @@ -appId: com.pagerviewexample -tags: - - smoke ---- -- launchApp - -- swipe: - direction: DOWN - duration: 100 - -- extendedWaitUntil: - visible: - id: 'example-basic-horizontal' - timeout: 15000 - -- tapOn: - id: 'example-basic-horizontal' - -- extendedWaitUntil: - visible: - id: 'pager-view-horizontal' - timeout: 10000 - -- assertVisible: - id: 'pageNumber0' - -- assertVisible: - text: 'page number 0\s*' - -- tapOn: - id: 'next-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber1' - timeout: 5000 - -- assertVisible: - text: 'page number 1\s*' - -- tapOn: - id: 'prev-page-button' - -- extendedWaitUntil: - visible: - id: 'pageNumber0' - timeout: 5000 - -- assertVisible: - text: 'page number 0\s*' diff --git a/.maestro/tests/material_top_bar_example.yaml b/.maestro/tests/material_top_bar_example.yaml deleted file mode 100644 index 4813f4c4..00000000 --- a/.maestro/tests/material_top_bar_example.yaml +++ /dev/null @@ -1,69 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../setup/material_top_bar_example_setup.yaml - -- tapOn: - id: 'material-top-bar-login-button' - -- extendedWaitUntil: - visible: - id: 'material-top-bar-post-auth-screen' - timeout: 10000 - -- extendedWaitUntil: - visible: - id: 'material-top-bar-tab-1' - timeout: 5000 - -- assertVisible: - text: 'Tab1' - -- tapOn: - id: 'material-top-bar-scroll-list-button' - -- extendedWaitUntil: - visible: - id: 'material-top-bar-list-item-30' - timeout: 5000 - -- tapOn: - id: 'material-top-bar-open-detail-button' - -- extendedWaitUntil: - visible: - id: 'material-top-bar-detail-screen' - timeout: 5000 - -- tapOn: - id: 'material-top-bar-back-button' - -- extendedWaitUntil: - visible: - id: 'material-top-bar-tab-1' - timeout: 5000 - -# The existing ComposeView and AndroidView host must survive the native-stack -# cover/reveal cycle, including the FlatList's native scroll position. -- assertVisible: - id: 'material-top-bar-list-item-30' - -- tapOn: 'Tab2' - -- extendedWaitUntil: - visible: - id: 'material-top-bar-tab-2' - timeout: 5000 - -- assertVisible: - id: 'material-top-bar-logout-button' - -- tapOn: - id: 'material-top-bar-logout-button' - -- extendedWaitUntil: - visible: - id: 'material-top-bar-pre-auth-screen' - timeout: 10000 - -- assertVisible: - id: 'material-top-bar-login-button' diff --git a/.maestro/tests/nested_pagerView_example.yaml b/.maestro/tests/nested_pagerView_example.yaml deleted file mode 100644 index a05e8fe8..00000000 --- a/.maestro/tests/nested_pagerView_example.yaml +++ /dev/null @@ -1,36 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../setup/nested_pagerView_example_setup.yaml - -- swipe: - from: - id: 'pager-view' - start: 90%, 50% - end: 10%, 50% - duration: 500 -- assertVisible: 'Horizontal page number 0' - -- swipe: - from: - id: '2-nd-nested' - start: 90%, 50% - end: 10%, 50% - duration: 500 -- assertVisible: 'Horizontal page number 1' - -- swipe: - from: - id: '2-nd-nested' - start: 50%, 90% - end: 50%, 60% - duration: 500 -- assertVisible: 'Vertical page number 1' - -- swipe: - from: - id: '2-nd-nested' - start: 90%, 50% - end: 10%, 50% - duration: 500 -- assertVisible: - id: '3-rd-pager-view' diff --git a/.maestro/tests/on_page_selected_example.yaml b/.maestro/tests/on_page_selected_example.yaml deleted file mode 100644 index 4237a59a..00000000 --- a/.maestro/tests/on_page_selected_example.yaml +++ /dev/null @@ -1,61 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../setup/on_page_selected_example_setup.yaml - -- tapOn: 'OK' - -- extendedWaitUntil: - visible: - id: 'pager' - timeout: 10000 - -- assertVisible: - text: 'Page Index: 0' - -- tapOn: - id: 'next-page-button' - -- extendedWaitUntil: - visible: - text: 'You are on 2 page' - timeout: 5000 - -- assertVisible: - text: 'Hey' - -- tapOn: 'OK' - -- extendedWaitUntil: - visible: - text: 'Page Index: 1' - timeout: 5000 - -- tapOn: - id: 'last-page-button' - -- extendedWaitUntil: - visible: - text: 'You are on 10 page' - timeout: 5000 - -- tapOn: 'OK' - -- extendedWaitUntil: - visible: - text: 'Page Index: 9' - timeout: 5000 - -- tapOn: - id: 'prev-page-button' - -- extendedWaitUntil: - visible: - text: 'You are on 9 page' - timeout: 5000 - -- tapOn: 'OK' - -- extendedWaitUntil: - visible: - text: 'Page Index: 8' - timeout: 5000 diff --git a/.maestro/tests/pager_basic_example.yaml b/.maestro/tests/pager_basic_example.yaml deleted file mode 100644 index d6bdc1c9..00000000 --- a/.maestro/tests/pager_basic_example.yaml +++ /dev/null @@ -1,12 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../flows/basic-pager/ensure-ltr.yaml - -- runFlow: - file: ../flows/basic-pager/open.yaml - env: - EXAMPLE_ID: 'example-basic-horizontal' - PAGER_ID: 'pager-view-horizontal' - -- runFlow: ../flows/basic-pager/verify-horizontal-ltr-swipe.yaml -- runFlow: ../flows/basic-pager/verify-controls.yaml diff --git a/.maestro/tests/pager_rtl_example.yaml b/.maestro/tests/pager_rtl_example.yaml deleted file mode 100644 index 7448acd1..00000000 --- a/.maestro/tests/pager_rtl_example.yaml +++ /dev/null @@ -1,12 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../flows/basic-pager/ensure-rtl.yaml - -- runFlow: - file: ../flows/basic-pager/open.yaml - env: - EXAMPLE_ID: 'example-basic-horizontal' - PAGER_ID: 'pager-view-horizontal' - -- runFlow: ../flows/basic-pager/verify-horizontal-rtl-swipe.yaml -- runFlow: ../flows/basic-pager/verify-controls.yaml diff --git a/.maestro/tests/pager_vertical_basic_example.yaml b/.maestro/tests/pager_vertical_basic_example.yaml deleted file mode 100644 index 2e02b221..00000000 --- a/.maestro/tests/pager_vertical_basic_example.yaml +++ /dev/null @@ -1,12 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../flows/basic-pager/ensure-ltr.yaml - -- runFlow: - file: ../flows/basic-pager/open.yaml - env: - EXAMPLE_ID: 'example-basic-vertical' - PAGER_ID: 'pager-view-vertical' - -- runFlow: ../flows/basic-pager/verify-vertical-swipe.yaml -- runFlow: ../flows/basic-pager/verify-controls.yaml diff --git a/.maestro/tests/scrollable_pagerView_example.yaml b/.maestro/tests/scrollable_pagerView_example.yaml deleted file mode 100644 index 4608a8ed..00000000 --- a/.maestro/tests/scrollable_pagerView_example.yaml +++ /dev/null @@ -1,56 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../setup/scrollable_pagerView_example_setup.yaml - -- swipe: - start: 90%, 30% - end: 10%, 30% - duration: 100 - -- extendedWaitUntil: - visible: - id: 'pageNumber1' - timeout: 5000 - -- assertVisible: - text: 'page number 1' - -- swipe: - from: - id: 'scroll-view' - start: 50%, 85% - end: 50%, 20% - duration: 500 - -- extendedWaitUntil: - visible: - id: 'scrollable-spacer-2' - timeout: 5000 - -- swipe: - from: - id: 'scroll-view' - start: 50%, 20% - end: 50%, 85% - duration: 500 - -- extendedWaitUntil: - visible: - id: 'pageNumber1' - timeout: 5000 - -- assertVisible: - text: 'page number 1' - -- swipe: - start: 90%, 30% - end: 10%, 30% - duration: 100 - -- extendedWaitUntil: - visible: - id: 'pageNumber2' - timeout: 5000 - -- assertVisible: - text: 'page number 2' diff --git a/.maestro/tests/tab_view_inside_scroll_view_example.yaml b/.maestro/tests/tab_view_inside_scroll_view_example.yaml deleted file mode 100644 index 9a833cdb..00000000 --- a/.maestro/tests/tab_view_inside_scroll_view_example.yaml +++ /dev/null @@ -1,44 +0,0 @@ -appId: com.pagerviewexample ---- -- runFlow: ../setup/tab_view_inside_scroll_view_example_setup.yaml - -- swipe: - start: 90%, 45% - end: 10%, 45% - duration: 100 - -- extendedWaitUntil: - visible: - text: 'Second Route' - timeout: 5000 - -- swipe: - from: - id: 'tab-view-scroll-view' - start: 50%, 85% - end: 50%, 20% - duration: 500 - -- extendedWaitUntil: - visible: - id: 'tab-view-second-route-bottom' - timeout: 5000 - -- swipe: - from: - id: 'tab-view-scroll-view' - start: 50%, 20% - end: 50%, 85% - duration: 500 - -- extendedWaitUntil: - visible: - text: 'Second Route' - timeout: 5000 - -- tapOn: 'First' - -- extendedWaitUntil: - visible: - text: 'First Route' - timeout: 5000 diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..6e22faea --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,121 @@ +# e2e + +End-to-end tests for the example app, written in TypeScript and run with +[e2e](https://github.com/tester-army/e2e) on the `@e2e-dev/mobile` engine. +Tests drive iOS simulators and Android emulators through the accessibility +tree, so the same file runs on both platforms. + +## Setup + +```bash +cd e2e && bun install +npx agent-device doctor +``` + +`agent-device doctor` checks for Xcode with a simulator runtime, or the Android +SDK with an emulator. + +## Run + +From the repository root, bundle the JS, build and install the release example +app, then run the suite: + +```bash +bun run e2e:ios +bun run e2e:android +``` + +The release app of react-native-test-app loads the prebuilt bundle from +`example/dist`, so `example/ios/Pods` must have been installed after that +directory existed at least once (`bun run --cwd example build:ios && bun run +bootstrap`). + +If the example app is already installed on a booted simulator or emulator: + +```bash +bun run e2e:test:ios +bun run e2e:test:android +``` + +From this directory, `bun run test:ios -- --tag smoke` runs only the smoke +test, and `bun run list` prints every test without running it. + +### Several simulators at once + +With nothing pinned, the engine uses every booted simulator as the pool, so +`bun run e2e:test:ios` already spreads the suite across whatever is open. To +choose explicitly, list simulators by name or UDID in `E2E_DEVICES` and point +`E2E_IOS_APP_PATH` at the build so each one gets the app installed before its +first test: + +```bash +APP=$(ls -d ~/Library/Developer/Xcode/DerivedData/PagerViewExample-*/Build/Products/Release-iphonesimulator/ReactTestApp.app | head -1) +E2E_DEVICES="iPhone 17,iPhone 16e,iPhone 17 Pro Max,iPhone Air" E2E_IOS_APP_PATH="$APP" bun run e2e:test:ios +``` + +`targets.ts` turns the list into a device pool. The engine boots every device +up front, declares one worker per device, and the runner spreads the test +files across them. 13 tests: about 375 s on one simulator, 190 s on two, 130 s +on four. Remote devices fit the same shape: one pool entry per device. + +| Variable | Effect | +| --- | --- | +| `E2E_DEVICE` | Simulator or emulator name or UDID. Default: every booted device of the platform. | +| `E2E_DEVICES` | Comma-separated pool of names or UDIDs, for example `iPhone 17,iPhone 17 Pro`. One worker per device; the test files spread across them. Every device needs the app installed, or set `E2E_IOS_APP_PATH`. | +| `E2E_IOS_APP_PATH` | `.app` bundle installed on every pooled device before its first test. Use the Xcode build product (`~/Library/Developer/Xcode/DerivedData/PagerViewExample-*/Build/Products/Release-iphonesimulator/ReactTestApp.app`), never a path inside a simulator's own container: installing replaces that container and the path disappears mid-run. | +| `E2E_ANDROID_APK_PATH` | `.apk` to install before the first test instead of using the installed build. | + +## Layout + +- `targets.ts` declares one mobile engine per platform, both pinned to + the `com.pagerviewexample` app; `e2e.config.ts` runs the suite with them. +- `tests/*.e2e.ts` hold one scenario per example screen. +- `tests/issues/*.e2e.ts` hold regression scenarios, tagged `regression` and + `issue-` where a GitHub issue exists. +- `tests/support/test.ts` re-exports `test` and `expect` (import them from + there so the engine can change in one place) and exports + `freshAppBeforeEach()`, which every test file calls to install the + `E2E_*_APP_PATH` build once per device and relaunch the app before each + test. +- `tests/support/app.ts` opens an example from the home list and forces the + layout direction. +- `tests/support/basic-pager.ts` holds the checks shared by the LTR, vertical, + and RTL basic pager scenarios. + +## Gotchas + +- Swipe directions are scroll directions: `swipe({ direction: 'right' })` + moves the finger left and reveals the next page in LTR. +- Node swipes fling. Use `screen.scrollUntilVisible` with a target that stays + on screen after any fling (the last item) instead of a middle one. +- The layout direction toggle persists across launches. `openExample` forces + the direction it is given, and every RTL describe block calls + `restoreLtrAfterEach()`. +- The engine neither installs nor launches anything on its own, so every + test file calls `freshAppBeforeEach()` right after its imports: it installs + `E2E_*_APP_PATH` through `device.installApp()` and relaunches the app with + `device.openApp(APP_ID, { relaunch: true })`, so every test starts fresh at + the home list. A module-level hook in `tests/support/test.ts` would attach + only to the first file evaluated in a realm, and `app.open()` or + `app.restart()` resume a running app the surface does not know about. + State shared between tests must be state the app persists (the layout + direction toggle is). +- A connected physical iPhone joins the default device pool and fails with + `ENGINE_FAILURE` when it is locked or lacks the app. Pin `E2E_DEVICE` to a + simulator when a phone is plugged in. + +## CI + +`.github/workflows/ios.yml` and `android.yml` build the release example app, +install it on a simulator or emulator, and run the suite for their platform. +The `github()` reporter from `@e2e-dev/github` is in `e2e.config.ts`; it does +nothing locally and on Actions writes the job summary and one PR comment per +run (the workflows pass `GITHUB_TOKEN` and hold `pull-requests: write`). +`.e2e/report.json` and the artifacts upload on every non-cancelled run. + +## Debugging + +A failed run prints the error code and the failing line. `.e2e/report.json` +lists every step with its screenshot under `.e2e/artifacts/`. Both are +gitignored. See `.agents/skills/e2e/references/debugging.md` for the error +code table. diff --git a/e2e/bun.lock b/e2e/bun.lock new file mode 100644 index 00000000..259e324b --- /dev/null +++ b/e2e/bun.lock @@ -0,0 +1,363 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "react-native-pager-view-e2e", + "devDependencies": { + "@e2e-dev/github": "^0.3.1", + "@e2e-dev/mobile": "^0.8.1", + "@types/node": "^26.5.0", + "e2e": "^0.15.1", + "typescript": "^7.0.2", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.18", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ=="], + + "@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="], + + "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], + + "@e2e-dev/github": ["@e2e-dev/github@0.3.1", "", { "peerDependencies": { "e2e": ">=0.15.0 <1" } }, "sha512-kRPDRhwst4HognhFu7H4kyZRiKxGkakYW+XjjBdFaQQvddOVAoriFW0kFcsKXt9w8Zzsc7HgAQoZR/MG4rMEeg=="], + + "@e2e-dev/mobile": ["@e2e-dev/mobile@0.8.1", "", { "dependencies": { "agent-device": "0.21.18", "pngjs": "7.0.0", "zod": "4.6.1" }, "peerDependencies": { "ai": "^7.0.0", "e2e": ">=0.15.0 <1" }, "optionalPeers": ["ai"] }, "sha512-aUbqIUxgeyxebNtySH7Cw+IvZPauHqOM5oUBTCzF8YMpInjqs8vi7iiW6eiZsQPxrINTy/3KoSvIAnPU8bXFYg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@vitest/expect": ["@vitest/expect@5.0.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "5.0.2", "@vitest/utils": "5.0.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.1" } }, "sha512-pd6YDkhOHptwC65NYNpGpNxWlJH9M9Gxwtej73osTddb1NpiE0Jz5CIjTTEGXRgK0YtiSN7qBokmBXZ5uPJL1w=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@5.0.2", "", { "dependencies": { "tinyrainbow": "^3.1.1" } }, "sha512-YHM+mQQ7N1ROHMugJ7P/M+fC/q1G4zs/iAMZZCvhHcY8WkwPQcXBHD+z18oz+808Cfa12K7jOr3XPQMcLIovjw=="], + + "@vitest/spy": ["@vitest/spy@5.0.2", "", {}, "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg=="], + + "@vitest/utils": ["@vitest/utils@5.0.2", "", { "dependencies": { "@vitest/pretty-format": "5.0.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.1" } }, "sha512-mgpUtxFhKeOKVaArBVHNJGdYHAeRh135o59l13uVmPUCS1OUtis5pBqXEgE25iqRgFqEBwXvJlkhknRsYnMeqg=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agent-device": ["agent-device@0.21.18", "", { "peerDependencies": { "ai": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["ai"], "bin": { "agent-device": "bin/agent-device.mjs" } }, "sha512-ptNJ7a4jkAXFLSmZqKJDwxL+YpCEUuZv3oCBY76PwE3b2W+yQqQuG0ej0/bOspIHJRmMGNOkWhtxm3gmRW9hRQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "e2e": ["e2e@0.15.1", "", { "dependencies": { "@ai-sdk/provider": "4.0.18", "@clack/prompts": "1.8.1", "@modelcontextprotocol/sdk": "1.30.1", "@vitest/expect": "5.0.2", "commander": "15.0.0", "picocolors": "1.1.1", "pngjs": "7.0.0", "tsx": "4.23.14", "zod": "4.6.1" }, "peerDependencies": { "@ai-sdk/openai": "^4.0.0", "@ai-sdk/openai-compatible": "^3.0.0", "@ai-sdk/xai": "^5.0.0", "ai": "^7.0.0" }, "optionalPeers": ["@ai-sdk/openai", "@ai-sdk/openai-compatible", "@ai-sdk/xai", "ai"], "bin": { "e2e": "./dist/cli/bin.js" } }, "sha512-HYuc4xFbLqG4TMZkzZEZEoYa20qKvqyZ9nodDOZ8Yo05YlspzC4GV4guEql1pVIdspqj0fDMINx2rsn3REVrHA=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tsx": ["tsx@4.23.14", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-yFwMnbAsUFz/T3kR8P2ENc/DDUaYd9tKg4oVYo0lhkX0LlK4UuqYAO6mpQ2y6lmcyip1uPJ34C2kPACopDwG4w=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.6.1", "", {}, "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + } +} diff --git a/e2e/e2e.config.ts b/e2e/e2e.config.ts new file mode 100644 index 00000000..e46f01ee --- /dev/null +++ b/e2e/e2e.config.ts @@ -0,0 +1,14 @@ +import type { E2EConfig } from 'e2e'; +import { github } from '@e2e-dev/github'; +import { targets, workers } from './targets.ts'; + +export default { + targets, + workers, + timeout: 180_000, + assertionTimeout: 10_000, + // restoreLtrAfterEach relaunches twice; ~30 s on an Android emulator. + cleanupTimeout: 60_000, + // github() only acts on GitHub Actions: job summary plus one PR comment. + reporters: ['list', github()], +} satisfies E2EConfig; diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..58563d03 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "react-native-pager-view-e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "e2e run", + "test:ios": "e2e run --target ios", + "test:android": "e2e run --target android", + "list": "e2e list", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@e2e-dev/github": "^0.3.1", + "@e2e-dev/mobile": "^0.8.1", + "@types/node": "^26.5.0", + "e2e": "^0.15.1", + "typescript": "^7.0.2" + } +} diff --git a/e2e/targets.ts b/e2e/targets.ts new file mode 100644 index 00000000..4a273d0c --- /dev/null +++ b/e2e/targets.ts @@ -0,0 +1,41 @@ +import { mobile } from '@e2e-dev/mobile'; + +export const APP_ID = 'com.pagerviewexample'; + +/** + * Devices named in `E2E_DEVICES` (comma-separated names or UDIDs) or the + * single `E2E_DEVICE`. Empty leaves discovery to the engine: every booted + * simulator or emulator of the platform forms the pool. + */ +export const devices: readonly string[] = ( + process.env.E2E_DEVICES ?? + process.env.E2E_DEVICE ?? + '' +) + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0); + +/** + * Worker cap for the run. The engine never runs more than one worker per + * device, so this only has to be large enough for the pool; CI would + * otherwise default to a single worker. + */ +export const workers = Math.max(4, devices.length); + +const device = devices.length === 0 ? undefined : devices; + +export const targets = [ + { + name: 'ios', + platform: 'ios', + engine: mobile({ platform: 'ios', device }), + app: { bundleId: APP_ID, appPath: process.env.E2E_IOS_APP_PATH }, + }, + { + name: 'android', + platform: 'android', + engine: mobile({ platform: 'android', device }), + app: { bundleId: APP_ID, appPath: process.env.E2E_ANDROID_APK_PATH }, + }, +]; diff --git a/e2e/tests/basic-pager.e2e.ts b/e2e/tests/basic-pager.e2e.ts new file mode 100644 index 00000000..8333ce8a --- /dev/null +++ b/e2e/tests/basic-pager.e2e.ts @@ -0,0 +1,38 @@ +import { freshAppBeforeEach, test } from './support/test.ts'; +import { restoreLtrAfterEach } from './support/app.ts'; +import { + openBasicPager, + verifyControls, + verifySwipeToNextPage, +} from './support/basic-pager.ts'; + +freshAppBeforeEach(); + +test.describe('basic pager', { tags: ['basic-pager'] }, () => { + test.describe('in LTR', () => { + test('pages horizontally', async ({ app, screen }) => { + await openBasicPager(app, screen, 'horizontal'); + await verifySwipeToNextPage(screen, 'horizontal', 'right'); + await verifyControls(screen); + }); + + test('pages vertically', async ({ app, screen }) => { + await openBasicPager(app, screen, 'vertical'); + await verifySwipeToNextPage(screen, 'vertical', 'down'); + await verifyControls(screen); + }); + }); + + test.describe('in RTL', () => { + restoreLtrAfterEach(); + + test('pages horizontally with the reversed gesture', async ({ + app, + screen, + }) => { + await openBasicPager(app, screen, 'horizontal', 'rtl'); + await verifySwipeToNextPage(screen, 'horizontal', 'left'); + await verifyControls(screen); + }); + }); +}); diff --git a/e2e/tests/issues/issue-1083-modal-set-page.e2e.ts b/e2e/tests/issues/issue-1083-modal-set-page.e2e.ts new file mode 100644 index 00000000..9e6973d0 --- /dev/null +++ b/e2e/tests/issues/issue-1083-modal-set-page.e2e.ts @@ -0,0 +1,36 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import type { Screen } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +/** Opens the native-stack modal and submits, which calls setPage while the pager is covered. */ +async function advanceThroughModal(screen: Screen): Promise { + await screen.getByTestId('issue-1083-open-modal').tap(); + await expect(screen.getByText('Modal screen')).toBeVisible(); + await screen.getByTestId('issue-1083-submit').tap(); +} + +test( + 'setPage called behind a native-stack modal lands on the requested page', + { tags: ['regression', 'issue-1083'] }, + async ({ app, screen }) => { + await openExample(app, screen, 'Issue #1083 Modal SetPage Repro'); + await expect(screen.getByTestId('issue-1083-requested-page')).toHaveText( + 'Last requested page: 0' + ); + await expect(screen.getByTestId('issue-1083-page-0')).toBeVisible(); + + await advanceThroughModal(screen); + await expect(screen.getByText('Last requested page: 1')).toBeVisible(); + await expect(screen.getByTestId('issue-1083-page-1')).toBeVisible(); + + await screen.getByTestId('issue-1083-advance-directly').tap(); + await expect(screen.getByText('Last requested page: 2')).toBeVisible(); + await expect(screen.getByTestId('issue-1083-page-2')).toBeVisible(); + + await advanceThroughModal(screen); + await expect(screen.getByText('Last requested page: 0')).toBeVisible(); + await expect(screen.getByTestId('issue-1083-page-0')).toBeVisible(); + } +); diff --git a/e2e/tests/issues/issue-1098-nested-pager.e2e.ts b/e2e/tests/issues/issue-1098-nested-pager.e2e.ts new file mode 100644 index 00000000..1cbee823 --- /dev/null +++ b/e2e/tests/issues/issue-1098-nested-pager.e2e.ts @@ -0,0 +1,34 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +test( + 'outer pager stays responsive after its page count changes around a nested pager', + { tags: ['regression', 'issue-1098'] }, + async ({ app, screen }) => { + await openExample(app, screen, 'Issue #1098 Nested Pager Repro'); + await expect(screen.getByTestId('issue-1098-outer-pager')).toBeVisible(); + const refresh = screen.getByTestId('issue-1098-refresh-outer-pages'); + await expect(refresh).toBeVisible(); + + await refresh.tap(); + await expect(screen.getByText('Refreshes: 1')).toBeVisible(); + + // Swipe on the outer page title so the gesture reaches the outer pager, not the nested one. + await screen.getByText('Outer page: nested pagers').swipe({ + direction: 'right', + momentum: 'fast', + }); + await expect(screen.getByText('Outer page 2')).toBeVisible(); + + await screen.getByText('Outer page 2').swipe({ + direction: 'left', + momentum: 'fast', + }); + await expect(screen.getByTestId('issue-1098-inner-pager')).toBeVisible(); + + await refresh.tap(); + await expect(screen.getByText('Refreshes: 2')).toBeVisible(); + } +); diff --git a/e2e/tests/material-top-bar.e2e.ts b/e2e/tests/material-top-bar.e2e.ts new file mode 100644 index 00000000..85fdd422 --- /dev/null +++ b/e2e/tests/material-top-bar.e2e.ts @@ -0,0 +1,52 @@ +import { freshAppBeforeEach, expect, test } from './support/test.ts'; +import { openExample } from './support/app.ts'; + +freshAppBeforeEach(); + +test('material top tabs survive a native-stack push and pop', async ({ + app, + screen, +}) => { + await openExample(app, screen, 'MaterialTopBarExample'); + await expect( + screen.getByTestId('material-top-bar-pre-auth-screen') + ).toBeVisible(); + + await screen.getByTestId('material-top-bar-login-button').tap(); + await expect( + screen.getByTestId('material-top-bar-post-auth-screen') + ).toBeVisible(); + await expect(screen.getByTestId('material-top-bar-tab-1')).toBeVisible(); + await expect(screen.getByText('Tab1')).toBeVisible(); + + await screen.getByTestId('material-top-bar-scroll-list-button').tap(); + await expect( + screen.getByTestId('material-top-bar-list-item-30') + ).toBeVisible(); + + await screen.getByTestId('material-top-bar-open-detail-button').tap(); + await expect( + screen.getByTestId('material-top-bar-detail-screen') + ).toBeVisible(); + + await screen.getByTestId('material-top-bar-back-button').tap(); + await expect(screen.getByTestId('material-top-bar-tab-1')).toBeVisible(); + // The native host must keep the FlatList scroll position across the cover/reveal cycle. + await expect( + screen.getByTestId('material-top-bar-list-item-30') + ).toBeVisible(); + + await screen.getByText('Tab2').tap(); + await expect(screen.getByTestId('material-top-bar-tab-2')).toBeVisible(); + await expect( + screen.getByTestId('material-top-bar-logout-button') + ).toBeVisible(); + + await screen.getByTestId('material-top-bar-logout-button').tap(); + await expect( + screen.getByTestId('material-top-bar-pre-auth-screen') + ).toBeVisible(); + await expect( + screen.getByTestId('material-top-bar-login-button') + ).toBeVisible(); +}); diff --git a/e2e/tests/nested-pager-view.e2e.ts b/e2e/tests/nested-pager-view.e2e.ts new file mode 100644 index 00000000..a6886df2 --- /dev/null +++ b/e2e/tests/nested-pager-view.e2e.ts @@ -0,0 +1,35 @@ +import { freshAppBeforeEach, expect, test } from './support/test.ts'; +import { openExample } from './support/app.ts'; + +freshAppBeforeEach(); + +test('nested horizontal and vertical pagers scroll independently', async ({ + app, + screen, +}) => { + await openExample(app, screen, 'Nested PagerView Example'); + const outerPager = screen.getByTestId('pager-view'); + await expect(outerPager).toBeVisible(); + await expect(screen.getByTestId('1-st-page')).toBeVisible(); + + await outerPager.swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByText('Horizontal page number 0')).toBeVisible(); + + await screen + .getByTestId('pager-view-content') + .filter({ hasText: 'Horizontal page number 0' }) + .swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByText('Horizontal page number 1')).toBeVisible(); + + await screen + .getByTestId('pager-view-content') + .filter({ hasText: 'Vertical page number 0' }) + .swipe({ direction: 'down', momentum: 'fast' }); + await expect(screen.getByText('Vertical page number 1')).toBeVisible(); + + await screen + .getByTestId('pager-view-content') + .filter({ hasText: 'Horizontal page number 1' }) + .swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByTestId('3-rd-pager-view')).toBeVisible(); +}); diff --git a/e2e/tests/on-page-selected.e2e.ts b/e2e/tests/on-page-selected.e2e.ts new file mode 100644 index 00000000..40da3d08 --- /dev/null +++ b/e2e/tests/on-page-selected.e2e.ts @@ -0,0 +1,34 @@ +import { freshAppBeforeEach, expect, test } from './support/test.ts'; +import type { Screen } from './support/test.ts'; +import { openExample } from './support/app.ts'; + +freshAppBeforeEach(); + +/** Waits for the onPageSelected alert for a 1-based page and dismisses it. */ +async function acceptPageAlert(screen: Screen, page: number): Promise { + await expect(screen.getByText(`You are on ${page} page`)).toBeVisible(); + await expect(screen.getByText('Hey')).toBeVisible(); + await screen.getByRole('button', { name: 'OK' }).tap(); +} + +test('onPageSelected fires an alert for every page change', async ({ + app, + screen, +}) => { + await openExample(app, screen, 'OnPageSelected Example'); + await acceptPageAlert(screen, 1); + await expect(screen.getByTestId('pager')).toBeVisible(); + await expect(screen.getByText('Page Index: 0')).toBeVisible(); + + await screen.getByTestId('next-page-button').tap(); + await acceptPageAlert(screen, 2); + await expect(screen.getByText('Page Index: 1')).toBeVisible(); + + await screen.getByTestId('last-page-button').tap(); + await acceptPageAlert(screen, 10); + await expect(screen.getByText('Page Index: 9')).toBeVisible(); + + await screen.getByTestId('prev-page-button').tap(); + await acceptPageAlert(screen, 9); + await expect(screen.getByText('Page Index: 8')).toBeVisible(); +}); diff --git a/e2e/tests/scrollable-pager-view.e2e.ts b/e2e/tests/scrollable-pager-view.e2e.ts new file mode 100644 index 00000000..316f8ed7 --- /dev/null +++ b/e2e/tests/scrollable-pager-view.e2e.ts @@ -0,0 +1,31 @@ +import { freshAppBeforeEach, expect, test } from './support/test.ts'; +import { openExample } from './support/app.ts'; + +freshAppBeforeEach(); + +test('pager inside a ScrollView keeps its page across vertical scrolls', async ({ + app, + screen, +}) => { + await openExample(app, screen, 'Scrollable PagerView Example'); + const pager = screen.getByTestId('pager-view'); + await expect(pager).toBeVisible(); + await expect(screen.getByTestId('scroll-view')).toBeVisible(); + await expect(screen.getByTestId('pageNumber0')).toHaveText('page number 0'); + + await pager.swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByTestId('pageNumber1')).toHaveText('page number 1'); + + await screen.scrollUntilVisible(screen.getByTestId('scrollable-spacer-9'), { + direction: 'down', + }); + await expect(screen.getByTestId('pageNumber1')).toBeHidden(); + + await screen.scrollUntilVisible(screen.getByTestId('pageNumber1'), { + direction: 'up', + }); + await expect(screen.getByTestId('pageNumber1')).toHaveText('page number 1'); + + await pager.swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByTestId('pageNumber2')).toHaveText('page number 2'); +}); diff --git a/e2e/tests/smoke.e2e.ts b/e2e/tests/smoke.e2e.ts new file mode 100644 index 00000000..017e734a --- /dev/null +++ b/e2e/tests/smoke.e2e.ts @@ -0,0 +1,20 @@ +import { freshAppBeforeEach, expect, test } from './support/test.ts'; +import { openExample } from './support/app.ts'; + +freshAppBeforeEach(); + +test( + 'horizontal pager pages with the panel buttons', + { tags: ['smoke'] }, + async ({ app, screen }) => { + await openExample(app, screen, 'example-basic-horizontal'); + await expect(screen.getByTestId('pager-view-horizontal')).toBeVisible(); + await expect(screen.getByTestId('pageNumber0')).toHaveText('page number 0'); + + await screen.getByTestId('next-page-button').tap(); + await expect(screen.getByTestId('pageNumber1')).toHaveText('page number 1'); + + await screen.getByTestId('prev-page-button').tap(); + await expect(screen.getByTestId('pageNumber0')).toHaveText('page number 0'); + } +); diff --git a/e2e/tests/support/app.ts b/e2e/tests/support/app.ts new file mode 100644 index 00000000..4175b720 --- /dev/null +++ b/e2e/tests/support/app.ts @@ -0,0 +1,69 @@ +import { expect, test } from './test.ts'; +import type { App, Screen } from './test.ts'; + +export type LayoutDirection = 'ltr' | 'rtl'; + +const HOME_READY_TEST_ID = 'example-basic-horizontal'; +// The first cold start of the release app on a freshly booted CI emulator +// can keep the window off the accessibility tree for over 15 s. +const LAUNCH_TIMEOUT = 30_000; + +/** Waits until the home list of the example app has rendered. */ +export async function waitForHome(screen: Screen): Promise { + await expect(screen.getByTestId(HOME_READY_TEST_ID)).toBeVisible({ + timeout: LAUNCH_TIMEOUT, + }); +} + +/** + * Opens an example from the home list by its testID. Examples without an + * explicit testID use their display name as the testID. The layout direction + * is forced first, LTR by default, because the toggle persists across + * launches and every gesture in the suite assumes the direction it asked for. + */ +export async function openExample( + app: App, + screen: Screen, + testId: string, + direction: LayoutDirection = 'ltr' +): Promise { + await ensureLayoutDirection(app, screen, direction); + const entry = screen.getByTestId(testId); + await screen.scrollUntilVisible(entry); + await entry.tap(); +} + +/** + * Forces the requested layout direction. The header toggle persists the + * direction and reloads only in debug builds, so the app is relaunched + * explicitly. A failed RTL run therefore cannot leak into the next LTR test. + */ +export async function ensureLayoutDirection( + app: App, + screen: Screen, + direction: LayoutDirection +): Promise { + await waitForHome(screen); + const opposite: LayoutDirection = direction === 'ltr' ? 'rtl' : 'ltr'; + const toggle = screen.getByTestId(`layout-direction-${opposite}`); + if (await toggle.isVisible()) { + await toggle.tap(); + await app.restart(); + await waitForHome(screen); + } + await expect(screen.getByTestId(`layout-direction-${direction}`)).toBeVisible( + { timeout: LAUNCH_TIMEOUT } + ); +} + +/** + * Registers an afterEach hook that hands the device back in LTR. Call it in + * every describe block that switches to RTL, because the direction persists + * across launches and the hook runs after failures too. + */ +export function restoreLtrAfterEach(): void { + test.afterEach(async ({ app, screen }) => { + await app.restart(); + await ensureLayoutDirection(app, screen, 'ltr'); + }); +} diff --git a/e2e/tests/support/basic-pager.ts b/e2e/tests/support/basic-pager.ts new file mode 100644 index 00000000..67b2fdbf --- /dev/null +++ b/e2e/tests/support/basic-pager.ts @@ -0,0 +1,62 @@ +import { expect } from './test.ts'; +import type { App, Screen, ScrollDirection } from './test.ts'; +import { openExample } from './app.ts'; +import type { LayoutDirection } from './app.ts'; + +export type Orientation = 'horizontal' | 'vertical'; + +/** Opens the basic example for the orientation and waits for its first page. */ +export async function openBasicPager( + app: App, + screen: Screen, + orientation: Orientation, + direction: LayoutDirection = 'ltr' +): Promise { + await openExample(app, screen, `example-basic-${orientation}`, direction); + await expect(screen.getByTestId(`pager-view-${orientation}`)).toBeVisible(); + await expect(screen.getByTestId('pageNumber0')).toBeVisible(); +} + +/** + * Swipes the pager towards the next page twice: once with scrolling disabled, + * which must keep page 0, and once enabled, which must land on page 1. + */ +export async function verifySwipeToNextPage( + screen: Screen, + orientation: Orientation, + direction: ScrollDirection +): Promise { + const pager = screen.getByTestId(`pager-view-${orientation}`); + const scrollToggle = screen.getByTestId('scroll-enabled-button'); + + await scrollToggle.tap(); + await pager.swipe({ direction, momentum: 'fast' }); + await expect(screen.getByTestId('pageNumber0')).toBeVisible(); + await expect(screen.getByTestId('pageNumber1')).toBeHidden(); + + await scrollToggle.tap(); + await pager.swipe({ direction, momentum: 'fast' }); + await expect(screen.getByTestId('pageNumber1')).toBeVisible(); +} + +/** Drives the navigation panel buttons starting from page 1. */ +export async function verifyControls(screen: Screen): Promise { + await screen.getByTestId('next-page-button').tap(); + await expect(screen.getByTestId('pageNumber2')).toBeVisible(); + + await screen.getByTestId('prev-page-button').tap(); + await expect(screen.getByTestId('pageNumber1')).toBeVisible(); + + await screen.getByTestId('start-page-button').tap(); + await expect(screen.getByTestId('pageNumber0')).toBeVisible(); + + await screen.getByTestId('last-page-button').tap(); + await expect(screen.getByTestId('pageNumber9')).toBeVisible(); + + await screen.getByTestId('remove-page-button').tap(); + await expect(screen.getByTestId('pageNumber8')).toBeVisible(); + + await screen.getByTestId('add-page-button').tap(); + await screen.getByTestId('next-page-button').tap(); + await expect(screen.getByTestId('pageNumber9')).toBeVisible(); +} diff --git a/e2e/tests/support/test.ts b/e2e/tests/support/test.ts new file mode 100644 index 00000000..85c54892 --- /dev/null +++ b/e2e/tests/support/test.ts @@ -0,0 +1,40 @@ +import { test } from '@e2e-dev/mobile'; +import { APP_ID } from '../../targets.ts'; + +export { test }; +export { expect } from 'e2e'; +export type { + App, + Locator, + Screen, + ScrollDirection, + TextMatch, +} from 'e2e'; + +const APP_PATHS: Record = { + ios: process.env.E2E_IOS_APP_PATH, + android: process.env.E2E_ANDROID_APK_PATH, +}; + +let appInstalled = false; + +/** + * Registers a beforeEach that installs the E2E_IOS_APP_PATH / + * E2E_ANDROID_APK_PATH build once and relaunches the app, so every test in + * the file starts fresh at the home list. Call it at the top of every test + * file: the engine neither installs nor launches anything on its own, a + * module-level hook in this shared file would only attach to the first file + * evaluated in a realm, and the explicit relaunch terminates first where + * app.open() or app.restart() only resume an app the surface does not know + * is running. State survives the relaunch only when the app persists it + * (the layout direction toggle does). + */ +export function freshAppBeforeEach(): void { + test.beforeEach(async ({ device, platform }) => { + if (!appInstalled && APP_PATHS[platform]) { + await device.installApp(); + appInstalled = true; + } + await device.openApp(APP_ID, { relaunch: true }); + }); +} diff --git a/e2e/tests/tab-view-inside-scroll-view.e2e.ts b/e2e/tests/tab-view-inside-scroll-view.e2e.ts new file mode 100644 index 00000000..eed18cb1 --- /dev/null +++ b/e2e/tests/tab-view-inside-scroll-view.e2e.ts @@ -0,0 +1,37 @@ +import { freshAppBeforeEach, expect, test } from './support/test.ts'; +import { openExample } from './support/app.ts'; + +freshAppBeforeEach(); + +test('TabView inside a ScrollView swipes tabs and scrolls the page', async ({ + app, + screen, +}) => { + await openExample(app, screen, 'TabView inside ScrollView Example'); + const scrollView = screen.getByTestId('tab-view-scroll-view'); + await expect(scrollView).toBeVisible(); + await expect(screen.getByText('First')).toBeVisible(); + await expect(screen.getByText('Second')).toBeVisible(); + await expect(screen.getByText('First Route')).toBeVisible(); + + // The route views extend below the screen, so swipe on the ScrollView, whose + // vertical centre lies inside the TabView pager. + await scrollView.swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByText('Second Route')).toBeVisible(); + + const secondRouteBottom = screen.getByTestId('tab-view-second-route-bottom'); + await screen.scrollUntilVisible(secondRouteBottom, { direction: 'down' }); + await expect(secondRouteBottom).toBeVisible(); + + // Scroll back until the tab bar itself is on screen: stopping at the route + // text can leave the tabs just above the viewport after a long fling. + // TabView stacks two copies of a tab label for its crossfade, so a bare + // getByText is LOCATOR_AMBIGUOUS; both copies share the tab's box. + await screen.scrollUntilVisible(screen.getByText('First').first(), { + direction: 'up', + }); + await expect(screen.getByText('Second Route')).toBeVisible(); + + await screen.getByText('First').first().tap(); + await expect(screen.getByText('First Route')).toBeVisible(); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..f1db36c4 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["es2022"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowImportingTsExtensions": true + }, + "include": ["*.ts", "tests/**/*.ts"] +} diff --git a/package.json b/package.json index 43a59954..fec712e5 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,11 @@ "example:ios": "cd example; bun ios", "example:android:release": "cd example;react-native run-android --mode \"Release\" --appId com.pagerviewexample --active-arch-only", "example:ios:release": "cd example;react-native run-ios --mode \"Release\"", - "maestro:test:android": "bash ./scripts/run-maestro-tests.sh android", - "maestro:test:ios": "bash ./scripts/run-maestro-tests.sh ios", - "maestro:smoke": "maestro test .maestro/smoke-test.yaml", - "maestro:debug": "maestro test --debug-output .maestro/debug-output --flatten-debug-output .maestro/smoke-test.yaml", - "e2e:ios": "bun run example:ios:release && bun run maestro:test:ios", - "e2e:android": "bun run example:android:release && bun run maestro:test:android" + "bootstrap:e2e": "bun install --cwd e2e", + "e2e:test:ios": "cd e2e; bun run test:ios", + "e2e:test:android": "cd e2e; bun run test:android", + "e2e:ios": "bun run --cwd example build:ios && bun run example:ios:release && bun run e2e:test:ios", + "e2e:android": "bun run --cwd example build:android && bun run example:android:release && bun run e2e:test:android" }, "keywords": [ "react-native", @@ -113,7 +112,8 @@ }, "eslintIgnore": [ "node_modules/", - "lib/" + "lib/", + "e2e/" ], "prettier": { "quoteProps": "consistent", diff --git a/scripts/run-maestro-tests.sh b/scripts/run-maestro-tests.sh deleted file mode 100644 index 6af663d7..00000000 --- a/scripts/run-maestro-tests.sh +++ /dev/null @@ -1,262 +0,0 @@ -#!/bin/bash - -# Source https://github.com/stripe/stripe-react-native/blob/master/scripts/run-maestro-tests -set -uo pipefail - -if [ -t 1 ] && [ "${NO_COLOR:-}" != "1" ] && [ "${TERM:-}" != "dumb" ]; then - BOLD=$'\033[1m' - DIM=$'\033[2m' - RESET=$'\033[0m' - BLUE=$'\033[34m' - GREEN=$'\033[32m' - RED=$'\033[31m' - YELLOW=$'\033[33m' -else - BOLD="" - DIM="" - RESET="" - BLUE="" - GREEN="" - RED="" - YELLOW="" -fi - -formatDuration() { - local seconds=$1 - printf '%dm %02ds' "$((seconds / 60))" "$((seconds % 60))" -} - -printDivider() { - printf '%s%s────────────────────────────────────────────────────────────────%s\n' "$DIM" "$BLUE" "$RESET" -} - -printError() { - printf '%sERROR%s %s\n' "$RED$BOLD" "$RESET" "$*" >&2 -} - -trap 'printf "\n%sINTERRUPTED%s Maestro run stopped.\n" "$YELLOW$BOLD" "$RESET"; exit 130' INT TERM - -PLATFORM="" -RETRY_FAILED_TESTS=false -APPID="com.pagerviewexample" -MAX_ATTEMPTS=${MAX_ATTEMPTS:-3} -RETRY_DELAYS=(5 15) -DEVICE_ID=${MAESTRO_DEVICE:-${DEVICE_ID:-}} -SHARD_COUNT=${SHARD_COUNT:-} -SHARD_INDEX=${SHARD_INDEX:-} - -for argument in "$@"; do - case $argument in - ios | android ) - if [ -n "$PLATFORM" ]; then - printError "Only one platform may be passed." - exit 1 - fi - PLATFORM=$argument - ;; - - --retry ) - RETRY_FAILED_TESTS=true - ;; - - *) - printError "Unknown argument '$argument'." - echo "Usage: $0 [--retry]" - exit 1 - ;; - esac -done - -# Validate passed platform -case $PLATFORM in - ios | android ) - ;; - - *) - printError "You must pass either 'android' or 'ios'." - echo "" - exit 1 - ;; -esac - -if { [ -n "$SHARD_COUNT" ] || [ -n "$SHARD_INDEX" ]; } && { [ -z "$SHARD_COUNT" ] || [ -z "$SHARD_INDEX" ]; }; then - printError "Both SHARD_COUNT and SHARD_INDEX must be set to enable sharding." - exit 1 -fi - -if [ -n "$SHARD_COUNT" ]; then - if ! [[ $SHARD_COUNT =~ ^[0-9]+$ ]] || ! [[ $SHARD_INDEX =~ ^[0-9]+$ ]]; then - printError "SHARD_COUNT and SHARD_INDEX must be integers." - exit 1 - fi - - if [ "$SHARD_COUNT" -le 0 ] || [ "$SHARD_INDEX" -lt 0 ] || [ "$SHARD_INDEX" -ge "$SHARD_COUNT" ]; then - printError "SHARD_INDEX must satisfy 0 <= SHARD_INDEX < SHARD_COUNT. Got SHARD_INDEX=$SHARD_INDEX, SHARD_COUNT=$SHARD_COUNT" - exit 1 - fi -fi - -shopt -s nullglob -allTestFiles=( - .maestro/tests/*.yaml - .maestro/issues/*.yaml - .maestro/"$PLATFORM"-only/*.yaml -) - -if [ ${#allTestFiles[@]} -eq 0 ]; then - printError "No Maestro test files found for platform '$PLATFORM'." - exit 1 -fi - -mkdir -p .maestro/debug-output - -testFiles=() -for idx in "${!allTestFiles[@]}"; do - if [ -z "$SHARD_COUNT" ] || [ "$((idx % SHARD_COUNT))" -eq "$SHARD_INDEX" ]; then - testFiles+=("${allTestFiles[$idx]}") - fi -done - -if [ ${#testFiles[@]} -eq 0 ]; then - printError "Shard $SHARD_INDEX/$SHARD_COUNT has no Maestro tests to run." - exit 1 -fi - -failedTests=() -totalTests=${#testFiles[@]} -runStartedAt=$(date +%s) -retryCount=0 - -runTest() { - local file=$1 - local attempt=$2 - local testName - local artifactDir - local maestroCommand - - testName=$(basename "${file%.*}") - if [ "$attempt" -eq 1 ]; then - artifactDir=".maestro/debug-output/$testName" - else - artifactDir=".maestro/debug-output/$testName-retry-$((attempt - 1))" - fi - - maestroCommand=( - maestro test - -p "$PLATFORM" - "$file" - -e APP_ID="$APPID" - --debug-output "$artifactDir" - --flatten-debug-output - ) - - if [ -n "$DEVICE_ID" ]; then - maestroCommand+=(--device "$DEVICE_ID") - fi - - "${maestroCommand[@]}" -} - -runAndReport() { - local file=$1 - local attempt=$2 - local position=$3 - local testName - local startedAt - local finishedAt - local duration - - testName=$(basename "${file%.*}") - startedAt=$(date +%s) - - printf '\n%s▶%s %s[%d/%d]%s %s%s%s' \ - "$BLUE$BOLD" "$RESET" "$DIM" "$position" "$totalTests" "$RESET" "$BOLD" "$testName" "$RESET" - if [ "$attempt" -gt 1 ]; then - printf ' %s(retry %d/%d)%s' "$YELLOW" "$((attempt - 1))" "$((MAX_ATTEMPTS - 1))" "$RESET" - fi - printf '\n%s %s%s\n' "$DIM" "$file" "$RESET" - - if runTest "$file" "$attempt"; then - finishedAt=$(date +%s) - duration=$((finishedAt - startedAt)) - printf '%s✓ PASS%s %s%s%s\n' "$GREEN$BOLD" "$RESET" "$DIM" "($(formatDuration "$duration"))" "$RESET" - return 0 - fi - - finishedAt=$(date +%s) - duration=$((finishedAt - startedAt)) - printf '%s✗ FAIL%s %s%s%s\n' "$RED$BOLD" "$RESET" "$DIM" "($(formatDuration "$duration"))" "$RESET" - return 1 -} - -printDivider -printf '%sMAESTRO TEST RUN%s\n' "$BOLD" "$RESET" -printf ' Platform %s%s%s\n' "$BLUE" "$PLATFORM" "$RESET" -printf ' Tests %s%d%s' "$BOLD" "$totalTests" "$RESET" -if [ -n "$SHARD_COUNT" ]; then - printf ' %s(shard %d/%d)%s' "$DIM" "$((SHARD_INDEX + 1))" "$SHARD_COUNT" "$RESET" -fi -printf '\n' -printf ' Device %s%s%s\n' "$DIM" "${DEVICE_ID:-default}" "$RESET" -printf ' Retries %s%s%s\n' "$DIM" "$([ "$RETRY_FAILED_TESTS" = true ] && printf '%d attempts' "$MAX_ATTEMPTS" || printf 'disabled')" "$RESET" -printf ' Artifacts %s.maestro/debug-output%s\n' "$DIM" "$RESET" -printDivider - -# Run every test once before retrying. This prevents one flaky test from -# delaying the first attempt of every test after it. -for position in "${!testFiles[@]}"; do - file=${testFiles[$position]} - if ! runAndReport "$file" 1 "$((position + 1))"; then - failedTests+=("$file") - fi -done - -if [ "$RETRY_FAILED_TESTS" = true ]; then - for ((attempt = 2; attempt <= MAX_ATTEMPTS && ${#failedTests[@]} > 0; attempt++)); do - delay=${RETRY_DELAYS[$((attempt - 2))]:-120} - retryCount=$((retryCount + ${#failedTests[@]})) - printf '\n%s↻ RETRY%s %d test(s) retrying in %ss (attempt %d/%d)\n' \ - "$YELLOW$BOLD" "$RESET" "${#failedTests[@]}" "$delay" "$attempt" "$MAX_ATTEMPTS" - sleep "$delay" - - retryTests=("${failedTests[@]}") - failedTests=() - - for file in "${retryTests[@]}"; do - for position in "${!testFiles[@]}"; do - [ "${testFiles[$position]}" = "$file" ] && break - done - if ! runAndReport "$file" "$attempt" "$((position + 1))"; then - failedTests+=("$file") - fi - done - done -fi - -runFinishedAt=$(date +%s) -runDuration=$((runFinishedAt - runStartedAt)) -passedTests=$((totalTests - ${#failedTests[@]})) -printDivider -if [ ${#failedTests[@]} -eq 0 ]; then - printf '%s✓ ALL TESTS PASSED%s %d/%d tests in %s' \ - "$GREEN$BOLD" "$RESET" "$passedTests" "$totalTests" "$(formatDuration "$runDuration")" -else - printf '%s✗ TEST RUN FAILED%s %d passed, %d failed, %d total in %s' \ - "$RED$BOLD" "$RESET" "$passedTests" "${#failedTests[@]}" "$totalTests" "$(formatDuration "$runDuration")" -fi -if [ "$retryCount" -gt 0 ]; then - printf ' %s(%d retried)%s' "$DIM" "$retryCount" "$RESET" -fi -printf '\n' - -if [ ${#failedTests[@]} -eq 0 ]; then - exit 0 -else - printf '%sFailed tests:%s\n' "$RED$BOLD" "$RESET" - for file in "${failedTests[@]}"; do - testName=$(basename "${file%.*}") - printf ' %s•%s %s %s(artifacts: .maestro/debug-output/%s)%s\n' \ - "$RED" "$RESET" "$file" "$DIM" "$testName" "$RESET" - done - exit 1 -fi diff --git a/skills-lock.json b/skills-lock.json index 73daae38..166c157c 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,11 +1,11 @@ { "version": 1, "skills": { - "maestro-mobile-testing": { - "source": "tovimx/maestro-mobile-testing-skill", + "e2e": { + "source": "tester-army/e2e", "sourceType": "github", - "skillPath": "SKILL.md", - "computedHash": "950a2e7195f37381249c221df70070856ccaab86d2f161fed1297791dcf6ec92" + "skillPath": "skills/e2e/SKILL.md", + "computedHash": "729dd9bee8b6f144b1d25fcfb7ff6c4fd5e984043a85cc12f07d9c5b569dcf67" } } } diff --git a/tsconfig.json b/tsconfig.json index 7e16836d..8f13fe07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "strict": true, "target": "esnext" }, - "exclude": ["example"] + "exclude": ["example", "e2e"] } From 1daa63680fbd53b7ec3d1f8c1835b0c40a83b09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 1 Oct 2026 14:53:19 +0200 Subject: [PATCH 2/9] test(e2e): drop appInstalled flag, install per relaunch installApp replaces the binary and keeps data, so repeating it when E2E_*_APP_PATH is set (~2s) beats tracking per-device state across realms and pools --- e2e/README.md | 24 +++++++++++++----------- e2e/tests/support/test.ts | 27 ++++++++++++--------------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 6e22faea..8b187e26 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -74,9 +74,8 @@ on four. Remote devices fit the same shape: one pool entry per device. `issue-` where a GitHub issue exists. - `tests/support/test.ts` re-exports `test` and `expect` (import them from there so the engine can change in one place) and exports - `freshAppBeforeEach()`, which every test file calls to install the - `E2E_*_APP_PATH` build once per device and relaunch the app before each - test. + `freshAppBeforeEach()`, which every test file calls to relaunch the app + before each test (installing the `E2E_*_APP_PATH` build first when set). - `tests/support/app.ts` opens an example from the home list and forces the layout direction. - `tests/support/basic-pager.ts` holds the checks shared by the LTR, vertical, @@ -92,14 +91,17 @@ on four. Remote devices fit the same shape: one pool entry per device. the direction it is given, and every RTL describe block calls `restoreLtrAfterEach()`. - The engine neither installs nor launches anything on its own, so every - test file calls `freshAppBeforeEach()` right after its imports: it installs - `E2E_*_APP_PATH` through `device.installApp()` and relaunches the app with - `device.openApp(APP_ID, { relaunch: true })`, so every test starts fresh at - the home list. A module-level hook in `tests/support/test.ts` would attach - only to the first file evaluated in a realm, and `app.open()` or - `app.restart()` resume a running app the surface does not know about. - State shared between tests must be state the app persists (the layout - direction toggle is). + test file calls `freshAppBeforeEach()` right after its imports: it + relaunches the app with `device.openApp(APP_ID, { relaunch: true })`, so + every test starts fresh at the home list. A module-level hook in + `tests/support/test.ts` would attach only to the first file evaluated in a + realm, and `app.open()` or `app.restart()` resume a running app the + surface does not know about. State shared between tests must be state the + app persists (the layout direction toggle is). +- With `E2E_*_APP_PATH` set, the hook runs `device.installApp()` before + every relaunch (~2 s each on a simulator) instead of tracking which device + already has the build: an install replaces the binary and keeps its data, + so repeating it is correct across device pools and realms. - A connected physical iPhone joins the default device pool and fails with `ENGINE_FAILURE` when it is locked or lacks the app. Pin `E2E_DEVICE` to a simulator when a phone is plugged in. diff --git a/e2e/tests/support/test.ts b/e2e/tests/support/test.ts index 85c54892..e0201f30 100644 --- a/e2e/tests/support/test.ts +++ b/e2e/tests/support/test.ts @@ -16,25 +16,22 @@ const APP_PATHS: Record = { android: process.env.E2E_ANDROID_APK_PATH, }; -let appInstalled = false; - /** - * Registers a beforeEach that installs the E2E_IOS_APP_PATH / - * E2E_ANDROID_APK_PATH build once and relaunches the app, so every test in - * the file starts fresh at the home list. Call it at the top of every test - * file: the engine neither installs nor launches anything on its own, a - * module-level hook in this shared file would only attach to the first file - * evaluated in a realm, and the explicit relaunch terminates first where - * app.open() or app.restart() only resume an app the surface does not know - * is running. State survives the relaunch only when the app persists it - * (the layout direction toggle does). + * Registers a beforeEach that relaunches the app, so every test in the file + * starts fresh at the home list. Call it at the top of every test file: the + * engine neither installs nor launches anything on its own, a module-level + * hook in this shared file would only attach to the first file evaluated in + * a realm, and the explicit relaunch terminates first where app.open() or + * app.restart() only resume an app the surface does not know is running. + * With E2E_IOS_APP_PATH / E2E_ANDROID_APK_PATH set, that build is installed + * before every relaunch: the install replaces the binary and keeps its data, + * so it stays correct across device pools and realms without tracking what + * is already on which device. State survives the relaunch only when the app + * persists it (the layout direction toggle does). */ export function freshAppBeforeEach(): void { test.beforeEach(async ({ device, platform }) => { - if (!appInstalled && APP_PATHS[platform]) { - await device.installApp(); - appInstalled = true; - } + if (APP_PATHS[platform]) await device.installApp(); await device.openApp(APP_ID, { relaunch: true }); }); } From 46023ca663d24ca134cce7c32417cdf918596f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 1 Oct 2026 15:43:53 +0200 Subject: [PATCH 3/9] test: port remaining maestro flows, delete .maestro - issue_1096 keyboard shrink and issue_1142 search bar inset flows came in via rebase with no runner left; ported 1:1 to e2e/tests/issues (1142 pinned to ios: the UISearchController inset never applies on Android) - clamp pod deployment targets to 15.1: Xcode 27 rejects RNSVG's 12.4 resource-bundle targets, which RN's post_install skips - verified: ios 13/13, android 12/12 (1083/1096/1098 rechecked on emulator) --- .../issue_1096_keyboard_shrink_repro.yaml | 38 ------------------- .../issue_1142_search_bar_inset_repro.yaml | 36 ------------------ ...ssue_1096_keyboard_shrink_repro_setup.yaml | 17 --------- ...sue_1142_search_bar_inset_repro_setup.yaml | 25 ------------ .../issues/issue-1096-keyboard-shrink.e2e.ts | 31 +++++++++++++++ .../issues/issue-1142-search-bar-inset.e2e.ts | 34 +++++++++++++++++ example/ios/Podfile | 11 ++++++ example/ios/Podfile.lock | 12 +++--- 8 files changed, 82 insertions(+), 122 deletions(-) delete mode 100644 .maestro/issues/issue_1096_keyboard_shrink_repro.yaml delete mode 100644 .maestro/issues/issue_1142_search_bar_inset_repro.yaml delete mode 100644 .maestro/setup/issue_1096_keyboard_shrink_repro_setup.yaml delete mode 100644 .maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml create mode 100644 e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts create mode 100644 e2e/tests/issues/issue-1142-search-bar-inset.e2e.ts diff --git a/.maestro/issues/issue_1096_keyboard_shrink_repro.yaml b/.maestro/issues/issue_1096_keyboard_shrink_repro.yaml deleted file mode 100644 index 6ac972f9..00000000 --- a/.maestro/issues/issue_1096_keyboard_shrink_repro.yaml +++ /dev/null @@ -1,38 +0,0 @@ -appId: com.pagerviewexample -tags: - - ios - - regression ---- -- runFlow: ../setup/issue_1096_keyboard_shrink_repro_setup.yaml - -# With the keyboard open, SwiftUI keyboard avoidance must not shrink the page. -# A shrunk page clips Row 5 and moves its native frame, so the tap misses and -# the counter stays at 0. -- tapOn: - id: 'issue-1096-input' - -- waitForAnimationToEnd: - timeout: 3000 - -- assertVisible: - id: 'issue-1096-last-row' - -- tapOn: - id: 'issue-1096-last-row' - -- assertVisible: 'last-row taps: 1' - -# Dismiss the keyboard deterministically before checking the sheet drops back -# down. `hideKeyboard` alone is unreliable here: on iOS it only performs two -# blind swipes at the screen centre, and this screen has no scroll view with a -# non-default `keyboardDismissMode` under that point, so the keyboard survives. -# A single-line TextInput blurs on submit, so Enter is the reliable dismiss. -- pressKey: Enter - -# No-op once the keyboard is already down; still dismisses natively on Android. -- hideKeyboard - -- tapOn: - id: 'issue-1096-last-row' - -- assertVisible: 'last-row taps: 2' diff --git a/.maestro/issues/issue_1142_search_bar_inset_repro.yaml b/.maestro/issues/issue_1142_search_bar_inset_repro.yaml deleted file mode 100644 index 7837459a..00000000 --- a/.maestro/issues/issue_1142_search_bar_inset_repro.yaml +++ /dev/null @@ -1,36 +0,0 @@ -appId: com.pagerviewexample -tags: - - ios - - regression ---- -- runFlow: ../setup/issue_1142_search_bar_inset_repro_setup.yaml - -# A FlatList inside PagerView on a native-stack screen with a stacked search -# bar must get the same automatic insets as a bare list. The example reports -# the first-row and bottom-marker window positions as "safe-area: pass". -- extendedWaitUntil: - visible: 'safe-area: pass' - timeout: 15000 - -- assertVisible: - id: 'issue-1142-pager-verdict' - -- assertVisible: - id: 'issue-1142-pager-first-row' - -- assertVisible: - id: 'issue-1142-pager-bottom-marker' - -- swipe: - from: - id: 'issue-1142-pager' - start: 90%, 60% - end: 10%, 60% - duration: 500 - -- extendedWaitUntil: - visible: - id: 'issue-1142-second-page' - timeout: 5000 - -- assertVisible: 'Second page' diff --git a/.maestro/setup/issue_1096_keyboard_shrink_repro_setup.yaml b/.maestro/setup/issue_1096_keyboard_shrink_repro_setup.yaml deleted file mode 100644 index 017759ce..00000000 --- a/.maestro/setup/issue_1096_keyboard_shrink_repro_setup.yaml +++ /dev/null @@ -1,17 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp - -# The issue examples sit below the fundamental examples on the home screen. -- scrollUntilVisible: - element: - id: 'Issue #1096 Keyboard Shrink Repro' - direction: DOWN - -- tapOn: - id: 'Issue #1096 Keyboard Shrink Repro' - -- extendedWaitUntil: - visible: - id: 'issue-1096-pager' - timeout: 10000 diff --git a/.maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml b/.maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml deleted file mode 100644 index 7516ada0..00000000 --- a/.maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml +++ /dev/null @@ -1,25 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp - -# The issue examples sit below the fundamental examples on the home screen. -- scrollUntilVisible: - element: - id: 'Issue #1142 Search Bar Inset Repro' - direction: DOWN - -- tapOn: - id: 'Issue #1142 Search Bar Inset Repro' - -- extendedWaitUntil: - visible: - id: 'issue-1142-hub' - timeout: 10000 - -- tapOn: - id: 'issue-1142-open-pager' - -- extendedWaitUntil: - visible: - id: 'issue-1142-pager' - timeout: 10000 diff --git a/e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts b/e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts new file mode 100644 index 00000000..90e39183 --- /dev/null +++ b/e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts @@ -0,0 +1,31 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +test( + 'keyboard avoidance does not shrink the page under the keyboard', + { tags: ['regression', 'issue-1096'] }, + async ({ app, screen }) => { + await openExample(app, screen, 'Issue #1096 Keyboard Shrink Repro'); + await expect(screen.getByTestId('issue-1096-pager')).toBeVisible(); + + // With the keyboard open, a shrunk page clips Row 5 and moves its native + // frame, so the tap misses and the counter stays at 0. + const input = screen.getByTestId('issue-1096-input'); + const lastRow = screen.getByTestId('issue-1096-last-row'); + const counter = screen.getByTestId('issue-1096-counter'); + + await input.tap(); + await expect(lastRow).toBeVisible(); + await lastRow.tap(); + await expect(counter).toHaveText('last-row taps: 1'); + + // A single-line TextInput blurs on submit, so Enter is the reliable + // keyboard dismiss on iOS, which exposes no dismiss key. + await input.press('Enter'); + + await lastRow.tap(); + await expect(counter).toHaveText('last-row taps: 2'); + } +); diff --git a/e2e/tests/issues/issue-1142-search-bar-inset.e2e.ts b/e2e/tests/issues/issue-1142-search-bar-inset.e2e.ts new file mode 100644 index 00000000..145de95c --- /dev/null +++ b/e2e/tests/issues/issue-1142-search-bar-inset.e2e.ts @@ -0,0 +1,34 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +test( + 'FlatList inside the pager gets the search bar safe-area insets', + // The stacked search bar is UISearchController; on Android the screen's own + // measurement reports "safe-area: fail" because the inset never applies. + { tags: ['regression', 'issue-1142'], platforms: ['ios'] }, + async ({ app, screen }) => { + await openExample(app, screen, 'Issue #1142 Search Bar Inset Repro'); + await expect(screen.getByTestId('issue-1142-hub')).toBeVisible(); + + await screen.getByTestId('issue-1142-open-pager').tap(); + await expect(screen.getByTestId('issue-1142-pager')).toBeVisible(); + + // The screen measures the first-row and bottom-marker window positions + // against a bare list and reports the verdict itself. + await expect(screen.getByTestId('issue-1142-pager-verdict')).toHaveText( + 'safe-area: pass', + { timeout: 15_000 } + ); + await expect(screen.getByTestId('issue-1142-pager-first-row')).toBeVisible(); + await expect( + screen.getByTestId('issue-1142-pager-bottom-marker') + ).toBeVisible(); + + await screen + .getByTestId('issue-1142-pager') + .swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByTestId('issue-1142-second-page')).toBeVisible(); + } +); diff --git a/example/ios/Podfile b/example/ios/Podfile index a31e73bd..469f6a7b 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -10,6 +10,17 @@ options = { :bridgeless_enabled => true, :fabric_enabled => true, :hermes_enabled => true, + # Xcode 27 rejects deployment targets below 15.0, and RN's own post_install + # skips resource-bundle targets, so RNSVG's bundles keep the podspec's 12.4. + :post_install => lambda { |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.1 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1' + end + end + end + }, } use_test_app! options diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 0f78b0c9..d2a1060e 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1402,7 +1402,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view (9.0.5): + - react-native-pager-view (9.0.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2458,7 +2458,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d - hermes-engine: 47b57a70ad9e894a55caa7b94b6a43a7a5c82a81 + hermes-engine: 4f427073d4e2955595e07546b6533e2024cfb56a RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12 RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702 @@ -2467,7 +2467,7 @@ SPEC CHECKSUMS: React: e2dc35338068bbd299c66f043ae0d7f25de8499e React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 React-Core: f90d375d3bab515ad00df30605ce1bf02e6db12f - React-Core-prebuilt: f48fd2d0dd563fd1f7beb26cabe8501dafac479f + React-Core-prebuilt: bf04a72f2d7cde411ad022ccf99f0b0a12dd661d React-CoreModules: da4f80202ef954bdfb926ca4c9ac5f685900aa5c React-cxxreact: 1d1d2a28a5f10e4e2e7cf2bfd54dc9c92c68c672 React-debug: 92944dc4d89f56d640e75498266cbde557a48189 @@ -2496,7 +2496,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 1aa9126122d4247ffc24bf9d28d50ce923499a71 React-microtasksnativemodule: d86581169e9bb5bb6f5fc3c5052f890016c1bf21 React-mutationobservernativemodule: 9a0c4e866f1ef2a57acebc902ebacbdf469ae741 - react-native-pager-view: a18f87e19eedc3053afd4150fb52ac5f92f02d64 + react-native-pager-view: 819b08cf1230373af91332cdc704f24b05486d25 react-native-safe-area-context: c1eb308f4b36372a4de4b3bdaa8ed695ec3dd461 React-NativeModulesApple: cc6ec4767844d610e92cc358bd3ea34937438d56 React-networking: a8ce15641ed7775d5b54a9d0d32defc367c2216e @@ -2531,7 +2531,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: a54c0c9b976766e1b6d5e2bb4d1ad0d15913e9e1 ReactCodegen: 9af5798e943781fd2318dab7b60f25337388047c ReactCommon: 3ec2a55999d296af90c24beb66c8a77dc660d3ef - ReactNativeDependencies: 15cf32e2807e82c7dca7ef543c5a095da41dc9e2 + ReactNativeDependencies: 8e144e977b79fb3a243d267cdb1f6ace82809e82 ReactNativeHost: 035cd87713a3b8ce508f37d721fc4433011cc200 ReactTestApp-DevSupport: 754876d5c25e6dd12e321a87d54bf618c71cbbd1 ReactTestApp-Resources: 1bd9ff10e4c24f2ad87101a32023721ae923bccf @@ -2543,6 +2543,6 @@ SPEC CHECKSUMS: SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b -PODFILE CHECKSUM: c21f5b764d10fb848650e6ae2ea533b823c1f648 +PODFILE CHECKSUM: 38b32c1a3d6f4d69695769a7dcfc88c8571587e4 COCOAPODS: 1.15.2 From 734a9a46edbc5fd2de0edd9f071bd42611031344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 1 Oct 2026 15:45:25 +0200 Subject: [PATCH 4/9] ci: address copilot review - trigger platform e2e on src/** and example/src/** changes - typecheck e2e project in the lint workflow - fix stale test count/timings in e2e README --- .github/workflows/android.yml | 2 ++ .github/workflows/ios.yml | 2 ++ .github/workflows/main.yml | 8 ++++++++ e2e/README.md | 6 ++++-- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5a4547c1..7bc5d887 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -7,7 +7,9 @@ on: paths: - '.github/workflows/android.yml' - 'android/**' + - 'src/**' - 'example/android/**' + - 'example/src/**' - 'e2e/**' push: branches: diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 8b1ee3c4..b13fed62 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -7,7 +7,9 @@ on: paths: - '.github/workflows/ios.yml' - 'ios/**' + - 'src/**' - 'example/ios/**' + - 'example/src/**' - 'e2e/**' push: branches: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 68dec58c..c2819708 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,5 +39,13 @@ jobs: - name: Typecheck files run: bun run typescript + - name: Install e2e dependencies + run: bun install + working-directory: e2e + + - name: Typecheck e2e + run: bun run typecheck + working-directory: e2e + - name: Build package run: bun run prepare \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md index 8b187e26..3a668667 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -55,8 +55,10 @@ E2E_DEVICES="iPhone 17,iPhone 16e,iPhone 17 Pro Max,iPhone Air" E2E_IOS_APP_PATH `targets.ts` turns the list into a device pool. The engine boots every device up front, declares one worker per device, and the runner spreads the test -files across them. 13 tests: about 375 s on one simulator, 190 s on two, 130 s -on four. Remote devices fit the same shape: one pool entry per device. +files across them. The suite (13 tests on iOS, 12 on Android) takes about +145 s on one simulator and 395 s on one emulator; a pool divides the files +across its devices. Remote devices fit the same shape: one pool entry per +device. | Variable | Effect | | --- | --- | From 334edce9a12c0ea5874ceec2cd17ce8f401cd35a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 1 Oct 2026 16:40:38 +0200 Subject: [PATCH 5/9] fix: exclude e2e from bob build tsconfig tsconfig.build.json exclude replaces the base exclude, so bob's tsc swept e2e/ (deps only in e2e/node_modules) and prepare failed every CI install --- tsconfig.build.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.build.json b/tsconfig.build.json index 2a21c289..adbca1cf 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig", - "exclude": ["example"] + "exclude": ["example", "e2e"] } From e79c16419825b6e638bf4cd846dbbc88b26e5c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 1 Oct 2026 17:36:52 +0200 Subject: [PATCH 6/9] ci(android): build apk before emulator, release bundle - gradle inside the emulator step starved the 4-vcpu runner: System UI ANRed and the dialog blocked all 12 tests (seen in run artifacts) - assembleRelease x86_64 runs before the AVD boots; emulator step only installs the apk and runs the suite, with hide_error_dialogs 1 - android bundle was --dev true (ios already false): dev-mode JS is slow enough on swiftshader to trigger ANRs - add job timeouts (60m ios, 75m android) --- .github/workflows/android.yml | 13 +++++++++++-- .github/workflows/ios.yml | 1 + example/package.json | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7bc5d887..fa424b1f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,6 +22,7 @@ permissions: jobs: android-build: runs-on: ubuntu-latest + timeout-minutes: 75 concurrency: group: ${{ github.ref }}-android cancel-in-progress: true @@ -48,6 +49,13 @@ jobs: run: bun run build:android working-directory: example + # Built before the emulator exists: Gradle competing with a swiftshader + # emulator for the runner's cores ANRs System UI, and the dialog then + # blocks every test. + - name: Build release APK + run: ./gradlew :app:assembleRelease -PreactNativeArchitectures=x86_64 + working-directory: example/android + - name: AVD cache uses: actions/cache@v4 id: avd-cache @@ -83,7 +91,7 @@ jobs: run: bun install working-directory: e2e - - name: Build and run e2e tests + - name: Run e2e tests uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # v2.34.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -97,7 +105,8 @@ jobs: emulator-boot-timeout: 12000 disable-animations: false script: | - bun example:android:release + adb shell settings put global hide_error_dialogs 1 + adb install -r example/android/app/build/outputs/apk/release/app-release.apk bun run e2e:test:android - name: Upload e2e report diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index b13fed62..cb8f5827 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -22,6 +22,7 @@ permissions: jobs: ios-build: runs-on: macos-26 + timeout-minutes: 60 concurrency: group: ${{ github.ref }}-ios cancel-in-progress: true diff --git a/example/package.json b/example/package.json index 064bcd7c..21addd3c 100644 --- a/example/package.json +++ b/example/package.json @@ -11,7 +11,7 @@ "android": "react-native run-android --appId com.pagerviewexample", "ios": "react-native run-ios", "visionos": "react-native run-visionos", - "build:android": "bun mkdist && react-native bundle --entry-file index.js --platform android --dev true --bundle-output dist/main.android.jsbundle --assets-dest dist/res", + "build:android": "bun mkdist && react-native bundle --entry-file index.js --platform android --dev false --minify true --bundle-output dist/main.android.jsbundle --assets-dest dist/res", "build:ios": "bun mkdist && react-native bundle --entry-file index.js --platform ios --dev false --bundle-output dist/main.ios.jsbundle --assets-dest dist", "build:visionos": "bun mkdist && react-native bundle --entry-file index.js --platform ios --dev true --bundle-output dist/main.visionos.jsbundle --assets-dest dist" }, From 7fb15d157366c8446cf07d9fc325cdcc281b8e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 1 Oct 2026 20:04:31 +0200 Subject: [PATCH 7/9] test: fix CI-only e2e failures found on runner-sized devices - RTL restore raced I18nManager's async pref write: the toggle now reflects the pending direction via state, and the helper waits for the flip before the relaunch's force-stop - nested pager: swipe the inner pagers by their own testIDs with a slow drag; Android hands a fast fling on the shared horizontal axis to the outer pager (seen as landing on 3-rd-pager-view) - tab-view: scroll the ScrollView node, not the viewport; on 1920px screens the viewport centre lands inside the TabView pager - issue-1096 pinned ios-only like the original Maestro flow (Android adjustResize shrinks the window by design); refocus input before Enter - ios.yml detaches the simulator hardware keyboard: Simulator.app's default hides the software keyboard, leaving keyboardReturn nothing to press verified: ios 13/13 (2m26s), android 11/11 (4m01s) at CI's 1080x1920 --- .github/workflows/ios.yml | 5 +++ .../issues/issue-1096-keyboard-shrink.e2e.ts | 10 +++-- e2e/tests/nested-pager-view.e2e.ts | 25 +++++++------ e2e/tests/support/app.ts | 6 +++ e2e/tests/tab-view-inside-scroll-view.e2e.ts | 7 +++- example/src/App.tsx | 37 ++++++++++++------- example/src/NestedPagerView.tsx | 7 +++- 7 files changed, 66 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index cb8f5827..bfaee7dc 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -56,6 +56,11 @@ jobs: run: bun pods working-directory: example + # Simulator.app attaches the Mac keyboard by default, which hides the + # software keyboard and leaves no Return key for the keyboard tests. + - name: Detach hardware keyboard from simulator + run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false + - name: Build iOS App run: | bun example:ios:release diff --git a/e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts b/e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts index 90e39183..6513530b 100644 --- a/e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts +++ b/e2e/tests/issues/issue-1096-keyboard-shrink.e2e.ts @@ -5,7 +5,10 @@ freshAppBeforeEach(); test( 'keyboard avoidance does not shrink the page under the keyboard', - { tags: ['regression', 'issue-1096'] }, + // iOS-only, like the original Maestro flow: the bug is SwiftUI keyboard + // avoidance, while Android's adjustResize shrinks the window by design and + // covers the last row on short screens. + { tags: ['regression', 'issue-1096'], platforms: ['ios'] }, async ({ app, screen }) => { await openExample(app, screen, 'Issue #1096 Keyboard Shrink Repro'); await expect(screen.getByTestId('issue-1096-pager')).toBeVisible(); @@ -21,8 +24,9 @@ test( await lastRow.tap(); await expect(counter).toHaveText('last-row taps: 1'); - // A single-line TextInput blurs on submit, so Enter is the reliable - // keyboard dismiss on iOS, which exposes no dismiss key. + // iOS exposes no dismiss key, so submit through the input, refocused + // first because the row tap can steal focus. + await input.tap(); await input.press('Enter'); await lastRow.tap(); diff --git a/e2e/tests/nested-pager-view.e2e.ts b/e2e/tests/nested-pager-view.e2e.ts index a6886df2..d6985c15 100644 --- a/e2e/tests/nested-pager-view.e2e.ts +++ b/e2e/tests/nested-pager-view.e2e.ts @@ -15,21 +15,22 @@ test('nested horizontal and vertical pagers scroll independently', async ({ await outerPager.swipe({ direction: 'right', momentum: 'fast' }); await expect(screen.getByText('Horizontal page number 0')).toBeVisible(); - await screen - .getByTestId('pager-view-content') - .filter({ hasText: 'Horizontal page number 0' }) - .swipe({ direction: 'right', momentum: 'fast' }); + // Swipe the inner pagers by their own nodes: a swipe on a page content + // node of a small nested pager can fling without changing the page. + const innerHorizontal = screen.getByTestId('nested-horizontal-pager'); + const innerVertical = screen.getByTestId('nested-vertical-pager'); + + // A slow drag, not a fling: inner and outer pager share the horizontal + // axis, and Android hands a fast fling to the outer one at times. + await innerHorizontal.swipe({ direction: 'right', momentum: 'slow' }); await expect(screen.getByText('Horizontal page number 1')).toBeVisible(); + await expect(screen.getByText('Vertical page number 0')).toBeVisible(); - await screen - .getByTestId('pager-view-content') - .filter({ hasText: 'Vertical page number 0' }) - .swipe({ direction: 'down', momentum: 'fast' }); + await innerVertical.swipe({ direction: 'down', momentum: 'fast' }); await expect(screen.getByText('Vertical page number 1')).toBeVisible(); + await expect(screen.getByText('Horizontal page number 1')).toBeVisible(); - await screen - .getByTestId('pager-view-content') - .filter({ hasText: 'Horizontal page number 1' }) - .swipe({ direction: 'right', momentum: 'fast' }); + // At its last page the inner pager hands the gesture to the outer one. + await innerHorizontal.swipe({ direction: 'right', momentum: 'fast' }); await expect(screen.getByTestId('3-rd-pager-view')).toBeVisible(); }); diff --git a/e2e/tests/support/app.ts b/e2e/tests/support/app.ts index 4175b720..710c19c7 100644 --- a/e2e/tests/support/app.ts +++ b/e2e/tests/support/app.ts @@ -48,6 +48,12 @@ export async function ensureLayoutDirection( const toggle = screen.getByTestId(`layout-direction-${opposite}`); if (await toggle.isVisible()) { await toggle.tap(); + // The toggle reflects the pending direction at once; waiting for the flip + // keeps the relaunch's force-stop from racing I18nManager's async + // preference write on a loaded Android emulator. + await expect( + screen.getByTestId(`layout-direction-${direction}`) + ).toBeVisible(); await app.restart(); await waitForHome(screen); } diff --git a/e2e/tests/tab-view-inside-scroll-view.e2e.ts b/e2e/tests/tab-view-inside-scroll-view.e2e.ts index eed18cb1..4b48e775 100644 --- a/e2e/tests/tab-view-inside-scroll-view.e2e.ts +++ b/e2e/tests/tab-view-inside-scroll-view.e2e.ts @@ -19,15 +19,18 @@ test('TabView inside a ScrollView swipes tabs and scrolls the page', async ({ await scrollView.swipe({ direction: 'right', momentum: 'fast' }); await expect(screen.getByText('Second Route')).toBeVisible(); + // Scroll the ScrollView node, not the viewport: on a short screen the + // viewport centre lands inside the TabView pager, which can swallow the + // gesture. const secondRouteBottom = screen.getByTestId('tab-view-second-route-bottom'); - await screen.scrollUntilVisible(secondRouteBottom, { direction: 'down' }); + await scrollView.scrollUntilVisible(secondRouteBottom, { direction: 'down' }); await expect(secondRouteBottom).toBeVisible(); // Scroll back until the tab bar itself is on screen: stopping at the route // text can leave the tabs just above the viewport after a long fling. // TabView stacks two copies of a tab label for its crossfade, so a bare // getByText is LOCATOR_AMBIGUOUS; both copies share the tab's box. - await screen.scrollUntilVisible(screen.getByText('First').first(), { + await scrollView.scrollUntilVisible(screen.getByText('First').first(), { direction: 'up', }); await expect(screen.getByText('Second Route')).toBeVisible(); diff --git a/example/src/App.tsx b/example/src/App.tsx index 8275475a..99818f85 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -211,6 +211,29 @@ function App() { ); } +/** + * Flips the layout direction. The button reflects the pending direction right + * away, so automation can wait for the flip before relaunching: the direction + * itself only applies on the next launch in release builds, and killing the + * app straight after the press can race I18nManager's async preference write. + */ +function LayoutDirectionToggle() { + const [isRTL, setIsRTL] = React.useState(I18nManager.getConstants().isRTL); + return ( +