add e2e tests

This commit is contained in:
swithek 2026-08-23 15:36:52 +02:00
parent 0a65395686
commit a9e0e57d33
27 changed files with 3002 additions and 4 deletions

123
.github/workflows/qa-e2e.yml vendored Normal file
View File

@ -0,0 +1,123 @@
name: QA
# the suite guards the composed product, so a merge to main runs it for a
# change anywhere that can break it. Pull requests only trigger it for
# changes to the suite itself: a full run builds a nuxt image and costs
# minutes, and paying that on every push of every frontend or backend PR
# is not worth catching a regression a few minutes earlier than main does.
on:
push:
branches:
- main
paths:
- e2e/**
- web/**
- server/**
- docker/**
- .github/workflows/qa-e2e.yml
pull_request:
branches:
- main
paths:
- e2e/**
- .github/workflows/qa-e2e.yml
# a burst of pushes would otherwise start a full run each, in parallel.
# Grouping per branch means a newer push supersedes the older run and
# cancels it where it stands — on main exactly as anywhere else. A
# superseded run is answering a question about a commit that is no longer
# the branch, so letting it finish only spends minutes on a stale answer.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
lint:
name: Linting e2e
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v7
- name: Set up pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: e2e/package.json
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: e2e/pnpm-lock.yaml
- name: Installing dependencies
run: pnpm install --frozen-lockfile
working-directory: e2e
- name: Run check-lint
run: pnpm run check-lint
working-directory: e2e
test:
name: Testing e2e
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out the code
uses: actions/checkout@v7
- name: Set up pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: e2e/package.json
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: e2e/pnpm-lock.yaml
# the suite drives the product built from this repo, so the go
# images have to be produced here: core's is made by goreleaser and
# compose cannot build it.
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: server/core/go.mod
cache-dependency-path: server/core/go.sum
- name: Set up goreleaser
uses: goreleaser/goreleaser-action@v6
with:
install-only: true
# a runner starts with no layers, so without this the nuxt image is
# rebuilt from scratch every run. The cache config itself lives in
# e2e/docker-compose.ci.yaml, applied below.
- name: Set up Docker buildx
uses: docker/setup-buildx-action@v3
- name: Installing dependencies
run: pnpm install --frozen-lockfile
working-directory: e2e
- name: Install playwright browsers
run: pnpm exec playwright install --with-deps chromium
working-directory: e2e
- name: Run e2e tests
run: make e2e E2E_COMPOSE_EXTRA="-f e2e/docker-compose.ci.yaml"
- name: Upload playwright report
if: failure()
uses: actions/upload-artifact@v5
with:
name: playwright-report
path: e2e/playwright-report
retention-days: 7

2
.gitignore vendored
View File

@ -64,6 +64,8 @@ coverage.*
*.coverprofile
profile.cov
web/coverage/
e2e/playwright-report/
e2e/test-results/
vendor/

View File

@ -75,8 +75,12 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- `server/core/` — Go API server (`oxynote-core`). Go module `github.com/oxynote/oxynote/server/core`. Details: [server/CLAUDE.md](server/CLAUDE.md).
- `server/auth-realtime/` — Node service (`@oxynote/auth-realtime`, **pnpm**) running Better Auth and a Hocuspocus (Yjs) server in one Hono process. Details: [server/CLAUDE.md](server/CLAUDE.md).
- `datagen/` — demo-data generator; separate Go module `github.com/oxynote/oxynote/datagen`. Demo/testing only.
- `e2e/` — Playwright end-to-end suite (`@oxynote/e2e`, **pnpm**) plus the docker-compose stack it drives. Not shipped; it exercises the composed product through a real backend built from this repo. Details: [e2e/CLAUDE.md](e2e/CLAUDE.md).
- `scripts/` — helpers the root Makefile calls. `run-quietly.sh` runs a build step with its output held back, replaying the log only if the step fails.
- `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds), `demo/` (demo-data configs for mariadb/postgres/prometheus).
**One `.gitignore`, and it lives at the repository root.** Components do not carry their own — a rule for a nested directory is written with its path (`e2e/test-results/`, `web/coverage/`), so every exclusion in the repository is readable in one file. The exception is `web/packages/lezer-promql/`, which is a vendored upstream fork and keeps the ignore file it ships with.
## Common commands
From the repository root:
@ -88,8 +92,14 @@ make run # build images + run the dev stack in the foreground (ctrl-c stop
make start # build images + run the dev stack in the background
make dev # backend containers + web dev server on the host (hot reload, :3000)
make stop # stop the dev stack
make e2e # one-shot: build, run playwright, tear the e2e stack down
make e2e-stack-build # build the e2e stack's images, then iterate with `pnpm test`
make e2e-stack-stop # stop the e2e stack and drop its data
```
The e2e stack listens on `:18080` (and mailpit on `:18025`), so it runs alongside the dev stack rather than fighting it for ports.
Component build/test/qa commands are listed in the nested CLAUDE.md files.
## Cross-component contracts

View File

@ -1,4 +1,10 @@
COMPOSE := docker-compose -p oxynote -f docker/docker-compose.dev.yaml
# extra compose files, so CI can layer its build cache on top without
# that config leaking into local builds (see e2e/docker-compose.ci.yaml)
E2E_COMPOSE_EXTRA ?=
E2E_COMPOSE := docker compose -f e2e/docker-compose.yaml $(E2E_COMPOSE_EXTRA)
QUIET := scripts/run-quietly.sh
# backend containers + web dev server on the host with hot reload. Ctrl-c
# stops the web dev server; `make stop` stops the containers.
@ -21,10 +27,35 @@ start: build-go sync-changedetection-key
stop:
$(COMPOSE) --profile web down
# the e2e stack runs on its own ports with throwaway state, so it can be
# up at the same time as the dev stack.
#
# make only ever builds; starting the stack belongs to the suite's own
# setup hook, so there is exactly one thing that runs `compose up`.
# Building has to live here because the hook cannot do it: goreleaser
# produces core's image, which compose cannot build at all, and the two
# images compose does own are only refreshed by an explicit build.
# Iterate with `make e2e-stack-build` once, then `cd e2e && pnpm test`.
.PHONY: e2e
e2e: e2e-stack-build
@( cd e2e && pnpm run test ); status=$$?; \
$(MAKE) --no-print-directory e2e-stack-stop; \
exit $$status
.PHONY: e2e-stack-build
e2e-stack-build:
@$(QUIET) "building go images" $(MAKE) build-go
@$(QUIET) "building stack images" $(E2E_COMPOSE) build
.PHONY: e2e-stack-stop
e2e-stack-stop:
@$(QUIET) "stopping the stack" $(E2E_COMPOSE) down -v
.PHONY: setup
setup:
cd web && pnpm run setup
cd server/auth-realtime && pnpm install
cd e2e && pnpm run setup
cd server/core && go mod download
cd datagen && go mod download
for f in core auth-realtime web; do \
@ -36,6 +67,7 @@ setup:
deps:
cd web && pnpm run deps
cd server/auth-realtime && pnpm install
cd e2e && pnpm install
cd server/core && go mod download
cd datagen && go mod download

View File

@ -28,3 +28,9 @@ OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET=
# limits
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS=5
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS=50
# better-auth's own rate limiter is on unless this is set to "true". It
# buckets by client IP and lets sign-in and sign-up share three requests
# per ten seconds, so switch it off where one address fronts many users
# (a test run, or a proxy that hides the original IP) and rate-limit
# upstream instead.
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED=false

6
e2e/.prettierignore Normal file
View File

@ -0,0 +1,6 @@
pnpm-lock.yaml
playwright-report
test-results
*.md
*.mdx

404
e2e/CLAUDE.md Normal file
View File

@ -0,0 +1,404 @@
# CLAUDE.md
Guidance for the `e2e/` end-to-end suite. Shared working principles and
the TS/JS comment/whitespace rules live in the root
[CLAUDE.md](../CLAUDE.md). The testing standards below are the `web/`
standards ([web/CLAUDE.md](../web/CLAUDE.md)) carried over and adapted —
where a rule differs, it differs because the tier does, and the reason is
stated.
## Project
`@oxynote/e2e` — Playwright tests that drive the composed product through
a browser, plus the docker-compose stack they run against.
**It lives at the repository root, not in `web/`, for two reasons.** The
tests exercise the whole system — the web app through Caddy, real core,
real auth-realtime, real Postgres — so they belong to no single
component. And keeping them out of `web/` keeps them out of `web/`'s
toolchain: Playwright's `test`/`expect` would collide with vitest's, and
the type-aware ESLint program, the knip scan and the Nuxt tsconfig
solution would each need a carve-out for files that are not part of the
app. This package owns its own tsconfig, eslint and prettier configs
instead.
## Common commands
Package manager is **pnpm**. From the repository root:
```sh
make e2e # one-shot: build images, start the stack, run the tests, tear down
make e2e-stack-build # build the stack's images, for iterating
make e2e-stack-stop # stop the stack and drop its data
```
From `e2e/`:
```sh
pnpm setup # deps + the playwright chromium browser
pnpm test # playwright test (brings the stack up first, see below)
pnpm check-lint # check-types + check-eslint + check-fmt + check-knip
pnpm lint # the fixing counterpart of all four
pnpm qa # check-lint + test; qa-fix = lint + test
pnpm check-types # tsc --noEmit
pnpm check-eslint # eslint --max-warnings 0 . (eslint / fmt are the
pnpm check-fmt # fixing counterparts)
pnpm check-knip # dead exports, files and dependencies
```
`pnpm test` runs [global-setup.ts](global-setup.ts), which brings the
stack up itself, so it works whether or not the stack is already running.
**What it never does is rebuild.** Compose builds `web` and
`auth-realtime` when their images are missing outright, but a source
change after that is invisible to it — refreshing them takes an
explicit build.
Core it cannot build at all: the service has no build section, so
`make build-go` is the only thing that produces that image. Teaching the
hook to build would drop goreleaser into the inner loop of every run.
That is the division of labour, and each verb has exactly one owner.
**Make only builds** — goreleaser for core, `compose build` for the two
images compose owns — and **the setup hook is the only thing that runs
`compose up`.** So: after changing backend or frontend source,
`make e2e-stack-build`; after changing only a test, `pnpm test`. Running
`pnpm test` against source you just edited silently tests the previous
build.
Both build steps run quietly, under a ticking line, and replay their
whole log if they fail. Docker and goreleaser say a great deal that
matters only when something breaks.
**The stack has no pnpm scripts of its own**, and should not grow any.
Starting it belongs to `global-setup.ts` and building to make; anything
else — tearing it down by hand, reading logs — is plain `docker compose`
run from this directory, which discovers `docker-compose.yaml` without
being told where it is. A `pnpm stack:*` wrapper would only be a second
name for a command that is already shorter than its alias.
## The stack
[docker-compose.yaml](docker-compose.yaml) is the whole backend, built
from this repository's own source — the same images and Dockerfiles the
dev stack uses, never a published release. It deliberately differs from
`docker/docker-compose.dev.yaml`:
- **No named volumes**, so every run starts from an empty database. This
is what makes the suite deterministic; do not add persistence.
- **No `container_name`**, and its own ports (`:18080` front door,
`:18025` mailpit), so it runs at the same time as the dev stack.
- **Env is inlined and committed.** There is nothing secret in it, so CI
needs no setup step. Unlike `docker/env/*.example.env`, it lists only
the variables that matter — it is a fixture, not an operator template.
- **Every image is pinned**, including the two the dev stack floats
(`minio` and `mailpit`). A run has to fail because this repository
changed, not because an upstream `latest` moved overnight.
- **Only the services the tests touch.** No changedetection,
sockpuppet-chrome, mariadb, datagen, prometheus or grafana.
- **The front door is `docker/Caddyfile` verbatim**, mounted from the dev
stack rather than copied. E2e must exercise the routing that ships,
including the `/api/x` and `/api/internal` blocks; a second copy would
drift.
Two knock-on constraints worth knowing:
- **Core boots with `ASSISTANT_PROVIDER=ollama`.** Core refuses to start
without a valid provider and model, and ollama is the only one that
needs no API key. Nothing dials it.
- **Core cannot have a compose healthcheck** — its image is distroless,
with no shell or http client to probe itself with. `global-setup.ts`
polls it through the front door instead, treating any response under
500 as ready (core answers 401 to an unauthenticated probe; Caddy
answers 502 on its behalf while it is still starting).
**Mailpit is what makes the auth flows testable end to end.** Email
verification is mandatory (`requireEmailVerification`), so tests fetch
the real message from mailpit's REST API and follow the real link —
nothing is stubbed or short-circuited.
## Testing standards
### Layout
- **Test files live in [tests/](tests/), one per user-facing flow**,
named after the flow as a user would name it — `signup.test.ts`,
`login.test.ts`. Never `.spec.ts`: the repo encodes meaning in the
suffix and `.spec` is not one of them.
- There is no mirror of the app's source tree and nothing to pair 1:1
with. `web/` co-locates because a test there has exactly one subject
file; an e2e test has none — it crosses four services by design, so it
is filed by flow instead.
- **Shared code lives in [helpers/](helpers/), one module per concern**
(`auth`, `mailpit`, `i18n`, `page`, `config`). Helpers are setup and
plumbing; they do not carry the assertion a test exists to make.
- The one exception: **a setup helper asserts its own success**, so a
broken precondition is reported where it broke.
`signUpAndVerify` checks the account actually activated rather than
letting the login three steps later fail for a reason that reads like
a login bug. Assertions about the _behaviour under test_ still belong
in the test.
- When two flows need the same starting state, the setup moves into
`helpers/` — never into a shared test file, and never into a test that
another test depends on having run.
### Naming
**The rule is the repo's: the group names the subject, the test name
completes the sentence "it …" with observable behaviour.**
```ts
test.describe("signup", () => {
test("sends a verification link that activates the new account")
test("refuses a password shorter than the minimum")
})
```
Read aloud: "signup sends a verification link that activates the new
account." If the concatenation is not a grammatical sentence, the name is
off — and reporters print exactly that concatenation on failure.
The rule carries over from `web/`; the identifiers do not. Playwright has
no `it` at all, and `describe` is not a typed export — `test()` and
`test.describe()` are the whole API. What a vitest suite writes as
`it("returns null")`, a Playwright suite writes as `test("returns null")`,
and it still reads as "it …" once concatenated.
- **`test.describe` names the flow the way a user would** — `signup`,
`login` — not the page component or the file.
- **Present tense, third person, no "should".**
- **Name the outcome, not the mechanics.**
`test("takes a verified user to workspace creation")`, not
`test("redirects to /welcome")`. The name should survive a route
rename; the URL belongs in the assertion.
- Be specific enough that the failure line diagnoses itself:
`test("signs in")` is useless, `test("rejects an unknown email")` is
not.
- One level of nesting is the ceiling, as in `web/`.
### Parameterized tests
Playwright has no `it.for`. The equivalent is generating tests from a
table at collection time:
```ts
const CASES = [
{ name: "no digit", password: "no-digits-in-here!" },
{ name: "no symbol", password: "Passw0rdPassw0rd" },
]
for (const c of CASES) {
test(`refuses a password with ${c.name}`, async ({ page }) => {
// ...
})
}
```
The rules carry over unchanged from `web/`:
- **A table is only for cases that differ in inputs and expected
output.** The moment two cases differ in their _steps_ — a different
page, a different sequence — they are separate tests, not rows sharing
one callback.
- **Every generated title must be distinct.** Playwright rejects
duplicates within a file, and a shared title makes a report
undiagnosable.
- Case objects use the repo's vocabulary: `name`, `input`, `expected`.
- Generate at module scope. Never call `test()` inside another `test()`.
### Scope — what earns a test here
This is the expensive tier: a browser, a full stack, seconds per case
instead of milliseconds. A test earns that cost only by observing
something no cheaper tier can.
- **Belongs here:** a flow crossing process boundaries — browser →
Caddy → web → auth-realtime → core → Postgres → SMTP. Signup qualifies
because the verification link is minted in auth-realtime, rendered and
sent by core, and redeemed back through the front door.
- **Does not belong here:** field validation messages, loading and empty
states, disabled buttons, copy, component branches — anything decided
in the browser alone. Those are component tests in `web/`, where they
run without a stack.
- **The test to apply: would this still fail if the backend were a
mock?** If yes, it belongs one tier down.
- **Coverage is by flow, not by branch.** `web/` owns "every exported
function is covered" and "every path gets a test"; this tier owns "the
wiring holds". Reaching for one more error-path e2e is nearly always
the wrong instinct — add it as a component test instead.
### A real backend, and no mocks
`web/` says only e2e is allowed a real backend. The counterpart here:
**e2e is required to use it.**
- **Never `page.route()` an application endpoint, never stub a response,
never seed the database out of band.** A stubbed e2e proves nothing
the component tier had not already proven more cheaply.
- **Reach state through the product's own surface.** An account exists
because a test signed up for it. Where the UI genuinely offers no path,
the product's HTTP API is the fallback — never SQL, which would let a
test set up state the product itself cannot produce.
- **Mail is real.** Verification links are read out of mailpit; nothing
marks an account verified behind the app's back.
- Needing a mock to make a case practical is the signal that the case
belongs in the component tier.
- Third-party edges (the OAuth providers) have no doubles here, which is
why only email-password auth is covered.
### Locators
- **Prefer role and placeholder over CSS**: `getByRole("button", { name })`,
`getByPlaceholder(...)`. A restyle must not break a test that still
describes the same user action.
- **Never hardcode a translation.** Assert through `t()` from
[helpers/i18n.ts](helpers/i18n.ts), which resolves the key against the
same locale files the app renders with, interpolation included. A
copied english string is a second, unmaintained definition of the
message: it keeps passing when the copy changes, and a message edit
silently breaks a suite that has nothing to do with it. The rules that
follow from it are the `web/` ones:
- **Interpolated messages take their values through `t()` too** —
`t("onboarding.verify-email.sent-title", { email })`, never a
hand-spliced string and never a bare prefix that skips the
interpolation.
- **Pick the key the component actually renders.** Many messages share
an english value, so the wrong key passes today and diverges the day
one of them is reworded. Follow the component to its key rather than
grepping the locale file for the text.
- **Overlays teleport out of the page's main tree** into `#teleports`.
They are reachable from `page`, but not from a locator scoped to the
container the trigger lives in.
- **Strict mode is a feature.** When a locator matches more than one
element, fix the locator — do not reach for `.first()`.
### Independence & concurrency
- **Every test creates its own account** via `newCredentials()`, which
mints a unique address. Unique-per-test state is what makes
`fullyParallel` safe against a single shared stack — the same
independence rule `web/` states, enforced by construction rather than
by mock resetting.
- **No test depends on another's state or on execution order**, and no
test cleans up after itself: the stack's database is thrown away
wholesale, which is why it carries no volumes.
- **No shared mutable module-level state.** Where several tests need the
same starting point, each builds its own from a helper.
- Browser contexts are per-test by default. Do not carry a signed-in
session across tests unless a fixture makes that sharing explicit.
### Determinism
The `web/` rule — "never sleepy" — applies with more force here, because
this tier has real network and real containers to be tempted by.
- **Never sleep.** No `page.waitForTimeout`, and no bare timeout used as
a synchronisation device. Playwright's assertions already retry.
- **Wait on the condition, not the clock**: `toHaveURL`, `toBeVisible`,
`toHaveText`. For state that lives outside the page — a delivered
email — `expect.poll` with a `message` naming what never arrived, so
the timeout reports the missing thing instead of a bare expiry.
- **Interact only after hydration.** Navigate with `visit()` from
[helpers/page.ts](helpers/page.ts) rather than `page.goto` whenever the
test then drives the page: server-rendered markup accepts clicks before
vue has attached anything to it, and those clicks do nothing.
- **Assert the exact URL when a parameter carries meaning.**
`toHaveURL(".../login?verified=true")` distinguishes an activated
account from a rejected token; a regex on the path alone would pass for
both.
- Any timeout raised above the default carries a comment saying what is
slow and why.
### Failure artifacts
- `trace: "on-first-retry"` and `screenshot: "only-on-failure"`;
`test-results/` and `playwright-report/` are gitignored.
- `docker compose logs` from this directory gives the service side of a
failure — the browser only ever shows one end of it.
- A failure that reproduces only under load or only in CI is almost
always a wait that was never really a wait. Fix the signal; do not
raise the timeout.
### Prior art
A root-level, self-contained e2e package is the norm for repositories
shaped like this one:
[immich](https://github.com/immich-app/immich/tree/main/e2e) (`e2e/`
beside `server/` and `web/`, own package.json and compose file),
[grafana](https://github.com/grafana/grafana/tree/main/e2e-playwright),
[mattermost](https://github.com/mattermost/mattermost/tree/master/e2e-tests)
(beside `server/` and `webapp/`), and
[cal.com](https://github.com/calcom/cal.com/tree/main/apps/web/playwright).
None of them nest the suite under a `tests/` tier, and none share the
frontend's toolchain.
## Formatting & TS
The same gates as `web/`, minus the ones that only make sense there.
Prettier uses **tabs, no semicolons, trailing commas**, and
[.prettierignore](.prettierignore) keeps it off the lockfile and the
markdown — the convention the sibling packages already follow.
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**
(`strictTypeChecked` + `stylisticTypeChecked` over a `projectService`),
for one reason above all: it is what reports a promise that was never
awaited. In a Playwright suite a missing `await` is not a style problem —
the assertion never runs, and the test passes without having checked
anything. It runs with `--max-warnings 0`, and `reportUnusedDisableDirectives`
makes a stale disable an error, so every `eslint-disable` states its
reason after `--` and is removed when it stops applying.
**`eslint-plugin-playwright` is this package's counterpart to `web/`'s
`@vitest/eslint-plugin`,** and it enforces conventions this file would
otherwise only ask for politely: `no-wait-for-timeout` is the "never
sleep" rule, `no-focused-test` and `no-skipped-test` stop a `.only` or a
`.skip` reaching main, and `missing-playwright-await` catches the
unawaited assertion described above. It is scoped to
[tests/](tests/) and [helpers/](helpers/).
**knip** guards dead exports, files and dependencies, and needs no config
file: its Playwright plugin reads [playwright.config.ts](playwright.config.ts),
resolves `globalSetup` from it, and treats the test files as entry
points. Nothing here has `web/`'s auto-import or vendored-directory
blind spots, so a `knip.ts` would only be a file to keep in sync.
TypeScript is strict, with `noUnusedLocals`, `noUnusedParameters`,
`noUncheckedIndexedAccess`, `noImplicitOverride` and
`verbatimModuleSyntax` on — the same posture as the other packages.
## CI
[.github/workflows/qa-e2e.yml](../.github/workflows/qa-e2e.yml) mirrors
the other QA workflows: a `lint` job running `check-lint`, and a `test`
job running `make e2e`. The test job needs more than a node toolchain —
it sets up Go and goreleaser, because the suite runs against images built
from this repository and core's is not something compose can build. On
failure it uploads the html report, which is why the CI reporter is
`github` **and** `html`: annotations on the pull request, and a full
report with traces to download.
**The triggers are asymmetric on purpose.** A merge to `main` runs the
suite for a change anywhere that can break it — `web/`, `server/`,
`docker/`, `e2e/` — because that is where regressions actually come from.
A pull request only triggers it for changes to `e2e/` itself. A full run
builds a nuxt image and costs real minutes on a private repository, and
paying that on every push of every frontend PR buys only a few minutes'
warning over the run on main. `concurrency` supersedes a pull request's
earlier runs but never cancels one on `main`, whose result is the record
for the branch.
**Layer caching is what keeps that affordable.** A runner starts with no
layers, so the workflow sets up buildx and applies
[docker-compose.ci.yaml](docker-compose.ci.yaml) through
`E2E_COMPOSE_EXTRA` — a separate file because `type=gha` is meaningless
outside a runner and would break every local build. Paired with the
dependency layer in `web/Dockerfile.dev`, a push that leaves `web/`
alone reuses the whole image instead of rebuilding it.
## Not covered yet
The desktop/Electron build. Playwright drives it through a different
entry point (`_electron`) and its auth runs over IPC rather than cookies,
so it is a separate track with its own launch mechanics — not a variant
of these tests.

View File

@ -0,0 +1,23 @@
# layer cache for CI only, applied on top of docker-compose.yaml.
#
# it is a separate file because `type=gha` is meaningless outside a
# GitHub runner — putting it in the main compose file would break every
# local build. The workflow supplies it through E2E_COMPOSE_EXTRA.
#
# without this, a runner starts from zero layers and rebuilds the whole
# nuxt image on every run, which is the bulk of the job's wall clock.
services:
web:
build:
cache_from:
- type=gha,scope=e2e-web
cache_to:
- type=gha,mode=max,scope=e2e-web
auth-realtime:
build:
cache_from:
- type=gha,scope=e2e-auth-realtime
cache_to:
- type=gha,mode=max,scope=e2e-auth-realtime

182
e2e/docker-compose.yaml Normal file
View File

@ -0,0 +1,182 @@
# the stack under test: the same images and Dockerfiles the dev stack
# builds, wired to their own ports and to throwaway state.
#
# differences from docker/docker-compose.dev.yaml are deliberate:
# - no named volumes, so every run starts from an empty database
# - no container_name, so this stack coexists with the dev one
# - env is inlined and committed; there is nothing secret to protect
# - only the services the tests exercise (no changedetection, mariadb,
# datagen, prometheus or grafana)
#
# the front door is the dev Caddyfile verbatim: e2e must exercise the
# same routing and the same /api/x and /api/internal blocks that ship.
#
# every image is pinned, including the two the dev stack floats. A run
# has to fail because this repository changed, not because something
# upstream published a new latest overnight.
name: oxynote-e2e
services:
caddy:
image: caddy:2.9-alpine
volumes:
- ../docker/Caddyfile:/etc/caddy/Caddyfile
ports:
- "18080:8080"
depends_on:
web:
condition: service_healthy
core:
condition: service_started
auth-realtime:
condition: service_healthy
web:
image: ghcr.io/oxynote/web:e2e
build:
context: ../web
dockerfile: Dockerfile.dev
environment:
- NUXT_PUBLIC_APP_BASE_URL=http://localhost:18080
- NUXT_PUBLIC_AUTH_REALTIME_API_BASE_HTTP_URL=http://localhost:18080/auth-realtime
- NUXT_PUBLIC_AUTH_REALTIME_API_BASE_WS_URL=ws://localhost:18080/auth-realtime/hocuspocus
- NUXT_PUBLIC_CORE_API_BASE_HTTP_URL=http://localhost:18080/core
- NUXT_PUBLIC_CORE_API_BASE_WS_URL=ws://localhost:18080/core
# SSR runs inside the container, where the public localhost origin
# is unreachable; these send server-side fetches over the compose
# network instead.
- NUXT_CORE_API_INTERNAL_HTTP_URL=http://core:8080
- NUXT_AUTH_REALTIME_API_INTERNAL_HTTP_URL=http://auth-realtime:8081
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/login"]
interval: 5s
timeout: 5s
retries: 30
start_period: 20s
auth-realtime:
image: ghcr.io/oxynote/auth-realtime:e2e
build:
context: ../server/auth-realtime
dockerfile: Dockerfile.dev
environment:
- OXYNOTE_AUTH_REALTIME_BACKEND_URL=http://core:8080
- OXYNOTE_AUTH_REALTIME_DB_DSN=postgresql://devuser:devpass@postgres/devdb?sslmode=disable
- OXYNOTE_AUTH_REALTIME_VALKEY_URL=redis://valkey:6379
- OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL=http://localhost:18080/auth-realtime
- OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN=localhost
- OXYNOTE_AUTH_REALTIME_FRONTEND_URL=http://localhost:18080
- OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL=http://localhost:18080/accept-invite
- OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS=http://localhost:18080
- OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET=e2esecret
- OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS=5
- OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS=50
# every request in a run arrives from one address, and better-auth
# gives sign-in and sign-up three per ten seconds between them, so
# the limiter would fail the suite rather than the code under test.
- OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED=true
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"wget",
"--spider",
"-q",
"http://localhost:8081/api/auth-config",
]
interval: 5s
timeout: 5s
retries: 30
core:
image: ghcr.io/oxynote/core:dev
platform: linux/amd64
environment:
- OXYNOTE_CORE_DB_DSN=postgresql://devuser:devpass@postgres/devdb?sslmode=disable
- OXYNOTE_CORE_MEILISEARCH_DSN=http://meilisearch:7700
- OXYNOTE_CORE_MEILISEARCH_MASTER_KEY=devmasterkey
- OXYNOTE_CORE_VALKEY_NETWORK=tcp
- OXYNOTE_CORE_VALKEY_ADDRESS=valkey:6379
- OXYNOTE_CORE_STORAGE_URL=http://minio:9000
- OXYNOTE_CORE_STORAGE_ACCESS_KEY=sparta
- OXYNOTE_CORE_STORAGE_SECRET_KEY=chad_warrior
- OXYNOTE_CORE_STORAGE_BUCKET=e2e
- OXYNOTE_CORE_SERVER_AUTH_BETTER_AUTH_URL=http://auth-realtime:8081/api/auth/get-session
- OXYNOTE_CORE_AUTH_REALTIME_URL=http://auth-realtime:8081
- OXYNOTE_CORE_SERVER_PUBLIC_URL=http://localhost:18080/core
- OXYNOTE_CORE_BASE_APP_URL=http://localhost:18080
- OXYNOTE_CORE_ORIGINS=http://localhost:18080,localhost:18080
- OXYNOTE_CORE_DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET=e2esigningsecret
# ollama is the one provider that boots without an API key. Nothing
# dials it — the tests never reach the assistant — but core refuses
# to start without a valid provider and model.
- OXYNOTE_CORE_ASSISTANT_PROVIDER=ollama
- OXYNOTE_CORE_ASSISTANT_MODEL=llama3
- OXYNOTE_CORE_ASSISTANT_BASE_URL=http://ollama.invalid:11434
- OXYNOTE_CORE_EMAIL_SMTP_HOST=mailpit
- OXYNOTE_CORE_EMAIL_SMTP_PORT=1025
- OXYNOTE_CORE_EMAIL_SMTP_TLS=none
- OXYNOTE_CORE_EMAIL_FROM_ADDRESS=Oxynote <team@oxynote.io>
depends_on:
postgres:
condition: service_healthy
meilisearch:
condition: service_healthy
minio:
condition: service_healthy
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: devdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 5s
timeout: 5s
retries: 20
valkey:
image: valkey/valkey:9.0-alpine
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 5s
retries: 20
meilisearch:
image: getmeili/meilisearch:v1.23
environment:
- MEILI_MASTER_KEY=devmasterkey
- MEILI_ENV=development
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:7700/health"]
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data
environment:
- MINIO_ROOT_USER=sparta
- MINIO_ROOT_PASSWORD=chad_warrior
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 20
# the tests read verification links out of mailpit's REST API, so its
# http port is published rather than proxied through caddy.
mailpit:
image: axllent/mailpit:v1.30.6
ports:
- "18025:8025"

46
e2e/eslint.config.mjs Normal file
View File

@ -0,0 +1,46 @@
// @ts-check
import eslint from "@eslint/js"
import eslintConfigPrettier from "eslint-config-prettier/flat"
import playwright from "eslint-plugin-playwright"
import tseslint from "typescript-eslint"
export default tseslint.config(
{ ignores: ["playwright-report", "test-results"] },
eslint.configs.recommended,
// type-aware, as in web/: it is what catches a promise that was never
// awaited, which in a playwright suite is the difference between an
// assertion that ran and one that only looked like it did.
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
linterOptions: {
reportUnusedDisableDirectives: "error",
},
},
// the config files are plain JS and are not in the tsconfig project
{
files: ["**/*.mjs", "**/*.js"],
extends: [tseslint.configs.disableTypeChecked],
},
// the playwright rules encode the suite's own conventions: no
// waitForTimeout (the "never sleep" rule), no committed .only or
// .skip, and no assertion left unawaited.
{
files: ["tests/**/*.ts", "helpers/**/*.ts"],
extends: [playwright.configs["flat/recommended"]],
},
eslintConfigPrettier,
{
rules: {
"no-tabs": "off",
indent: ["off"], // clashes with prettier
},
},
)

