diff --git a/.agents/skills/aventuras-plan-slice/SKILL.md b/.agents/skills/aventuras-plan-slice/SKILL.md index 24f2884b..0e598236 100644 --- a/.agents/skills/aventuras-plan-slice/SKILL.md +++ b/.agents/skills/aventuras-plan-slice/SKILL.md @@ -30,10 +30,11 @@ You MUST create a task for each of these items and complete them in order: 1. **Read the slice doc** — and its parent milestone doc and `docs/implementation/conventions.md` 2. **Resolve required reading** — open every Required-reading anchor at its named section, not just the file 3. **Surface open questions** — the slice doc's Open questions section, plus any underspecified seam you find while reading -4. **Classify each question** — developer-decision, implementer-choice, monitor-during-work, or blocker -5. **Resolve developer-decisions** — one question at a time, with the developer -6. **Check slice size and scope** — if the slice is too large for one PR, or its scope is wrong, stop and recommend an amendment -7. **Transition to planning** — invoke aventuras-writing-plans +4. **Decide E2E coverage** — does the slice change a cross-subsystem seam or user-facing flow? If so, the plan must carry an E2E test + its run as a verification command (see Deciding E2E coverage) +5. **Classify each question** — developer-decision, implementer-choice, monitor-during-work, or blocker +6. **Resolve developer-decisions** — one question at a time, with the developer +7. **Check slice size and scope** — if the slice is too large for one PR, or its scope is wrong, stop and recommend an amendment +8. **Transition to planning** — invoke aventuras-writing-plans ## Process Flow @@ -42,6 +43,7 @@ digraph planning { "Read slice doc + milestone + conventions" [shape=box]; "Resolve required-reading anchors" [shape=box]; "Surface open questions" [shape=box]; + "Decide E2E coverage" [shape=box]; "Classify each question" [shape=box]; "Slice too large or scope wrong?" [shape=diamond]; "Recommend slice amendment, stop" [shape=box]; @@ -53,7 +55,8 @@ digraph planning { "Read slice doc + milestone + conventions" -> "Resolve required-reading anchors"; "Resolve required-reading anchors" -> "Surface open questions"; - "Surface open questions" -> "Classify each question"; + "Surface open questions" -> "Decide E2E coverage"; + "Decide E2E coverage" -> "Classify each question"; "Classify each question" -> "Slice too large or scope wrong?"; "Slice too large or scope wrong?" -> "Recommend slice amendment, stop" [label="yes"]; "Slice too large or scope wrong?" -> "Needs a canonical spec change?" [label="no"]; @@ -82,6 +85,15 @@ digraph planning { - Add what you find while reading: an acceptance criterion with no clear verification path, a module seam the slice doc doesn't pin, a type or interface the slice needs but no doc defines, behavior the canonical spec leaves ambiguous. - An open question is anything an autonomous coding pass would otherwise resolve silently by guessing. +**Deciding E2E coverage:** + +The E2E layer ([`docs/testing.md`](../../../docs/testing.md)) is expensive and targeted — it exists for the cross-subsystem seams, not for logic or rendering. Decide whether this slice needs one: + +- **Warrants an E2E test** when the slice changes a seam only a running app exercises: renderer↔main IPC, migrations, the `app://` protocol, a native module, a generation pipeline, or a user-facing flow end-to-end (a reader turn, the wizard, story open). +- **Does not** when the slice is `lib/*` logic (unit-tested), a component (Storybook), or docs — the default for most slices. + +When it warrants one, the plan MUST carry an E2E task (write the spec against the harness, reusing `e2e/locators` + `e2e/flows`; drive the UI, assert through the DB bridge) and `pnpm test:e2e` as a named verification command. Size the coverage per [`docs/testing.md` → Coverage](../../../docs/testing.md#coverage-thorough-not-exhaustive): thorough, not exhaustive — the flow's happy path, its common alternative flows, and the common edge cases that cross a seam; leave pure-logic branches to unit. New selector needs go in as `testID`s per the doc's tier rule, co-designed with the UI work — not bolted on later. + **Classifying questions:** Use the taxonomy from `docs/implementation/conventions.md` → Slice planning. That doc is the source of truth; follow it if it changes. diff --git a/.agents/skills/aventuras-test-driven-development/SKILL.md b/.agents/skills/aventuras-test-driven-development/SKILL.md index 2f4be2ea..701a1161 100644 --- a/.agents/skills/aventuras-test-driven-development/SKILL.md +++ b/.agents/skills/aventuras-test-driven-development/SKILL.md @@ -31,6 +31,8 @@ Write the test first. Watch it fail. Write minimal code to pass. For a carve-out, the slice doc's Tests section and the execution plan's evidence matrix name the evidence that stands in for a test — a typecheck, a lint, a Storybook story, a manual smoke. Test coverage is calibrated to the slice's risk profile, per `docs/implementation/conventions.md`. +Behavior that only a running app exercises — cross-subsystem seams and user-facing flows — is the **E2E layer**'s domain ([`docs/testing.md`](../../../docs/testing.md)), the third test form beside unit and Storybook. A slice that warrants one carries it as a planned task (decided at plan time); drive the UI, assert through the DB. + If your work is behavior-bearing and you're thinking "skip TDD just this once" — Stop. That's rationalization. The carve-outs above are the ONLY exceptions. ## Project code conventions diff --git a/.agents/skills/aventuras-verification-before-completion/SKILL.md b/.agents/skills/aventuras-verification-before-completion/SKILL.md index 205d3364..5735f43b 100644 --- a/.agents/skills/aventuras-verification-before-completion/SKILL.md +++ b/.agents/skills/aventuras-verification-before-completion/SKILL.md @@ -50,6 +50,7 @@ Skip any step = lying, not verifying | Build succeeds | Build command: exit 0 | Linter passing, logs look good | | Bug fixed | Test original symptom: passes | Code changed, assumed fixed | | Regression test works | Red-green cycle verified | Test passes once | +| Flow works end-to-end | `pnpm test:e2e`: 0 failures | Unit + Storybook green | | Agent completed | VCS diff shows changes | Agent reports "success" | | Requirements met | Line-by-line checklist | Tests passing | diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000..22d93a88 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,57 @@ +--- +paths: + - 'e2e/**' + - '**/*.spec.ts' + - '**/*.test.{ts,tsx}' + - '.claude/rules/**' +--- + +# Testing rules + +Project-scoped rules for test work. Auto-loads when Claude reads or +writes anything under `e2e/`, a `*.spec.ts`, or a `*.test.{ts,tsx}`. +The full spec lives in [`docs/testing.md`](../../docs/testing.md) — +that's the source of truth for what / how / where; this file adds +operational reminders for AI-assisted edits. + +## Which layer + +Put a test in the cheapest layer that can catch its regression: +unit (`lib/*` logic), component (Storybook stories), or E2E (seams — +IPC, migrations, `app://`, native modules, full pipelines). E2E +**drives through the UI and asserts against the database** — it does +not re-verify rendering or logic. See +[testing.md → Test taxonomy](../../docs/testing.md#test-taxonomy). + +## E2E is desktop-only + +Playwright + Electron, packaged build as the target of record. +Android is out of scope; web-only E2E is impossible (no preload → no +DB bridge → "settings corrupted"). See +[testing.md → E2E target: desktop only](../../docs/testing.md#e2e-target-desktop-only). + +## Fixtures build at test time + +Seed into a fresh temp `userData` per run via `--user-data-dir`; +nothing binary in git. **Seeded IDs under a substitutable prefix must +be real `prefix_`** (deterministic UUIDv5 from the mnemonic) or +turns fail on the placeholder return trip — mnemonic IDs like +`char_kael` pass `substituteIds` untouched and break silently. See +[testing.md → Fixture + seed contract](../../docs/testing.md#fixture--seed-contract). + +## No `__DEV__` dependence; no `stub` provider + +`__DEV__` is `true` locally and `false` in the packaged build, and +the `stub` provider throws when it's false. Mock the LLM with the +local HTTP server + a seeded `openai-compatible` provider. See +[testing.md → Mock LLM](../../docs/testing.md#mock-llm). + +## Selectors: DB first, then i18n role/name, then testID + +Prefer a DB assertion; else role + accessible name resolved through +the **same i18n key** the app uses (never hardcoded English); add a +`testID` only for scope anchors, no-role targets, non-unique icon +labels, and virtualized items — per flow, no repo-wide retrofit. +Compose from `e2e/locators/` and `e2e/flows/`; never inline a raw +selector string. See +[testing.md → Selector strategy](../../docs/testing.md#selector-strategy). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdd3aed4..feadcfd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,3 +88,63 @@ jobs: - name: Run story tests run: pnpm test:run + + e2e: + name: E2E (Playwright + Electron, packaged) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Electron binary + builder downloads (~100 MB); cache across runs, keyed + # on the lockfile so a version bump busts it. + - name: Restore Electron cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/electron + ~/.cache/electron-builder + key: electron-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Build web bundle + run: pnpm build:web + + - name: Compile Electron main + run: pnpm electron:compile + + # --dir skips AppImage/deb compression but still produces the asar, + # unpacked native modules, and extraResources migrations the packaged + # tier exercises (docs/testing.md → CI). + - name: Package desktop (unpacked) + run: pnpm exec electron-builder --linux --dir + + # Electron needs Chromium's shared libraries; install-deps provides them + # (and xvfb) without downloading a browser. + - name: Install system libraries for Electron + run: pnpm exec playwright install-deps chromium + + # Fixture embedder model (~24 MB from Hugging Face); the harness downloads + # it on a cache miss. Keyed on the catalog so a model/revision bump busts. + - name: Restore embedder model cache + uses: actions/cache@v4 + with: + path: ~/.cache/aventuras-e2e + key: e2e-embedder-${{ hashFiles('lib/embedder/catalog-data.json') }} + + # Electron has no true headless mode on Linux — run under a virtual + # display. The packaged project launches the built binary; the harness + # seeds a throwaway userData per run. + - name: Run E2E (packaged, headless via xvfb) + run: xvfb-run -a pnpm test:e2e:packaged diff --git a/.gitignore b/.gitignore index c17a5c6e..3186b547 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,12 @@ app-example /electron/dist /release +# Playwright (E2E) +/test-results +/playwright-report +/blob-report +/.playwright + # IDE stuff .vscode/ .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index b35782ac..0d9278e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,10 @@ vars, no BaaS. translation, retrieval invariants. - [`docs/code-conventions.md`](./docs/code-conventions.md) — code conventions: module structure, state placement, action layer, - component taxonomy, i18n, testing, forms, pnpm. + component taxonomy, i18n, unit/component testing, forms, pnpm. +- [`docs/testing.md`](./docs/testing.md) — the E2E layer: + Playwright/Electron harness, fixture/seed contract, mock LLM, + selector strategy; when a slice warrants an E2E test. - [`docs/generation-pipeline.md`](./docs/generation-pipeline.md) — pipeline framework: phases, orchestrator, action layer, event bus, transactions, concurrency model. @@ -156,6 +159,10 @@ file is symlinked from `AGENTS.md`, so non-Claude agents that read (commenting discipline, import-wildcard ban with rn-primitives exception). Applies to any `app/**`, `components/**`, `hooks/**`, `lib/**`, `types/**`, `electron/**`, or `scripts/**` work. +- [`testing.md`](./.claude/rules/testing.md) — test-layer rules + (which layer to use, desktop-only E2E, fixture/seed contract, + DB-first selectors). Applies to any `e2e/**`, `*.spec.ts`, or + `*.test.{ts,tsx}` work. Code work also draws on [`docs/implementation/lessons-learned/`](./docs/implementation/lessons-learned/README.md) diff --git a/docs/README.md b/docs/README.md index 307c1fec..ff226d9c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,3 +53,7 @@ implementation, and the open questions tracked across the project. - **[code-conventions.md](./code-conventions.md)** — code conventions: module structure (`lib/*` public-API rule), state placement, action layer, component taxonomy, i18n, testing, forms, pnpm and patches. +- **[testing.md](./testing.md)** — the E2E layer: Playwright + Electron + target, harness structure, fixture/seed contract, embedder seeding, + mock LLM, and the DB-first selector strategy. Unit + component + discipline stays in code-conventions.md. diff --git a/docs/code-conventions.md b/docs/code-conventions.md index 897708b3..a55d8abf 100644 --- a/docs/code-conventions.md +++ b/docs/code-conventions.md @@ -200,6 +200,10 @@ bar). Coverage settings live exclusively in `vitest.config.ts` — CLI `--coverage.*` dot-overrides crash the storybook project's preset loader, so never pass them; change the config instead. +End-to-end (Playwright + Electron) coverage of the cross-subsystem +seams is a separate layer with its own spec: +[`testing.md`](./testing.md). + ## Forms Input clusters with a submit button use `react-hook-form` — multi diff --git a/docs/implementation/milestones/03-memory-floor/slices/01b-embedder-lifecycle.md b/docs/implementation/milestones/03-memory-floor/slices/01b-embedder-lifecycle.md index 444e4478..3b30ed1e 100644 --- a/docs/implementation/milestones/03-memory-floor/slices/01b-embedder-lifecycle.md +++ b/docs/implementation/milestones/03-memory-floor/slices/01b-embedder-lifecycle.md @@ -179,31 +179,6 @@ Zod, and `stories.settings.effectiveDim` is locked at creation. without it, and no per-row deletion path (entity, lore, happening, thread, chapter) calls it at all — so whichever slice introduces one inherits this question. Surfaced by M3.1a review (2026-07-21). -- **Embedding-model install fails its smoke test in packaged desktop - builds.** Reported on a packaged Linux build (2026-07-22): the - download itself completes, then the dialog shows - `embedder:failure.smokeTestHint` ("The files are intact but this - device couldn't run the model"). So this is the post-download - inference check, not the transfer. The path is - `embedder-download-dialog.tsx` calling `driver.smokeTestEmbed`, the - `smokeTest` IPC, then `getPipeline` in - `electron/embedder/service.ts`, whose `buildPipeline` opens with a - dynamic import of `@huggingface/transformers`. The reporter's - initial "ONNX runtime packaging" hypothesis is **falsified** by - inspecting the artifact: `@huggingface/transformers` is present - inside `app.asar`, and `onnxruntime-node` is correctly listed in - `asarUnpack` and present under `app.asar.unpacked` with the - `linux/x64` `onnxruntime_binding.node` and `libonnxruntime.so.1` in - place. Live leads instead: transformers.js v3 may select its wasm - backend inside the Electron main process and then fail to read - `ort-wasm-simd-threaded.jsep.wasm` from inside the asar, or the - `modelDir` handed to the pipeline may not resolve under packaging - with `allowRemoteModels` disabled. First step is probably to surface - the real error: the failure envelope carries a message the dialog - collapses into generic copy, and the logger is gated off by default, - so nothing reaches the user or diagnostics. Not reproduced in dev; - nothing beyond the artifact inspection is verified. Surfaced by - M3.11 review (2026-07-22). ## Implementation notes diff --git a/docs/implementation/triage.md b/docs/implementation/triage.md index 058e1459..b2900595 100644 --- a/docs/implementation/triage.md +++ b/docs/implementation/triage.md @@ -319,3 +319,38 @@ slice-planning gate forces its resolution before that slice is planned. [`parked.md → Time-advance selection at user-entry submit`](../parked.md#time-advance-selection-at-user-entry-submit) — so the sentence should carry the anchor. Small canonical edit; fold into the next cleanup pass touching that section. + +- **A profile's `structuredOutput: 'force-on'` never reaches the + provider.** The flag exists on the profile schema + (`modelProfileSchema.structuredOutput`, `auto | force-on | force-off`) + and round-trips through the DB, but has no UI to set it (DB-only until + the settings editing surface lands) — and even when set, + `createProviderModel` (`lib/ai/providers.ts`) never passes + `supportsStructuredOutputs` to `createOpenAICompatible`, so a force-on + structured call emits `response_format: { type: 'json_object' }` with + no schema on the wire (and force-on skips prompt-injection, so no + schema reaches the model at all). To wire it: thread the resolved + profile's `structuredOutput` into provider creation and set + `supportsStructuredOutputs` when force-on **and** the endpoint supports + `json_schema` (capability-gate — most openai-compatible / local + endpoints don't); the structured schemas then also need + `optional`→`nullable` to satisfy strict json_schema (the classifier's + `currentLocation?` / `summary?`). Low priority — prod and E2E rely on + the prompt-embedded (auto) path; `e2e/tests/structured-force-on.spec.ts` + pins current behavior and flags the change if the flag is wired. + Surfaced by the M3 E2E harness work (2026-07-24). + +- **E2E suite sits at the happy-path core; backfill to the "thorough" + bar.** The harness and the eight specs cover one representative path + per seam (home, embedder, wizard-create, turn, classifier, force-on), + but [`docs/testing.md → Coverage`](../testing.md#coverage-thorough-not-exhaustive) + sets the bar at thorough — common alternative flows and common + seam-crossing edge cases too. Known gaps on flows that already exist: + creative-mode create (no lead / no embed), regenerate, undo, + resume-draft, open-existing, composer modes; and the edge cases — + generation-failure → retry surface, cancel mid-turn, + embedder-gate-blocked wizard, opening-only-branch turn, settings-corrupt + recovery. New seam-touching slices meet the bar at plan time (the + plan-slice gate); this queues the backfill for the pre-existing flows + so it isn't assumed done. Surfaced by the M3 E2E harness work + (2026-07-24). diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..c9864448 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,322 @@ +# Testing + +How Aventuras is tested across layers — what each layer owns, and +the end-to-end (E2E) harness that drives the real desktop app. + +The **unit + component** disciplines (what to unit-test, the +Storybook/Playwright story tests, coverage posture) are specified in +[`code-conventions.md → Testing discipline`](./code-conventions.md#testing-discipline). +This doc is the source of truth for the **E2E** layer: its target, +harness structure, fixture contract, and selector strategy. + +## Test taxonomy + +Three layers, each owning a distinct failure class. A test belongs +to the cheapest layer that can catch its regression. + +| Layer | Runner | Owns | +| ------------- | ----------------------------- | ----------------------------------------------------------------------------- | +| **Unit** | Vitest (`unit` project, node) | `lib/*` logic, pure functions, reducers, state machines, parsers | +| **Component** | Vitest + Playwright (browser) | Story-driven render + interaction of `components/**` in isolation | +| **E2E** | Playwright + Electron | The seams: IPC, migrations, `app://` protocol, native modules, full pipelines | + +The E2E layer's unique value is the **seams between subsystems** — +renderer ↔ main IPC, on-disk SQLite with `node:sqlite` + `sqlite-vec`, +the embedder loading a real ONNX model, a pipeline run committing +deltas. It does **not** re-verify rendering (Storybook owns that) or +logic (unit owns that). The rule of thumb: **drive through the UI, +assert against the database.** + +## Coverage: thorough, not exhaustive + +E2E is **broad across flows, shallow within each** — it proves the +seams hold under realistic use, not that every branch is correct. + +**In scope — write these:** + +- **Every happy path.** The main success route through each flow — + create a story, take a turn, install + embed, browse, classify. +- **Common alternative flows.** The routes real users hit often: + creative-mode story (no lead, no embed), regenerate / undo a turn, + open an existing story, resume a draft, the composer modes. +- **Common edge cases _that cross a seam_.** The boundary and failure + states only a running app reaches: a generation failure surfacing + the retry path, cancel mid-turn, the embedder-unavailable gate + blocking the wizard, a turn on an opening-only branch. + +**Out of scope — leave to the cheaper layers, or skip:** + +- **Pure-logic branches and parser edge cases** → unit (malformed + classifier output, id-substitution failures). Don't re-drive them + through the packaged app. +- **Rendering variations** → Storybook. +- **Rare / pathological permutations.** Exhaustive enumeration in the + slowest layer isn't worth the wall-clock. + +**The boundary test:** an edge case earns an E2E test when it is a +flow/seam behavior the cheaper layers _can't_ reach. If unit or +Storybook can catch it, it lives there. No coverage threshold — +calibrate to how common the path is and whether a running app is the +only place the behavior is observable. + +## E2E target: desktop only + +E2E runs against **Electron**, packaged. Two platform decisions are +settled and load-bearing: + +- **Android is out of scope.** The single-document reader pivot + (see [`ui/patterns/reader-document.md`](./ui/patterns/reader-document.md)) + collapsed the reader onto one shared web document across platforms, + which shrank the Android-only surface to the `expo-sqlite` driver, + `onnxruntime-react-native`, and gesture/keyboard behavior. Those + are covered by targeted checks, not a full E2E suite. Revisit only + if an Android-specific regression class emerges. +- **Web-only E2E is impossible.** The plain web bundle has no + Electron preload, so `window.aventurasDb` is absent and the very + first `app_settings` write fails — the app degrades to a + "settings corrupted" screen. There is no database to test against. + E2E therefore always launches real Electron main; only _where the + renderer HTML is served from_ varies between local and CI (below). + +### Launch modes + +| Mode | Renderer source | Electron main | Used for | +| --------- | --------------------------------------- | ------------- | ---------------------------------- | +| **Local** | `expo start --web` dev server | unpackaged | Authoring tests; hot reload | +| **CI** | `electron-builder --linux --dir` bundle | packaged | The suite of record; real `app://` | + +The packaged build is the target of record because it is the only +mode that exercises `app://bundle` protocol handling, asar packing, +the `asarUnpack` native modules (`sqlite-vec`, `onnxruntime-node`), +and the `extraResources` migrations — the code paths that break in +production and nowhere else. + +Two launch gotchas the harness must handle: + +- **`firstWindow()` is unreliable in unpackaged/dev mode.** Dev-mode + `electron/main.ts` opens a detached DevTools window that races the + app window. Select the app window by URL prefix, not by first-open + order. (Packaged mode has no DevTools window, so `firstWindow()` is + safe there — but the harness selects by URL uniformly.) +- **`__DEV__` differs by mode.** It is `true` under the dev server + and `false` in the packaged bundle. Tests must never depend on it — + in particular the `stub` provider (`lib/ai/providers.ts`) throws + when `__DEV__` is false, so it is unavailable to E2E. Use the mock + LLM server instead (below). + +## Harness structure + +E2E lives under `e2e/`, structured so tests compose reusable pieces +and never hand-write a selector string or a launch sequence. + +``` +e2e/ + harness/ + launch.ts electron.launch, --user-data-dir, window-by-URL, app.evaluate helpers + seed.ts build a temp userData: seed → temp DB, copy fixture embedder model + db.ts typed DB assertions via app.evaluate (entries, deltas, vec rows) + i18n.ts boot i18next once; t(key, vars) → resolved accessible-name strings + mock-llm.ts local HTTP server; a seeded openai-compatible provider points at it + locators/ role/name + scope-anchor Locator factories, one file per surface + flows/ reusable multi-step drivers (e.g. create-story-via-wizard) + tests/ *.spec.ts — compose flows + locators + db assertions +``` + +`locators/` and `flows/` are the reusable-selector layer: a copy +change or a new locale propagates in one place, and a renamed i18n +key fails loudly rather than silently missing an element. Specs +import from `locators/` and `flows/`; they do not construct raw +selectors inline. + +## Fixture + seed contract + +Fixtures are **built at test time**, not checked in. Per run (per +worker), the harness creates a fresh temp `userData` dir, runs the +seed dataset into `/aventuras.db`, and copies a fixture +embedder model into `/embedders/` (below). `--user-data-dir` +points Electron at it, giving isolation and seeding for free — +`getDbFilePath()` resolves under `userData` (`electron/db/service.ts`), +and Electron honors the Chromium switch. Nothing binary lands in git, +and the fixture always matches the current migrations. + +### Substitutable IDs must be real UUIDs + +Why the fixture can't use mnemonic IDs. During a turn, `substituteIds` +(`lib/ids/substitute.ts`) walks the generation context and replaces +every entity ID that matches `ID_PATTERN` — `prefix_` for +the prefixes in `SUBSTITUTABLE_PREFIXES` (`lib/ids/prefixes.ts`) — with +a compact placeholder, so the model sees `c1` not a UUID. The +classifier / piggyback layer maps the placeholders back to UUIDs on +the return trip. + +A mnemonic ID like `char_kael` does **not** match `ID_PATTERN`, so +`substituteIds` passes it through untouched and no placeholder is +allocated. Nothing errors at context-build time — which is why +browsing seeded data looks fine — but the return trip has no +placeholder to resolve and the turn fails with a malformed +placeholder. + +**Contract:** every seeded ID under a substitutable prefix is a real +`prefix_` value. `buildSeedSteps` (`lib/db/devtools/seed-dataset.ts`) +authors readable mnemonics, then a final pass (`seed-ids.ts`) rewrites +every substitutable ID to `prefix_` — the UUID is a deterministic +pure-JS hash of the mnemonic (v4-shaped, engine-agnostic so it matches +under both the Node seed script and the Hermes reseed), so +cross-references stay wired and the fixture is byte-stable across runs +without a checked-in DB. The same pass corrects two authored off-spec +prefixes (`fac_`→`fact_`, `thread_`→`thr_`) and re-canonicalizes +character-relationship pairs whose `a_id < b_id` order the remap +inverts. Non-substitutable IDs keep readable suffixes (`br_hero_main`). +This also repaired `pnpm db:seed` for dev, unlocking +turns-on-seeded-data in the dev app. + +## Embedder in E2E + +The embedder is central to the story flow, so E2E exercises the real +embedding and retrieval machinery — with only the LLM mocked. + +"Installed" is purely on-disk state: a model is installed when +`/embedders//` holds `model.onnx` + `meta.json` +(`electron/embedder/service.ts`), and `embed()` loads it through +transformers.js with `local_files_only: true`. So the harness seeds +a real ONNX model on disk next to the fixture DB — **no product +change, no network**. The seed dataset marks embeddings stale but +cannot populate vectors (the `vec0` tables are virtual and absent +from `dbSchema`, per `lib/db/embeddings/vec-tables.ts`); a real +embedding pass over the seeded content fills them, which is exactly +the machinery under test. + +- **Model:** `Xenova/all-MiniLM-L6-v2` (384-dim), the seed's declared + model. At ~23 MB it is too large for git — CI downloads it once + into a cached directory (mirroring the Playwright-browser cache in + `.github/workflows/ci.yml`); local runs reuse the dev + `userData/embedders` when present. +- **Download flow is not E2E-tested — manual only.** + `assertAllowedDownloadUrl` (`electron/embedder/paths.ts`) hardcodes a + single allowed origin, so the download IPC path can't target a local + mock without a test-only origin seam. The setup cost isn't worth the + payoff for this one flow, so it stays a manual check; the download + itself is verified against a real packaged build. + +## Mock LLM + +Provider calls route to a local HTTP server (`e2e/harness/mock-llm.ts`). +The fixture already seeds an `openai-compatible` provider; the harness +starts the mock, then `setProviderEndpoint` repoints that provider's +endpoint at the mock's URL before launch (the port is dynamic, so the +override happens at seed time, not in the dataset). This exercises the +real transport (`lib/ai/transport`) rather than bypassing it, and works +identically in local and packaged modes — unlike the `stub` provider, +which is `__DEV__`-gated and absent from the packaged build. It sends +CORS headers (and answers the preflight) because the renderer's fetch +is cross-origin. + +**One endpoint, many output shapes.** A single turn fans out into +several calls on the same `…/chat/completions` URL, so the mock routes +on the request: + +- **`stream: true`** → an SSE prose stream (the narrative call). +- **otherwise (structured)** → a JSON chat completion whose body is + chosen by matching the exact TypeScript block the app injects into + the prompt — `schemaToTypeScriptBlock` over each agent's Zod schema + (`lib/ai/prompt-schema.ts`). Each structured agent is one + `STRUCTURED_AGENTS` entry `{ name, block, example }`; the match + can't drift because it reuses the app's own renderer, and tests + override a specific agent's reply via `setStructured(name, value)`. + +Block-matching keys on the **auto** (prompt-injection) path the fixture +uses — the only path E2E relies on. A `force-on` profile takes the +native path instead, but the app doesn't set the openai-compatible +provider's `supportsStructuredOutputs`, so force-on currently sends +`response_format: { type: 'json_object' }` with **no schema on the +wire**; the `structured-force-on` spec pins that (and will flag it if +the provider flag is ever wired — an app followup, since native schema +output needs endpoint support and `optional`→`nullable` schemas). + +Only the LLM is mocked — the pipeline, transport, entry writes, and +delta log all run for real; the `turn` and `classifier` specs assert +their effects through the DB bridge. + +## Selector strategy + +Three tiers, cheapest and most stable first. Reach for a lower tier +only when the one above genuinely can't express the target. + +### Tier 1 — assert in the database + +The strongest assertion is not in the DOM. With `app.evaluate()` into +main and a real SQLite file, verify outcomes by query: did the turn +write an entry, did the delta log record the reverse, did the +classifier tag the entity, did the embedding pass populate `vec0`. +Far more stable than any selector, and it matches the taxonomy — +the DOM drives, the DB asserts. + +### Tier 2 — role + accessible name, resolved through i18n + +Drive the UI by ARIA role and accessible name. The app's a11y layer +already produces disambiguated, per-instance names — +`t('storyCard.open', { title })` renders as `aria-label="Open 'My +Story'"`, unique per row. React Native Web maps `accessibilityLabel` +to `aria-label` and `accessibilityRole` to `role`. **Resolve the same +i18n key the app uses** (via `e2e/harness/i18n.ts`), never a hardcoded +English string — copy churn and locale growth then propagate for +free, and a renamed key fails loudly. Note `locales/` uses plurals +(`entries_other`, `unsavedChanges_one`), so the harness boots real +i18next rather than reading JSON naively. + +### Tier 3 — testID, only where roles can't reach + +`testID` maps to `data-testid` and `dataSet={{ storyId }}` to +`data-story-id` (React Native Web, hyphenated). All `components/ui` +primitives spread `...props`, so both land without touching a +primitive. Add a `testID` only for these four cases: + +| Case | Why role + name fails | Convention | +| -------------------------- | ------------------------------------------ | ----------------------------------------------- | +| Repeated-row scope anchor | Need "the control _inside_ row X" | `testID="story-card"` + `dataSet={{ storyId }}` | +| No-role assertion target | Delta-log rows, meters — no role, no label | `testID="delta-log-row"` | +| Non-unique icon-only label | e.g. a shared "Collision warning" label | `testID="collision-warning"` | +| Virtualized list item | Node may be unmounted; text selector fails | `testID` + programmatic scroll | + +`testID`s are added **per flow, as E2E reaches them** — there is no +repo-wide retrofit. Virtualization is narrow: `@tanstack/react-virtual` +/ `FlatList` appear only in `searchable-overlay-list.tsx`; the story +list and the single-document reader are fully in the DOM, so text and +role selectors are safe there. + +## CI + +One packaged job (`e2e`) in +[`.github/workflows/ci.yml`](../.github/workflows/ci.yml), running on +every PR alongside `check` and `test`: + +1. `pnpm build:web` — export the web bundle (~21 s). +2. `pnpm electron:compile` — compile the Electron main. +3. `pnpm exec electron-builder --linux --dir` — package unpacked, + skipping AppImage/deb compression (~58 s locally vs ~2m38s for a + full package). Produces the asar, unpacked native modules, and + `extraResources` migrations the tests need. The Electron binary and + builder downloads are cached, keyed on the lockfile. +4. `playwright install-deps chromium` — Electron's shared libraries + (no browser download). +5. `xvfb-run -a pnpm test:e2e:packaged` (the `packaged` Playwright + project) — Electron has no true headless mode on Linux, so it runs + under a virtual display. The harness seeds a throwaway `userData` per run + and launches the packaged binary against it. + +The embedder model cache (for the retrieval/turn tiers) is added when +those tests land. + +## Known limitations and open questions + +- **CI wall-clock is estimated, not measured.** Confirm on the first + green run; if the packaging step dominates, consider gating `e2e` + behind `check` or caching the unpacked build. +- **Embedder download flow** is manual-only — the origin-seam setup + isn't worth the payoff for one flow (see the embedder section). +- **`force-on` structured output doesn't put the schema on the wire.** + The app never sets the openai-compatible provider's + `supportsStructuredOutputs`, so a force-on profile degrades to + schema-less `json_object`. Wiring the flag (capability-gated, with + `optional`→`nullable` schemas) is an app followup; the + `structured-force-on` spec pins current behavior meanwhile. diff --git a/e2e/flows/create-story.ts b/e2e/flows/create-story.ts new file mode 100644 index 00000000..8e5325d7 --- /dev/null +++ b/e2e/flows/create-story.ts @@ -0,0 +1,29 @@ +import { expect, type Page } from '@playwright/test' + +import { wizard } from '../locators/wizard' + +export type NewAdventureStory = { lead: string; title: string; opening: string } + +// Drive the wizard end-to-end to create an adventure story with a lead entity — +// the path create-story.ts embeds the lead through the local embedder. Assumes +// the wizard is already open (past the embedder gate) on step 1. Steps run +// 1 → 2 → 5; the calendar step self-populates a valid origin on mount. +export async function createAdventureStory(page: Page, story: NewAdventureStory): Promise { + // Step 1 — adventure mode surfaces the lead-name field (needsLead). + await wizard.modeOption(page, 'adventure').click() + await wizard.leadName(page).fill(story.lead) + await wizard.next(page).click() + + // Step 2 — calendar defaults are valid; advance. + await wizard.next(page).click() + + // Step 5 — opening + title are the remaining Finish requirements. + await expect(wizard.opening(page)).toBeVisible() + await wizard.opening(page).fill(story.opening) + await wizard.title(page).fill(story.title) + + await wizard.finish(page).click() + + // Finish commits the story and routes to the reader. + await page.waitForURL(/\/reader-composer\//, { timeout: 30_000 }) +} diff --git a/e2e/harness/db.ts b/e2e/harness/db.ts new file mode 100644 index 00000000..91b679a8 --- /dev/null +++ b/e2e/harness/db.ts @@ -0,0 +1,30 @@ +import { DatabaseSync } from 'node:sqlite' + +// Read-only assertion handle over the fixture DB file. E2E drives the app +// through the UI and asserts the outcome here — the DB is the source of truth +// for "did the write actually land" (docs/testing.md → Selector strategy, +// Tier 1). Opening the file directly keeps assertions independent of the +// renderer; the renderer→IPC→main bridge is exercised by the app under test. +export class FixtureDb { + private readonly db: DatabaseSync + + constructor(dbPath: string) { + this.db = new DatabaseSync(dbPath, { readOnly: true }) + } + + count(table: string): number { + return (this.db.prepare(`SELECT count(*) AS n FROM "${table}"`).get() as { n: number }).n + } + + all>(sql: string, ...params: unknown[]): T[] { + return this.db.prepare(sql).all(...(params as never[])) as T[] + } + + get>(sql: string, ...params: unknown[]): T | undefined { + return this.db.prepare(sql).get(...(params as never[])) as T | undefined + } + + close(): void { + this.db.close() + } +} diff --git a/e2e/harness/embedder.ts b/e2e/harness/embedder.ts new file mode 100644 index 00000000..e46991e2 --- /dev/null +++ b/e2e/harness/embedder.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto' +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +// A model is "installed" purely as on-disk state: model.onnx + meta.json (read +// by listInstalled) plus the config/tokenizer files transformers.js loads +// (electron/embedder/service.ts). The harness provisions the default catalog +// model into a cache once, then copies it into each run's userData/embedders — +// no product change, and the real embed path runs. See docs/testing.md → +// Embedder in E2E. + +const REPO_ROOT = join(__dirname, '..', '..') + +type CatalogFile = { repoPath: string; sha256: string } +type CatalogModel = { + id: string + huggingfaceRevision: string + dim: number + files: Record + tags: string[] +} + +function defaultModel(): CatalogModel { + const catalog = JSON.parse( + readFileSync(join(REPO_ROOT, 'lib', 'embedder', 'catalog-data.json'), 'utf8'), + ) as { models: CatalogModel[] } + const model = catalog.models.find((m) => m.tags.includes('default')) + if (!model) throw new Error('catalog has no default model') + return model +} + +// Mirrors sanitizeModelDirName (electron/embedder/paths.ts). +function sanitizeModelDirName(id: string): string { + return id.toLowerCase().replaceAll('/', '--') +} + +// Persistent, CI-cacheable cache outside any single run's userData. +function cacheRoot(): string { + return join(homedir(), '.cache', 'aventuras-e2e', 'embedders') +} + +function sha256(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +// The marker holds the catalog revision, written last (after every file is +// hash-verified). A cache is trustworthy only if the marker records the current +// revision AND every expected file is still present — otherwise a catalog bump +// or a partial/corrupted cache could be reused, passing the test against a stale +// model. Anything else is a miss. +const MARKER = '.e2e-complete' + +function cacheIsValid(dir: string, model: CatalogModel): boolean { + const marker = join(dir, MARKER) + if (!existsSync(marker)) return false + if (readFileSync(marker, 'utf8').trim() !== model.huggingfaceRevision) return false + return Object.keys(model.files).every((name) => existsSync(join(dir, name))) +} + +// Per-file cap so a stalled Hugging Face connection fails cleanly instead of +// hanging the run to its outer timeout. +const DOWNLOAD_TIMEOUT_MS = 60_000 + +/** Idempotently download + verify the default model into the cache. */ +export async function ensureEmbedderModel(): Promise<{ modelId: string; dim: number }> { + const model = defaultModel() + const dir = join(cacheRoot(), sanitizeModelDirName(model.id)) + + if (cacheIsValid(dir, model)) return { modelId: model.id, dim: model.dim } + + // Rebuild from scratch: a stale-revision or partial cache must not survive. + rmSync(dir, { recursive: true, force: true }) + mkdirSync(dir, { recursive: true }) + const base = `https://huggingface.co/${model.id}/resolve/${model.huggingfaceRevision}` + for (const [onDiskName, file] of Object.entries(model.files)) { + const dest = join(dir, onDiskName) + const res = await fetch(`${base}/${file.repoPath}`, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }) + if (!res.ok) throw new Error(`download ${file.repoPath}: HTTP ${res.status}`) + await writeFile(dest, Buffer.from(await res.arrayBuffer())) + const actual = sha256(dest) + if (actual !== file.sha256) { + throw new Error(`sha256 mismatch for ${onDiskName}: expected ${file.sha256}, got ${actual}`) + } + } + // listInstalled requires meta.json alongside model.onnx. + writeFileSync(join(dir, 'meta.json'), JSON.stringify({ id: model.id, installedAt: 0 })) + // Marker last, stamped with the revision the cache now holds. + writeFileSync(join(dir, MARKER), model.huggingfaceRevision) + return { modelId: model.id, dim: model.dim } +} + +/** Copy the provisioned model into a run's userData so the app sees it installed. */ +export async function installEmbedderModel( + userDataDir: string, +): Promise<{ modelId: string; dim: number }> { + const info = await ensureEmbedderModel() + const dirName = sanitizeModelDirName(info.modelId) + cpSync(join(cacheRoot(), dirName), join(userDataDir, 'embedders', dirName), { recursive: true }) + return info +} diff --git a/e2e/harness/i18n.ts b/e2e/harness/i18n.ts new file mode 100644 index 00000000..5b9bbaec --- /dev/null +++ b/e2e/harness/i18n.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { createInstance } from 'i18next' + +// Loose signature on purpose: lib/i18n augments the i18next module with the +// app's resource types (strict keys), which we don't want to duplicate in the +// harness — selectors pass keys as plain strings. +type Translate = (key: string, options?: Record) => string + +// Namespace → locale filename (storySettings is kebab on disk). Mirrors the +// app's i18n init (lib/i18n/i18n.ts) so selectors resolve the exact strings the +// app renders — including interpolation and plurals — instead of hardcoded +// English that rots as copy changes. The react-i18next plugin is omitted: it +// only backs the React hooks, not t(), so a bare i18next instance is faithful. +const NAMESPACES = [ + 'common', + 'embedder', + 'landing', + 'reader', + 'settings', + 'storySettings', + 'wizard', +] as const + +const FILE_OVERRIDE: Partial> = { + storySettings: 'story-settings', +} + +const LOCALES_DIR = join(__dirname, '..', '..', 'locales', 'en') + +function loadNamespace(ns: (typeof NAMESPACES)[number]): unknown { + const file = FILE_OVERRIDE[ns] ?? ns + return JSON.parse(readFileSync(join(LOCALES_DIR, `${file}.json`), 'utf8')) +} + +let cached: Translate | undefined + +export const t: Translate = (key, options) => { + if (!cached) { + // Cast to a loose shape: the app's global i18next augmentation types both + // init() options and t() keys against the app resources, which we don't + // mirror here. + const instance = createInstance() as unknown as { + init: (options: Record) => void + t: Translate + } + instance.init({ + resources: { en: Object.fromEntries(NAMESPACES.map((ns) => [ns, loadNamespace(ns)])) }, + lng: 'en', + fallbackLng: 'en', + ns: [...NAMESPACES], + defaultNS: 'common', + returnNull: false, + interpolation: { escapeValue: false }, + }) + cached = instance.t.bind(instance) + } + return cached(key, options) +} diff --git a/e2e/harness/launch.ts b/e2e/harness/launch.ts new file mode 100644 index 00000000..155e3ad2 --- /dev/null +++ b/e2e/harness/launch.ts @@ -0,0 +1,158 @@ +import { readFile, rm } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { extname, join, normalize } from 'node:path' + +import { _electron as electron, test, type ElectronApplication, type Page } from '@playwright/test' + +const REPO_ROOT = join(__dirname, '..', '..') +const DIST = join(REPO_ROOT, 'dist') + +// Launch mode (docs/testing.md → Launch modes), selected by the Playwright +// project (playwright.config.ts `projects`), not an env var: +// dev — unpackaged main + renderer from a static dist. Fast, no +// packaging step; the default for local authoring. +// packaged — the electron-builder --dir binary loading app://bundle. The +// CI target of record: exercises the app:// protocol, asar, the +// unpacked native modules, and extraResources migrations. +type Mode = 'dev' | 'packaged' + +function currentMode(): Mode { + return test.info().project.name === 'packaged' ? 'packaged' : 'dev' +} + +// Linux electron-builder --dir output. +const PACKAGED_APP = join(REPO_ROOT, 'release', 'linux-unpacked', 'aventuras') + +const APP_SCHEME_ORIGIN = 'app://' + +const MIME: Record = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.ico': 'image/x-icon', + '.png': 'image/png', + '.ttf': 'font/ttf', + '.woff2': 'font/woff2', + '.map': 'application/json', +} + +function serveDist(): Promise<{ server: Server; origin: string }> { + const server = createServer((req, res) => { + void (async () => { + const pathname = new URL(req.url ?? '/', 'http://x').pathname + let filePath = normalize(join(DIST, decodeURIComponent(pathname))) + if (!filePath.startsWith(DIST)) filePath = join(DIST, 'index.html') + try { + const body = await readFile(filePath) + res.writeHead(200, { + 'content-type': MIME[extname(filePath)] ?? 'application/octet-stream', + }) + res.end(body) + } catch { + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(await readFile(join(DIST, 'index.html'))) + } + })() + }) + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ server, origin: `http://127.0.0.1:${port}` }) + }) + }) +} + +// The app window is the one serving the app's own origin. In dev, main also +// opens a detached DevTools window that races it, so first-open order is +// unreliable; selecting by origin is correct in both modes. +async function selectAppWindow(app: ElectronApplication, originPrefix: string): Promise { + const isApp = (page: Page) => page.url().startsWith(originPrefix) + let window = app.windows().find(isApp) + while (!window) { + const page = await app.waitForEvent('window', { timeout: 30_000 }) + if (isApp(page)) window = page + } + await window.waitForLoadState('domcontentloaded') + return window +} + +export type LaunchedApp = { + app: ElectronApplication + window: Page + close: () => Promise +} + +export async function launchApp(opts: { + userDataDir: string + /** Remove userDataDir on close. */ + cleanupUserData?: boolean +}): Promise { + const cleanupUserData = async () => { + if (opts.cleanupUserData) await rm(opts.userDataDir, { recursive: true, force: true }) + } + const stopServer = (server: Server) => + new Promise((resolve) => server.close(() => resolve())) + + if (currentMode() === 'packaged') { + let app: ElectronApplication | undefined + try { + app = await electron.launch({ + executablePath: PACKAGED_APP, + args: [`--user-data-dir=${opts.userDataDir}`], + timeout: 60_000, + }) + const window = await selectAppWindow(app, APP_SCHEME_ORIGIN) + const launched = app + return { + app: launched, + window, + // finally so a failing app.close() still cleans up the temp dir. + close: async () => { + try { + await launched.close() + } finally { + await cleanupUserData() + } + }, + } + } catch (err) { + await app?.close().catch(() => {}) + await cleanupUserData() + throw err + } + } + + const { server, origin } = await serveDist() + let app: ElectronApplication | undefined + try { + app = await electron.launch({ + args: ['electron/dist/main.js', `--user-data-dir=${opts.userDataDir}`], + cwd: REPO_ROOT, + env: { ...process.env, EXPO_WEB_URL: origin }, + timeout: 60_000, + }) + const window = await selectAppWindow(app, origin) + const launched = app + return { + app: launched, + window, + // Each step runs regardless of an earlier failure — a throwing + // app.close() must not leave the server holding the worker open. + close: async () => { + try { + await launched.close() + } finally { + await stopServer(server) + await cleanupUserData() + } + }, + } + } catch (err) { + await app?.close().catch(() => {}) + await stopServer(server) + await cleanupUserData() + throw err + } +} diff --git a/e2e/harness/mock-llm.ts b/e2e/harness/mock-llm.ts new file mode 100644 index 00000000..1154e939 --- /dev/null +++ b/e2e/harness/mock-llm.ts @@ -0,0 +1,176 @@ +import { createServer, type IncomingMessage } from 'node:http' +import type { AddressInfo } from 'node:net' + +import { z } from 'zod' + +import { schemaToTypeScriptBlock, type JsonSchema } from '@/lib/ai' +import { fallbackClassifierSchema } from '@/lib/pipeline' + +// A local OpenAI-compatible endpoint. The whole pipeline talks to one URL +// (POST …/chat/completions) but a turn fans out into calls with different +// shapes. The mock routes on the request: +// - stream: true → an SSE prose stream (the narrative call). +// - otherwise (structured) → a JSON chat completion, whose body is chosen by +// matching the exact TypeScript block the app injects into the prompt +// (schemaToTypeScriptBlock over the agent's zod schema). Each structured +// agent is one STRUCTURED_AGENTS entry; adding one is mechanical and the +// match can't drift because it reuses the app's own renderer. +// Exercises the real transport (lib/ai/transport), unlike the __DEV__-gated +// stub provider. See docs/testing.md → Mock LLM. + +export type MockRequest = { + body: Record + streamed: boolean + /** For structured calls, which agent's schema the prompt matched (or null). */ + agent: string | null +} + +// One entry per structured agent. `block` is the exact string the app renders +// into the prompt for this schema; `example` is a schema-valid default reply. +type StructuredAgent = { name: string; block: string; example: unknown } + +const STRUCTURED_AGENTS: StructuredAgent[] = [ + { + name: 'per-turn-classifier', + block: schemaToTypeScriptBlock(z.toJSONSchema(fallbackClassifierSchema) as JsonSchema), + // No-op: empty scene, no time change — parses and applies cleanly. + example: { sceneEntities: [], worldTimeDelta: 0 }, + }, +] + +export type MockLlm = { + /** baseURL to seed as the provider endpoint (already includes /v1). */ + url: string + /** Set the prose the next streaming (narrative) call returns. */ + setNarrative: (content: string) => void + /** Override a structured agent's reply (defaults to its schema-valid example). */ + setStructured: (agentName: string, value: unknown) => void + /** Every completion request received, in order. */ + requests: MockRequest[] + close: () => Promise +} + +const DEFAULT_NARRATIVE = + 'The blade rasps free of its sheath. Somewhere in the drowned city, a bell answers, and the rain leans closer to listen.' + +function sse(obj: unknown): string { + return `data: ${JSON.stringify(obj)}\n\n` +} + +function narrativeSse(content: string): string { + const base = { id: 'chatcmpl-mock', object: 'chat.completion.chunk', created: 0, model: 'mock' } + const frames = [ + { + ...base, + choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }], + }, + { ...base, choices: [{ index: 0, delta: { content }, finish_reason: null }] }, + { ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }, + ] + return frames.map(sse).join('') + 'data: [DONE]\n\n' +} + +function structuredCompletion(value: unknown): string { + return JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion', + created: 0, + model: 'mock', + choices: [ + { + index: 0, + message: { role: 'assistant', content: JSON.stringify(value) }, + finish_reason: 'stop', + }, + ], + }) +} + +// The injected schema block lives in the message content (the app's auto path +// renders it into the prompt); flatten every message to one searchable string. +function promptText(body: Record): string { + const messages = Array.isArray(body.messages) ? body.messages : [] + return messages + .map((m) => { + const content = (m as { content?: unknown }).content + if (typeof content === 'string') return content + if (Array.isArray(content)) + return content.map((p) => (p as { text?: string }).text ?? '').join('\n') + return '' + }) + .join('\n') +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = '' + req.on('data', (c) => (data += c)) + req.on('end', () => resolve(data)) + req.on('error', reject) + }) +} + +export async function startMockLlm(): Promise { + let narrative = DEFAULT_NARRATIVE + const overrides = new Map() + const requests: MockRequest[] = [] + + // The renderer fetches cross-origin (its own origin → this server), which + // triggers a CORS preflight; answer it and tag every response, so the call + // isn't blocked in dev (http origin) the way it would be without headers. + const cors = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'POST, OPTIONS', + 'access-control-allow-headers': '*', + } + + const server = createServer((req, res) => { + void (async () => { + if (req.method === 'OPTIONS') { + res.writeHead(204, cors).end() + return + } + if (!req.url?.endsWith('/chat/completions') || req.method !== 'POST') { + res.writeHead(404, cors).end() + return + } + const raw = await readBody(req) + const body = (raw ? JSON.parse(raw) : {}) as Record + const streamed = body.stream === true + + if (streamed) { + requests.push({ body, streamed, agent: null }) + res.writeHead(200, { + ...cors, + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + res.end(narrativeSse(narrative)) + return + } + + const text = promptText(body) + const agent = STRUCTURED_AGENTS.find((a) => text.includes(a.block)) ?? null + requests.push({ body, streamed, agent: agent?.name ?? null }) + const value = agent ? (overrides.get(agent.name) ?? agent.example) : {} + res.writeHead(200, { ...cors, 'content-type': 'application/json' }) + res.end(structuredCompletion(value)) + })() + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + return { + url: `http://127.0.0.1:${port}/v1`, + setNarrative: (content) => { + narrative = content + }, + setStructured: (agentName, value) => { + overrides.set(agentName, value) + }, + requests, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} diff --git a/e2e/harness/seed.ts b/e2e/harness/seed.ts new file mode 100644 index 00000000..d9d3a958 --- /dev/null +++ b/e2e/harness/seed.ts @@ -0,0 +1,103 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +const REPO_ROOT = join(__dirname, '..', '..') + +// Remove a seeded temp dir; safe to call more than once (launchApp also removes +// it on close). Specs call it in afterAll so a setup failure between seed and +// launch doesn't orphan the dir. +export function removeUserDataDir(dir: string | undefined): void { + if (dir) rmSync(dir, { recursive: true, force: true }) +} + +// Build a throwaway Electron userData dir seeded with the dev fixture. The DB +// lands at /aventuras.db — exactly where getDbFilePath() resolves under +// --user-data-dir — so a launched app opens it with zero product changes. +// Reuses scripts/seed (migrations + Zod-validated dataset) rather than +// duplicating the seed path. Per-worker isolation comes for free: each call +// mints its own temp dir. See docs/testing.md → Fixture + seed contract. +export function createSeededUserDataDir(): { userDataDir: string; dbPath: string } { + const userDataDir = mkdtempSync(join(tmpdir(), 'aventuras-e2e-')) + const dbPath = join(userDataDir, 'aventuras.db') + try { + execFileSync('pnpm', ['db:seed', dbPath], { cwd: REPO_ROOT, stdio: 'pipe' }) + } catch (err) { + // Don't orphan the dir if seeding fails after mkdtemp. + removeUserDataDir(userDataDir) + throw err + } + return { userDataDir, dbPath } +} + +// Repoint every openai-compatible provider at the mock server. Runs before +// launch, so the app reads the mock URL into its settings store on boot. +export function setProviderEndpoint(dbPath: string, url: string): void { + const db = new DatabaseSync(dbPath) + try { + const row = db.prepare(`SELECT providers FROM app_settings WHERE id = 'singleton'`).get() as { + providers: string + } + const providers = JSON.parse(row.providers) as { type: string; endpoint?: string }[] + for (const provider of providers) { + if (provider.type === 'openai-compatible') provider.endpoint = url + } + db.prepare(`UPDATE app_settings SET providers = ? WHERE id = 'singleton'`).run( + JSON.stringify(providers), + ) + } finally { + db.close() + } +} + +// Set a profile's structuredOutput mode (auto | force-on | force-off). force-on +// routes structured calls through native response_format instead of the +// prompt-injected schema. Runs before launch. +export function setProfileStructuredOutput( + dbPath: string, + profileId: string, + mode: 'auto' | 'force-on' | 'force-off', +): void { + const db = new DatabaseSync(dbPath) + try { + const row = db.prepare(`SELECT profiles FROM app_settings WHERE id = 'singleton'`).get() as { + profiles: string + } + const profiles = JSON.parse(row.profiles) as { id: string; structuredOutput?: string }[] + for (const profile of profiles) { + if (profile.id === profileId) profile.structuredOutput = mode + } + db.prepare(`UPDATE app_settings SET profiles = ? WHERE id = 'singleton'`).run( + JSON.stringify(profiles), + ) + } finally { + db.close() + } +} + +// Clear taggedBlockReliable on every cached model so piggyback can't ride +// in-band — forcing the per-turn fallback classifier (a separate structured +// call) to fire. Runs before launch. +export function disablePiggybackCapability(dbPath: string): void { + const db = new DatabaseSync(dbPath) + try { + const row = db.prepare(`SELECT providers FROM app_settings WHERE id = 'singleton'`).get() as { + providers: string + } + const providers = JSON.parse(row.providers) as { + cachedModels?: { capabilities?: Record }[] + }[] + for (const provider of providers) { + for (const model of provider.cachedModels ?? []) { + if (model.capabilities) delete model.capabilities.taggedBlockReliable + } + } + db.prepare(`UPDATE app_settings SET providers = ? WHERE id = 'singleton'`).run( + JSON.stringify(providers), + ) + } finally { + db.close() + } +} diff --git a/e2e/locators/home.ts b/e2e/locators/home.ts new file mode 100644 index 00000000..c1158be1 --- /dev/null +++ b/e2e/locators/home.ts @@ -0,0 +1,20 @@ +import type { Locator, Page } from '@playwright/test' + +import { t } from '../harness/i18n' + +// Home-screen locators resolved through the app's own i18n keys, so copy +// changes and locale growth propagate here for free and a renamed key fails +// loudly (docs/testing.md → Selector strategy, Tier 2). +export const home = { + // The per-story open control. Its accessible name is unique per row + // (t('storyCard.open', { title })), so it doubles as a row selector. + openStory: (page: Page, title: string): Locator => + page.getByRole('button', { name: t('storyCard.open', { title }) }), + + // The list header count: t('landing:list.total', { count }). + listTotal: (page: Page, count: number): Locator => + page.getByText(t('landing:list.total', { count }), { exact: false }), + + // Starts the wizard (goes straight to /wizard when no live session exists). + newStory: (page: Page): Locator => page.getByRole('button', { name: t('landing:list.newStory') }), +} diff --git a/e2e/locators/wizard.ts b/e2e/locators/wizard.ts new file mode 100644 index 00000000..55dbb306 --- /dev/null +++ b/e2e/locators/wizard.ts @@ -0,0 +1,25 @@ +import type { Locator, Page } from '@playwright/test' + +import { t } from '../harness/i18n' + +// Wizard locators resolved through the app's own i18n keys +// (docs/testing.md → Selector strategy, Tier 2). +export const wizard = { + // Step 1 (Frame): mode is a radio segment; its accessible name carries the + // option label. `adventure` makes needsLead true, surfacing the lead input. + modeOption: (page: Page, mode: 'adventure' | 'creative'): Locator => + page.getByRole('radio', { name: t(`wizard:frame.mode.${mode}.label`), exact: false }), + + leadName: (page: Page): Locator => page.getByPlaceholder(t('wizard:frame.leadName.placeholder')), + + // Step 5 (Opening): both carry an aria-label. + opening: (page: Page): Locator => + page.getByRole('textbox', { name: t('wizard:opening.opening.label') }), + + title: (page: Page): Locator => + page.getByRole('textbox', { name: t('wizard:opening.title.label') }), + + next: (page: Page): Locator => page.getByRole('button', { name: t('wizard:footer.next') }), + + finish: (page: Page): Locator => page.getByRole('button', { name: t('wizard:footer.finish') }), +} diff --git a/e2e/tests/classifier.spec.ts b/e2e/tests/classifier.spec.ts new file mode 100644 index 00000000..c62a0bc9 --- /dev/null +++ b/e2e/tests/classifier.spec.ts @@ -0,0 +1,86 @@ +import { expect, test, type Page } from '@playwright/test' + +import { t } from '../harness/i18n' +import { launchApp, type LaunchedApp } from '../harness/launch' +import { startMockLlm, type MockLlm } from '../harness/mock-llm' +import { + createSeededUserDataDir, + disablePiggybackCapability, + removeUserDataDir, + setProviderEndpoint, +} from '../harness/seed' +import { home } from '../locators/home' + +async function dbRows(page: Page, sql: string, params: unknown[] = []): Promise { + const result = await page.evaluate( + ({ sql, params }) => + ( + window as unknown as { + aventurasDb: { + query: (s: string, p: unknown[], m: string) => Promise<{ rows: unknown[][] }> + } + } + ).aventurasDb.query(sql, params, 'all'), + { sql, params }, + ) + return result.rows +} + +// With piggyback disabled, a single turn fans out into TWO calls on the one +// endpoint — the streaming narrative and the non-streaming fallback classifier +// — which is the mock's request-routing under real load. See docs/testing.md +// → Mock LLM. +test.describe('turn fanning out to the fallback classifier', () => { + let app: LaunchedApp + let mock: MockLlm + let userDataDir: string | undefined + + test.beforeAll(async () => { + const seeded = createSeededUserDataDir() + userDataDir = seeded.userDataDir + mock = await startMockLlm() + mock.setNarrative('E2E-CLASSIFIER-TURN the courier moves.') + setProviderEndpoint(seeded.dbPath, mock.url) + disablePiggybackCapability(seeded.dbPath) + app = await launchApp({ userDataDir, cleanupUserData: true }) + }) + + test.afterAll(async () => { + await app?.close() + await mock?.close() + removeUserDataDir(userDataDir) + }) + + test('routes the narrative to the SSE stream and the classifier to the JSON path', async () => { + await home.openStory(app.window, 'The Veilstone Courier').click() + const composer = app.window.getByPlaceholder(t('reader:composerPlaceholder')) + await expect(composer).toBeVisible({ timeout: 20_000 }) + await composer.fill('E2E-CLASSIFIER-USER I wait in the rain.') + await app.window.getByRole('button', { name: t('reader:send') }).click() + + await expect(app.window.getByText('E2E-CLASSIFIER-TURN', { exact: false })).toBeVisible({ + timeout: 30_000, + }) + + // One endpoint, two shapes: a streaming narrative and a structured call the + // mock matched to the classifier by its injected schema block. The + // classifier fires after the narrative, so poll for it. + expect(mock.requests.some((r) => r.streamed)).toBe(true) + await expect + .poll(() => mock.requests.some((r) => !r.streamed && r.agent === 'per-turn-classifier'), { + timeout: 15_000, + }) + .toBe(true) + + // The AI reply still commits despite the extra classifier round-trip. + const branchId = ( + await dbRows(app.window, `SELECT current_branch_id FROM stories WHERE id = 'story_hero'`) + )[0][0] as string + const reply = await dbRows( + app.window, + `SELECT kind FROM story_entries WHERE branch_id = ? AND content LIKE '%E2E-CLASSIFIER-TURN%'`, + [branchId], + ) + expect(reply.length, 'AI reply committed').toBe(1) + }) +}) diff --git a/e2e/tests/embedder.spec.ts b/e2e/tests/embedder.spec.ts new file mode 100644 index 00000000..5c9e604e --- /dev/null +++ b/e2e/tests/embedder.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test' + +import { installEmbedderModel } from '../harness/embedder' +import { launchApp, type LaunchedApp } from '../harness/launch' +import { createSeededUserDataDir, removeUserDataDir } from '../harness/seed' + +type SmokeResult = { ok: true; dim: number } | { ok: false; error: unknown } +type Installed = { id: string; installedAt: number; sizeBytes: number } + +// The evaluate callbacks run in the renderer, reaching the embedder bridge the +// Electron preload exposes (window.aventurasEmbedder) — so each call crosses +// the real renderer → IPC → main → transformers.js path. The bridge type is +// declared inline in each callback because the function is serialized into the +// page, not closed over from here. +test.describe('local embedder', () => { + let app: LaunchedApp + let userDataDir: string | undefined + let modelId: string + let expectedDim: number + + test.beforeAll(async () => { + // Cold cache downloads ~24 MB from Hugging Face before launch. + test.setTimeout(180_000) + ;({ userDataDir } = createSeededUserDataDir()) + ;({ modelId, dim: expectedDim } = await installEmbedderModel(userDataDir)) + app = await launchApp({ userDataDir, cleanupUserData: true }) + }) + + test.afterAll(async () => { + await app?.close() + removeUserDataDir(userDataDir) + }) + + test('lists the seeded model as installed', async () => { + const installed = await app.window.evaluate(() => + ( + window as unknown as { aventurasEmbedder: { listInstalled: () => Promise } } + ).aventurasEmbedder.listInstalled(), + ) + expect(installed.map((m) => m.id)).toContain(modelId) + }) + + test('embeds through the real IPC → main → onnxruntime path', async () => { + const result = await app.window.evaluate( + (id) => + ( + window as unknown as { + aventurasEmbedder: { smokeTest: (a: { modelId: string }) => Promise } + } + ).aventurasEmbedder.smokeTest({ modelId: id }), + modelId, + ) + expect(result.ok, JSON.stringify(result)).toBe(true) + expect((result as { dim: number }).dim).toBe(expectedDim) + }) +}) diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts new file mode 100644 index 00000000..7bbd8e49 --- /dev/null +++ b/e2e/tests/home.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test' + +import { FixtureDb } from '../harness/db' +import { launchApp, type LaunchedApp } from '../harness/launch' +import { createSeededUserDataDir, removeUserDataDir } from '../harness/seed' +import { home } from '../locators/home' + +// Spike: proves the harness end-to-end — seed a temp userData, launch the real +// Electron main against it, then assert through BOTH the DOM (i18n-resolved +// locator) and the fixture DB. The DB is the source of truth; the DOM is the +// drive surface. See docs/testing.md. +test.describe('seeded home screen', () => { + let app: LaunchedApp + let db: FixtureDb + let seededUserDataDir: string + + test.beforeAll(async () => { + const { userDataDir, dbPath } = createSeededUserDataDir() + seededUserDataDir = userDataDir + db = new FixtureDb(dbPath) + app = await launchApp({ userDataDir, cleanupUserData: true }) + }) + + test.afterAll(async () => { + db?.close() + await app?.close() + removeUserDataDir(seededUserDataDir) + }) + + test('renders the seeded stories that exist in the DB', async () => { + const hero = db.get<{ title: string }>(`SELECT title FROM stories WHERE id = 'story_hero'`) + expect(hero, 'hero story seeded').toBeDefined() + + // DOM, driven by the app's own i18n copy. + await expect(home.openStory(app.window, hero!.title)).toBeVisible({ timeout: 15_000 }) + + const storyCount = db.count('stories') + await expect(home.listTotal(app.window, storyCount)).toBeVisible() + }) + + test('runs against the isolated seeded userData over a working DB bridge', async () => { + // Main process resolved userData to the isolated temp dir we seeded. + const userData = await app.app.evaluate(({ app: electronApp }) => + electronApp.getPath('userData'), + ) + expect(userData).toBe(seededUserDataDir) + + // The renderer's DB bridge is live, so the app is reading the seeded file + // through the real IPC path — the seam E2E exists to cover. + const bridgePresent = await app.window.evaluate(() => + Boolean((window as unknown as { aventurasDb?: unknown }).aventurasDb), + ) + expect(bridgePresent, 'db bridge present in renderer').toBe(true) + }) +}) diff --git a/e2e/tests/structured-force-on.spec.ts b/e2e/tests/structured-force-on.spec.ts new file mode 100644 index 00000000..209dc05b --- /dev/null +++ b/e2e/tests/structured-force-on.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test' + +import { t } from '../harness/i18n' +import { launchApp, type LaunchedApp } from '../harness/launch' +import { startMockLlm, type MockLlm } from '../harness/mock-llm' +import { + createSeededUserDataDir, + disablePiggybackCapability, + removeUserDataDir, + setProfileStructuredOutput, + setProviderEndpoint, +} from '../harness/seed' +import { home } from '../locators/home' + +// Pins what force-on actually puts on the wire for an openai-compatible +// provider: it engages native JSON mode (response_format) and skips the +// prompt-injected schema block — but the SDK sends only `{type:'json_object'}`, +// i.e. no schema travels with it. That's the concrete reason every other E2E +// leans on the prompt-embedded schema (the auto path). If the SDK/provider ever +// starts emitting json_schema, this test flags the change. See +// docs/testing.md → Mock LLM. +test.describe('force-on structured output', () => { + let app: LaunchedApp + let mock: MockLlm + let userDataDir: string | undefined + + test.beforeAll(async () => { + const seeded = createSeededUserDataDir() + userDataDir = seeded.userDataDir + mock = await startMockLlm() + mock.setNarrative('E2E-FORCEON-REPLY the courier waits.') + setProviderEndpoint(seeded.dbPath, mock.url) + disablePiggybackCapability(seeded.dbPath) // force the structured classifier call + setProfileStructuredOutput(seeded.dbPath, 'prof_classifier', 'force-on') + app = await launchApp({ userDataDir, cleanupUserData: true }) + }) + + test.afterAll(async () => { + await app?.close() + await mock?.close() + removeUserDataDir(userDataDir) + }) + + test('engages native response_format and skips the prompt-injected schema', async () => { + await home.openStory(app.window, 'The Veilstone Courier').click() + const composer = app.window.getByPlaceholder(t('reader:composerPlaceholder')) + await expect(composer).toBeVisible({ timeout: 20_000 }) + await composer.fill('E2E-FORCEON-USER I wait.') + await app.window.getByRole('button', { name: t('reader:send') }).click() + + // Distinct from the user action text ("E2E-FORCEON-USER …") so the match + // is unambiguous under strict mode. + await expect(app.window.getByText('E2E-FORCEON-REPLY', { exact: false })).toBeVisible({ + timeout: 30_000, + }) + + await expect.poll(() => mock.requests.some((r) => !r.streamed), { timeout: 15_000 }).toBe(true) + const structured = mock.requests.find((r) => !r.streamed)! + + // force-on engaged native JSON mode on the request… + expect(structured.body.response_format).toEqual({ type: 'json_object' }) + // …and did NOT prompt-inject the schema (so the block-matcher finds nothing). + expect(structured.agent).toBeNull() + }) +}) diff --git a/e2e/tests/turn.spec.ts b/e2e/tests/turn.spec.ts new file mode 100644 index 00000000..c5ebd9a2 --- /dev/null +++ b/e2e/tests/turn.spec.ts @@ -0,0 +1,86 @@ +import { expect, test, type Page } from '@playwright/test' + +import { t } from '../harness/i18n' +import { launchApp, type LaunchedApp } from '../harness/launch' +import { startMockLlm, type MockLlm } from '../harness/mock-llm' +import { createSeededUserDataDir, removeUserDataDir, setProviderEndpoint } from '../harness/seed' +import { home } from '../locators/home' + +async function dbRows(page: Page, sql: string, params: unknown[] = []): Promise { + const result = await page.evaluate( + ({ sql, params }) => + ( + window as unknown as { + aventurasDb: { + query: (s: string, p: unknown[], m: string) => Promise<{ rows: unknown[][] }> + } + } + ).aventurasDb.query(sql, params, 'all'), + { sql, params }, + ) + return result.rows +} + +// A distinctive marker so the reply is unambiguous in both DOM and DB. +const REPLY = 'E2E-TURN-MARKER — the blade sings and the rain leans in.' + +test.describe('reader turn against the mock LLM', () => { + let app: LaunchedApp + let mock: MockLlm + let userDataDir: string | undefined + + test.beforeAll(async () => { + const seeded = createSeededUserDataDir() + userDataDir = seeded.userDataDir + mock = await startMockLlm() + mock.setNarrative(REPLY) + setProviderEndpoint(seeded.dbPath, mock.url) + app = await launchApp({ userDataDir, cleanupUserData: true }) + }) + + test.afterAll(async () => { + await app?.close() + await mock?.close() + removeUserDataDir(userDataDir) + }) + + test('submits a user action and commits the mock AI reply on the seeded story', async () => { + // Open the seeded hero story into the reader. + await home.openStory(app.window, 'The Veilstone Courier').click() + + const composer = app.window.getByPlaceholder(t('reader:composerPlaceholder')) + await expect(composer).toBeVisible({ timeout: 20_000 }) + + // Marker keeps the query off seeded action lines (the hero branch already + // has "I draw the blade …"). + await composer.fill('E2E-USER-MARKER I draw the blade and wait.') + await app.window.getByRole('button', { name: t('reader:send') }).click() + + // The streamed reply renders in the document. + await expect(app.window.getByText('E2E-TURN-MARKER', { exact: false })).toBeVisible({ + timeout: 30_000, + }) + + // The narrative call actually hit the mock as a streaming request. + expect(mock.requests.some((r) => r.streamed)).toBe(true) + + // Both the user action and the AI reply are committed to the branch. + const branchId = ( + await dbRows(app.window, `SELECT current_branch_id FROM stories WHERE id = 'story_hero'`) + )[0][0] as string + + const reply = await dbRows( + app.window, + `SELECT kind FROM story_entries WHERE branch_id = ? AND content LIKE '%E2E-TURN-MARKER%'`, + [branchId], + ) + expect(reply.length, 'AI reply entry committed').toBe(1) + + const userEntry = await dbRows( + app.window, + `SELECT kind FROM story_entries WHERE branch_id = ? AND content LIKE '%E2E-USER-MARKER%'`, + [branchId], + ) + expect(userEntry.length, 'user action entry committed').toBe(1) + }) +}) diff --git a/e2e/tests/wizard.spec.ts b/e2e/tests/wizard.spec.ts new file mode 100644 index 00000000..2c78b85f --- /dev/null +++ b/e2e/tests/wizard.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type Page } from '@playwright/test' + +import { createAdventureStory } from '../flows/create-story' +import { installEmbedderModel } from '../harness/embedder' +import { launchApp, type LaunchedApp } from '../harness/launch' +import { createSeededUserDataDir, removeUserDataDir } from '../harness/seed' +import { home } from '../locators/home' +import { wizard } from '../locators/wizard' + +// Query the fixture DB through the app's own bridge (window.aventurasDb) so the +// read goes through the same main-process connection that just wrote — the +// faithful outcome check (docs/testing.md → Selector strategy, Tier 1). +// sqlite-proxy hands back rows as arrays-of-values. +async function dbRows(page: Page, sql: string, params: unknown[] = []): Promise { + const result = await page.evaluate( + ({ sql, params }) => + ( + window as unknown as { + aventurasDb: { + query: (s: string, p: unknown[], m: string) => Promise<{ rows: unknown[][] }> + } + } + ).aventurasDb.query(sql, params, 'all'), + { sql, params }, + ) + return result.rows +} + +test.describe('create-story wizard', () => { + let app: LaunchedApp + let userDataDir: string | undefined + const STORY = { + lead: 'Wren Calloway', + title: 'The Salt Road', + opening: 'The tide went out and did not come back.', + } + + test.beforeAll(async () => { + test.setTimeout(180_000) + ;({ userDataDir } = createSeededUserDataDir()) + await installEmbedderModel(userDataDir) + app = await launchApp({ userDataDir, cleanupUserData: true }) + }) + + test.afterAll(async () => { + await app?.close() + removeUserDataDir(userDataDir) + }) + + test('creates a story and embeds its lead entity through the real pipeline', async () => { + // The seeded embedder clears the wizard's hard entry gate. + await home.newStory(app.window).click() + await expect(wizard.modeOption(app.window, 'adventure')).toBeVisible({ timeout: 15_000 }) + + await createAdventureStory(app.window, STORY) + + // Story row committed and active. + const storyRows = await dbRows( + app.window, + `SELECT id, current_branch_id, status FROM stories WHERE title = ?`, + [STORY.title], + ) + expect(storyRows.length, 'exactly one story with the title').toBe(1) + const [, branchId, status] = storyRows[0] as [string, string, string] + expect(status).toBe('active') + + // Lead entity persisted on the story's branch. + const entityRows = await dbRows( + app.window, + `SELECT id FROM entities WHERE branch_id = ? AND name = ? AND kind = 'character'`, + [branchId, STORY.lead], + ) + expect(entityRows.length, 'lead entity row').toBe(1) + const leadId = (entityRows[0] as [string])[0] + + // The real embed populated the vec0 table for the lead (dim 384). + const vecRows = await dbRows( + app.window, + `SELECT count(*) FROM entities_vec_384 WHERE id = ? AND branch_id = ?`, + [leadId, branchId], + ) + expect((vecRows[0] as [number])[0], 'lead entity has a stored embedding').toBe(1) + }) +}) diff --git a/lib/ai/index.ts b/lib/ai/index.ts index 5585a450..21c2583a 100644 --- a/lib/ai/index.ts +++ b/lib/ai/index.ts @@ -24,3 +24,4 @@ export { type ResolvedParams, } from './resolve-model' export { resolveModelCapabilities, type ModelCapabilities } from './model-capabilities' +export { schemaToTypeScriptBlock, type JsonSchema } from './prompt-schema' diff --git a/lib/db/devtools/seed-dataset.test.ts b/lib/db/devtools/seed-dataset.test.ts index 67a28d1f..a7579ecb 100644 --- a/lib/db/devtools/seed-dataset.test.ts +++ b/lib/db/devtools/seed-dataset.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' +import { ID_PATTERN, IdBiMap, SUBSTITUTABLE_PREFIXES } from '@/lib/ids' import { detectRichEntryHtml, parseMarkdownToHtml } from '@/lib/markdown' import { buildSeedSteps } from './seed-dataset' @@ -12,6 +13,26 @@ function rowsOf(name: string): Row[] { return step!.rows as Row[] } +const SUBSTITUTABLE = new Set(SUBSTITUTABLE_PREFIXES) + +// Every string reachable in a row, deep — mirrors what substituteIds walks at +// prompt-build time, so the scan sees ids wherever they hide (nested state +// JSON, sceneEntities arrays, delta targets). +function collectStrings(value: unknown, out: string[]): void { + if (typeof value === 'string') out.push(value) + else if (Array.isArray(value)) for (const v of value) collectStrings(v, out) + else if (value !== null && typeof value === 'object') + for (const v of Object.values(value)) collectStrings(v, out) +} + +function allSubstitutableIds(): Set { + const strings: string[] = [] + for (const step of buildSeedSteps()) for (const row of step.rows) collectStrings(row, strings) + // A real id is prefix_; the underscore guard excludes bare kind words + // like "item" that share a substitutable prefix but aren't ids. + return new Set(strings.filter((s) => s.includes('_') && SUBSTITUTABLE.has(s.split('_')[0]))) +} + describe('buildSeedSteps', () => { it('builds without throwing (every Zod parse in the dataset passes)', () => { expect(() => buildSeedSteps()).not.toThrow() @@ -31,7 +52,7 @@ describe('buildSeedSteps', () => { }) it('seeds the rich-rendering story with entries the detector actually flags', () => { - const richEntries = rowsOf('story_entries').filter((r) => r.branchId === 'branch_rich_main') + const richEntries = rowsOf('story_entries').filter((r) => r.branchId === 'br_rich_main') expect(richEntries.length).toBeGreaterThanOrEqual(40) const flagged = richEntries.filter((r) => @@ -42,7 +63,7 @@ describe('buildSeedSteps', () => { it('routes every security probe through the rich path (a plain-path probe tests nothing)', () => { const probes = rowsOf('story_entries').filter( - (r) => r.branchId === 'branch_rich_main' && (r.content as string).startsWith('PROBE'), + (r) => r.branchId === 'br_rich_main' && (r.content as string).startsWith('PROBE'), ) expect(probes.length).toBeGreaterThanOrEqual(5) for (const probe of probes) { @@ -53,3 +74,66 @@ describe('buildSeedSteps', () => { } }) }) + +// The substitution layer (lib/ids) only rewrites ids matching ID_PATTERN +// (prefix_); a mnemonic id like char_kael passes through untouched and +// the turn fails on the placeholder return trip. The fixture must therefore +// carry real prefixed UUIDs — see docs/testing.md → Fixture + seed contract. +describe('seed id substitution contract', () => { + // Canonical id prefix per LLM-facing kind (docs/data-model.md → ID shape). + const ENTITY_PREFIX: Record = { + character: 'char', + location: 'loc', + item: 'item', + faction: 'fact', + } + const TABLE_PREFIX: Record string | undefined> = { + entities: (r) => ENTITY_PREFIX[r.kind as string], + lore: () => 'lore', + threads: () => 'thr', + happenings: () => 'hap', + chapters: () => 'chap', + } + + it('gives every LLM-facing row a prefix_ id with the kind-correct prefix', () => { + for (const [table, prefixOf] of Object.entries(TABLE_PREFIX)) { + for (const row of rowsOf(table)) { + const id = row.id as string + expect(ID_PATTERN.test(id), `${table}: ${id}`).toBe(true) + expect(id.split('_')[0], `${table}: ${id}`).toBe(prefixOf(row)) + } + } + }) + + it('round-trips every substitutable id through IdBiMap.allocate without throwing', () => { + const ids = allSubstitutableIds() + expect(ids.size).toBeGreaterThan(0) + const map = new IdBiMap() + for (const id of ids) expect(() => map.allocate(id), id).not.toThrow() + }) + + it('assigns identical ids across repeated builds (deterministic fixture)', () => { + const idsOf = () => buildSeedSteps().flatMap((s) => s.rows.map((r) => (r as Row).id)) + expect(idsOf()).toEqual(idsOf()) + }) + + it('keeps entity + happening cross-references consistent after the id remap', () => { + const entityIds = new Set(rowsOf('entities').map((r) => r.id)) + const happeningIds = new Set(rowsOf('happenings').map((r) => r.id)) + + for (const inv of rowsOf('happening_involvements')) { + expect(happeningIds, `involvement ${inv.id}`).toContain(inv.happeningId) + expect(entityIds, `involvement ${inv.id}`).toContain(inv.entityId) + } + for (const aw of rowsOf('happening_awareness')) { + expect(happeningIds, `awareness ${aw.id}`).toContain(aw.happeningId) + expect(entityIds, `awareness ${aw.id}`).toContain(aw.characterId) + } + for (const e of rowsOf('entities')) { + const state = e.state as { faction_id?: string | null; current_location_id?: string | null } + if (state?.faction_id) expect(entityIds, `${e.id}.faction_id`).toContain(state.faction_id) + if (state?.current_location_id) + expect(entityIds, `${e.id}.current_location_id`).toContain(state.current_location_id) + } + }) +}) diff --git a/lib/db/devtools/seed-dataset.ts b/lib/db/devtools/seed-dataset.ts index 0a092caa..eeb94a10 100644 --- a/lib/db/devtools/seed-dataset.ts +++ b/lib/db/devtools/seed-dataset.ts @@ -2,6 +2,7 @@ import type { SQLiteTable } from 'drizzle-orm/sqlite-core' import { BUNDLED_PACK_ID } from '@/lib/prompts' +import { remapSeedIds } from './seed-ids' import { appearanceSchema, modelProfileSchema, @@ -80,8 +81,8 @@ const DAY = 86_400_000 // --------------------------------------------------------------------------- const HERO = 'story_hero' -const MAIN = 'branch_hero_main' -const FORK = 'branch_hero_fork' +const MAIN = 'br_hero_main' +const FORK = 'br_hero_fork' const CAL = 'cal_default' @@ -103,7 +104,7 @@ const ID = { } as const function entryId(prefix: string, i: number): string { - return `e_${prefix}_${String(i).padStart(4, '0')}` + return `entry_${prefix}_${String(i).padStart(4, '0')}` } // Validate-or-throw: build-time guard that every JSON payload satisfies its Zod @@ -543,6 +544,15 @@ const heroEntities: NewEntity[] = [ ] // Canonical order requires aId < bId; the chosen char IDs already sort kael { const storyId = `story_${f.key}` - const branchId = `branch_${f.key}_main` + const branchId = `br_${f.key}_main` const t0 = BASE + (fi + 2) * DAY storyRows.push({ id: storyId, @@ -1266,7 +1276,7 @@ function fillerStoryRows(): { // --------------------------------------------------------------------------- const RICH = 'story_rich' -const RMAIN = 'branch_rich_main' +const RMAIN = 'br_rich_main' const RICH_OPENING = 'A plain opening: the gallery of impossible rooms admits one visitor at a time. Every door beyond this one is painted in styles no honest wall should hold.' @@ -1480,7 +1490,7 @@ const pipelineRunRows: NewPipelineRun[] = [ { runId: 'run_hero_1', kind: 'narrative', - actionId: 'seed_act_run_1', + actionId: 'act_run_1', storyId: HERO, startedAt: BASE + 70 * MIN, finishedAt: BASE + 70 * MIN + 4_200, @@ -1498,7 +1508,10 @@ const appSettingsRow: NewAppSettings = { apiKey: '', endpoint: 'http://localhost:1234/v1', favoriteModelIds: ['seed/narrative'], - cachedModels: [{ id: 'seed/narrative' }], + // taggedBlockReliable lets piggyback ride in-band on the narrative call; + // the classifier profile below still backs the periodic classifier and + // the piggyback fallback so a turn resolves on seeded data. + cachedModels: [{ id: 'seed/narrative', capabilities: { taggedBlockReliable: true } }], }), ], profiles: [ @@ -1509,8 +1522,14 @@ const appSettingsRow: NewAppSettings = { modelRef: { providerId: 'prov_local', modelId: 'seed/narrative' }, temperature: 0.8, }), + modelProfileSchema.parse({ + id: 'prof_classifier', + kind: 'agent', + name: 'Seed Classifier', + modelRef: { providerId: 'prov_local', modelId: 'seed/narrative' }, + }), ], - assignments: { narrative: 'prof_narrative' }, + assignments: { narrative: 'prof_narrative', classifier: 'prof_classifier' }, defaultProviderId: 'prov_local', embeddingModelId: 'Xenova/all-MiniLM-L6-v2', embeddingProviderId: 'prov_local', @@ -1590,7 +1609,7 @@ export function buildSeedSteps(): SeedStep[] { // Order encodes FK dependencies: parents before children, assets before // entry_assets. The runner inserts in this order with foreign_keys ON, so a // broken reference fails the seed loudly instead of landing silently. - return [ + const steps: SeedStep[] = [ step('vault_calendars', vaultCalendars, vaultCalendarRows), step('assets', assets, assetRows), step('stories', stories, [heroStory, rich.story, ...filler.stories]), @@ -1611,4 +1630,21 @@ export function buildSeedSteps(): SeedStep[] { step('pipeline_runs', pipelineRuns, pipelineRunRows), step('app_settings', appSettings, [appSettingsRow]), ] + + // Authored ids are readable mnemonics (char_kael); rewrite them to canonical + // prefix_ so the substitution layer round-trips and a turn can run on + // seeded data. Deterministic, so the fixture stays byte-stable across builds. + // See docs/testing.md → Fixture + seed contract. + return steps.map((s) => { + const rows = s.rows.map((row) => remapSeedIds(row)) + // Relationships carry an a_id < b_id invariant that the uuid remap can + // invert; re-canonicalize once the ids are final. + if (s.name === 'character_relationships') { + return { + ...s, + rows: rows.map((r) => canonicalizeRelationship(r as NewCharacterRelationship)), + } + } + return { ...s, rows } + }) } diff --git a/lib/db/devtools/seed-ids.test.ts b/lib/db/devtools/seed-ids.test.ts new file mode 100644 index 00000000..ee1d7fd0 --- /dev/null +++ b/lib/db/devtools/seed-ids.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { ID_PATTERN } from '@/lib/ids' + +import { canonicalSeedId, remapSeedIds } from './seed-ids' + +describe('canonicalSeedId', () => { + it('rewrites a mnemonic id to a matching prefix_', () => { + const id = canonicalSeedId('char_kael') + expect(id).not.toBeNull() + expect(ID_PATTERN.test(id!)).toBe(true) + expect(id!.startsWith('char_')).toBe(true) + }) + + it('corrects the off-spec authored prefixes (fac→fact, thread→thr)', () => { + expect(canonicalSeedId('fac_watch')!.startsWith('fact_')).toBe(true) + expect(canonicalSeedId('thread_trust')!.startsWith('thr_')).toBe(true) + }) + + it('is deterministic and distinct per authored id, even across shared suffixes', () => { + expect(canonicalSeedId('lore_veil')).toBe(canonicalSeedId('lore_veil')) + // item_amulet, lore_amulet, thread_amulet share a suffix but are distinct ids. + const ids = new Set([ + canonicalSeedId('item_amulet'), + canonicalSeedId('lore_amulet'), + canonicalSeedId('thread_amulet'), + ]) + expect(ids.size).toBe(3) + }) + + it('leaves non-ids, non-substitutable prefixes, and already-canonical ids untouched', () => { + expect(canonicalSeedId('Just some prose about a blade.')).toBeNull() + expect(canonicalSeedId('item')).toBeNull() // bare kind word, no id + expect(canonicalSeedId('ai_classifier')).toBeNull() // delta source enum + expect(canonicalSeedId('story_hero')).toBeNull() // non-substitutable prefix + expect(canonicalSeedId('char_9f8e7d6c-1a2b-4c3d-8e4f-0a1b2c3d4e5f')).toBeNull() + }) +}) + +describe('remapSeedIds', () => { + it('rewrites ids inside nested state, arrays, and delta targets while preserving structure', () => { + const row = { + id: 'char_kael', + kind: 'character', + state: { current_location_id: 'loc_hollow', inventory: ['item_blade'], faction_id: null }, + metadata: { sceneEntities: ['char_kael', 'char_mira'] }, + count: 3, + undoPayload: null, + } + const out = remapSeedIds(row) + + expect(out.id).toBe(canonicalSeedId('char_kael')) + expect(out.state.current_location_id).toBe(canonicalSeedId('loc_hollow')) + expect(out.state.inventory[0]).toBe(canonicalSeedId('item_blade')) + // Same authored id resolves identically wherever it appears (FK integrity). + expect(out.metadata.sceneEntities[0]).toBe(out.id) + // Non-id scalars and nulls pass through. + expect(out.kind).toBe('character') + expect(out.count).toBe(3) + expect(out.state.faction_id).toBeNull() + expect(out.undoPayload).toBeNull() + }) +}) diff --git a/lib/db/devtools/seed-ids.ts b/lib/db/devtools/seed-ids.ts new file mode 100644 index 00000000..fa22df2c --- /dev/null +++ b/lib/db/devtools/seed-ids.ts @@ -0,0 +1,72 @@ +import { ID_PATTERN, type SubstitutablePrefix } from '@/lib/ids' + +// Deterministic, dependency-free UUID derivation. buildSeedSteps runs under +// both the Node seed script and Hermes (native reseed), so no node:crypto — a +// small mixing hash is enough: a fixture id only needs stable, well-distributed +// hex, not cryptographic strength. Shaped as a v4 UUID so it reads as normal. +function seedUuid(seed: string): string { + let h1 = 0x9e3779b1 ^ seed.length + let h2 = 0x85ebca77 + let h3 = 0xc2b2ae3d + let h4 = 0x27d4eb2f + for (let i = 0; i < seed.length; i++) { + const c = seed.charCodeAt(i) + h1 = Math.imul(h1 ^ c, 2654435761) + h2 = Math.imul(h2 ^ c, 1597334677) + h3 = Math.imul(h3 ^ c, 2246822519) + h4 = Math.imul(h4 ^ c, 3266489917) + h1 ^= h1 >>> 15 + h2 ^= h2 >>> 13 + h3 ^= h3 >>> 16 + h4 ^= h4 >>> 11 + } + const hex8 = (n: number) => (n >>> 0).toString(16).padStart(8, '0') + const raw = (hex8(h1) + hex8(h2) + hex8(h3) + hex8(h4)).split('') + raw[12] = '4' + raw[16] = ((parseInt(raw[16], 16) & 0x3) | 0x8).toString(16) + const s = raw.join('') + return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}` +} + +// Authored seed prefix → canonical kind prefix. The dataset authored two kinds +// off-spec (fac→fact, thread→thr); everything else maps to itself. See +// docs/data-model.md → ID shape. +const CANONICAL_PREFIX: Partial> = { + char: 'char', + loc: 'loc', + item: 'item', + fac: 'fact', + fact: 'fact', + lore: 'lore', + thr: 'thr', + thread: 'thr', + hap: 'hap', + chap: 'chap', +} + +const MNEMONIC = /^([a-z]+)_([a-z][a-z0-9_]*)$/ + +// A seeded mnemonic id (char_kael, fac_watch, thread_trust) → its canonical +// prefix_ form. Anything else — prose, non-substitutable ids, ids already +// in prefix_ form — returns null and is left untouched. +export function canonicalSeedId(value: string): string | null { + if (ID_PATTERN.test(value)) return null + const match = MNEMONIC.exec(value) + if (!match) return null + const canonical = CANONICAL_PREFIX[match[1]] + if (!canonical) return null + // Seed from the canonical form so correcting an off-spec authored prefix in + // the dataset later leaves the generated uuid unchanged. + return `${canonical}_${seedUuid(`${canonical}_${match[2]}`)}` +} + +// Deep-walk a seed row, rewriting mnemonic ids wherever they appear — top-level +// columns, nested state/metadata JSON, and id arrays alike. Mirrors the reach +// of substituteIds so no reference is missed. +export function remapSeedIds(value: T): T { + if (typeof value === 'string') return (canonicalSeedId(value) ?? value) as T + if (Array.isArray(value)) return value.map((v) => remapSeedIds(v)) as T + if (value !== null && typeof value === 'object') + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, remapSeedIds(v)])) as T + return value +} diff --git a/lib/pipeline/definitions/per-turn-piggyback.ts b/lib/pipeline/definitions/per-turn-piggyback.ts index ab552545..b62cafee 100644 --- a/lib/pipeline/definitions/per-turn-piggyback.ts +++ b/lib/pipeline/definitions/per-turn-piggyback.ts @@ -34,7 +34,7 @@ export function shouldFallbackFire(outcome?: PiggybackOutcome): boolean { return !outcome.attempted || !outcome.succeeded } -const fallbackClassifierSchema = z.object({ +export const fallbackClassifierSchema = z.object({ sceneEntities: z.array(z.string()), currentLocation: z.string().optional(), worldTimeDelta: z.number(), diff --git a/lib/pipeline/index.ts b/lib/pipeline/index.ts index 77f73208..3ba72402 100644 --- a/lib/pipeline/index.ts +++ b/lib/pipeline/index.ts @@ -1,6 +1,7 @@ export { toPipelineError } from './call-error' export { definePhase, definePipeline } from './authoring/define' export { ensurePerTurnPipelineRegistered, PER_TURN_KIND } from './definitions/per-turn' +export { fallbackClassifierSchema } from './definitions/per-turn-piggyback' export { __resetRegistry, getPipeline, registerPipeline } from './authoring/registry' export { __resetBus, pipelineEventBus } from './runtime/event-bus' export { diff --git a/package.json b/package.json index 49dabeb5..31566e7c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "typecheck": "tsc --noEmit", "test": "vitest", "test:run": "vitest run", + "test:e2e": "playwright test --project=dev", + "test:e2e:packaged": "playwright test --project=packaged", "coverage:lib": "vitest run --project unit --coverage", "electron:compile": "tsc -p electron/tsconfig.json", "electron:start": "electron electron/dist/main.js", @@ -130,6 +132,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.1.2", + "@playwright/test": "1.59.1", "@storybook/addon-a11y": "^10.3.5", "@storybook/addon-docs": "^10.3.5", "@storybook/addon-mcp": "^0.6.0", @@ -192,7 +195,9 @@ ], "asarUnpack": [ "**/node_modules/sqlite-vec-*/**", - "**/node_modules/onnxruntime-node/**" + "**/node_modules/onnxruntime-node/**", + "**/node_modules/sharp/**", + "**/node_modules/@img/**" ], "extraResources": [ { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..c7c60d58 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test' + +// E2E runner, separate from Vitest (which owns unit + Storybook). Tests drive a +// real Electron app; the harness launches it, so there is no global webServer. +// Launch mode is a project, not an env var — the harness reads the project name +// (dev | packaged). Run one with `--project=`; the package scripts do. +// See docs/testing.md. +export default defineConfig({ + testDir: './e2e/tests', + testMatch: '**/*.spec.ts', + // Electron launch + seed are heavy; keep runs serial until the suite grows a + // per-worker fixture story (docs/testing.md → Fixture + seed contract). + workers: 1, + fullyParallel: false, + timeout: 90_000, + expect: { timeout: 10_000 }, + // A real app launched serially per spec has occasional window/IPC timing + // hiccups; one retry absorbs a transient without masking a real failure + // (a bug fails both attempts). + retries: 1, + // Deterministic: the github reporter is a no-op outside GitHub Actions, so + // both run everywhere without an env check. + reporter: [['list'], ['github']], + projects: [{ name: 'dev' }, { name: 'packaged' }], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9734c7b2..1eb59087 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: '@chromatic-com/storybook': specifier: ^5.1.2 version: 5.1.2(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) + '@playwright/test': + specifier: 1.59.1 + version: 1.59.1 '@storybook/addon-a11y': specifier: ^10.3.5 version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.0)(prettier@3.8.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) @@ -2249,6 +2252,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -11399,6 +11407,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@polka/url@1.0.0-next.29': {} '@protobufjs/aspromise@1.1.2': {}