Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5f8e0d7
docs(plans): commit the reconciled phase 6 plan
rsml Jul 21, 2026
d75ad01
test(e2e): land the walking skeleton for the journey suite
rsml Jul 21, 2026
98beb7e
test(e2e): run the port contract against the scripted model
rsml Jul 21, 2026
5de26e6
test(e2e): seed books on disk and read them in the reader
rsml Jul 21, 2026
41fc286
test(e2e): smoke the packaged app's rehydration, wire both CI jobs
rsml Jul 21, 2026
75f1347
test(e2e): complete journey a, revision through chapter 1 approval
rsml Jul 21, 2026
67caa08
test(e2e): cover the epub export journey from library to downloaded file
rsml Jul 21, 2026
e7d7077
test(e2e): cover library rename, tagging, search, and delete
rsml Jul 21, 2026
f60c7ff
test(e2e): add epub import journey with a generated fixture book
rsml Jul 21, 2026
8a052d1
docs(e2e): name every accessibility gap the journeys uncovered
rsml Jul 21, 2026
5aee500
build(e2e): put the epub fixture builder under typecheck and lint
rsml Jul 21, 2026
8b2718a
test(e2e): add the audiobook install gate and generation journey
rsml Jul 21, 2026
3268c5f
test(e2e): disambiguate the reader's duplicated next section control
rsml Jul 21, 2026
ed39fcb
docs(plans): record the production bug the journeys uncovered
rsml Jul 21, 2026
4cd2025
docs(e2e): point the readme at the two issues the journeys produced
rsml Jul 21, 2026
13466c3
test(e2e): add the adaptive loop journey, quarantine chapter 2 rendering
rsml Jul 21, 2026
7f85738
test(e2e): lock in that a scripted provider failure reaches the reade…
rsml Jul 21, 2026
3ea25a7
test(e2e): run every failure case through the surface that still works
rsml Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,79 @@ jobs:
- run: pnpm lint

- run: pnpm test

# The end-to-end journey suite, in its own job so it runs alongside verify
# rather than behind it. `pnpm test` stays the fast one and never waits on a
# browser. See e2e/README.md for what the journeys cover and why the server
# is faked at its edges rather than mocked at its HTTP boundary.
e2e-web:
runs-on: macos-14
env:
# The web project never launches Electron, so the binary is dead weight
# here. The e2e-electron job below deliberately omits this.
ELECTRON_SKIP_BINARY_DOWNLOAD: 1
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/Library/Caches/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-playwright-

- run: pnpm exec playwright install chromium

# No separate build step. e2e/global-setup.ts runs `vite build` itself,
# so there is exactly one code path and no way for a job to run the
# journeys against a bundle it did not build.
- run: pnpm e2e --project=web

- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-web
path: playwright-report/
retention-days: 7

# Isolated from e2e-web because it launches a real GUI application and needs
# the Electron binary, which is the one thing every other job skips
# downloading. One `--project` flag keeps it out of the web suite.
e2e-electron:
runs-on: macos-14
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm

- name: Cache Electron binary
uses: actions/cache@v4
with:
path: ~/Library/Caches/electron
key: ${{ runner.os }}-electron-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-electron-

- run: pnpm install --frozen-lockfile

- run: pnpm e2e --project=electron

- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-electron
path: playwright-report/
retention-days: 7
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ books/
*.log
.worktrees/
.claude/worktrees/
.DS_Store
.DS_Store
playwright-report/
test-results/
135 changes: 135 additions & 0 deletions docs/plans/refactor/phase-6.md

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# End-to-end journeys

Up: [../README.md](../README.md)

This suite drives the real browser UI against the real Fastify server, with the server's outermost adapters swapped for fakes. It is the answer to a fair question a cold reader asks about a hexagonal codebase, which is whether the ports are load bearing or decorative. Every journey here boots the production `buildServer` and hands it fakes through the same `overrides` argument Electron uses to hand it a diagram renderer, and it works.

## Running it

```bash
pnpm e2e # everything, both projects
pnpm e2e --project=web # the browser journeys
pnpm e2e --project=electron # the packaged-app smoke, needs the Electron binary
pnpm e2e create-book # one file
pnpm e2e --ui # Playwright's time-travel debugger
```

The run builds `dist/` first, every time, in `global-setup.ts`. That is one code path rather than two, so a journey can never test yesterday's client. Set `E2E_SKIP_BUILD=1` while iterating on a journey to skip it.

Set `E2E_VERBOSE=1` to see the Fastify server's log lines. They are suppressed by default because each test boots its own instance and the JSON request log drowns the reporter.

`pnpm test` is a different thing and stays fast. It is the vitest unit and contract suite and it never opens a browser. The one file under `e2e/` that vitest DOES own is `support/scripted-text-generation.test.ts`, because that test needs no browser.

## What a journey gets

```ts
import { expect, test } from '../support/app.js'

test('...', async ({ page, model, app }) => { /* ... */ })
```

| Fixture | What it is |
|---|---|
| `page` | A browser page already pointed at this test's own server instance, so `page.goto('/')` just works |
| `app` | `{ origin, dataDir, bookDir(id), fastify }`. `app.fastify.inject(...)` gives an API-level assertion without a second HTTP client |
| `model` | The scripted `TextGeneration` this test's server is wired to. Add rules before the action that triggers a call, read `model.requests` after |

Plus the helpers:

- `support/seed.ts` puts books on disk through the real filesystem repository.
- `support/journeys/*.ts` are page objects that expose intent, such as `wizard.approveToc()` and `reader.finishChapter()`.
- `fixtures/*.ts` are the content the scripted model returns, as TypeScript so `tsc` catches drift.

## Adding a journey

1. Add a `*.spec.ts` under `e2e/journeys/`, importing `test` and `expect` from `../support/app.js`, never from `@playwright/test`. The re-export is what carries the fixtures.
2. Get to your subject the cheap way. If the journey is about exporting a book, `seedBook(app.dataDir, ...)` rather than driving the whole creation wizard, so a wizard regression fails the wizard journey and not yours.
3. Address the UI the way a screen reader does. `getByRole` first, `getByText` second, and never a CSS class.
4. Assert with `await expect(locator)...`, which retries to a deadline. Never sleep.
5. If your journey needs a model call nobody has scripted yet, add a rule to `support/default-script.ts` matching a phrase lifted verbatim from the server prompt that produces it, and put the content in `fixtures/`.

## Why fakes rather than mocks

The server is faked at its ports, not mocked at its HTTP boundary. Everything between the browser and the AI provider is the real thing, meaning real routes, real services, real domain rules, real YAML on a real disk. Only the four edges that would cost money, need a network, or need a 90MB binary are swapped, and they are swapped through `buildServer(overrides)`, the same seam production already has.

Mocking `fetch` in the browser instead would have tested the client against a fiction of the server. This tests it against the server.

Zero live-provider traffic is a property of the design rather than a promise. The real key vault is never constructed, the fake one lives only in memory, and the scripted model has no SDK import and throws on any call nobody wrote a fixture for. There is no path from a journey to a provider even if someone wanted one.

## Why the model answers by intent

`support/scripted-text-generation.ts` matches on the shape of a request, not on the order calls arrive in. Phase 2's `createFakeTextGeneration` answers strictly first-in first-out, which is right for a unit test that knows exactly how many calls the code under test makes. A journey does not know that. Approving a table of contents fires a skill classification, a chapter stream, and a quiz, in an order the UI decides and may reorder tomorrow, and a fixture list coupled to that order is the largest available source of flake.

Rules are consulted newest first, so a test shadows a default with one line, which is also how failure injection works:

```ts
model.onStreamText({
name: 'chapter 2 is rate limited',
match: isChapterStreamFor(2),
respond: { throws: new Error('rate limit exceeded') },
})
```

`throws` takes any value and rethrows it unchanged, so a typed error class survives all the way to the caller.

The fidelity guarantee is `support/scripted-text-generation.test.ts`, which runs the port's own contract suite against this adapter with no exemptions, plus `req.schema.parse(value)` on every generated object so a drifted fixture fails as loudly as a malformed model response would.

## Why no test ids

There is not one `data-testid` in the client, and adding one would be the easy way out of every hard locator in this suite. The rule holds because addressing the UI by role and by name means the suite doubles as an accessibility net. Writing these journeys found seven real gaps, all filed as issue 51 rather than papered over with a test id.

1. A book card is a `div` with an `onClick`, so it has no role, no accessible name, and no focus. A keyboard user cannot open a book at all. This is the serious one.
2. Two controls share the name "Next section", the chapter rail's and the tap zone's, both wired to the same callback.
3. The book context menu is a plain `div` of buttons with no `menu` or `menuitem` semantics.
4. The feedback form's textareas are not label-associated, so neither has an accessible name.
5. The Rename dialog's Title and Subtitle labels are not wired to their inputs.
6. The Delete dialog's confirmation input has no label.
7. The library toolbar's icon-only search and view toggles carry a `title` attribute but no `aria-label`.

The one sanctioned exception is a hidden `<input type="file">`, which has no role and no accessible name by construction. It is commented where it appears.

## Flake policy

`retries: 0`, and that is the most load-bearing line in `playwright.config.ts`. The server is in process, the adapters are fakes, and nothing touches a network, so there is no legitimate source of nondeterminism left. A retry could only convert a real bug into a green run. A journey that flakes twice gets quarantined with `test.fixme` and an issue, never a blanket retry.

Two assertions are quarantined today, and neither is flake. Both are blocked on issue 50, a real bug this suite found in chapter generation's event stream, and each `test.fixme` names it. They flip green when it is fixed.

One real source of nondeterminism does exist inside the product and journeys have to respect it. `server/services/generate-quiz.ts` shuffles a quiz's options with `Math.random()` before saving, so a quiz option must be located by its text and never by its position.

## Projects

| Project | Selects | Runs where |
|---|---|---|
| `web` | everything not tagged `@electron` | the `e2e-web` CI job, Chromium, headless |
| `electron` | tests tagged `@electron` | the `e2e-electron` CI job, which is the only one that downloads the Electron binary |

The Electron project exists for one thing that the web project cannot reach at all. In the browser redux-persist writes to localStorage, and in the packaged app it writes through IPC to a file the main process owns, so the rehydration path is only observable with a real main process. Fakes cannot be injected into a packaged main process, so that journey seeds its library rather than generating one.
32 changes: 32 additions & 0 deletions e2e/fixtures/chapter-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { TOC_CHAPTERS } from './toc-stream.js'

/**
* The chapter prose the scripted model streams back.
*
* Every chapter carries a distinctive marker sentence, so a journey can
* assert it is looking at chapter 2 and not a stale chapter 1 without
* matching on a heading the UI might also render in a sidebar.
*/

/** The sentence that appears only in chapter `num`. Journeys locate on this. */
export function chapterMarker(num: number): string {
return `This is the seeded prose for chapter ${num}.`
}

/** The full markdown for chapter `num`, matching the shape the reader renders. */
export function chapterMarkdown(num: number): string {
const chapter = TOC_CHAPTERS[num - 1]
const title = chapter ? chapter.title : `Chapter ${num}`
return [
`# ${title}\n\n`,
`${chapterMarker(num)}\n\n`,
'## Why it matters\n\n',
'A body that spins loses energy to the tide it raises on its partner, and it keeps losing energy until the spin and the orbit agree.\n\n',
'> The brake never releases, it only runs out of spin to remove.\n',
].join('')
}

/** The same markdown split into stream chunks, so the journey exercises the real SSE reassembly. */
export function chapterStreamChunks(num: number): string[] {
return chapterMarkdown(num).split(/(?<=\n\n)/)
}
64 changes: 64 additions & 0 deletions e2e/fixtures/quiz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* The quiz the scripted model returns after each chapter.
*
* Note for journeys: `server/services/generate-quiz.ts` shuffles the options
* with `Math.random()` before saving, so the stored `correctIndex` is not the
* one written here and the on-screen option order is not this order. A
* journey must therefore locate an option by its TEXT, never by its position,
* and must decide right from wrong by comparing against these strings.
*/

export interface QuizFixtureQuestion {
question: string
options: string[]
/** Index into `options` as written here, before the service shuffles them. */
correctIndex: number
}

export const QUIZ_QUESTIONS: QuizFixtureQuestion[] = [
{
question: 'What removes a body\'s rotational energy during tidal locking?',
options: [
'The tidal bulge dragging against the body\'s own rotation',
'The solar wind stripping momentum from the upper atmosphere',
'Magnetic coupling between the two bodies\' iron cores',
'Radioactive decay slowly redistributing mass in the mantle',
],
correctIndex: 0,
},
{
question: 'What state does a tidally locked body settle into?',
options: [
'Its rotation period matches its orbital period around the partner',
'Its rotation stops entirely relative to the background stars',
'Its orbit becomes perfectly circular and stops precessing',
'Its axis of rotation aligns exactly with the orbital plane normal',
],
correctIndex: 0,
},
{
question: 'What mainly sets how long locking takes?',
options: [
'The separation between the bodies, which the tidal force depends on steeply',
'The absolute temperature of the smaller body at the time of formation',
'The total number of other satellites sharing the same orbital resonance',
'The chemical composition of the atmosphere retained by the larger body',
],
correctIndex: 0,
},
]

/** The value the scripted adapter returns for a quiz generateObject call. */
export const QUIZ_FIXTURE = { questions: QUIZ_QUESTIONS }

/** The correct option text for a question, located by its text rather than its shuffled position. */
export function correctOptionFor(question: QuizFixtureQuestion): string {
return question.options[question.correctIndex]
}

/** Any option that is not the correct one, for journeys that need to answer wrongly on purpose. */
export function wrongOptionFor(question: QuizFixtureQuestion): string {
const wrong = question.options.find((_option, index) => index !== question.correctIndex)
if (!wrong) throw new Error('quiz fixture: every option is the correct one, so there is no wrong answer to pick')
return wrong
}
47 changes: 47 additions & 0 deletions e2e/fixtures/redux-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Builds the `redux-state.json` file the Electron main process reads on
* behalf of redux-persist.
*
* The nesting is redux-persist's, not this project's, and it is worth
* spelling out because it is easy to get wrong from the outside. The file is
* an object keyed by storage key. Under `persist:tutor` sits a JSON STRING
* whose parsed value is an object with one entry per persisted slice, and
* each of THOSE values is itself a JSON string. Two levels of stringification,
* because redux-persist serialises each slice independently so that one
* corrupt slice cannot take the rest down with it.
*
* `client/store/persist.ts` sets `key: 'tutor'` and swaps its storage for
* Electron IPC when `window.electronAPI.storageGet` exists, and
* `electron/main.ts` answers that IPC out of this file.
*
* Only the reading position is written here, deliberately. redux-persist's
* default reconciler merges one level deep, meaning a persisted slice
* REPLACES that slice's initial state wholesale rather than being merged into
* it. Writing a partial `settings` slice therefore deletes the provider
* configuration the client reads on its first render, and the renderer dies
* with "Cannot read properties of undefined". Found the hard way. Persist a
* slice here only if the fixture supplies every field that slice's initial
* state declares.
*/

export interface ReduxStateFixture {
/** Where the reader resumes. Omit it to make the reader fall back to the server-derived position. */
position?: { bookId: string; chapter: number; section: number }
}

/** The parsed contents of `redux-state.json` for the given fixture. */
export function buildReduxState(fixture: ReduxStateFixture): Record<string, string> {
const slices: Record<string, string> = {
_persist: JSON.stringify({ version: -1, rehydrated: true }),
}

if (fixture.position) {
const { bookId, chapter, section } = fixture.position
slices.readingProgress = JSON.stringify({
positions: { [bookId]: { chapter, section, lastReadAt: new Date().toISOString() } },
furthest: { [bookId]: chapter },
})
}

return { 'persist:tutor': JSON.stringify(slices) }
}
Binary file added e2e/fixtures/sample-book.epub
Binary file not shown.
35 changes: 35 additions & 0 deletions e2e/fixtures/sample-book.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* The EPUB fixture the import journey (`e2e/journeys/epub-import.spec.ts`) drives
* through the real epub2 parser (`server/adapters/epub2-import.ts`), unlike every
* other journey, whose fixtures are consumed by the scripted model instead.
*
* These constants are the single source of truth for what
* `scripts/build-e2e-epub-fixture.ts` bakes into the committed binary at
* `e2e/fixtures/sample-book.epub`, and for what the journey then asserts
* against, so the fixture's real content and the journey's expectations
* cannot drift the way two hand-copied string literals could.
*/

export const SAMPLE_BOOK_TITLE = 'Bioluminescence in the Midnight Zone'

export interface SampleBookChapter {
title: string
/** Wrapped in a single <p> when the fixture script builds the EPUB. */
body: string
}

/** The chapters the fixture EPUB contains, in spine order. The journey asserts against this title list and its length. */
export const SAMPLE_BOOK_CHAPTERS: SampleBookChapter[] = [
{
title: 'Light Without Heat',
body: 'Bioluminescent light comes from a chemical reaction rather than from heat, so a deep-sea body can glow without ever warming the water around it.',
},
{
title: 'Signaling in the Dark',
body: 'Below the reach of sunlight, a flash or a glow carries the signal that color and shape would otherwise carry, from a mating display to a warning.',
},
{
title: 'The Predators That Glow Back',
body: 'Some hunters borrow the very glow their prey uses to hide, turning a warning signal into a lure that draws the next meal closer.',
},
]
Loading
Loading