113
e2e/global-setup.ts Normal file
View File

@ -0,0 +1,113 @@
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"
import { BASE_URL, MAILPIT_URL } from "./helpers/config"
const READY_TIMEOUT = 180_000
const POLL_INTERVAL = 2_000
const TICK_INTERVAL = 1_000
const E2E_DIR = fileURLToPath(new URL(".", import.meta.url))
// bring the stack up and wait until every service the tests touch answers
// through the front door. `up --wait` only covers the containers that
// declare a healthcheck, and core cannot declare one — its image is
// distroless, with no shell or http client to probe itself with.
export default async function globalSetup(): Promise<void> {
await progress("starting the stack", () => compose(["up", "-d", "--wait"]))
await progress("waiting for services", () =>
Promise.all([
waitForService("web", `${BASE_URL}/login`),
waitForService("core", `${BASE_URL}/core/api/github`),
waitForService(
"auth-realtime",
`${BASE_URL}/auth-realtime/api/auth-config`,
),
waitForService("mailpit", `${MAILPIT_URL}/api/v1/info`),
]),
)
}
// progress runs a step under a ticking line, so a terminal waiting on
// docker is never mistaken for a hung one.
async function progress(
label: string,
step: () => Promise<unknown>,
): Promise<void> {
process.stdout.write(` ${label}`)
const ticker = setInterval(() => process.stdout.write("."), TICK_INTERVAL)
try {
await step()
} catch (err) {
process.stdout.write(" failed\n\n")
throw err
} finally {
clearInterval(ticker)
}
process.stdout.write(" ok\n")
}
// compose swallows docker's output and replays it only on failure: the
// container-by-container churn says nothing while it is working, and
// everything once it is not.
function compose(args: string[]): Promise<void> {
const child = spawn("docker", ["compose", ...args], {
cwd: E2E_DIR,
stdio: ["ignore", "pipe", "pipe"],
})
// decoded as text rather than concatenated as buffers, so a multi-byte
// character split across two chunks survives the join.
child.stdout.setEncoding("utf8")
child.stderr.setEncoding("utf8")
let output = ""
const collect = (chunk: string) => {
output += chunk
}
child.stdout.on("data", collect)
child.stderr.on("data", collect)
return new Promise((resolve, reject) => {
child.on("error", reject)
child.on("close", (code) => {
if (code === 0) {
resolve()
return
}
reject(new Error(`docker compose ${args.join(" ")} failed\n\n${output}`))
})
})
}
async function waitForService(name: string, url: string): Promise<void> {
const deadline = Date.now() + READY_TIMEOUT
for (;;) {
try {
const response = await fetch(url)
// any answer from the service itself means it is serving —
// core replies 401 to an unauthenticated probe. Caddy answers
// 502 on its behalf while it is still starting up.
if (response.status < 500) {
return
}
} catch {
// connection refused while the stack is still coming up
}
if (Date.now() > deadline) {
throw new Error(`${name} never became ready at ${url}`)
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL))
}
}

106
e2e/helpers/auth.ts Normal file
View File

@ -0,0 +1,106 @@
import { randomUUID } from "node:crypto"
import { expect, type APIRequestContext, type Page } from "@playwright/test"
import { BASE_URL } from "./config"
import { fetchVerificationLink } from "./mailpit"
import { t } from "./i18n"
import { visit } from "./page"
export interface Credentials {
email: string
password: string
}
// newCredentials mints an account nobody else uses, which is what keeps
// the tests independent of each other on a shared stack. The password
// satisfies the signup rules: at least 16 characters, one digit and one
// symbol.
export function newCredentials(): Credentials {
return {
email: `e2e-${randomUUID()}@oxynote.test`,
password: "e2e-Passw0rd!-secure",
}
}
// submitSignupForm walks the email-password branch of the signup page and
// leaves the browser wherever the app sends it.
export async function submitSignupForm(
page: Page,
credentials: Credentials,
): Promise<void> {
await visit(page, "/signup")
await page
.getByRole("button", {
name: t("onboarding.signup.signup-email-password"),
})
.click()
await page
.getByPlaceholder(
t("onboarding.signup.email-password-form.email-placeholder"),
)
.fill(credentials.email)
await page
.getByPlaceholder(
t("onboarding.signup.email-password-form.password-placeholder"),
)
.fill(credentials.password)
await page
.getByRole("button", {
name: t("onboarding.signup.email-password-form.continue"),
})
.click()
}
// submitLoginForm walks the email-password branch of the login page and
// leaves the browser wherever the app sends it.
export async function submitLoginForm(
page: Page,
credentials: Credentials,
): Promise<void> {
await visit(page, "/login")
await page
.getByRole("button", { name: t("onboarding.login.login-email-password") })
.click()
await page
.getByPlaceholder(
t("onboarding.login.email-password-form.email-placeholder"),
)
.fill(credentials.email)
await page
.getByPlaceholder(
t("onboarding.login.email-password-form.password-placeholder"),
)
.fill(credentials.password)
await page
.getByRole("button", {
name: t("onboarding.login.email-password-form.continue"),
})
.click()
}
// signUpAndVerify creates an account that can log in. Verification is not
// optional: the server refuses to sign in an unverified user, so this is
// the setup any test needing an existing account starts from.
export async function signUpAndVerify(
page: Page,
request: APIRequestContext,
): Promise<Credentials> {
const credentials = newCredentials()
await submitSignupForm(page, credentials)
await expect(page).toHaveURL(/\/verify-email\?/)
await page.goto(await fetchVerificationLink(request, credentials.email))
// asserted here rather than left to the caller: a rejected token still
// lands on the login page, and the sign-in that follows would then fail
// somewhere far away from the reason it failed.
await expect(page).toHaveURL(`${BASE_URL}/login?verified=true`)
return credentials
}

