15 KiB
AGENTS.md
Guidance for the e2e/ suite. Shared principles and TS/JS style live in the
root AGENTS.md. The testing standards are web/'s
(web/AGENTS.md) adapted to this tier; where a rule
differs, the difference is stated.
Project
@oxynote/e2e: Playwright tests driving the composed product through a
browser, plus the compose stacks they run against.
Two suites share every test. The dev suite drives the four containers
this repo builds; the prod suite drives the all-in-one image
(docker/prod/) and adds prod-*.test.ts.
Everything belonging to one stack says which, and dev is never the
unnamed default: docker-compose.{dev,prod}.yaml,
playwright.{dev,prod}.config.ts, test:{dev,prod}, make e2e-{dev,prod},
qa-e2e-{dev,prod}.yml. Use prod, never production, in names.
The package lives at the repo root, not in web/: the tests belong to no
component, and Playwright's test/expect would collide with vitest's
inside web's toolchain. It owns its own tsconfig, eslint and prettier
configs.
Commands
make e2e-dev | e2e-dev-stack-build | e2e-dev-stack-stop # from the repo root
make e2e-prod | e2e-prod-stack-build | e2e-prod-stack-stop
pnpm setup # deps + playwright chromium (from e2e/)
pnpm test:dev # full cycle against the dev stack; test:prod likewise
pnpm check-lint # check-types + eslint + prettier + knip; lint = fixing variant
pnpm qa # check-lint + test:dev; qa-fix = lint + test:dev
The playwright config owns the whole cycle: global-setup.ts builds and
starts the stack, global-teardown.ts stops it and drops its volumes, so
every entry point (make, pnpm, the VS Code extension) runs the same
cycle and nothing tests a stale image. The build shells out to make e2e-*-stack-build because compose cannot build core (goreleaser does) and
because E2E_DEV_COMPOSE_EXTRA is how CI layers docker-compose.dev.ci.yaml
over the base file.
Both configs set E2E_STACK as their first statement; helpers/config.ts
resolves ports from it and helpers/stack.ts picks the compose file and
make targets. Neither config sets use.baseURL (a config cannot influence a
module it imports), so visit() splices the origin on.
To iterate without a full build each run: make e2e-dev-stack-build once,
then pnpm exec playwright test -c playwright.dev.config.ts. -c is
required; neither config has playwright's default filename. You are then
responsible for rebuilding after source changes; a stale image fails
silently.
The stack has no pnpm scripts and should not grow any. Logs and container
access are plain docker compose -f docker-compose.{dev,prod}.yaml ... from
this directory.
The dev stack
docker-compose.dev.yaml is the backend built from this repo's source,
differing from docker/docker-compose.dev.yaml in:
- No named volumes; every run starts empty. Do not add persistence.
- No
container_name, own ports (:18080front door,:18025mailpit). - Env inlined and committed; a fixture, not an operator template.
- Every image pinned.
- Images tagged
:e2e-dev(:e2e-prodin the prod stack), never:dev, so a run never replaces the imagemake startruns. Core's tag threads throughmake build-go CORE_IMAGE_TAG=e2e-dev→CORE_SNAPSHOT_TAGin.goreleaser.yml; the prod image takesPROD_IMAGE_TAG. - Only the services the tests touch: no changedetection, mariadb, datagen, prometheus, grafana.
docker/Caddyfilemounted verbatim, so the/api/xand/api/internalblocks that ship are what is tested.- Core has no compose healthcheck (distroless image);
global-setup.tspolls it through the front door, treating any response under 500 as ready.
Mailpit makes the auth flows testable: with an email sender, verification is mandatory, so tests fetch the real message via mailpit's REST API and follow the link. Neither stack runs valkey or an object store; that is the configuration most installs run. A test that needs either says why.
The prod stack
docker-compose.prod.yaml is docker/prod/docker-compose.example.yaml
retuned as a fixture (:19080, :19025, inlined env, pinned images). It
sets no OXYNOTE_DB_DSN, so the image runs its embedded Postgres. A second
instance, oxynote-without-email (:19081), sets no OXYNOTE_SMTP_DSN;
only prod-without-email.test.ts drives it, through the origin in
helpers/without-email.ts.
A third, oxynote-single-workspace (:19082), keeps the default workspace
limits; only prod-single-workspace.test.ts drives it, through
helpers/single-workspace.ts. Its tests share the one workspace and its
admin, so the file runs serially, without retries, read-only tests first.
Compose builds nothing; the image comes from make prod-build, tagged
:e2e-prod, and CI's cache reaches it through PROD_BUILD_EXTRA. A probe
container (idle alpine on the private network) exists for the
trust-boundary tests: internal ports are not published, so only a sibling
container can tell loopback from wildcard binds. Postgres speaks no HTTP, so
its port is probed with nc -z, against a control on the front door.
Testing standards
Layout
- Test files live in
tests/, one per user-facing flow, named as a user would (signup,login,editor,collaboration). Never.spec.ts. There is nothing to pair 1:1 with; an e2e test crosses four services. prod-*.test.tsis the one exception to naming by flow: each observes something only true of the all-in-one image. The dev config ignores them (testIgnore). A test earns the prefix only if it would be meaningless or misleading against the dev stack.- Shared code lives in
helpers/, one module per concern. Helpers are setup and plumbing, not the assertion a test exists to make. A setup helper does assert its own success (signUpAndVerifychecks the account activated) so a broken precondition is reported where it broke. signUpWithWorkspaceis the starting point for almost everything: signup, verify, login, create workspace, welcome document open. There is no seeding.- Shared starting state moves into
helpers/, never into a test another test depends on.
Naming
test.describe names the flow as a user would; test() completes "it …"
with observable behaviour, present tense, no "should", naming the outcome
not the mechanics (test("takes a verified user to workspace creation"),
not "redirects to /welcome"). Specific enough to diagnose from the failure
line. One level of nesting is the ceiling.
Parameterized tests
Playwright has no it.for; generate tests from a table at module scope
(for (const c of CASES) test(\refuses a password with ${c.name}`, ...)). A table is only for cases differing in inputs and expected output; different steps mean separate tests. Every generated title must be distinct. Case fields: name, input, expected. Never call test()inside atest()`.
Scope
This is the expensive tier. A test earns its cost only by observing what no cheaper tier can:
- Belongs here: a flow crossing process boundaries (browser → Caddy → web → auth-realtime → core → Postgres → SMTP).
- Does not: validation messages, loading/empty states, disabled
buttons, copy, component branches. Those are
web/component tests. - The test: would this still fail if the backend were a mock? If yes, it belongs one tier down. Coverage here is by flow, not by branch.
- A negative case belongs here when the server decides it: "user A
cannot open user B's document" is answered by core alone. Access denials
are grouped in
tests/access.test.ts, since a feature test always runs as the user who has access. - The trust boundary is tested from outside the image in
tests/prod-trust-boundary.test.ts: the loopback binds (throughprobe) and caddy's prefix blocks are asserted separately, since either alone is a single point of failure. The bind table carries a positive control (the probe reaching the front door). Front-door cases userawRequestovernode:http, becausefetchnormalises.., duplicate slashes and encoding away; recognised spellings must be403, traversal attempts must merely never succeed. - A denial that does not hold yet is
test.fail()with a comment saying what is missing. Check its duration: a hanging test also registers as an expected failure.
A real backend, no mocks
- Never
page.route()an application endpoint, stub a response or seed the database out of band. Reach state through the product's own surface; the HTTP API is the fallback where the UI offers no path, never SQL. - Mail is real (mailpit). Third-party edges (OAuth providers) have no doubles, so only email-password auth is covered.
- Needing a mock is the signal the case belongs in the component tier.
- Collaboration runs as two real users:
joinAsSecondUsersends the invitation from settings and has the invitee sign up, verify, log in and accept in a fresh context. Presence assertions key on the caret's name label, never its colour (random per client).
Locators
- Prefer role and placeholder over CSS.
- Never hardcode a translation. Assert through
t()fromhelpers/i18n.ts, interpolation included (t("onboarding.verify-email.sent-title", { email })), and pick the key the component actually renders rather than grepping the locale file for the text. - Overlays teleport into
#teleports; menus and dialogs are appended to<body>. Reach them frompage(openSlashMenu(),getByRole("dialog")), not from a locator scoped to the trigger's container. - Strict mode is a feature: fix a locator that matches twice, do not
.first().sidebarDocument()andopenDocumentActions()scope the common duplicates. - Read editor text through
editorText(): a collaborator's caret label is rendered inside the paragraph, so a plain text read splices the other user's name into the content. - The title and the body are two editors (
titleEditor(),contentEditor()); Enter in the title moves into the body. With "show changes" on, the body is a third,diffEditor(), and everything an editor renders outside its ProseMirror element (handle, bubble menu, comment popover) is reached through its pane,contentPane()ordiffPane(). - Block handles come through
openBlockMenu(), which hovers the block until the handle shows: the handle stays away while the pointer is level with anydrag-handle-ignoreelement in the editor, such as a parameter separator in the other column of a split block.
Independence & concurrency
- Every test mints its own account and workspace (
newCredentials(),newWorkspace()), which is what makesfullyParallelsafe against one shared stack. No test depends on another or cleans up; the database is thrown away wholesale. - Workers: four locally, two on CI. Turning workers down is not how a flake gets fixed: every failure that only appeared under load was a real defect (a session read once, sync connections never torn down, a toast asserted after auto-dismiss). Chase it. The test budget is 60s; collaboration tests are marked slow.
- Browser contexts are per-test; share a session only through an explicit fixture.
Determinism
- Never sleep: no
page.waitForTimeout, no bare timeout as synchronisation. Wait on the condition (toHaveURL,toBeVisible); for state outside the page (a delivered email) useexpect.pollwith amessage. - Interact only after hydration: navigate with
visit()(helpers/page.ts), since SSR markup accepts clicks before vue has attached. - Assert the exact URL when a parameter carries meaning (
?verified=true). documentPersisted()is the one permittedwaitForTimeout: hocuspocus stores two seconds after the last change and nothing reports it.- The editor's selection lags the browser's caret. A click or an arrow
key moves the caret at once; tiptap learns of it from the queued
selectionchangeevent. A key tiptap handles itself (Enter, Backspace) or a click handler that reads the selection, sent straight after, acts on the old one. Place and extend selections throughplaceCaret()andselectText(), and open a text comment throughopenTextComment(), which all wait for the editor to catch up. NeverHomeorEnd: macOS chromium binds them to the document, not the line. - Three waits are raised above the default (post-signup redirect, post-login redirect, cold document load), all cross-service chains. Any other raised timeout carries a comment saying what is slow.
A test that fails after a code change
- Never edit a test just to make it pass again. First decide whether the code or the test is wrong. A failure caused by anything other than an intended change is a bug the test caught; fix the code.
- Change a test only where the behaviour it asserts changed on purpose, and change only those assertions. Name that intended change when reporting the edit.
Failure artifacts
trace: "on-first-retry", screenshot: "only-on-failure"; test-results/
and playwright-report/ are gitignored. docker compose logs gives the
service side. A failure only under load or in CI is almost always a wait
that was not a wait: fix the signal, do not raise the timeout.
Formatting & TS
Prettier: tabs, no semicolons, trailing commas. ESLint is type-aware
(strictTypeChecked + stylisticTypeChecked), above all because it reports
an unawaited promise, which in Playwright is an assertion that never ran.
--max-warnings 0; every eslint-disable states its reason after --.
eslint-plugin-playwright (scoped to tests/ and helpers/) enforces
no-wait-for-timeout, no-focused-test, no-skipped-test and
missing-playwright-await. knip needs no config: its Playwright plugin
resolves both configs and their global hooks. TypeScript is strict with
noUnusedLocals, noUnusedParameters, noUncheckedIndexedAccess,
noImplicitOverride, verbatimModuleSyntax.
CI
qa-e2e-dev.yml: a lint job and a test job running make e2e-dev (it
sets up Go and goreleaser for core's image and uploads the html report on
failure, so the reporter is github and html). Triggers are asymmetric
on purpose: a merge to main runs it for changes anywhere that can break
it; a pull request only for changes to e2e/, since a full run builds a
nuxt image. concurrency supersedes earlier runs. Layer caching comes from
docker-compose.dev.ci.yaml via E2E_DEV_COMPOSE_EXTRA (type=gha would
break local builds).
qa-e2e-prod.yml has no trigger of its own: release.yml calls it and
refuses to publish unless it passes; workflow_dispatch re-verifies an
image without a release. Its cache reaches the build through
PROD_BUILD_EXTRA. Every workflow release.yml calls declares
workflow_call; path filters do not apply to a called workflow, so each
runs in full on a tag. The dev suite is deliberately not a gate and is the
one QA workflow without workflow_call.
Not covered
The Electron build: Playwright drives it through _electron and its auth
runs over IPC, a separate track.