diff --git a/.github/workflows/qa-e2e.yml b/.github/workflows/qa-e2e.yml new file mode 100644 index 00000000..c97a76e0 --- /dev/null +++ b/.github/workflows/qa-e2e.yml @@ -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 diff --git a/.gitignore b/.gitignore index 577c5326..48d5a355 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ coverage.* *.coverprofile profile.cov web/coverage/ +e2e/playwright-report/ +e2e/test-results/ vendor/ diff --git a/CLAUDE.md b/CLAUDE.md index 761dd71a..0b0d76c4 100644 --- a/CLAUDE.md +++ b/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 diff --git a/Makefile b/Makefile index c1186e98..2fe96fe4 100644 --- a/Makefile +++ b/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 diff --git a/docker/env/auth-realtime.example.env b/docker/env/auth-realtime.example.env index 766fa000..1266aba8 100644 --- a/docker/env/auth-realtime.example.env +++ b/docker/env/auth-realtime.example.env @@ -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 diff --git a/e2e/.prettierignore b/e2e/.prettierignore new file mode 100644 index 00000000..4bc193a1 --- /dev/null +++ b/e2e/.prettierignore @@ -0,0 +1,6 @@ +pnpm-lock.yaml +playwright-report +test-results + +*.md +*.mdx diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md new file mode 100644 index 00000000..b1acb2a9 --- /dev/null +++ b/e2e/CLAUDE.md @@ -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. diff --git a/e2e/docker-compose.ci.yaml b/e2e/docker-compose.ci.yaml new file mode 100644 index 00000000..f3b8b2ad --- /dev/null +++ b/e2e/docker-compose.ci.yaml @@ -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 diff --git a/e2e/docker-compose.yaml b/e2e/docker-compose.yaml new file mode 100644 index 00000000..a180f858 --- /dev/null +++ b/e2e/docker-compose.yaml @@ -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 + 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" diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs new file mode 100644 index 00000000..37f0b6e3 --- /dev/null +++ b/e2e/eslint.config.mjs @@ -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 + }, + }, +) diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..8ee4e80e --- /dev/null +++ b/e2e/global-setup.ts @@ -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 { + 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, +): Promise { + 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 { + 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 { + 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)) + } +} diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 00000000..a08741f5 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/e2e/helpers/config.ts b/e2e/helpers/config.ts new file mode 100644 index 00000000..f2eea336 --- /dev/null +++ b/e2e/helpers/config.ts @@ -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" diff --git a/e2e/helpers/i18n.ts b/e2e/helpers/i18n.ts new file mode 100644 index 00000000..1e27f612 --- /dev/null +++ b/e2e/helpers/i18n.ts @@ -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 = {} + +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 { + 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, + ) +} diff --git a/e2e/helpers/mailpit.ts b/e2e/helpers/mailpit.ts new file mode 100644 index 00000000..8ba9b25d --- /dev/null +++ b/e2e/helpers/mailpit.ts @@ -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 { + 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 { + 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 "" +} diff --git a/e2e/helpers/page.ts b/e2e/helpers/page.ts new file mode 100644 index 00000000..a8132e06 --- /dev/null +++ b/e2e/helpers/page.ts @@ -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 { + await page.goto(path) + await page.waitForFunction( + () => "__vue_app__" in (document.getElementById("__nuxt") ?? {}), + ) +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..f45d0fc2 --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..305cd44a --- /dev/null +++ b/e2e/playwright.config.ts @@ -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"] }, + }, + ], +}) diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml new file mode 100644 index 00000000..33787506 --- /dev/null +++ b/e2e/pnpm-lock.yaml @@ -0,0 +1,1590 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.39.5 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^20.11.17 + version: 20.19.43 + eslint: + specifier: ^9.33.0 + version: 9.39.5(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-playwright: + specifier: ^2.11.0 + version: 2.11.0(eslint@9.39.5(jiti@2.7.0)) + knip: + specifier: ^6.32.2 + version: 6.32.2 + prettier: + specifier: 3.6.2 + version: 3.6.2 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.40.0 + version: 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + +packages: + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oxc-parser/binding-android-arm-eabi@0.143.0': + resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.143.0': + resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.143.0': + resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.143.0': + resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.143.0': + resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-playwright@2.11.0: + resolution: {integrity: sha512-zOIEYyv1TSn2izXkyg/yj0LXc4YBQI6pl3xIFWwMCcXt/dgQCz8dBqXiXFMoM3xc/GqZThAtaGYo8aPu/vBd+Q==} + engines: {node: '>=16.9.0'} + peerDependencies: + eslint: '>=8.40.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + knip@6.32.2: + resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + oxc-parser@0.143.0: + resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbash@4.0.10: + resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==} + engines: {node: '>=14'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-parser/binding-android-arm-eabi@0.143.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.143.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + optional: true + + '@oxc-project/types@0.143.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + + eslint-plugin-playwright@2.11.0(eslint@9.39.5(jiti@2.7.0)): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + globals: 17.11.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + + fsevents@2.3.2: + optional: true + + get-tsconfig@4.14.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.11.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + knip@6.32.2: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + formatly: 0.3.0 + get-tsconfig: 4.14.1 + jiti: 2.7.0 + oxc-parser: 0.143.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.5 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.10 + yaml: 2.9.0 + zod: 4.4.3 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + oxc-parser@0.143.0: + dependencies: + '@oxc-project/types': 0.143.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.143.0 + '@oxc-parser/binding-android-arm64': 0.143.0 + '@oxc-parser/binding-darwin-arm64': 0.143.0 + '@oxc-parser/binding-darwin-x64': 0.143.0 + '@oxc-parser/binding-freebsd-x64': 0.143.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 + '@oxc-parser/binding-linux-arm64-musl': 0.143.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-musl': 0.143.0 + '@oxc-parser/binding-openharmony-arm64': 0.143.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 + '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + prelude-ls@1.2.1: {} + + prettier@3.6.2: {} + + punycode@2.3.1: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + smol-toml@1.8.0: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbash@4.0.10: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + walk-up-path@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/e2e/prettier.config.js b/e2e/prettier.config.js new file mode 100644 index 00000000..44e6924b --- /dev/null +++ b/e2e/prettier.config.js @@ -0,0 +1,6 @@ +export default { + trailingComma: "all", + useTabs: true, + tabWidth: 2, + semi: false, +} diff --git a/e2e/tests/login.test.ts b/e2e/tests/login.test.ts new file mode 100644 index 00000000..a0182a90 --- /dev/null +++ b/e2e/tests/login.test.ts @@ -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$/) + }) +}) diff --git a/e2e/tests/signup.test.ts b/e2e/tests/signup.test.ts new file mode 100644 index 00000000..1114c9cc --- /dev/null +++ b/e2e/tests/signup.test.ts @@ -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() + }) +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..8e76b361 --- /dev/null +++ b/e2e/tsconfig.json @@ -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"] +} diff --git a/scripts/run-quietly.sh b/scripts/run-quietly.sh new file mode 100755 index 00000000..f06772d3 --- /dev/null +++ b/scripts/run-quietly.sh @@ -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