4
e2e/helpers/config.ts Normal file
View File

@ -0,0 +1,4 @@
// the ports the e2e stack publishes; they differ from the dev stack's so
// both can run at once. Changing one means changing docker-compose.yaml.
export const BASE_URL = "http://localhost:18080"
export const MAILPIT_URL = "http://localhost:18025"

44
e2e/helpers/i18n.ts Normal file
View File

@ -0,0 +1,44 @@
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
type MessageNode = string | { [key: string]: MessageNode }
const LOCALE_DIR = fileURLToPath(
new URL("../../web/i18n/locales/en/", import.meta.url),
)
// the locale files are combined the same way the app combines them: each
// file carries its own root namespace key, so merging them at the top
// level reproduces the object the running app resolves keys against.
const MESSAGES: Record<string, MessageNode> = {}
for (const file of readdirSync(LOCALE_DIR).filter((f) => f.endsWith(".json"))) {
Object.assign(
MESSAGES,
JSON.parse(readFileSync(join(LOCALE_DIR, file), "utf8")),
)
}
// t resolves a message the way the app does, so an assertion tracks a
// copy change instead of pinning a second, unmaintained definition of it.
export function t(key: string, values: Record<string, string> = {}): string {
let node: MessageNode | undefined = MESSAGES
for (const part of key.split(".")) {
if (typeof node !== "object") {
break
}
node = node[part]
}
if (typeof node !== "string") {
throw new Error(`no message found for "${key}"`)
}
return node.replace(
/{([\w-]+)}/g,
(placeholder, name: string) => values[name] ?? placeholder,
)
}

71
e2e/helpers/mailpit.ts Normal file
View File

@ -0,0 +1,71 @@
import { expect, type APIRequestContext } from "@playwright/test"
import { MAILPIT_URL } from "./config"
// delivery is asynchronous on both sides — core hands the message to its
// supervisor and mailpit accepts it over SMTP — so the inbox is polled.
const DELIVERY_TIMEOUT = 30_000
const VERIFICATION_LINK = /href="([^"]*\/api\/auth\/verify-email[^"]*)"/
// fetchVerificationLink waits for the account-activation email and hands
// back the link it carries.
//
// The inbox is searched for the message carrying an activation link
// rather than the newest one being taken: an address can receive more
// than one message and nothing guarantees the order they land in. Every
// test signs up a freshly generated address, so a search only ever sees
// that test's own mail.
export async function fetchVerificationLink(
request: APIRequestContext,
address: string,
): Promise<string> {
const found = { link: "" }
await expect
.poll(
async () => {
found.link = await findVerificationLink(request, address)
return found.link
},
{
message: `no activation email was delivered to ${address}`,
timeout: DELIVERY_TIMEOUT,
},
)
.not.toBe("")
return found.link
}
async function findVerificationLink(
request: APIRequestContext,
address: string,
): Promise<string> {
const search = await request.get(`${MAILPIT_URL}/api/v1/search`, {
params: { query: `to:${address}` },
})
expect(search.ok()).toBe(true)
const { messages } = (await search.json()) as { messages: { ID: string }[] }
for (const message of messages) {
const response = await request.get(
`${MAILPIT_URL}/api/v1/message/${message.ID}`,
)
expect(response.ok()).toBe(true)
const { HTML } = (await response.json()) as { HTML: string }
const match = VERIFICATION_LINK.exec(HTML)
if (match?.[1]) {
// an href holds HTML, where a literal "&" is written "&amp;".
// A mail client decodes it on the way to the address bar;
// pulling the attribute straight out of the source skips that
// parser, so the entity has to be resolved here instead.
return match[1].replaceAll("&amp;", "&")
}
}
return ""
}

16
e2e/helpers/page.ts Normal file
View File

@ -0,0 +1,16 @@
import type { Page } from "@playwright/test"
// visit navigates to a path and waits until the app is interactive.
//
// These pages are server-rendered, so their markup — buttons included —
// is served before vue has attached anything to it, and an interaction
// that lands before hydration finishes does nothing. Vue stamps the app
// instance onto the mount root as it finishes, which is the first moment
// a click is guaranteed to register. Navigate through this rather than
// page.goto whenever a test then drives the page.
export async function visit(page: Page, path: string): Promise<void> {
await page.goto(path)
await page.waitForFunction(
() => "__vue_app__" in (document.getElementById("__nuxt") ?? {}),
)
}

34
e2e/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "@oxynote/e2e",
"private": true,
"type": "module",
"packageManager": "pnpm@11.1.3",
"scripts": {
"deps": "pnpm install",
"setup": "pnpm run deps && playwright install chromium",
"test": "playwright test",
"check-types": "tsc --noEmit",
"eslint": "eslint --fix --max-warnings 0 .",
"check-eslint": "eslint --max-warnings 0 .",
"fmt": "prettier --write .",
"check-fmt": "prettier --check .",
"knip": "knip --fix",
"check-knip": "knip",
"lint": "pnpm run check-types && pnpm run knip && pnpm run eslint && pnpm run fmt",
"check-lint": "pnpm run check-types && pnpm run check-eslint && pnpm run check-fmt && pnpm run check-knip",
"qa": "pnpm run check-lint && pnpm run test",
"qa-fix": "pnpm run lint && pnpm run test"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@playwright/test": "^1.62.1",
"@types/node": "^20.11.17",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-playwright": "^2.11.0",
"knip": "^6.32.2",
"prettier": "3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
}
}

26
e2e/playwright.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test"
import { BASE_URL } from "./helpers/config"
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
// github gives inline annotations on the PR; html is what the
// workflow uploads when a run fails, traces and all.
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: [["list"]],
globalSetup: "./global-setup.ts",
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})

1590
e2e/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
e2e/prettier.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
trailingComma: "all",
useTabs: true,
tabWidth: 2,
semi: false,
}

33
e2e/tests/login.test.ts Normal file
View File

@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test"
import {
newCredentials,
signUpAndVerify,
submitLoginForm,
} from "../helpers/auth"
import { t } from "../helpers/i18n"
test.describe("login", () => {
test("takes a verified user to workspace creation", async ({
page,
request,
}) => {
const credentials = await signUpAndVerify(page, request)
await submitLoginForm(page, credentials)
// a logged in user without an organization is routed to onboarding,
// so landing there is what proves the session was established.
await expect(page).toHaveURL(/\/welcome$/)
await expect(page.getByText(t("onboarding.welcome.title"))).toBeVisible()
await expect(page.getByText(credentials.email)).toBeVisible()
})
test("rejects an unknown email and password", async ({ page }) => {
await submitLoginForm(page, newCredentials())
await expect(
page.getByText(t("onboarding.login.errors.invalid-credentials")),
).toBeVisible()
await expect(page).toHaveURL(/\/login$/)
})
})

