Test/e2e setup (#395)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a Playwright + Electron E2E test setup for the packaged desktop
app, including seeded-data validation, mock LLM coverage, and UI flows.
* Introduced reusable E2E harness helpers for launching the app,
installing the local embedder model, seeding/configuring the test
database, and using i18n-based stable locators.
* Added deterministic fixture ID remapping with contract tests to keep
seeded references consistent.
* **CI**
* Added an E2E job that builds/packages Linux and runs `pnpm test:e2e`
against the packaged build.
* **Documentation**
* Added project guidance for E2E test conventions and AI-assisted test
edits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
failerko 2026-07-24 02:05:13 +02:00 committed by GitHub
parent 56861d47ab
commit b62ac05727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1985 additions and 64 deletions

View file

@ -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.

View file

@ -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

View file

@ -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 |

57
.claude/rules/testing.md Normal file
View file

@ -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_<uuid>`** (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).

View file

@ -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

6
.gitignore vendored
View file

@ -49,6 +49,12 @@ app-example
/electron/dist
/release
# Playwright (E2E)
/test-results
/playwright-report
/blob-report
/.playwright
# IDE stuff
.vscode/
.idea/

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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).

322
docs/testing.md Normal file
View file

@ -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 `<userData>/aventuras.db`, and copies a fixture
embedder model into `<userData>/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_<uuid-shape>` 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_<uuid>` value. `buildSeedSteps` (`lib/db/devtools/seed-dataset.ts`)
authors readable mnemonics, then a final pass (`seed-ids.ts`) rewrites
every substitutable ID to `prefix_<uuid>` — 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
`<userData>/embedders/<id>/` 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.

29
e2e/flows/create-story.ts Normal file
View file

@ -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<void> {
// 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 })
}

30
e2e/harness/db.ts Normal file
View file

@ -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<T = Record<string, unknown>>(sql: string, ...params: unknown[]): T[] {
return this.db.prepare(sql).all(...(params as never[])) as T[]
}
get<T = Record<string, unknown>>(sql: string, ...params: unknown[]): T | undefined {
return this.db.prepare(sql).get(...(params as never[])) as T | undefined
}
close(): void {
this.db.close()
}
}

104
e2e/harness/embedder.ts Normal file
View file

@ -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<string, CatalogFile>
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
}

60
e2e/harness/i18n.ts Normal file
View file

@ -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, unknown>) => 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<Record<(typeof NAMESPACES)[number], string>> = {
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<string, unknown>) => 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)
}

158
e2e/harness/launch.ts Normal file
View file

@ -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<string, string> = {
'.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<Page> {
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<void>
}
export async function launchApp(opts: {
userDataDir: string
/** Remove userDataDir on close. */
cleanupUserData?: boolean
}): Promise<LaunchedApp> {
const cleanupUserData = async () => {
if (opts.cleanupUserData) await rm(opts.userDataDir, { recursive: true, force: true })
}
const stopServer = (server: Server) =>
new Promise<void>((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
}
}

176
e2e/harness/mock-llm.ts Normal file
View file

@ -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<string, unknown>
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<void>
}
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, unknown>): 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<string> {
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<MockLlm> {
let narrative = DEFAULT_NARRATIVE
const overrides = new Map<string, unknown>()
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<string, unknown>
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<void>((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<void>((resolve) => server.close(() => resolve())),
}
}

103
e2e/harness/seed.ts Normal file
View file

@ -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 <dir>/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<string, unknown> }[]
}[]
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()
}
}

20
e2e/locators/home.ts Normal file
View file

@ -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') }),
}

25
e2e/locators/wizard.ts Normal file
View file

@ -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') }),
}

View file

@ -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<unknown[][]> {
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)
})
})

View file

@ -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<Installed[]> } }
).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<SmokeResult> }
}
).aventurasEmbedder.smokeTest({ modelId: id }),
modelId,
)
expect(result.ok, JSON.stringify(result)).toBe(true)
expect((result as { dim: number }).dim).toBe(expectedDim)
})
})

55
e2e/tests/home.spec.ts Normal file
View file

@ -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)
})
})

View file

@ -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()
})
})

86
e2e/tests/turn.spec.ts Normal file
View file

@ -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<unknown[][]> {
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)
})
})

84
e2e/tests/wizard.spec.ts Normal file
View file

@ -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<unknown[][]> {
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)
})
})

View file

@ -24,3 +24,4 @@ export {
type ResolvedParams,
} from './resolve-model'
export { resolveModelCapabilities, type ModelCapabilities } from './model-capabilities'
export { schemaToTypeScriptBlock, type JsonSchema } from './prompt-schema'

View file

@ -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<string>(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<string> {
const strings: string[] = []
for (const step of buildSeedSteps()) for (const row of step.rows) collectStrings(row, strings)
// A real id is prefix_<rest>; 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_<uuid>); 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<string, string> = {
character: 'char',
location: 'loc',
item: 'item',
faction: 'fact',
}
const TABLE_PREFIX: Record<string, (row: Row) => 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_<uuid> 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)
}
})
})

View file

@ -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<mira<vorne.
// The char_rel_canonical_order CHECK stores each pair with a_id < b_id. Rows
// are authored in mnemonic order, but remapped uuids sort differently, so
// re-canonicalize after the remap — the POV columns (kind / inverseKind) swap
// with the ids so the a→b / b→a semantics survive the flip.
function canonicalizeRelationship(r: NewCharacterRelationship): NewCharacterRelationship {
if (r.aId <= r.bId) return r
return { ...r, aId: r.bId, bId: r.aId, kind: r.inverseKind, inverseKind: r.kind }
}
const heroRelationships: NewCharacterRelationship[] = [
{
id: 'rel_kael_mira',
@ -773,49 +783,49 @@ const heroHappenings: NewHappening[] = [
const heroInvolvements: NewHappeningInvolvement[] = [
{
id: 'inv_ambush_kael',
id: 'hinv_ambush_kael',
branchId: MAIN,
happeningId: 'hap_ambush',
entityId: ID.kael,
role: 'target',
},
{
id: 'inv_ambush_vorne',
id: 'hinv_ambush_vorne',
branchId: MAIN,
happeningId: 'hap_ambush',
entityId: ID.vorne,
role: 'aggressor',
},
{
id: 'inv_fire_mira',
id: 'hinv_fire_mira',
branchId: MAIN,
happeningId: 'hap_fire',
entityId: ID.mira,
role: 'witness',
},
{
id: 'inv_fire_watch',
id: 'hinv_fire_watch',
branchId: MAIN,
happeningId: 'hap_fire',
entityId: ID.watch,
role: 'responder',
},
{
id: 'inv_betrayal_sage',
id: 'hinv_betrayal_sage',
branchId: MAIN,
happeningId: 'hap_betrayal',
entityId: ID.sage,
role: 'betrayer',
},
{
id: 'inv_pact_kael',
id: 'hinv_pact_kael',
branchId: MAIN,
happeningId: 'hap_pact',
entityId: ID.kael,
role: 'party',
},
{
id: 'inv_pact_vorne',
id: 'hinv_pact_vorne',
branchId: MAIN,
happeningId: 'hap_pact',
entityId: ID.vorne,
@ -826,7 +836,7 @@ const heroInvolvements: NewHappeningInvolvement[] = [
// characterId must be a character entity; the natural key is (branch, character, happening).
const heroAwareness: NewHappeningAwareness[] = [
{
id: 'aw_ambush_kael',
id: 'haw_ambush_kael',
branchId: MAIN,
happeningId: 'hap_ambush',
characterId: ID.kael,
@ -836,7 +846,7 @@ const heroAwareness: NewHappeningAwareness[] = [
source: 'witnessed',
},
{
id: 'aw_ambush_mira',
id: 'haw_ambush_mira',
branchId: MAIN,
happeningId: 'hap_ambush',
characterId: ID.mira,
@ -846,7 +856,7 @@ const heroAwareness: NewHappeningAwareness[] = [
source: 'told',
},
{
id: 'aw_fire_mira',
id: 'haw_fire_mira',
branchId: MAIN,
happeningId: 'hap_fire',
characterId: ID.mira,
@ -856,7 +866,7 @@ const heroAwareness: NewHappeningAwareness[] = [
source: 'witnessed',
},
{
id: 'aw_fire_kael',
id: 'haw_fire_kael',
branchId: MAIN,
happeningId: 'hap_fire',
characterId: ID.kael,
@ -866,7 +876,7 @@ const heroAwareness: NewHappeningAwareness[] = [
source: 'told',
},
{
id: 'aw_betrayal_kael',
id: 'haw_betrayal_kael',
branchId: MAIN,
happeningId: 'hap_betrayal',
characterId: ID.kael,
@ -876,7 +886,7 @@ const heroAwareness: NewHappeningAwareness[] = [
source: 'discovered',
},
{
id: 'aw_pact_kael',
id: 'haw_pact_kael',
branchId: MAIN,
happeningId: 'hap_pact',
characterId: ID.kael,
@ -886,7 +896,7 @@ const heroAwareness: NewHappeningAwareness[] = [
source: 'witnessed',
},
{
id: 'aw_pact_vorne',
id: 'haw_pact_vorne',
branchId: MAIN,
happeningId: 'hap_pact',
characterId: ID.vorne,
@ -959,7 +969,7 @@ const heroTranslations: NewTranslation[] = [
const heroEntryAssets: NewEntryAsset[] = [
{
id: 'ea_hero_1',
id: 'ast_hero_1',
branchId: MAIN,
entryId: entryId('hero', 6),
assetId: 'asset_inline_1',
@ -973,7 +983,7 @@ const heroDeltas: NewDelta[] = [
id: 'delta_hero_1',
branchId: MAIN,
entryId: entryId('hero', 14),
actionId: 'seed_act_edit_1',
actionId: 'act_edit_1',
logPosition: 1,
source: 'user_edit',
targetTable: 'story_entries',
@ -987,7 +997,7 @@ const heroDeltas: NewDelta[] = [
id: 'delta_hero_2',
branchId: MAIN,
entryId: entryId('hero', 22),
actionId: 'seed_act_class_1',
actionId: 'act_class_1',
logPosition: 2,
source: 'ai_classifier',
targetTable: 'happenings',
@ -1001,7 +1011,7 @@ const heroDeltas: NewDelta[] = [
id: 'delta_hero_3',
branchId: MAIN,
entryId: null,
actionId: 'seed_act_chapter_1',
actionId: 'act_chapter_1',
logPosition: 3,
source: 'chapter_close',
targetTable: 'chapters',
@ -1026,7 +1036,7 @@ function entryCreateDeltas(allEntries: NewStoryEntry[]): NewDelta[] {
id: `delta_create_${e.branchId}_${e.id}`,
branchId: e.branchId,
entryId: null,
actionId: `seed_act_create_${e.branchId}_${e.id}`,
actionId: `act_create_${e.branchId}_${e.id}`,
logPosition: lp,
source: e.kind === 'user_action' ? ('user_edit' as const) : ('ai_classifier' as const),
targetTable: 'story_entries',
@ -1059,7 +1069,7 @@ type Filler = {
const fillerLead: NewEntity = {
id: 'char_sable',
branchId: 'branch_active2_main',
branchId: 'br_active2_main',
kind: 'character',
name: 'Sable',
description: 'A cartographer mapping a coast that keeps redrawing itself.',
@ -1189,7 +1199,7 @@ function fillerStoryRows(): {
fillers.forEach((f, fi) => {
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_<uuid> 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 }
})
}

View file

@ -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_<uuid>', () => {
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()
})
})

View file

@ -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<Record<string, SubstitutablePrefix>> = {
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_<uuid> form. Anything else — prose, non-substitutable ids, ids already
// in prefix_<uuid> 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<T>(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
}

View file

@ -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(),

View file

@ -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 {

View file

@ -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": [
{

25
playwright.config.ts Normal file
View file

@ -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=<name>`; 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' }],
})

12
pnpm-lock.yaml generated
View file

@ -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': {}