mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add e2e tests
This commit is contained in:
parent
0a65395686
commit
a9e0e57d33
123
.github/workflows/qa-e2e.yml
vendored
Normal file
123
.github/workflows/qa-e2e.yml
vendored
Normal 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
2
.gitignore
vendored
|
|
@ -64,6 +64,8 @@ coverage.*
|
|||
*.coverprofile
|
||||
profile.cov
|
||||
web/coverage/
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
|
||||
vendor/
|
||||
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
32
Makefile
32
Makefile
|
|
@ -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
|
||||
|
||||
|
|
|
|||
6
docker/env/auth-realtime.example.env
vendored
6
docker/env/auth-realtime.example.env
vendored
|
|
@ -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
6
e2e/.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pnpm-lock.yaml
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
*.md
|
||||
*.mdx
|
||||
404
e2e/CLAUDE.md
Normal file
404
e2e/CLAUDE.md
Normal 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.
|
||||
23
e2e/docker-compose.ci.yaml
Normal file
23
e2e/docker-compose.ci.yaml
Normal 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
182
e2e/docker-compose.yaml
Normal 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
46
e2e/eslint.config.mjs
Normal 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
113
e2e/global-setup.ts
Normal 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
106
e2e/helpers/auth.ts
Normal 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
4
e2e/helpers/config.ts
Normal 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
44
e2e/helpers/i18n.ts
Normal 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
71
e2e/helpers/mailpit.ts
Normal 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 "&".
|
||||
// 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("&", "&")
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
16
e2e/helpers/page.ts
Normal file
16
e2e/helpers/page.ts
Normal 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
34
e2e/package.json
Normal 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
26
e2e/playwright.config.ts
Normal 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
1590
e2e/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
e2e/prettier.config.js
Normal file
6
e2e/prettier.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
trailingComma: "all",
|
||||
useTabs: true,
|
||||
tabWidth: 2,
|
||||
semi: false,
|
||||
}
|
||||
33
e2e/tests/login.test.ts
Normal file
33
e2e/tests/login.test.ts
Normal 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
35
e2e/tests/signup.test.ts
Normal 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
21
e2e/tsconfig.json
Normal 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
38
scripts/run-quietly.sh
Executable 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
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user