35
e2e/tests/signup.test.ts Normal file
View File

@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test"
import { newCredentials, submitSignupForm } from "../helpers/auth"
import { BASE_URL } from "../helpers/config"
import { t } from "../helpers/i18n"
import { fetchVerificationLink } from "../helpers/mailpit"
test.describe("signup", () => {
test("sends a verification link that activates the new account", async ({
page,
request,
}) => {
const credentials = newCredentials()
await submitSignupForm(page, credentials)
await expect(
page.getByText(t("onboarding.verify-email.sent-heading")),
).toBeVisible()
await expect(
page.getByText(
t("onboarding.verify-email.sent-title", {
email: credentials.email,
}),
),
).toBeVisible()
await page.goto(await fetchVerificationLink(request, credentials.email))
// better-auth appends an error parameter to the callback URL when a
// token is rejected, so the bare success URL is what proves the
// account was actually activated.
await expect(page).toHaveURL(`${BASE_URL}/login?verified=true`)
await expect(page.getByText(t("onboarding.login.title"))).toBeVisible()
})
})

21
e2e/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
},
"exclude": ["node_modules"]
}

38
scripts/run-quietly.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/sh
# Run a command without its output: tick a line while it works, and
# replay the whole log only if it fails. goreleaser and docker are both
# very chatty, and none of what they say matters unless the step breaks.
#
# usage: run-quietly.sh <label> <command> [args...]
set -u
label=$1
shift
log=$(mktemp) || exit 1
"$@" >"$log" 2>&1 &
pid=$!
# an interrupt has to take the child with it and stop here — falling
# through would report a failure and replay a log that is already gone.
trap 'kill "$pid" 2>/dev/null; rm -f "$log"; exit 130' INT TERM
trap 'rm -f "$log"' EXIT
printf ' %s' "$label"
# kill -0 sends no signal, it only asks whether the process is still
# there; wait then reports the exit status the shell held on to.
while kill -0 "$pid" 2>/dev/null; do
printf '.'
sleep 1
done
if wait "$pid"; then
printf ' ok\n'
else
printf ' failed\n\n'
cat "$log"
exit 1
fi

View File

@ -23,6 +23,7 @@ const PUBLIC_AUTH_BASE_URL = process.env
const AUTH_ORIGIN = new URL(PUBLIC_AUTH_BASE_URL).origin
<<<<<<< HEAD
// the canonical MCP protected-resource URL (RFC 8707/9728) — core's MCP
// endpoint as clients reach it through the front door. Issued access tokens
// are audience-bound to it.
@ -32,6 +33,16 @@ export const MCP_RESOURCE = process.env
// MCP access tokens carry better-auth's resolved base URL (origin + basePath)
// as their issuer. The internal MCP session endpoint verifies against it.
export const MCP_TOKEN_ISSUER = `${AUTH_ORIGIN}/api/auth`
=======
// better-auth's limiter buckets by client IP, and sign-in and sign-up
// share one allowance of three requests per ten seconds. That is wrong
// for any caller whose traffic arrives from a single address on behalf
// of many users — an end-to-end test run, or a deployment fronted by a
// proxy that does not pass the original IP through — so it can be
// switched off and left to whatever sits in front.
const RATE_LIMIT_DISABLED =
process.env.OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED === "true"
>>>>>>> c8db990 (add e2e tests)
const GOOGLE_CONFIGURED = Boolean(
process.env.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID &&
@ -166,6 +177,9 @@ export const auth = betterAuth({
})
},
},
rateLimit: {
enabled: !RATE_LIMIT_DISABLED,
},
advanced: {
cookiePrefix: "auth",
crossSubDomainCookies: {

View File

@ -167,7 +167,10 @@ Note `noUncheckedIndexedAccess` makes every index access `T | undefined`, and Ty
| `.bench.ts` | `vitest bench` | perf on hot paths (diff/lcs) — add only when a regression bites |
- Only **e2e** (Playwright, incl. visual regression via `toHaveScreenshot`)
lives outside the source tree, in `tests/`.
lives outside this component altogether, in the repo-root
[e2e/](../e2e/) package. Those tests drive the whole composed product
against a real backend, so they belong to no single component and stay
out of `web/`'s toolchain — see [e2e/CLAUDE.md](../e2e/CLAUDE.md).
- There is no separate "integration" tier: component tests are the
integration layer (real children, mocked IO); e2e covers cross-system.
- Co-location inside `app/` is safe by design: Nuxt's default `ignore`

View File

@ -10,11 +10,21 @@ WORKDIR /app
# the vite client build exceeds node's default ~2GB heap
ENV NODE_OPTIONS="--max-old-space-size=4096"
# dependencies come from the manifests alone, so this layer survives every
# change that does not touch them — which is what makes a rebuild after an
# ordinary source edit cheap. Scripts are skipped here because the root
# `prepare` builds the promql grammar and runs `nuxt prepare`, and neither
# has its sources at this point.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/lezer-promql/package.json ./packages/lezer-promql/
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY . .
# install runs `prepare` (lezer-promql build + nuxt prepare); the node-server
# preset produces a self-contained SSR server in .output that reads
# NUXT_PUBLIC_* / NUXT_* runtime config from env at startup.
# the dependency tree is already in place, so this install only runs the
# scripts skipped above (`prepare`, and the allowBuilds native packages).
# The node-server preset produces a self-contained SSR server in .output
# that reads NUXT_PUBLIC_* / NUXT_* runtime config from env at startup.
RUN pnpm install --frozen-lockfile && \
NITRO_PRESET=node-server pnpm run build:web