mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-08-12 18:43:21 +00:00
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:
parent
56861d47ab
commit
b62ac05727
37 changed files with 1985 additions and 64 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
57
.claude/rules/testing.md
Normal 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).
|
||||
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
|
|
@ -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
6
.gitignore
vendored
|
|
@ -49,6 +49,12 @@ app-example
|
|||
/electron/dist
|
||||
/release
|
||||
|
||||
# Playwright (E2E)
|
||||
/test-results
|
||||
/playwright-report
|
||||
/blob-report
|
||||
/.playwright
|
||||
|
||||
# IDE stuff
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
322
docs/testing.md
Normal 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
29
e2e/flows/create-story.ts
Normal 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
30
e2e/harness/db.ts
Normal 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
104
e2e/harness/embedder.ts
Normal 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
60
e2e/harness/i18n.ts
Normal 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
158
e2e/harness/launch.ts
Normal 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
176
e2e/harness/mock-llm.ts
Normal 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
103
e2e/harness/seed.ts
Normal 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
20
e2e/locators/home.ts
Normal 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
25
e2e/locators/wizard.ts
Normal 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') }),
|
||||
}
|
||||
86
e2e/tests/classifier.spec.ts
Normal file
86
e2e/tests/classifier.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
56
e2e/tests/embedder.spec.ts
Normal file
56
e2e/tests/embedder.spec.ts
Normal 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
55
e2e/tests/home.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
65
e2e/tests/structured-force-on.spec.ts
Normal file
65
e2e/tests/structured-force-on.spec.ts
Normal 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
86
e2e/tests/turn.spec.ts
Normal 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
84
e2e/tests/wizard.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -24,3 +24,4 @@ export {
|
|||
type ResolvedParams,
|
||||
} from './resolve-model'
|
||||
export { resolveModelCapabilities, type ModelCapabilities } from './model-capabilities'
|
||||
export { schemaToTypeScriptBlock, type JsonSchema } from './prompt-schema'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
63
lib/db/devtools/seed-ids.test.ts
Normal file
63
lib/db/devtools/seed-ids.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
72
lib/db/devtools/seed-ids.ts
Normal file
72
lib/db/devtools/seed-ids.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
25
playwright.config.ts
Normal 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
12
pnpm-lock.yaml
generated
|
|
@ -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': {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue