# E2E coverage audit

What the browser automation in `e2e/` walks through, set against every
production route and `data-testid` hook, and what to do about the gaps.
Refreshed 2026-10-22 and re-counted at the merge of main on 2026-10-23 (the
first snapshot was PR #117, 2026-10-17, hand-written: `git show
c955cd1e:docs/e2e-audit.md`). How the suite works:
[Testing locally](testing#browser-tests).

The numbers below the line are generated: `python3 scripts/e2e-audit.py
--write` refreshes them in place. Re-run it before acting on a count.

## Findings

- **33 % of the keyless tests never run in CI.** The exact-spec job in
  `.github/workflows/smoke.yml` passes a fixed list of 18 files
  (`specs: specs/exact.spec.ts …`); the smoke job passes the folder
  `specs/smoke`. A spec file not on the list runs only when someone runs
  `scripts/e2e.sh` by hand. Missing today, 15 files and 79 tests, none of
  them needing a key: `ai-off`, `avatar-colour`, `course-report`,
  `dev-payments`, `editor-errors`, `home`, `hosted-ai`, `lessons`,
  `profile-autosave`, `prompt-limits`, `review-ai-changes`,
  `review-and-revise`, `revise`, `share`, `subscription`. That is most of the
  course editor (lessons, revise, review), the home page, sharing,
  profile autosave and the paid plans. `create` joined the list in #422,
  `docs` and `course-copy` since, one file at a time: new spec files keep
  landing outside it. `CLAUDE.md` says "the exact and smoke e2e specs
  run on every PR", which reads as all of them.
- **The learner's paths are covered, in CI.** The smoke specs play every
  mode, question type, Sprint exercise kind and game on the test course,
  right and wrong, as a fresh account, and check `/api/progress` after;
  Spanish lesson 1 and French get the same in each mode.
- **Nothing with a model in the loop runs unattended.** The 14 Jev journeys,
  2 judged specs, 4 personas (`scripts/e2e-explore.sh`) and the course
  playthroughs need `TYPESAFE_API_KEY` and no workflow has a `schedule`.
  `plagiarism.yml` already reads that secret.
- **Routes.** Of 137 production routes, 3 are reached by no flow:
  `POST /login/code/resend`, `POST /login/code/cancel`,
  `POST /profile/two-factor/code`. The script lists nine more that are
  reached in ways a grep cannot see: `GET /theme.css` and
  `GET /courses/themes.css` (stylesheet links the pages load;
  `custom-themes.spec.ts` checks the first), `POST /profile/google/confirm`
  (a hidden form submitted by `profile-delete-google`, in
  `google-signin.spec.ts`), `GET /profile/export/download` (the link in the
  export e-mail, in `smoke/export.spec.ts`), `/courses/shared/{token}` (from the invitation
  e-mail, in `share.spec.ts`, local only), `POST /api/courses/{id}/lessons`
  (the editor's autosave, the URL built by concatenation in
  `static/courses.js`), `/pictures/{*path}` (a course's SVG pictures),
  `/play/conjugation/play` (`drill-setup-start`), `/auth/google/callback`
  (the dev Google stand-in redirects there).
- **Testids.** 69 % of the hooks are referenced. The learner-facing gaps
  are the home page's submenu links (`home-play-*`, `home-ai-*`,
  `home-guide-next`), the settings of Numbers by ear, Listening and Date
  Dash (rate, range, replay, reveal), the tutor's provider picker and
  no-key state, and the Matching leaderboard. Most of the rest are `-form`
  wrappers, dialogs' close buttons and the dev tools.

## Flows by area

The per-test list is below the line. In short:

| Area | Specs (CI unless marked) |
|---|---|
| Sign-up, sign-in, e-mail, 2FA | `smoke/verify-email`, `google-signin`, `mail`, `two-factor`, `authenticator`, `terms`, `i18n` |
| Learning, every mode and game | `smoke/happy-paths`, `smoke/complete-test-course`, `smoke/test-course-games`, `smoke/wrong-answers`, `smoke/wrong-answers-games`, `smoke/start-level`, `smoke/french`, `smoke/daily-goal`, `smoke/motivator*`, `modals`, `lesson-tables` |
| Home page | `home` (local only) |
| Profile | `profile-autosave`, `avatar-colour`, `ai-off` (all local only); `custom-themes`, `profile-nav`; `smoke/privacy`, `smoke/export` (export, delete) |
| Plans and payments | `subscription`, `dev-payments`, `hosted-ai` (all local only) |
| Generating a course | `exact`, `pathways`, `dev-ai`, `create`; `prompt-limits` (local only) |
| Editing a course | `editor-autosave`, `forms`, `course-copy`; `lessons`, `editor-errors`, `course-report`, `revise`, `review-ai-changes`, `review-and-revise` (local only) |
| Sharing | `share` (local only) |
| Docs, self-hosting, layout | `smoke/self-host`, `smoke/phone-width`, `docs` |
| Jev, with a key | `journeys` (14 goals from `e2e/journeys.ts`), `judged` (2), `explore` (a persona from `e2e/personas/`), `record` (drafts a spec) |

Journeys (`e2e/journeys.ts`): open a lesson, find the generate form, start
Sprint on a phone (impatient), find "ser vs estar" (classroom), visit every
nav section, share a course, add a lesson with AI, leave an AI lesson out in
the review, edit a lesson as a form, add a lesson to a unit by hand, name a
new course, turn off AI features, start at A2, change the level.

Personas (`e2e/personas/`): `accurate` (reads everything, Study mode,
patience 20), `classroom` (wants one topic now, 10), `impatient` (a
teenager on a phone in Sprint, 6), `testing-bot` (sees only markup, 12).

## Recommendations

1. **Run every keyless spec in CI.** Pass `specs` (the whole folder) to the
   exact-spec job instead of a list; the Jev specs already skip themselves
   without a key (`--grep-invert @jev` stays). Expect some of the 79 to
   fail at first: they have not run since they were written. Fix or delete
   each, then the list can never go stale again.
2. **Make the docs say what CI runs.** One line in `CLAUDE.md` and
   `docs/testing.md`: CI runs every file under `e2e/specs/` except the Jev
   ones (after 1), or, until then, only the files listed in `smoke.yml`.
3. **Cover the three unreached routes** with exact specs: resend and
   cancel on `/login/code`, and the profile's two-factor code change.
4. **Spec the learner surfaces with no testid referenced**: home submenu
   links, game settings (rate, range, replay), tutor without a key, the
   Matching leaderboard.
5. **Schedule the model-driven checks**: a nightly workflow that runs
   `scripts/e2e-jev.sh` and one persona when `TYPESAFE_API_KEY` is set,
   reporting, not gating.

## Generated numbers

<!-- e2e-audit:generated -->
<!-- generated by scripts/e2e-audit.py at 7eff084b -->

| What | Count |
|---|---|
| Spec files | 91 (71 run in CI) |
| Tests | 387: 287 run in CI, 89 keyless but local only, 11 need a Jev key |
| Production routes (`src/routes/mod.rs`, no `/dev`) | 182: 103 named by an e2e file, 66 more reached through a page, 13 not reached |
| `data-testid` hooks (templates, static JS) | 1630, 1284 referenced (78 %) |
| Jev journeys / personas | 14 / 4 |

### Spec files

| File | Tests | CI | Needs a key |
|---|---|---|---|
| `specs/accessibility.spec.ts` | 7 | yes |  |
| `specs/ai-off.spec.ts` | 2 | **no** |  |
| `specs/authenticator.spec.ts` | 4 | yes |  |
| `specs/avatar-colour.spec.ts` | 3 | **no** |  |
| `specs/consent.spec.ts` | 2 | yes |  |
| `specs/content-check.spec.ts` | 3 | yes |  |
| `specs/content-reports.spec.ts` | 1 | yes |  |
| `specs/content-review.spec.ts` | 2 | yes |  |
| `specs/course-card-stats.spec.ts` | 2 | yes |  |
| `specs/course-copy.spec.ts` | 5 | yes |  |
| `specs/course-report.spec.ts` | 3 | **no** |  |
| `specs/course-table.spec.ts` | 6 | yes |  |
| `specs/course-translate.spec.ts` | 2 | yes |  |
| `specs/create.spec.ts` | 11 | yes |  |
| `specs/custom-themes.spec.ts` | 6 | yes |  |
| `specs/dev-ai.spec.ts` | 11 | yes |  |
| `specs/dev-payments.spec.ts` | 1 | **no** |  |
| `specs/docs.spec.ts` | 8 | yes |  |
| `specs/editor-autosave.spec.ts` | 5 | yes |  |
| `specs/editor-errors.spec.ts` | 4 | **no** |  |
| `specs/editor-fixes.spec.ts` | 3 | **no** |  |
| `specs/email-owner.spec.ts` | 2 | yes |  |
| `specs/exact.spec.ts` | 29 | yes |  |
| `specs/explore.spec.ts` | 3 | **no** | yes |
| `specs/forms.spec.ts` | 4 | yes |  |
| `specs/google-signin.spec.ts` | 11 | yes |  |
| `specs/home.spec.ts` | 4 | **no** |  |
| `specs/hosted-ai.spec.ts` | 6 | **no** |  |
| `specs/i18n.spec.ts` | 2 | yes |  |
| `specs/journeys.spec.ts` | 2 | **no** | yes |
| `specs/judged.spec.ts` | 3 | **no** | yes |
| `specs/lesson-conversations.spec.ts` | 1 | yes |  |
| `specs/lesson-duplicate.spec.ts` | 1 | yes |  |
| `specs/lesson-editor.spec.ts` | 10 | yes |  |
| `specs/lesson-header.spec.ts` | 1 | yes |  |
| `specs/lesson-tables.spec.ts` | 2 | yes |  |
| `specs/lessons.spec.ts` | 17 | **no** |  |
| `specs/mail.spec.ts` | 7 | yes |  |
| `specs/mistakes.spec.ts` | 1 | yes |  |
| `specs/modals.spec.ts` | 5 | yes |  |
| `specs/moderation.spec.ts` | 4 | yes |  |
| `specs/owner-resets.spec.ts` | 3 | yes |  |
| `specs/pathways.spec.ts` | 7 | yes |  |
| `specs/privacy-requests.spec.ts` | 2 | yes |  |
| `specs/profile-autosave.spec.ts` | 8 | **no** |  |
| `specs/profile-nav.spec.ts` | 1 | yes |  |
| `specs/prompt-limits.spec.ts` | 5 | **no** |  |
| `specs/record.spec.ts` | 3 | **no** | yes |
| `specs/release.spec.ts` | 2 | yes |  |
| `specs/review-ai-changes.spec.ts` | 14 | **no** |  |
| `specs/review-and-revise.spec.ts` | 4 | **no** |  |
| `specs/revise.spec.ts` | 10 | **no** |  |
| `specs/share-edges.spec.ts` | 9 | yes |  |
| `specs/share-sign-in.spec.ts` | 3 | yes |  |
| `specs/share.spec.ts` | 2 | **no** |  |
| `specs/smoke/complete-test-course.spec.ts` | 3 | yes |  |
| `specs/smoke/daily-goal.spec.ts` | 1 | yes |  |
| `specs/smoke/drill-first-set.spec.ts` | 2 | yes |  |
| `specs/smoke/export.spec.ts` | 2 | yes |  |
| `specs/smoke/french.spec.ts` | 6 | yes |  |
| `specs/smoke/happy-paths.spec.ts` | 10 | yes |  |
| `specs/smoke/mini.spec.ts` | 6 | yes |  |
| `specs/smoke/motivator-reward.spec.ts` | 1 | yes |  |
| `specs/smoke/motivator-skc.spec.ts` | 1 | yes |  |
| `specs/smoke/motivator-social.spec.ts` | 1 | yes |  |
| `specs/smoke/motivator-xp.spec.ts` | 1 | yes |  |
| `specs/smoke/motivator.spec.ts` | 1 | yes |  |
| `specs/smoke/password-reset.spec.ts` | 2 | yes |  |
| `specs/smoke/phone-width.spec.ts` | 2 | yes |  |
| `specs/smoke/placement.spec.ts` | 2 | yes |  |
| `specs/smoke/practice-empty-check.spec.ts` | 1 | yes |  |
| `specs/smoke/practice-keys.spec.ts` | 2 | yes |  |
| `specs/smoke/practice-nothing-yet.spec.ts` | 2 | yes |  |
| `specs/smoke/practice-result-order.spec.ts` | 1 | yes |  |
| `specs/smoke/practice-summary.spec.ts` | 2 | yes |  |
| `specs/smoke/privacy.spec.ts` | 3 | yes |  |
| `specs/smoke/self-host.spec.ts` | 3 | yes |  |
| `specs/smoke/skc-shop.spec.ts` | 2 | yes |  |
| `specs/smoke/sprint-check-error.spec.ts` | 2 | yes |  |
| `specs/smoke/sprint-ladder.spec.ts` | 2 | yes |  |
| `specs/smoke/sprint-sort.spec.ts` | 2 | yes |  |
| `specs/smoke/start-level.spec.ts` | 4 | yes |  |
| `specs/smoke/test-course-games.spec.ts` | 7 | yes |  |
| `specs/smoke/verify-email.spec.ts` | 3 | yes |  |
| `specs/smoke/wrong-answers-games.spec.ts` | 5 | yes |  |
| `specs/smoke/wrong-answers.spec.ts` | 7 | yes |  |
| `specs/subscription.spec.ts` | 3 | **no** |  |
| `specs/term-flag.spec.ts` | 3 | yes |  |
| `specs/terms.spec.ts` | 5 | yes |  |
| `specs/two-factor.spec.ts` | 4 | yes |  |
| `specs/word-list.spec.ts` | 3 | yes |  |

### Routes reached only through a page

No e2e file names them; a spec drives the form, link (its testid) or script that calls them.

- `POST /api/practice/finish`: `static/quiz.js`
- `POST /api/listening/check`: `static/listening.js`
- `POST /api/listening/finish`: `static/listening.js`
- `POST /courses/{id}/file/delete`: `course-edit-file-delete-form`
- `POST /courses/{id}/cancel`: `course-ai-modal-cancel-form`, `course-edit-cancel-form`, `courses-cancel`
- `POST /courses/{id}/undo`: `course-undo-form`
- `POST /courses/{id}/redo`: `course-undo-form`
- `POST /courses/{id}/report`: `content-report-form`
- `POST /courses/{id}/share/remove`: `course-share-remove-form`
- `GET /cookies`: `footer-cookies`
- `GET POST /legal/copyright`: `copyright-form`, `terms-copyright-link`
- `GET POST /terms/accept`: `terms-accept-check`
- `POST /terms/dismiss`: `terms-notice-dismiss`
- `GET POST /password/change`: `password-change-form`
- `POST /api/sprint/check`: `static/sprint.js`
- `POST /api/sprint/finish`: `static/sprint.js`
- `POST /api/guide/step`: `static/guide.js`
- `POST /api/guide/restart`: `static/guide.js`
- `POST /api/games/matching/start`: `static/matching.js`
- `POST /api/games/matching/finish`: `static/matching.js`
- `POST /api/games/conjugation/deal`: `static/conjugation.js`
- `POST /api/games/conjugation/check`: `static/conjugation.js`
- `POST /api/games/conjugation/finish`: `static/conjugation.js`
- `POST /api/games/numbers/finish`: `static/numbers.js`
- `POST /api/games/dates/finish`: `static/dates.js`
- `POST /api/vocabulary/review`: `static/vocab.js`
- `GET POST /login/code`: `login-code-input`
- `POST /login/code/resend`: `login-code-cancel`
- `GET /login/code/status`: `login-code`
- `POST /login/code/cancel`: `login-code-cancel`
- `POST /waitlist`: `waitlist-form{{ suffix }}`
- `POST /logout`: `account-deletion-logout`, `nav-logout-form`, `password-change-logout`, `terms-gate-logout`
- `POST /account/deletion/cancel`: `account-deletion-cancel`
- `POST /profile/password`: `profile-password-form`
- `POST /profile/sessions/end-others`: `profile-sign-out-others-form`
- `POST /profile/google/link`: `profile-google-link-form`
- `POST /profile/google/unlink`: `profile-google-unlink-form`
- `POST /profile/api-keys/remove`: `profile-api-key-remove-form`
- `POST /profile/ai`: `static/profile.js`, `consent-ai-form`
- `POST /profile/own-keys`: `static/profile.js`, `profile-own-keys-form`
- `POST /profile/export`: `profile-export-form`
- `GET POST /profile/export/download`: `profile-export-download-form`
- `POST /profile/delete`: `profile-delete-form`
- `POST /profile/verify-email/resend`: `verify-email-resend`
- `POST /profile/phone/send`: `phone-locked-dialog`
- `POST /profile/phone/verify`: `phone-verify-form`
- `POST /profile/two-factor/totp/confirm`: `two-factor-totp-code`
- `POST /superuser/release/invite`: `release-invite`
- `POST /superuser/invites`: `superuser-invite-form`
- `POST /superuser/invites/revoke`: `superuser-invite-revoke`
- `POST /superuser/terms-announce`: `superuser-terms-form`
- `POST /superuser/reports/forward`: `superuser-report-forward`
- `POST /superuser/reports/resolve`: `superuser-report-resolve`
- `POST /superuser/courses/put-back`: `superuser-put-back-form`
- `POST /superuser/courses/override`: `superuser-override-form`
- `POST /superuser/courses/clear-override`: `superuser-clear-override-form`
- `POST /superuser/courses/delete`: `superuser-course-delete-form`
- `POST /superuser/courses/email-owner`: `superuser-email-owner-form`
- `POST /superuser/unsuspend`: `superuser-account-unsuspend`
- `POST /superuser/privacy/export`: `superuser-privacy-export-form`
- `POST /superuser/privacy/forget`: `superuser-privacy-forget-form`
- `POST /superuser/unblock-ip`: `superuser-unblock`
- `POST /api/lesson-check`: `static/lesson-check.js`
- `POST /billing/cancel`: `cancel-form`
- `POST /billing/checkout`: `plans-form`
- `POST /billing/portal`: `profile-billing-portal-form`

### Routes no e2e flow reaches

Not named by an e2e file, and no driven form, link or script calls them.

- `GET /courses/themes.css`
- `POST /api/courses/{id}/preview-answer`
- `GET /pictures/{*path}`
- `GET /theme.css`
- `GET /badges/{file}`
- `GET /waitlist/confirm`
- `GET /account/deletion`
- `GET /auth/google/callback`
- `POST /profile/google/confirm`: called from `templates/profile.html`
- `POST /profile/two-factor/code`: called from `templates/profile.html`
- `POST /superuser/release/switch`: called from `templates/superuser_release.html`
- `POST /superuser/release/invite-next`: called from `templates/superuser_release.html`
- `GET /manifest.webmanifest`: called from `templates/base.html`, `templates/sprint.html`, `templates/sprint_play.html`, `templates/tutor.html`

### Testids per template

| File | Hooks | Referenced | Not referenced |
|---|---|---|---|
| `templates/dev_ai.html` | 42 | 14 | `dev-ai-base`, `dev-ai-clear`, `dev-ai-clear-inject`, `dev-ai-detail`, `dev-ai-fixture`, `dev-ai-fixture-edited`, `dev-ai-fixture-open`, `dev-ai-fixtures`, `dev-ai-fixtures-info`, `dev-ai-fixtures-info-dialog`, `dev-ai-hold-info`, `dev-ai-hold-info-dialog` (+16) |
| `templates/home.html` | 51 | 25 | `home-ai-api-keys`, `home-ai-lessons`, `home-ai-open`, `home-ai-recommended`, `home-guide-next`, `home-guide-open`, `home-play-dates`, `home-play-drill`, `home-play-flashcards`, `home-play-matching`, `home-play-numbers`, `home-play-open` (+14) |
| `templates/superuser_release.html` | 24 | 6 | `release-back`, `release-counts`, `release-empty`, `release-error`, `release-inbox`, `release-info`, `release-info-dialog`, `release-invite-next`, `release-invite-next-form`, `release-invite-next-n`, `release-list-card`, `release-next` (+6) |
| `templates/dev_payments_state.html` | 21 | 4 | `dev-payments-cancel-at-end`, `dev-payments-customer`, `dev-payments-customers`, `dev-payments-mark-paid`, `dev-payments-pay-next`, `dev-payments-payment`, `dev-payments-payment-status`, `dev-payments-payments`, `dev-payments-refund`, `dev-payments-resume`, `dev-payments-session`, `dev-payments-session-open` (+5) |
| `templates/numbers.html` | 23 | 7 | `numbers-again`, `numbers-card`, `numbers-form`, `numbers-max`, `numbers-min`, `numbers-novoice`, `numbers-range`, `numbers-rate`, `numbers-replay`, `numbers-reveal`, `numbers-settings`, `numbers-setup` (+4) |
| `templates/dev_box.html` | 26 | 12 | `dev-box-info`, `dev-box-info-dialog`, `dev-box-off`, `dev-inbox-clear`, `dev-inbox-filter`, `dev-inbox-off`, `dev-inbox-to`, `dev-mail-providers`, `dev-mail-send-form`, `dev-phone-clear`, `dev-phone-off`, `dev-phone-on` (+2) |
| `templates/profile.html` | 185 | 172 | `email-no-mail-close`, `email-no-mail-dialog`, `email-unverified-close`, `two-factor-confirm-code`, `two-factor-confirm-form`, `two-factor-confirm-google`, `two-factor-confirm-google-done`, `two-factor-confirm-method`, `two-factor-confirm-send`, `two-factor-confirm-sent`, `two-factor-confirm-with-google`, `two-factor-totp-error` (+1) |
| `templates/listening_play.html` | 19 | 6 | `listen`, `listen-empty`, `listen-form`, `listen-novoice`, `listen-rate`, `listen-replay`, `listen-reveal`, `listen-setup`, `listen-slow`, `listen-status`, `listen-stop`, `listen-unsupported` (+1) |
| `templates/dev_payments.html` | 11 | 0 | `dev-payments-api-base`, `dev-payments-clear`, `dev-payments-info`, `dev-payments-info-dialog`, `dev-payments-log`, `dev-payments-mode`, `dev-payments-notice`, `dev-payments-off`, `dev-payments-reset`, `dev-payments-state`, `dev-payments-webhook-url` |
| `templates/dev_inbox_message.html` | 11 | 3 | `dev-inbox-back`, `dev-inbox-headers`, `dev-inbox-link`, `dev-inbox-message`, `dev-inbox-message-archive`, `dev-inbox-message-delete`, `dev-inbox-raw`, `dev-inbox-text` |
| `templates/dev_jobs.html` | 10 | 2 | `dev-jobs-attempts`, `dev-jobs-empty`, `dev-jobs-error`, `dev-jobs-queue-fail`, `dev-jobs-row`, `dev-jobs-run-now`, `dev-jobs-state`, `dev-jobs-table` |
| `templates/dev_mail_message.html` | 12 | 4 | `dev-mail-back`, `dev-mail-detail`, `dev-mail-error`, `dev-mail-headers`, `dev-mail-html`, `dev-mail-message-archive`, `dev-mail-message-delete`, `dev-mail-text` |
| `templates/dev_text_message.html` | 15 | 7 | `dev-text-back`, `dev-text-compose-error`, `dev-text-compose-form`, `dev-text-error`, `dev-text-headers`, `dev-text-thread`, `{{ prefix() }}-message-archive`, `{{ prefix() }}-message-delete` |
| `templates/lesson.html` | 40 | 32 | `lesson-check-form`, `lesson-head`, `lesson-mastery`, `lesson-pass`, `lesson-practise`, `lesson-prerequisite`, `lesson-read-form`, `lesson-test` |
| `templates/dates.html` | 16 | 9 | `dates-again`, `dates-card`, `dates-ear`, `dates-hud`, `dates-replay`, `dates-score`, `dates-setup` |
| `templates/dev_payments_entry.html` | 8 | 1 | `dev-payments-back`, `dev-payments-entry-detail`, `dev-payments-entry-request`, `dev-payments-entry-resend`, `dev-payments-entry-response`, `dev-payments-entry-result`, `dev-payments-entry-what` |
| `templates/conjugation_setup.html` | 13 | 7 | `drill-setup-count`, `drill-setup-empty`, `drill-setup-form`, `drill-setup-hide`, `drill-setup-locked-lesson`, `drill-setup-locked-list` |
| `templates/matching.html` | 11 | 5 | `matching-again`, `matching-board-area`, `matching-leaderboard`, `matching-leaderboard-row`, `matching-status`, `matching-timer` |
| `templates/dev_ai_held.html` | 9 | 4 | `dev-ai-answer-error`, `dev-ai-answer-form`, `dev-ai-held-empty`, `dev-ai-reply`, `dev-ai-request-state` |
| `templates/dev_ai_call.html` | 8 | 3 | `dev-ai-back`, `dev-ai-call-detail`, `dev-ai-call-headers`, `dev-ai-call-purpose`, `dev-ai-call-result` |
| `templates/lookup.html` | 10 | 5 | `lookup-back`, `lookup-collected`, `lookup-hits`, `lookup-more`, `lookup-term` |
| `templates/register.html` | 11 | 6 | `register-dev`, `register-dev-login`, `register-form`, `register-invite`, `register-owner-note` |
| `templates/reset.html` | 13 | 8 | `reset-again`, `reset-ask-form`, `reset-choose-form`, `reset-error`, `reset-login` |
| `templates/tutor.html` | 12 | 7 | `tutor-add-key`, `tutor-exit`, `tutor-form`, `tutor-provider-form`, `tutor-score` |
| `templates/dev_authenticator.html` | 5 | 1 | `dev-authenticator-add-form`, `dev-authenticator-info`, `dev-authenticator-info-dialog`, `dev-authenticator-list` |
| `templates/dev_payments_checkout.html` | 10 | 6 | `dev-checkout-closed`, `dev-checkout-email`, `dev-checkout-form`, `dev-checkout-product` |
| `templates/dev_payments_log.html` | 5 | 1 | `dev-payments-empty`, `dev-payments-entry-open`, `dev-payments-entry-status`, `dev-payments-resend` |
| `templates/dev_payments_portal.html` | 8 | 4 | `dev-portal-cancel`, `dev-portal-cancel-now`, `dev-portal-empty`, `dev-portal-update-card` |
| `templates/docs.html` | 9 | 5 | `docs`, `docs-llms`, `docs-nav`, `docs-page-link` |
| `templates/lessons.html` | 9 | 5 | `lessons-bar`, `lessons-item-practise`, `lessons-list`, `lessons-shown` |
| `templates/listening.html` | 4 | 0 | `listening-lesson`, `listening-lesson-mastery`, `listening-lesson-play`, `listening-mixed` |
| `templates/login.html` | 16 | 12 | `login-dev`, `login-dev-forget-google-form`, `login-dev-login`, `login-privacy-link` |
| `static/tutor.js` | 10 | 6 | `tutor-buy-credits`, `tutor-out-of-ai`, `tutor-own-key`, `tutor-upgrade` |
| `static/devbox.js` | 17 | 14 | `dev-jobs`, `dev-text-message`, `login-code-device` |
| `templates/dev_ai_fixture.html` | 7 | 4 | `dev-ai-fixture-form`, `dev-ai-fixture-key`, `dev-ai-fixture-save` |
| `templates/conjugation.html` | 12 | 9 | `drill-again`, `drill-again-misses`, `drill-form` |
| `templates/generate_form.html` | 22 | 19 | `generate-instructions`, `generate-kind`, `generate-depth` |
| `templates/google_confirm.html` | 6 | 3 | `google-confirm`, `google-confirm-cancel`, `google-confirm-form` |
| `templates/guide.html` | 6 | 3 | `guide-continue`, `guide-stages`, `guide-three-star` |
| `templates/landing.html` | 4 | 1 | `landing-course`, `landing-privacy`, `landing-terms` |
| `templates/register_closed.html` | 3 | 0 | `register-closed`, `register-closed-login`, `register-closed-message` |
| `templates/terms.html` | 5 | 2 | `terms-privacy-link`, `terms-raw`, `terms-version` |
| `templates/verify_email.html` | 7 | 4 | `verify-email`, `verify-email-continue`, `verify-email-resend-form` |
| `templates/dev_ai_rows.html` | 5 | 3 | `dev-ai-call-outcome`, `dev-ai-empty` |
| `templates/games.html` | 3 | 1 | `games-list`, `games-play` |
| `templates/lang_picker.html` | 3 | 1 | `lang-picker-form`, `lang-picker-submit` |
| `templates/login_code.html` | 9 | 7 | `login-code-form`, `login-code-notice` |
| `templates/login_code_device_rows.html` | 5 | 3 | `login-code-device-code`, `login-code-device-empty` |
| `templates/map.html` | 5 | 3 | `map-lesson-link`, `map-practice` |
| `templates/matching_pick.html` | 2 | 0 | `matching-board`, `matching-board-play` |
| `templates/terms_accept.html` | 6 | 4 | `terms-accept-error`, `terms-accept-form` |
| `templates/terms_notice.html` | 5 | 3 | `terms-notice-change`, `terms-notice-form` |
| `templates/dev_authenticator_rows.html` | 7 | 6 | `dev-authenticator-account` |
| `templates/error.html` | 5 | 4 | `error-message` |
| `templates/goal_message.html` | 1 | 0 | `goal-message` |
| `templates/history.html` | 4 | 3 | `history-games-scroll` |
| `static/lessons.js` | 3 | 2 | `lessons-to-top` |
| `static/login_code.js` | 2 | 1 | `login-code-resend` |
| `templates/login_code_device.html` | 2 | 1 | `login-code-device-off` |
| `templates/mistakes.html` | 9 | 8 | `mistakes-scroll` |
| `templates/vocabulary.html` | 4 | 3 | `vocabulary-study-all` |
| `templates/vocabulary_lesson.html` | 4 | 3 | `vocabulary-study` |
| `templates/account_deletion.html` | 6 | 6 |  |
| `templates/ai_form.html` | 12 | 12 |  |
| `templates/ai_out.html` | 4 | 4 |  |
| `static/autosave.js` | 3 | 3 |  |
| `templates/base.html` | 17 | 17 |  |
| `static/profile.js` | 37 | 37 |  |
| `templates/content_report_dialog.html` | 7 | 7 |  |
| `templates/course_shared_dot.html` | 4 | 4 |  |
| `templates/copyright.html` | 13 | 13 |  |
| `static/courses.js` | 25 | 25 |  |
| `templates/ai_job_modal.html` | 14 | 14 |  |
| `templates/course_edit.html` | 21 | 21 |  |
| `templates/course_file.html` | 6 | 6 |  |
| `templates/lessons_modes.html` | 1 | 1 |  |
| `static/lesson-editor.js` | 3 | 3 |  |
| `templates/lessons_panel.html` | 58 | 58 |  |
| `templates/lessons_row.html` | 7 | 7 |  |
| `static/word-list.js` | 10 | 10 |  |
| `templates/undo_revision.html` | 5 | 5 |  |
| `templates/course_report.html` | 7 | 7 |  |
| `templates/course_review_modal.html` | 27 | 27 |  |
| `templates/course_share_modal.html` | 13 | 13 |  |
| `templates/courses.html` | 41 | 41 |  |
| `templates/course_copy.html` | 2 | 2 |  |
| `templates/course_settings.html` | 19 | 19 |  |
| `static/course-settings.js` | 8 | 8 |  |
| `static/placement.js` | 4 | 4 |  |
| `templates/course_wizard.html` | 3 | 3 |  |
| `templates/course_stat.html` | 8 | 8 |  |
| `templates/course_translate.html` | 11 | 11 |  |
| `templates/dev_box_kinds.html` | 5 | 5 |  |
| `static/conjugation.js` | 3 | 3 |  |
| `templates/flag_matches.html` | 6 | 6 |  |
| `templates/vocab_study.html` | 8 | 8 |  |
| `static/lesson-check.js` | 1 | 1 |  |
| `templates/lesson_pager.html` | 3 | 3 |  |
| `templates/masked_word.html` | 1 | 1 |  |
| `templates/nav_play.html` | 12 | 12 |  |
| `templates/nav_study.html` | 11 | 11 |  |
| `templates/pager.html` | 4 | 4 |  |
| `templates/password_change.html` | 9 | 9 |  |
| `templates/placement_test.html` | 10 | 10 |  |
| `templates/proxy_signin.html` | 4 | 4 |  |
| `templates/quiz.html` | 11 | 11 |  |
| `static/quiz.js` | 2 | 2 |  |
| `templates/register_sent.html` | 2 | 2 |  |
| `static/say.js` | 1 | 1 |  |
| `templates/self_host.html` | 15 | 15 |  |
| `templates/shop.html` | 10 | 10 |  |
| `templates/sprint.html` | 24 | 24 |  |
| `static/sprint.js` | 5 | 5 |  |
| `templates/sprint_play.html` | 13 | 13 |  |
| `templates/stage.html` | 12 | 12 |  |
| `static/guide.js` | 1 | 1 |  |
| `templates/superuser.html` | 86 | 86 |  |
| `templates/superuser_courses.html` | 78 | 78 |  |
| `templates/superuser_privacy.html` | 19 | 19 |  |
| `templates/waitlist.html` | 10 | 10 |  |
| `templates/dev_box_rows.html` | 5 | 5 |  |
| `templates/dev_google.html` | 2 | 2 |  |
| `templates/pick_tabs.html` | 2 | 2 |  |
| `templates/terms_check.html` | 5 | 5 |  |
| `templates/ai_switch.html` | 4 | 4 |  |

### Every test, by file

Titles as Playwright lists them (from the source: run `npm ci` in e2e/ for loops expanded).

- **`specs/accessibility.spec.ts`** (7, CI): pages fit a phone at 200 % text size (#342) · the root font size follows the browser's text size (#342) · the system's reduce-motion setting stops animations and transitions (#342) · the first Tab reaches a skip link that jumps to the content (#342) · /sprint has one page heading (#342) · status pills and links in text have 4.5:1 contrast (#342) · inline code keeps its size with the % root font size (#342)
- **`specs/ai-off.spec.ts`** (2, local only): the sign-up box hides every AI feature, and the profile turns them back on · the profile's switch is a negative, hides the AI features at once, and asks first when a key would be deleted
- **`specs/authenticator.spec.ts`** (4, CI): a pasted otpauth link is added, shows the RFC 6238 code with a countdown, copies it, and is removed (#291) · the next code shows by itself when the period runs out, and a link's settings are honoured · a scan link opens the form filled in, a setup key works too, and a bad key says why · when the list goes empty, an empty add box offers the example account again (#291)
- **`specs/avatar-colour.spec.ts`** (3, local only): a colour is picked in the course's settings and the corner wears it in the course · a theme picked in the settings adds scenes of its world, offered only while it is picked · a theme brings its own colours for the same slot
- **`specs/consent.spec.ts`** (2, CI): the consent card shows each consent, and Do Not Show Me AI Features hides them and shows them again across a reload · with a key on file the card's AI switch asks first, and the key line goes
- **`specs/content-check.spec.ts`** (3, CI): a course is scanned before it is shared; a high score the AI review passes blocks nothing · the nightly check waits in the job table for the next midnight (#1038)
- **`specs/content-reports.spec.ts`** (1, CI): a shared course is reported, a copyright notice is sent, and the superuser forwards and resolves them
- **`specs/content-review.spec.ts`** (2, CI): a harmful course is flagged with a short report; an override note is followed until new harm comes
- **`specs/course-card-stats.spec.ts`** (2, CI): a course card shows passed and mastered as a bar and chips; a chip opens its stat in a modal and its name fits a phone (#684) · a chip's modal fetches what is next as it opens: the next lesson to pass and each unit, the lessons to practise, and opens the course at one (#900)
- **`specs/course-copy.spec.ts`** (5, CI): the copy icon in Spanish's settings opens Copy of Spanish in the editor, with every file, and lists it · copies count against the account's course bytes: past the limit the copy is refused with a notice · no copy or translate icon in the settings of a course shared with you; copying or translating it, or another user's private course, is a 404 · no copy icon while a course is being generated, nor after it stopped; a POST says why · at phone width the copy icon sits beside the X, inside the dialog and clear of its title
- **`specs/course-report.spec.ts`** (3, local only): the report shows which features each lesson uses · the report fits a phone · the report follows a save
- **`specs/course-table.spec.ts`** (6, CI): a flagged course: search, the reason, override with a note, clear the override · more than five matches show as a count that opens them all · take down and put back from the course's dialog · delete a course after a warning: gone from the table and the owner's list · suspend a course's owner from its dialog: their session ends
- **`specs/course-translate.spec.ts`** (2, CI): Copy and translate to Español makes a translated copy; the original stays as it was · the dialog does not offer the language a course is already explained in
- **`specs/create.spec.ts`** (11, CI): create, close the dialog, name the course and its lessons, then generate into it · editing a lesson's content takes Generate away · a refused generate form comes back open with what was typed · the old pages lead to the new flow · the dialog asks which provider only when two or more keys are saved · the progress modal can't be dismissed while it runs; stopped, the course waits on the list with Resume · a generation that fails before its outline: Retry on the card, the dialog filled in, and a retry writes the same course · a stopped course can be deleted from its card · a stopped course's red ! does not widen the course list on a phone (#675) · a stopped course's red ! tip stays on a phone screen while it shows (#864) · the old status page leads to the editor
- **`specs/custom-themes.spec.ts`** (6, CI): wear a theme you made in a course, see it everywhere in it, then delete it · a theme you made is invisible to everyone else · a made theme is checked like a theme file, within the account's limits · the course settings with your themes fit a phone · a course wears a theme of its own; outside a course the app wears Default (#565, #577) · a themed example's mark reads right for a theme title that starts with The (#952)
- **`specs/dev-ai.spec.ts`** (11, CI): a course generated through the dev AI provider shows in /dev/ai; limits, a timeout and an edited fixture reach the learner · the wire API refuses unknown keys and answers the OpenAI format; the platform key has its own profile · Manual replies: a request waits, folded at the bottom of /dev/ai, for the answer typed there; an ERROR: answer is the provider's error · leaving the tutor while its first turn waits on the model does not lock the tutor (#659) · a learner who leaves the tutor mid-turn twice has one call with the model, not two (#848) · coming back while the tutor is still answering, Cancel lets go of the old reply and starts again (#890) · a slow reply gets a Cancel after ten seconds, and the learner can carry on (#890) · in another language, coming back while the tutor is still answering also offers Cancel (#890) · coming back to the tutor in the same tab after a failed first turn starts again (#932) · each profile's keys are copy buttons in their provider's colours
- **`specs/dev-payments.spec.ts`** (1, local only): subscribe through the fake checkout, fail a renewal, cancel; a tampered webhook signature is refused
- **`specs/docs.spec.ts`** (8, CI): the docs index is reachable logged out, with user and developer pages apart · every user page is served as HTML and as raw Markdown · /llms.txt lists the course format and its schema, and both are served · no docs page scrolls sideways at phone width · a long bare link in docs prose wraps at phone width · every in-app link on every docs page opens, and its #heading exists · a docs page's heading is on the first screen, the page list after it · on a desktop the page list stays beside the page
- **`specs/editor-autosave.spec.ts`** (5, CI): typing in a lesson's JSON saves 3 s after the last keystroke, or when the drawer loses focus · a hidden tab sends what waits at once · what does not validate turns the circle red, says why, and is not stored but stays on the page · a save the server fails is tried again, and a page behind another tab is told to reload · an AI job locks the editor: what was typed is saved first, then nothing is saved until it ends
- **`specs/editor-errors.spec.ts`** (4, local only): a parse error, an unknown prerequisite and a duplicate id each say what, where and how to fix it · a problem in another lesson opens that lesson's drawer · the file page words a parse error as the editor does · Undo takes back changes that cannot be saved, after saving what can (#288)
- **`specs/editor-fixes.spec.ts`** (3, local only): the file page's path box checks the path and logs no console error (#273) · on a phone, Course settings and + Add unit do not overlap (#274) · closing a rename box by clicking elsewhere leaves focus where the click went (#275)
- **`specs/email-owner.spec.ts`** (2, CI): e-mail a flagged course's owner from its dialog, with an edit
- **`specs/exact.spec.ts`** (29, CI): generate a course from the fixtures and open it · revise a generated course with feedback, then undo it · the lesson table: move, rename, delete, add, edit JSON, each saved as it is made, then undo each page's edits · a course someone makes stays private to them · a failed generation explains itself in the dialog; its card keeps the raw detail behind a red ! · a key the provider does not know is refused by the provider, not by the app · the course list fills a desktop row and stacks on a phone (#428) · filter the lessons list, and say when nothing matches · look a word up from the lessons filter: its meaning and the lesson that teaches it (#348) · lessons show one unit at a time, with a tab per unit and page links for a long unit (#837) · the verb drill setup shows one set at a time, and its locked lessons a page at a time (#837) · the verb drill draws the person slots with profile pictures, the learner's own for yo (#879) · pass a lesson's reading check · a lesson's tappable words look tappable and take the keyboard (#403) · tapping a word shows its meaning in a bubble that fits the screen · finish a Sprint mode session · Sprint home fits a phone in every app language (#670) · on a phone the History table scrolls in its box, not the page (#424) · on a phone the Vocabulary term table scrolls in its box, not the page (#933) · on a phone the Study and Play mode bars take two rows and scroll sideways (#423) · the modes' old paths redirect, keeping the query string · the mode bars link to Study's Lessons, not to the /study redirect · Free has no Skrongle AI: no usage card, no own-keys pill, the tutor offers only the saved keys · the dev toggle switches the plan on the profile · the profile has no Dev section for anyone; the seeded keys answer generate and the tutor · the profile's e-mail field: a Verified check, and an (i) dialog with the name others see · Start asks for a course's settings before it opens, then keeps them behind its gear; the password changes in a dialog · a page that does not exist is a 404 inside the normal layout, with ways back (#397) · the site installs as an app: every layout links a manifest that opens at the home page (#345)
- **`specs/explore.spec.ts`** (3, needs a key): explore ${replayPath ? "replay" : who}
- **`specs/forms.spec.ts`** (4, CI): Forms mode: add a word and a question in the lesson editor, see them as JSON, and find them in the file as saved · the JSON switch: a drawer reopened after a flip follows it, and quick flips end where they point · a new lesson is a whole form before it is saved · Forms mode edits the course settings too, and fits a phone
- **`specs/google-signin.spec.ts`** (11, CI): a new learner signs up with Google, confirms the Terms, and signs in again · a learner connects a Google account with the password, nobody else can take it, and unlinks it (#360) · Google with a password account's address: refused while unconfirmed, then joins that account once confirmed · with two-factor sign-in on, Google joins the account only once the code is entered (#677) · registering with a password for a Google-only address makes nothing and mails its owner · a Google-only account confirms with Google before its deletion is scheduled (#376, #970) · signing out everywhere else ends the learner's other sessions (#375) · the ${error} button: the sign-in fails and comes back to the login page with the reason · the register page has the same buttons, each leading to its message · the buttons fit a phone screen · the timeout button: a Google that never answers fails the sign-in after the app's own timeout
- **`specs/home.spec.ts`** (4, local only): the home page has a map row and the Guide as a mode of its own · each mode's explanation sits behind its (i) · the home page fits a phone · the map row and the Guide card are plain links
- **`specs/hosted-ai.spec.ts`** (6, local only): a Plus learner with no key tutors and generates on Skrongle AI; the month's bar goes down · the platform key's limit reaches the learner as a plain sentence · a plan with no Skrongle AI (Free) is not offered it, and the tutor card says why · Basic, the CA$3 plan, has no Skrongle AI either: the tutor and the generator want an own key (#931) · credit packs are not sold without Stripe: the checkout answers 400 · only a superuser sees everyone's Skrongle AI spend today; a learner sees their own bar
- **`specs/i18n.spec.ts`** (2, CI): a visitor picks Spanish on the register page, the account keeps it, the profile changes it back · the browser's language is used before any pick, and an unknown one falls back to English
- **`specs/journeys.spec.ts`** (2, needs a key): journey ${journey.id}
- **`specs/judged.spec.ts`** (3, needs a key): a lesson page teaches a topic and ends with a check · a failed generation explains itself in plain words
- **`specs/lesson-conversations.spec.ts`** (1, CI): a lesson with a conversation shows both speakers' lines
- **`specs/lesson-duplicate.spec.ts`** (1, CI): duplicate a lesson: the copy sits below the original, with new ids, and the course still loads
- **`specs/lesson-editor.spec.ts`** (10, CI): the outline lists every section of the lesson, opens its items, and the preview knows which modes show them · a multiple-choice question: the right answer is picked, a new option grows from the dashed box, the type switch reshapes the form, and it all lands in the file · checks appear under the field as you type, in the outline, and from the server · Try it: the preview grades a question with the course's own checker, in Study and in Sprint · Sprint exercises: buckets and items, the wrong word by a click, tiles and distractors, played in the preview · words, sentences, conversations and tables have their own forms · items are added and deleted from the outline, the Guide's steps are ordered by hand, and the lesson's prerequisites are picked · on a phone the three columns are tabs, and nothing overflows · the gear's Language features switch hides what only a language course fills, and is kept in the course settings · the gear's Levels switch holds the Placement test switch, which adds or takes out placement.json under the course settings (#1106)
- **`specs/lesson-header.spec.ts`** (1, CI): on a phone the lesson text starts under 420 px down a 390x844 screen: one main button, the rest under More ways, the details behind an (i) (#999)
- **`specs/lesson-tables.spec.ts`** (2, CI): the test course's lessons with tables fit a phone · Spanish paradigm tables scroll in their box, not the page
- **`specs/lessons.spec.ts`** (17, local only): the server refuses a table it cannot use, and changes nothing · the last lesson cannot be deleted, and Escape keeps a lesson's name · a stopped run keeps its Resume, and has no editor until it is finished · while an AI lesson is being written, every way to change the course is refused · three saves at once leave a course that loads · a folded unit hides its lessons and still takes new ones · Add unit: named in place, saved in the settings, it takes lessons, an empty one goes at once and one with lessons asks first (#232) · course settings are the first row: pinned, kept, and their units reshape the table as they are typed · add-lesson refusals: short brief, and a reply that cannot be used leaves the course alone · Use AI: Only add appends a lesson each time; Allow overall changes can replace the whole course · a hand-made course can be changed overall too · the download is every file by path, with a format version · Delete this course is a red button that warns first · without JavaScript the table still lists the lessons, and Edit opens one · the lesson table fits a phone · a course with progress warns before a delete, and the table is for the owner's own courses only · a lesson page links the lessons before and after it in course order, below the lesson only, and neither past the ends (#402, #999)
- **`specs/mail.spec.ts`** (7, CI): a new account's confirmation e-mail opens in the dev inbox, and its link confirms the address · the outbox's test message arrives in the inbox and opens from the outbox · the dev mailbox is a mail client's table: bold until opened, archive strikes out and sinks, delete removes, all without a reload (#223) · a superuser resets another account's e-mail verification, and the new link confirms it again · only a superuser sees the Superuser page · the Text section works like the e-mail one, styled like a messaging app (#225) · the dev mailbox needs no sign-in: signed out and as a learner, every section and its JSON open
- **`specs/mistakes.spec.ts`** (1, CI): a wrong quiz answer shows on the mistakes list from the course home, and a right one clears it (#338)
- **`specs/modals.spec.ts`** (5, CI): a Sprint mode stop's sheet closes on a tap outside it and on Escape (#233) · profile dialogs close on a click outside; one with something typed in its form stays (#226) · the Generate dialog closes on a click outside until a wish is typed; a running job's box stays (#226) · the /dev/jobs (i) opens its dialog; OK and a click outside close it (#872) · without JavaScript a server-opened dialog opens in view at ${viewport.width}px (#1057)
- **`specs/moderation.spec.ts`** (4, CI): a course taken down shows its owner why, and comes back when put back · a suspended account is signed out and can't sign in until the suspension is lifted · a superuser deletes an account after a warning; the address can register again · a superuser blocks an address range and removes the block; others get 404
- **`specs/owner-resets.spec.ts`** (3, CI): a superuser sets a temporary password; the learner signs in with it and must choose a new one · a superuser turns off another account's two-factor; others get 404 · reloading after a temporary password is set does not set another
- **`specs/pathways.spec.ts`** (7, CI): switch course: Spanish, then a generated course, then back · generate from notes and uploaded files, with no wish · cancel a slow generation, then resume it to a ready course · resume a run that stopped on a lesson: the resumed job keeps what was written · saving into a stopped run keeps it resumable, and Resume keeps the edit (#81) · the editor opened between the outline and lesson 1 still follows the job to its end · remove an API key: the profile says no key, and generating no longer offers it
- **`specs/privacy-requests.spec.ts`** (2, CI): a superuser sends an account its data; the link goes to that account's address · a superuser forgets an address with no account, not one with an account
- **`specs/profile-autosave.spec.ts`** (8, local only): the green checks come a moment after the save, fade after 5 s, and come back with the next edit · names save a second after typing stops, in one request, and the heading follows · a field that needs attention turns red at once, is not sent, and leaving asks first · the server refusing a field marks it red with the server's reason · without a connection the circle turns red, leaving asks, and it saves once back online · the phone number wears a +1 and punctuates the digits as they are typed (#496) · the profile has no picture and no theme: both are a course's settings · API keys: folded away, a pasted key saves at once, a typed one 3 s after typing stops
- **`specs/profile-nav.spec.ts`** (1, CI): the profile nav marks the section in view
- **`specs/prompt-limits.spec.ts`** (5, local only): the Generate dialog counts the wish and the notes, red outside 20 to the maximum · the server refuses Generate prompts outside the limits, in the same words · Use AI counts its text, red outside 20 to the maximum, and the server agrees · a Generate form that is too short is refused in place, calmly · short notes are refused in place too, and the notes box is the one marked
- **`specs/record.spec.ts`** (3, needs a key): record ${id}
- **`specs/release.spec.ts`** (2, CI): a person joins the waitlist, confirms, is invited and signs up
- **`specs/review-ai-changes.spec.ts`** (14, local only): a generated course reviews as new lessons, listed like the lesson table · Only add shows the new lesson as one column; what was there keeps no red · Use AI runs on the course page: the modal comes up in place, holds the page, then the review opens · a refused Use AI request comes back on the same page, text kept · a revision shows red and green side by side, and later hand edits do not change it · leaving out a line and a lesson: Accept writes only the rest, and the review remembers · editing a line in place as text: it turns blue and stays in, Undo/Redo step through edits, Accept writes them · after a lesson-table edit, leaving out part of a lesson still goes in (#280) · after Undo of the AI run the review says so and offers no Accept (#281) · the review labels lines in plain words, not JSON keys (#282) · Don't show this automatically next time: the review waits for its button · Review changes is a button next to Submit · a course nobody used AI on has no review button · without JavaScript the review is a page whose boxes Accept sends
- **`specs/review-and-revise.spec.ts`** (4, local only): while a revision runs the modal covers the locked workspace; closing the review shows the new lessons · a lesson edited by hand survives a revision that keeps it · undo puts back the files from before the revision and drops later hand edits · a hand edit that breaks the course rules does not block a revision of a course that loads again
- **`specs/revise.spec.ts`** (10, local only): revise a course twice in a row, and undo goes back one step · undo and redo icons right after a revision, from the course page the review closes onto (#231) · Undo and Redo: rows the step adds fade in, rows it takes away fade out, the rest stay still; none under reduced motion (#251) · a generated course is changed with Only add or Allow overall changes, not generated again · feedback that is too short is refused on the form and kept · a plan that removes a lesson drops it, keeps the others' progress, and undo brings it back · a rewritten lesson keeps its progress · a plan the AI cannot give leaves the course alone and offers neither undo nor resume · while a revision runs the course still opens but its files are locked; cancel releases it · two revisions asked for at the same moment start only one job
- **`specs/share-edges.spec.ts`** (9, CI): a used link turns a second account away; the invited address, confirmed later, has the course with or without the link · unknown, odd and padded tokens: a 404 for the link, the usual sign-in pages · removed and invited again: a new link, the old one dead, the learner's progress back · an old link after the share was removed says it no longer works · every mode opens on a shared course with the learner's own progress, kept when the owner edits the lesson · the share removed mid-session: the next page goes to the course list, the API answers 400 · the share removed mid-Sprint: the page goes to the course list instead of 'try again' · the owner deletes the course, or the whole account: the course leaves the learner's list and session · the invitation is in the owner's language; the learner's pages stay in theirs
- **`specs/share-sign-in.spec.ts`** (3, CI): a share link survives the two-factor code step, and cancelling it keeps the link · a share link survives Google sign-up's confirm step · a share link survives a Google sign-in with two-factor on
- **`specs/share.spec.ts`** (2, local only): an invited address registers from the link and has the course read only, until the owner removes the share · a shared course's circle tip wraps inside a phone screen while it shows, even with a long owner address (#954)
- **`specs/smoke/complete-test-course.spec.ts`** (3, CI): K: read and practise every lesson · D: every lesson's session, every exercise kind, the review mix and lightning · G: every Guide stage, in order
- **`specs/smoke/daily-goal.spec.ts`** (1, CI): every mode fills one daily goal and resumes where the learner left off
- **`specs/smoke/drill-first-set.spec.ts`** (2, CI): the drill opens on the first set with unlocked verbs, not Present · Start drill works without JavaScript
- **`specs/smoke/export.spec.ts`** (2, CI): a learner asks for their data, gets the link by e-mail and downloads the JSON once
- **`specs/smoke/french.spec.ts`** (6, CI): the course is on /courses, offers its four levels, and starts at greetings · Study: read greetings, pass its check, practise it; numbers' numeric questions grade · Sprint: play greetings' session right at phone width · Play: play the first stage · Realm: the theme's French material comes first and the course's French praise is used · games: the verb drill (être, avoir) and Date Dash
- **`specs/smoke/happy-paths.spec.ts`** (10, CI): 1. a new learner sees lesson 1 as next · 1b. a stage read in Study but never played says Start in the Guide, not Try again (#417) · 1c. a Guide stage played to the end under 70 % says Try again, not Continue past its last step (#417) · 2. Study: read lesson 1, pass its check, practise it: passed everywhere · 3. Sprint: play lesson 1's session right: passed everywhere · 4. Play: play stage 1: passed everywhere · 5. a lesson passed in one mode is passed in the other two · 6. progress survives logging out and back in · 7. a lesson whose every answer was right is not listed as needing practice (#681) · 8. a Guide stage's quick checks mix their options, so the right one is not always first (#962)
- **`specs/smoke/mini.spec.ts`** (6, CI): a learner registers, signs in again and passes a lesson with nothing configured · there is no dev mailbox and no dev phone · the server says it is a Skrongle mini · the profile has no Subscription, no phone field and no red ! on the e-mail · sharing shows the link once, and a reload goes back to the dialog without inviting again
- **`specs/smoke/motivator-reward.spec.ts`** (1, CI): every mode's end screen shows the worn motivator's reward line
- **`specs/smoke/motivator-skc.spec.ts`** (1, CI): Skrongle Cash draws the hero and reward line, and keeps counting while another motivator is worn
- **`specs/smoke/motivator-social.spec.ts`** (1, CI): Social compares the learner with the course's other learners, anonymously
- **`specs/smoke/motivator-xp.spec.ts`** (1, CI): XP counts what was earned under any motivator and levels the learner up
- **`specs/smoke/motivator.spec.ts`** (1, CI): the motivator worn draws the course home, and switching keeps progress
- **`specs/smoke/password-reset.spec.ts`** (2, CI): registering a taken address looks the same and mails its owner · a forgotten password is reset from the login page through the mailed link
- **`specs/smoke/phone-width.spec.ts`** (2, CI): the main pages and a lesson in each mode fit a 390 px phone · the course editor fits a 390 px phone
- **`specs/smoke/placement.spec.ts`** (2, CI): the placement test suggests the first unit with a miss, shows a report card, and the course starts there · the marking is the server's: answers come back with each unit's tally and no question carries its answer
- **`specs/smoke/practice-empty-check.spec.ts`** (1, CI): Check with no answer asks for one inline, on a choice and on a typed question
- **`specs/smoke/practice-keys.spec.ts`** (2, CI): number keys pick an option and Enter checks, then advances · digits typed into a fill-in answer stay in the text box
- **`specs/smoke/practice-nothing-yet.spec.ts`** (2, CI): a fresh learner's practice shows Read next and no counter · a round with questions still shows the counter
- **`specs/smoke/practice-result-order.spec.ts`** (1, CI): the result box sits above Next, and Hint goes once the answer is graded
- **`specs/smoke/practice-summary.spec.ts`** (2, CI): the mastery change is shown as (+N), and left out when nothing changed · a round too short to count says what it needs the answers for
- **`specs/smoke/privacy.spec.ts`** (3, CI): the privacy policy is served to visitors · deleting the account signs out and waits 14 days; signing in can cancel it (#970) · the AI forms say which provider gets the notes
- **`specs/smoke/self-host.spec.ts`** (3, CI): the self-hosting guide is linked from the docs and readable on a phone · the guide is only linked from the docs, not the landing page or the footer · the self-hosting FAQ is linked from the technical guide and points at the diagnostics
- **`specs/smoke/skc-shop.spec.ts`** (2, CI): the shop sells a badge once, keeps it across motivators and draws it in the worn theme · without JavaScript a refused buy says why
- **`specs/smoke/sprint-check-error.spec.ts`** (2, CI): a failing check shows one error line, replaced on retry, cleared on success · a typed answer stays editable after a failed check (issue #672)
- **`specs/smoke/sprint-ladder.spec.ts`** (2, CI): fast taps all land on the rungs, a filled rung gives its form back, all right grades right · one form on a wrong rung grades wrong, shows the key, and History agrees with its ✗
- **`specs/smoke/sprint-sort.spec.ts`** (2, CI): a wrong bucket shakes the word and keeps it; the sort checks itself, wrong, and History says why · a clean sort, by the keyboard: Enter on the right bucket drops the word, and the sort checks itself right
- **`specs/smoke/start-level.spec.ts`** (4, CI): start Spanish at A2: the A1 lessons are skipped and never offered · change level mid-course (#333): progress stays, every mode moves to the chosen level · the test course offers each of its units as a level · a course without levels never asks for one
- **`specs/smoke/test-course-games.spec.ts`** (7, CI): every game on /play is played here · K Listening: a lesson's round, then a mixed round · Vocabulary: collect words in Sprint mode, then the vocabulary page and the flashcards · Word Match: the lesson board and the all-lessons board · the paradigm drill, Numbers by ear, Date Dash (each drill) · Tutor: a tutored lesson over the fixtures, one tapped and one typed answer · Theme: the worn theme's material comes first, and nothing is missing without it
- **`specs/smoke/verify-email.spec.ts`** (3, CI): a new learner confirms their e-mail address from the link · sending the e-mail again replaces the first link
- **`specs/smoke/wrong-answers-games.spec.ts`** (5, CI): Guide: a stage of wrong quick checks isn't passed and the next stays locked; a replay passes it · Word Match: wrong tries are counted and keep the lesson from passing · the games: a wrong answer is marked wrong and History counts it · flashcards: a card marked not known comes back in the missed deck · Tutor: a wrong answer gets a wrong verdict
- **`specs/smoke/wrong-answers.spec.ts`** (7, CI): K: every question type, answered wrong, is marked wrong and doesn't pass · K: one lesson answered wrong reads as \"1 lesson needs practice\" on the course home and map · K: the 70 % line, 3 of 5 doesn't pass and 4 of 5 does · near misses are wrong, and the answers they nearly are stay right · a reading check with one wrong answer fails, the lesson stays unread, and a retry passes · D: every exercise kind, answered wrong, is marked wrong, and no lesson passes on wrong answers · D: the session's score is the answers given, on the screen, in History and in progress
- **`specs/subscription.spec.ts`** (3, local only): Plus: bought through Checkout, shown with its renewal date, the AI bar and the own-keys pill; cancelled, it lasts the month · Basic: no Skrongle AI card or pill, an Upgrade to Plus button that opens the Billing Portal, and the chooser marks Basic as the current plan · dev login ?plan= takes basic, plus and the old pro (now Plus) through the dev Checkout; anything else names the plans
- **`specs/term-flag.spec.ts`** (3, CI): a course with a listed word can't start or be shared until the word is gone · more than five matches show as a count that opens the list, ten to a page
- **`specs/terms.spec.ts`** (5, CI): the terms page is public and says when it was last updated · registration needs the terms checkbox, and records the acceptance · a material change: the notice cannot be dismissed and blocks the API until accepted · a minor change: the notice shows what changed and Continue closes it for good · the profile's read link shows the terms in a dialog and closes back to the profile (#152)
- **`specs/two-factor.spec.ts`** (4, CI): a phone number is verified with a texted code, and text-message sign-in then works (#292) · e-mail sign-in codes are not offered until the address is verified; then the code comes by e-mail and a wrong one is refused (#292) · an authenticator app is added through the dev authenticator and its codes sign in, each once (#292) · a number that replied STOP gets its sign-in code by e-mail, and START plus a new code turns texts back on (#969)
- **`specs/word-list.spec.ts`** (3, CI): a pasted word list previews, flags what it can't read, and becomes a lesson that opens with the words · a paste past the cap is refused in words, not with a body-limit error · the button follows the Language features switch
