diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcfd376..3558bd4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,6 +2,10 @@ name: Deploy on: workflow_call: + outputs: + stage_url: + description: URL of the deployed stage environment + value: ${{ jobs.deploy.outputs.stage_url }} secrets: SA_DEPLOYER_PRIVATE_KEY: required: true @@ -48,6 +52,8 @@ jobs: deploy: timeout-minutes: 5 runs-on: ubuntu-latest + outputs: + stage_url: ${{ steps.configure_api_gateway.outputs.stage_url }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..0b19e43 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,55 @@ +name: E2E tests + +on: + workflow_call: + inputs: + base_url: + description: Base URL of the deployed environment to run the tests against + type: string + required: true + secrets: + TEST_USER_AUTH_LOGIN: + description: Login of the seeded test user used by the suite's global setup + required: true + TEST_USER_AUTH_PASSWORD: + description: Password of the seeded test user used by the suite's global setup + required: true + +jobs: + test: + name: "Screenshot tests" + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: tree:0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run integration (screenshot) tests + run: pnpm --filter web-e2e test:integration + env: + BASE_URL: ${{ inputs.base_url }} + # The test code reads AUTH_LOGIN / AUTH_PASSWORD (see apps/web-e2e/core/utils/login.ts); + # in CI they are fed from the more descriptive TEST_USER_AUTH_* secrets. + AUTH_LOGIN: ${{ secrets.TEST_USER_AUTH_LOGIN }} + AUTH_PASSWORD: ${{ secrets.TEST_USER_AUTH_PASSWORD }} + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: apps/web-e2e/playwright-report/ + retention-days: 30 + if-no-files-found: warn diff --git a/.github/workflows/precommit-checks.yml b/.github/workflows/precommit-checks.yml index 7cb0eb2..bb14aa1 100644 --- a/.github/workflows/precommit-checks.yml +++ b/.github/workflows/precommit-checks.yml @@ -161,5 +161,20 @@ jobs: - name: Check docs coverage and links run: pnpm run docs:check -# # TODO: is all har sanitized? + + check-har: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Check HAR mocks are sanitized (no secrets) + run: pnpm run har:check # # TODO: is i18n extracted? diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d61ce96..83ce1f4 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -79,6 +79,17 @@ jobs: bundle_stats_s3_dir: /pull-requests/${{ github.event.pull_request.number }}/bundle-stats working_directory: apps/web + e2e-tests: + name: "E2E tests" + uses: ./.github/workflows/e2e-tests.yml + needs: [deploy, check-deploy-affected] + if: needs.check-deploy-affected.outputs.deploy-affected == 'true' + secrets: + TEST_USER_AUTH_LOGIN: ${{ secrets.TEST_USER_AUTH_LOGIN }} + TEST_USER_AUTH_PASSWORD: ${{ secrets.TEST_USER_AUTH_PASSWORD }} + with: + base_url: ${{ needs.deploy.outputs.stage_url }} + precommit-checks: name: "Pre-commit checks" needs: npm-ci diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6de4f1b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# HAR mocks are produced and sanitized by tooling (see pnpm sanitize-har). Keep Prettier away so it +# does not reformat them — the har:check guard compares them byte-for-byte against the sanitizer's +# output. +**/*.har +**/*.dat diff --git a/apps/web-e2e/.gitignore b/apps/web-e2e/.gitignore index 11ef066..263ce65 100644 --- a/apps/web-e2e/.gitignore +++ b/apps/web-e2e/.gitignore @@ -1 +1,4 @@ auth.*.json + +# Temporary HAR copies written by the mockNetwork fixture during replay (host de-normalization). +*.replay.har diff --git a/apps/web-e2e/README.md b/apps/web-e2e/README.md index ee1b36b..88d2175 100644 --- a/apps/web-e2e/README.md +++ b/apps/web-e2e/README.md @@ -2,15 +2,105 @@ End-to-end and integration tests for the **Languages Learner** web app. +In this repo, **"integration tests" are full-page screenshot (visual regression) tests**: each spec +opens a page and compares it against committed baselines in both light and dark themes. + ## Links - [Live app](https://languages-learner.chernigin.tech/) - [Storybook](https://languages-learner-static.website.yandexcloud.net/prod/storybook/index.html) +- Package docs: [docs/packages/web-e2e.md](../../docs/packages/web-e2e.md) ## Layout -Integration-specific env templates may live under `integration/` (see `integration/.env.example` when present). +- `integration/playwright.config.ts` — Playwright config (`testDir` = `integration/tests`). +- `integration/tests/**` — specs and their `*-snapshots/` baselines (`landing`, `office`, + `dictionary`). +- `pages/**` — Page Object Models. +- `core/**` — shared login/setup helpers. +- `integration/.env.example` — template for the local env. ## Scripts -See [`package.json`](package.json). Extend this package with real test runners (e.g. Playwright) as the suite grows. +| Script | What it does | +| -------------------------------- | ---------------------------------------------------------------------- | +| `test:integration` | Runs the Playwright screenshot suite against `BASE_URL` (native). | +| `test:integration:update` | Same, but regenerates the screenshot baselines (`--update-snapshots`). | +| `test:integration:docker` | Runs the suite inside the CI-matched Linux Docker image. | +| `test:integration:update:docker` | Regenerates the baselines inside that Docker image (see below). | +| `typecheck` | `tsgo --noEmit`. | +| `lint` / `lint:fix` | ESLint (check / autofix). | + +Run a single script with a filter, e.g. `pnpm --filter web-e2e test:integration`. + +## Running locally + +1. Copy `integration/.env.example` → `integration/.env` and fill in: + - `BASE_URL` — a running server, e.g. `http://localhost:5173` (start `apps/web` first with + `pnpm --filter app-web dev`) or a staging URL. + - `AUTH_LOGIN` / `AUTH_PASSWORD` — a seeded, confirmed test user. `global_setup` performs a real + UI login and persists the session as storage state. +2. `pnpm --filter web-e2e test:integration`. + +> Baselines are Linux-rendered (`…-chromium-linux.png`, hardcoded in the config). On Windows/macOS +> a **native** `:update` would overwrite them with locally-rendered images that then mismatch on the +> CI Linux runner — so never update baselines natively. Use the Docker flow below instead. + +## Updating screenshot baselines (from any OS) + +This mirrors how `uikit` component tests are updated: baselines are (re)generated inside a Docker +image that matches the CI runner (Ubuntu + the same fonts + pinned Playwright, see +`Dockerfile.tests.component`), while CI itself runs the comparison directly on `ubuntu-latest` +without Docker. + +1. Make sure Docker Desktop is running. +2. Put `AUTH_LOGIN` / `AUTH_PASSWORD` (a seeded, confirmed test user) in + `integration/.env`. `BASE_URL` there is only for native runs — the Docker flow overrides it. +3. Start the app **natively** on the host (it must not be containerised): + `pnpm --filter app-web dev` (serves `http://localhost:5173`). +4. Regenerate the baselines in the container: + `pnpm --filter web-e2e test:integration:update:docker`. + +The container reaches the host dev server via `host.docker.internal:5173` (set as `BASE_URL` by the +`playwright-ct` service in `docker-compose.yml`; override with the `E2E_BASE_URL` env var to point at +a different URL, e.g. staging). The updated `*-snapshots/*.png` are written back to the working tree +— review and commit them. Run `test:integration:docker` (without `:update`) to verify against the +committed baselines the same way. + +### Network mocks (HAR) + +Backend traffic (`/api/**`) is mocked via committed `…-linux.har` files next to each +snapshot (the `mockNetwork` fixture from `@languages-learner/app-integration-tests-utils`, which +also sanitizes the HAR). The `:update` scripts refresh the HAR **together with** the screenshots — +`test:integration:update` sets `UPDATE=1` (via `cross-env`), so `test:integration:update:docker` +re-records and sanitizes the HAR while regenerating the PNGs. Commit the refreshed `*.har`/`*.dat` +alongside the screenshots. Plain `test:integration` / `:docker` (and CI) only **replay** the +committed HAR. + +Because Playwright matches HAR entries by exact request URL (host included) and the backend is +served under the app's own origin — which differs between local recording and the dynamic CI stage +domain — the committed HAR is **host-normalized**: the recording origin is rewritten to the stable +placeholder `http://mocked.local`, and on replay a throwaway `*.replay.har` copy is rewritten back +to the current `baseURL`. Login itself is not mocked (it goes to Supabase auth directly). + +> After this change the previously committed HARs are stale (they targeted the old direct-Supabase +> URLs). Re-run `test:integration:update:docker` once to record the `/api` traffic, then commit the +> regenerated `*.har`/`*.dat` — until then CI has nothing to replay. + +Committed HARs must carry no secrets (session cookies, bearer tokens, API keys). The `mockNetwork` +fixture sanitizes them automatically on record; `pnpm sanitize-har` re-sanitizes every HAR in the +repo on demand, and CI enforces it via `pnpm har:check` (re-runs the sanitizer and scans for tokens +— see [`scripts/check-har-sanitized.ts`](../../scripts/check-har-sanitized.ts)). Extend +[`@languages-learner/har-sanitizer`](../../packages/har-sanitizer) if a new field ever leaks. + +## CI + +The reusable workflow `.github/workflows/e2e-tests.yml` runs `test:integration` on **pull requests, +after the staging deploy**, only when the web project is affected (gated on the +`check-deploy-affected` job). It passes the freshly-deployed stage URL as `BASE_URL` and feeds the +test account from the `TEST_USER_AUTH_LOGIN` / `TEST_USER_AUTH_PASSWORD` repository secrets (mapped +to `AUTH_LOGIN` / `AUTH_PASSWORD`). On failure the Playwright HTML report is uploaded as an +artifact for inspecting screenshot diffs. + +When the UI changes, regenerate the baselines with the Docker flow above +(`test:integration:update:docker`) and commit the updated `*-snapshots/*.png` in the same PR. diff --git a/apps/web-e2e/core/index.ts b/apps/web-e2e/core/index.ts new file mode 100644 index 0000000..bc4d19c --- /dev/null +++ b/apps/web-e2e/core/index.ts @@ -0,0 +1,3 @@ +export { test } from "@languages-learner/app-core-tests-utils"; + +export { commonSetup } from "./setup/commonSetup"; diff --git a/apps/web-e2e/integration/core/index.ts b/apps/web-e2e/integration/core/index.ts new file mode 100644 index 0000000..d14a4ce --- /dev/null +++ b/apps/web-e2e/integration/core/index.ts @@ -0,0 +1 @@ +export { test } from "@languages-learner/app-integration-tests-utils"; diff --git a/apps/web-e2e/integration/global.setup.ts b/apps/web-e2e/integration/global.setup.ts index 8eaf031..aa90497 100644 --- a/apps/web-e2e/integration/global.setup.ts +++ b/apps/web-e2e/integration/global.setup.ts @@ -1,4 +1,4 @@ -import * as path from "path"; +import * as path from "node:path"; import { getAuthStorageStateFileName } from "@@/packages/playwright-utils/src/auth-storage"; diff --git a/apps/web-e2e/integration/playwright.config.ts b/apps/web-e2e/integration/playwright.config.ts index 7a00c2e..50b3e55 100644 --- a/apps/web-e2e/integration/playwright.config.ts +++ b/apps/web-e2e/integration/playwright.config.ts @@ -1,7 +1,7 @@ import * as process from "node:process"; -import * as path from "path"; +import * as path from "node:path"; -import { defineConfig, devices } from "@playwright/experimental-ct-react"; +import { defineConfig, devices } from "@playwright/test"; import * as dotenv from "dotenv"; import { getAuthStorageStateFileName } from "../../../packages/playwright-utils/src/auth-storage"; @@ -11,16 +11,12 @@ dotenv.config({ path: envPath, override: false }); const { BASE_URL } = process.env; -const pathFromRoot = (p: string) => { - return path.resolve(__dirname, "../../..", p); -}; - /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - tsconfig: pathFromRoot("tests/app/tsconfig.json"), - testDir: pathFromRoot("tests/app/integration/tests"), + tsconfig: path.resolve(__dirname, "../tsconfig.json"), + testDir: path.resolve(__dirname, "tests"), testMatch: "*.spec.*", /* Maximum time one test can run for */ timeout: 30000, diff --git a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts index 7904a5d..205804a 100644 --- a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts +++ b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts @@ -3,28 +3,6 @@ import { DictionaryPage } from "@/tests/app/pages/dictionary/DictionaryPage"; test.describe("dictionary page @dictionary", () => { test("visual", async ({ page, expectScreenshot }) => { - await page.route("*/**/rest/v1/words**", async (route) => { - const json = []; - json.push({ - id: "e77753b4-4739-4d66-bd90-e911eb1f466c", - user_id: "user_id", - created_at: "2025-05-31T20:42:33.237739+00:00", - updated_at: "2025-05-31T20:42:33.237739", - language: "en", - text: "test", - status: "Learn", - sort_id: 22, - translations: [ - { - id: "a42f7a2f-cd0c-4390-857e-21f9573e4624", - text: "test", - language: "ru", - }, - ], - }); - await route.fulfill({ json }); - }); - const testPage = new DictionaryPage(page); await testPage.open(); await expectScreenshot({ options: { fullPage: true } }); diff --git a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/3168a7552c2e68e82390a2032bbd41a19b7921fd.json b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/3168a7552c2e68e82390a2032bbd41a19b7921fd.json new file mode 100644 index 0000000..cbb6195 --- /dev/null +++ b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/3168a7552c2e68e82390a2032bbd41a19b7921fd.json @@ -0,0 +1 @@ +{"words":[{"id":"7680fd8c-a814-4311-b392-26569c73809d","user_id":"0139901a-f520-4b67-a1ba-c75e1a8ddaaa","created_at":"2026-03-29T14:38:39.000354+00:00","updated_at":"2026-03-29T14:38:39.000354","language":"en","text":"asd","status":"New","sort_id":34,"translations":[{"id":"c7eb6d56-cdfc-4523-933a-62124f4311eb","text":"das","language":"en"}]}],"nextPageToken":null} \ No newline at end of file diff --git a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json new file mode 100644 index 0000000..b39e598 --- /dev/null +++ b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json @@ -0,0 +1 @@ +{"id":"0139901a-f520-4b67-a1ba-c75e1a8ddaaa","active_learning_language":"en","interface_language":"en","native_language":"en","theme":"system"} \ No newline at end of file diff --git a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-chromium-linux.har b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-chromium-linux.har index 3ebf493..20a5e75 100644 --- a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-chromium-linux.har +++ b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-chromium-linux.har @@ -11,167 +11,162 @@ }, "entries": [ { - "startedDateTime": "2025-11-30T09:50:30.128Z", - "time": 3.364, + "startedDateTime": "2026-07-24T20:24:56.771Z", + "time": 1.356, "request": { "method": "GET", - "url": "https://xvqmpesikdezfqklonvs.supabase.co/rest/v1/user?select=*", - "httpVersion": "HTTP/2.0", + "url": "http://mocked.local/api/user", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": "Accept-Language", - "value": "en-US" - }, - { - "name": "Origin", - "value": "http://host.docker.internal:5173" + "name": "Accept", + "value": "*/*" }, { - "name": "Referer", - "value": "http://host.docker.internal:5173/" + "name": "Accept-Encoding", + "value": "gzip, deflate" }, { - "name": "User-Agent", - "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.7103.25 Safari/537.36" + "name": "Accept-Language", + "value": "en-US" }, { - "name": "accept", - "value": "application/vnd.pgrst.object+json" + "name": "Authorization", + "value": "STUBBED_VALUE" }, { - "name": "accept-profile", - "value": "public" + "name": "Connection", + "value": "keep-alive" }, { - "name": "apikey", - "value": "STUBBED_VALUE" + "name": "Content-Type", + "value": "application/json" }, { - "name": "authorization", + "name": "Cookie", "value": "STUBBED_VALUE" }, { - "name": "sec-ch-ua", - "value": "\"Chromium\";v=\"136\", \"HeadlessChrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"" + "name": "Host", + "value": "host.docker.internal:5173" }, { - "name": "sec-ch-ua-mobile", - "value": "?0" - }, - { - "name": "sec-ch-ua-platform", - "value": "\"Windows\"" + "name": "Referer", + "value": "http://mocked.local/en/workspace/dictionary" }, { - "name": "x-client-info", - "value": "supabase-ssr/0.6.1 createBrowserClient" - } - ], - "queryString": [ - { - "name": "select", - "value": "*" + "name": "User-Agent", + "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.7103.25 Safari/537.36" } ], + "queryString": [], "headersSize": -1, "bodySize": -1 }, "response": { "status": 200, - "statusText": "", - "httpVersion": "HTTP/2.0", + "statusText": "OK", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": "access-control-allow-origin", - "value": "http://host.docker.internal:5173" + "name": "X-Powered-By", + "value": "Express" + }, + { + "name": "access-control-allow-credentials", + "value": "true" + }, + { + "name": "connection", + "value": "close" }, { - "name": "access-control-expose-headers", - "value": "Content-Encoding, Content-Location, Content-Range, Content-Type, Date, Location, Server, Transfer-Encoding, Range-Unit" + "name": "content-length", + "value": "143" }, { - "name": "alt-svc", - "value": "h3=\":443\"; ma=86400" + "name": "content-security-policy", + "value": "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" }, { - "name": "cf-cache-status", - "value": "DYNAMIC" + "name": "content-type", + "value": "application/json; charset=utf-8" }, { - "name": "cf-ray", - "value": "9a6970e34c01fb76-HEL" + "name": "cross-origin-opener-policy", + "value": "same-origin" }, { - "name": "content-encoding", - "value": "gzip" + "name": "cross-origin-resource-policy", + "value": "same-origin" }, { - "name": "content-location", - "value": "/user?select=%2A" + "name": "date", + "value": "Fri, 24 Jul 2026 20:24:57 GMT" }, { - "name": "content-profile", - "value": "public" + "name": "etag", + "value": "W/\"8f-lLK5dkCjq4wwJth0G2K/U1kCN94\"" }, { - "name": "content-range", - "value": "0-0/*" + "name": "origin-agent-cluster", + "value": "?1" }, { - "name": "content-type", - "value": "application/vnd.pgrst.object+json; charset=utf-8" + "name": "referrer-policy", + "value": "no-referrer" }, { - "name": "date", - "value": "Sun, 30 Nov 2025 09:50:30 GMT" + "name": "strict-transport-security", + "value": "max-age=31536000; includeSubDomains" }, { - "name": "sb-gateway-version", - "value": "1" + "name": "vary", + "value": "Origin" }, { - "name": "sb-project-ref", - "value": "STUBBED_VALUE" + "name": "x-content-type-options", + "value": "nosniff" }, { - "name": "sb-request-id", - "value": "019ad42b-ca16-7f45-a12e-d77c4aebe325" + "name": "x-dns-prefetch-control", + "value": "off" }, { - "name": "server", - "value": "cloudflare" + "name": "x-download-options", + "value": "noopen" }, { - "name": "set-cookie", - "value": "STUBBED_VALUE" + "name": "x-frame-options", + "value": "SAMEORIGIN" }, { - "name": "strict-transport-security", - "value": "max-age=31536000; includeSubDomains; preload" + "name": "x-permitted-cross-domain-policies", + "value": "none" }, { - "name": "vary", - "value": "Accept-Encoding" + "name": "x-ratelimit-limit", + "value": "120" }, { - "name": "x-content-type-options", - "value": "nosniff" + "name": "x-ratelimit-remaining", + "value": "115" }, { - "name": "x-envoy-attempt-count", - "value": "1" + "name": "x-ratelimit-reset", + "value": "59" }, { - "name": "x-envoy-upstream-service-time", - "value": "3" + "name": "x-xss-protection", + "value": "0" } ], "content": { "size": -1, - "mimeType": "application/vnd.pgrst.object+json; charset=utf-8", - "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.dat" + "mimeType": "application/json; charset=utf-8", + "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.json" }, "headersSize": -1, "bodySize": -1, @@ -181,15 +176,15 @@ "timings": { "send": -1, "wait": -1, - "receive": 3.364 + "receive": 1.356 } }, { - "startedDateTime": "2025-11-30T09:50:30.271Z", - "time": 1.57, + "startedDateTime": "2026-07-24T20:24:57.170Z", + "time": 0.955, "request": { "method": "GET", - "url": "https://xvqmpesikdezfqklonvs.supabase.co/rest/v1/words?select=*%2Ctranslations%28id%2Ctext%2Clanguage%29&language=eq.en&order=sort_id.desc&limit=101", + "url": "http://mocked.local/api/words?pageSize=100&language=en", "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ @@ -198,66 +193,50 @@ "value": "*/*" }, { - "name": "Accept-Language", - "value": "en-US" - }, - { - "name": "Origin", - "value": "http://host.docker.internal:5173" + "name": "Accept-Encoding", + "value": "gzip, deflate" }, { - "name": "Referer", - "value": "http://host.docker.internal:5173/" + "name": "Accept-Language", + "value": "en-US" }, { - "name": "User-Agent", - "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.7103.25 Safari/537.36" + "name": "Authorization", + "value": "STUBBED_VALUE" }, { - "name": "accept-profile", - "value": "public" + "name": "Connection", + "value": "keep-alive" }, { - "name": "apikey", - "value": "STUBBED_VALUE" + "name": "Content-Type", + "value": "application/json" }, { - "name": "authorization", + "name": "Cookie", "value": "STUBBED_VALUE" }, { - "name": "sec-ch-ua", - "value": "\"Chromium\";v=\"136\", \"HeadlessChrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"" - }, - { - "name": "sec-ch-ua-mobile", - "value": "?0" + "name": "Host", + "value": "host.docker.internal:5173" }, { - "name": "sec-ch-ua-platform", - "value": "\"Windows\"" + "name": "Referer", + "value": "http://mocked.local/en/workspace/dictionary" }, { - "name": "x-client-info", - "value": "supabase-ssr/0.6.1 createBrowserClient" + "name": "User-Agent", + "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.7103.25 Safari/537.36" } ], "queryString": [ { - "name": "select", - "value": "*,translations(id,text,language)" + "name": "pageSize", + "value": "100" }, { "name": "language", - "value": "eq.en" - }, - { - "name": "order", - "value": "sort_id.desc" - }, - { - "name": "limit", - "value": "101" + "value": "en" } ], "headersSize": -1, @@ -270,30 +249,102 @@ "cookies": [], "headers": [ { - "name": "access-control-allow-origin", - "value": "http://host.docker.internal:5173" + "name": "X-Powered-By", + "value": "Express" + }, + { + "name": "access-control-allow-credentials", + "value": "true" + }, + { + "name": "connection", + "value": "close" }, { "name": "content-length", - "value": "310" + "value": "366" + }, + { + "name": "content-security-policy", + "value": "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" }, { "name": "content-type", - "value": "application/json" + "value": "application/json; charset=utf-8" + }, + { + "name": "cross-origin-opener-policy", + "value": "same-origin" + }, + { + "name": "cross-origin-resource-policy", + "value": "same-origin" + }, + { + "name": "date", + "value": "Fri, 24 Jul 2026 20:24:57 GMT" + }, + { + "name": "etag", + "value": "W/\"16e-MWinVSwuaOgjkKIDK71BoZt5If0\"" + }, + { + "name": "origin-agent-cluster", + "value": "?1" + }, + { + "name": "referrer-policy", + "value": "no-referrer" + }, + { + "name": "strict-transport-security", + "value": "max-age=31536000; includeSubDomains" }, { "name": "vary", "value": "Origin" }, { - "name": "access-control-allow-credentials", - "value": "true" + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "x-dns-prefetch-control", + "value": "off" + }, + { + "name": "x-download-options", + "value": "noopen" + }, + { + "name": "x-frame-options", + "value": "SAMEORIGIN" + }, + { + "name": "x-permitted-cross-domain-policies", + "value": "none" + }, + { + "name": "x-ratelimit-limit", + "value": "120" + }, + { + "name": "x-ratelimit-remaining", + "value": "119" + }, + { + "name": "x-ratelimit-reset", + "value": "60" + }, + { + "name": "x-xss-protection", + "value": "0" } ], "content": { "size": -1, - "mimeType": "application/json", - "_file": "5e68623905d9c094cc38cf3b3b8c05e819654054.json" + "mimeType": "application/json; charset=utf-8", + "_file": "3168a7552c2e68e82390a2032bbd41a19b7921fd.json" }, "headersSize": -1, "bodySize": -1, @@ -303,9 +354,8 @@ "timings": { "send": -1, "wait": -1, - "receive": 1.57 - }, - "_wasFulfilled": true + "receive": 0.955 + } } ] } diff --git a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-dark-chromium-linux.png b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-dark-chromium-linux.png index 6b732c4..0980fe3 100644 Binary files a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-dark-chromium-linux.png and b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-dark-chromium-linux.png differ diff --git a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-light-chromium-linux.png b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-light-chromium-linux.png index 1612149..539f61a 100644 Binary files a/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-light-chromium-linux.png and b/apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts-snapshots/dictionary-page-dictionary-visual-light-chromium-linux.png differ diff --git a/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json new file mode 100644 index 0000000..b39e598 --- /dev/null +++ b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json @@ -0,0 +1 @@ +{"id":"0139901a-f520-4b67-a1ba-c75e1a8ddaaa","active_learning_language":"en","interface_language":"en","native_language":"en","theme":"system"} \ No newline at end of file diff --git a/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-chromium-linux.har b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-chromium-linux.har index c7d1718..5137ed6 100644 --- a/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-chromium-linux.har +++ b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-chromium-linux.har @@ -11,203 +11,162 @@ }, "entries": [ { - "startedDateTime": "2025-11-30T09:50:30.127Z", - "time": 63.387, + "startedDateTime": "2026-07-24T20:24:57.070Z", + "time": 1.103, "request": { "method": "GET", - "url": "https://xvqmpesikdezfqklonvs.supabase.co/rest/v1/user?select=*", - "httpVersion": "HTTP/2.0", + "url": "http://mocked.local/api/user", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": ":authority", - "value": "xvqmpesikdezfqklonvs.supabase.co" + "name": "Accept", + "value": "*/*" }, { - "name": ":method", - "value": "GET" + "name": "Accept-Encoding", + "value": "gzip, deflate" }, { - "name": ":path", - "value": "/rest/v1/user?select=*" - }, - { - "name": ":scheme", - "value": "https" - }, - { - "name": "accept", - "value": "application/vnd.pgrst.object+json" - }, - { - "name": "accept-encoding", - "value": "gzip, deflate, br, zstd" - }, - { - "name": "accept-language", + "name": "Accept-Language", "value": "en-US" }, { - "name": "accept-profile", - "value": "public" - }, - { - "name": "apikey", + "name": "Authorization", "value": "STUBBED_VALUE" }, { - "name": "authorization", - "value": "STUBBED_VALUE" + "name": "Connection", + "value": "keep-alive" }, { - "name": "origin", - "value": "http://host.docker.internal:5173" + "name": "Content-Type", + "value": "application/json" }, { - "name": "priority", - "value": "u=1, i" - }, - { - "name": "referer", - "value": "http://host.docker.internal:5173/" - }, - { - "name": "sec-ch-ua", - "value": "\"Chromium\";v=\"136\", \"HeadlessChrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"" - }, - { - "name": "sec-ch-ua-mobile", - "value": "?0" - }, - { - "name": "sec-ch-ua-platform", - "value": "\"Windows\"" - }, - { - "name": "sec-fetch-dest", - "value": "empty" + "name": "Cookie", + "value": "STUBBED_VALUE" }, { - "name": "sec-fetch-mode", - "value": "cors" + "name": "Host", + "value": "host.docker.internal:5173" }, { - "name": "sec-fetch-site", - "value": "cross-site" + "name": "Referer", + "value": "http://mocked.local/en" }, { - "name": "user-agent", + "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.7103.25 Safari/537.36" - }, - { - "name": "x-client-info", - "value": "supabase-ssr/0.6.1 createBrowserClient" - } - ], - "queryString": [ - { - "name": "select", - "value": "*" } ], + "queryString": [], "headersSize": -1, "bodySize": -1 }, "response": { "status": 200, - "statusText": "", - "httpVersion": "HTTP/2.0", + "statusText": "OK", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": "access-control-allow-origin", - "value": "http://host.docker.internal:5173" + "name": "X-Powered-By", + "value": "Express" }, { - "name": "access-control-expose-headers", - "value": "Content-Encoding, Content-Location, Content-Range, Content-Type, Date, Location, Server, Transfer-Encoding, Range-Unit" + "name": "access-control-allow-credentials", + "value": "true" }, { - "name": "alt-svc", - "value": "h3=\":443\"; ma=86400" + "name": "connection", + "value": "close" }, { - "name": "cf-cache-status", - "value": "DYNAMIC" + "name": "content-length", + "value": "143" }, { - "name": "cf-ray", - "value": "9a6970e37ee175f6-HEL" + "name": "content-security-policy", + "value": "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" }, { - "name": "content-encoding", - "value": "gzip" + "name": "content-type", + "value": "application/json; charset=utf-8" }, { - "name": "content-location", - "value": "/user?select=%2A" + "name": "cross-origin-opener-policy", + "value": "same-origin" }, { - "name": "content-profile", - "value": "public" + "name": "cross-origin-resource-policy", + "value": "same-origin" }, { - "name": "content-range", - "value": "0-0/*" + "name": "date", + "value": "Fri, 24 Jul 2026 20:24:57 GMT" }, { - "name": "content-type", - "value": "application/vnd.pgrst.object+json; charset=utf-8" + "name": "etag", + "value": "W/\"8f-lLK5dkCjq4wwJth0G2K/U1kCN94\"" }, { - "name": "date", - "value": "Sun, 30 Nov 2025 09:50:31 GMT" + "name": "origin-agent-cluster", + "value": "?1" }, { - "name": "sb-gateway-version", - "value": "1" + "name": "referrer-policy", + "value": "no-referrer" }, { - "name": "sb-project-ref", - "value": "STUBBED_VALUE" + "name": "strict-transport-security", + "value": "max-age=31536000; includeSubDomains" + }, + { + "name": "vary", + "value": "Origin" }, { - "name": "sb-request-id", - "value": "019ad42b-ca35-7747-9215-6aedf5fe933d" + "name": "x-content-type-options", + "value": "nosniff" }, { - "name": "server", - "value": "cloudflare" + "name": "x-dns-prefetch-control", + "value": "off" }, { - "name": "set-cookie", - "value": "STUBBED_VALUE" + "name": "x-download-options", + "value": "noopen" }, { - "name": "strict-transport-security", - "value": "max-age=31536000; includeSubDomains; preload" + "name": "x-frame-options", + "value": "SAMEORIGIN" }, { - "name": "vary", - "value": "Accept-Encoding" + "name": "x-permitted-cross-domain-policies", + "value": "none" }, { - "name": "x-content-type-options", - "value": "nosniff" + "name": "x-ratelimit-limit", + "value": "120" + }, + { + "name": "x-ratelimit-remaining", + "value": "112" }, { - "name": "x-envoy-attempt-count", - "value": "1" + "name": "x-ratelimit-reset", + "value": "58" }, { - "name": "x-envoy-upstream-service-time", - "value": "4" + "name": "x-xss-protection", + "value": "0" } ], "content": { "size": -1, - "mimeType": "application/vnd.pgrst.object+json; charset=utf-8", - "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.dat" + "mimeType": "application/json; charset=utf-8", + "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.json" }, "headersSize": -1, "bodySize": -1, @@ -217,7 +176,7 @@ "timings": { "send": -1, "wait": -1, - "receive": 63.387 + "receive": 1.103 } } ] diff --git a/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-dark-chromium-linux.png b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-dark-chromium-linux.png index c3839d3..3ef0e66 100644 Binary files a/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-dark-chromium-linux.png and b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-dark-chromium-linux.png differ diff --git a/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-light-chromium-linux.png b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-light-chromium-linux.png index dd90b09..b227418 100644 Binary files a/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-light-chromium-linux.png and b/apps/web-e2e/integration/tests/landing/landing.spec.ts-snapshots/landing-page-landing-visual-light-chromium-linux.png differ diff --git a/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json new file mode 100644 index 0000000..b39e598 --- /dev/null +++ b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json @@ -0,0 +1 @@ +{"id":"0139901a-f520-4b67-a1ba-c75e1a8ddaaa","active_learning_language":"en","interface_language":"en","native_language":"en","theme":"system"} \ No newline at end of file diff --git a/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-chromium-linux.har b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-chromium-linux.har index a47e0bd..234ae45 100644 --- a/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-chromium-linux.har +++ b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-chromium-linux.har @@ -11,203 +11,162 @@ }, "entries": [ { - "startedDateTime": "2025-11-30T09:50:31.352Z", - "time": 82.847, + "startedDateTime": "2026-07-24T20:24:56.787Z", + "time": 1.5, "request": { "method": "GET", - "url": "https://xvqmpesikdezfqklonvs.supabase.co/rest/v1/user?select=*", - "httpVersion": "HTTP/2.0", + "url": "http://mocked.local/api/user", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": ":authority", - "value": "xvqmpesikdezfqklonvs.supabase.co" + "name": "Accept", + "value": "*/*" }, { - "name": ":method", - "value": "GET" + "name": "Accept-Encoding", + "value": "gzip, deflate" }, { - "name": ":path", - "value": "/rest/v1/user?select=*" - }, - { - "name": ":scheme", - "value": "https" - }, - { - "name": "accept", - "value": "application/vnd.pgrst.object+json" - }, - { - "name": "accept-encoding", - "value": "gzip, deflate, br, zstd" - }, - { - "name": "accept-language", + "name": "Accept-Language", "value": "en-US" }, { - "name": "accept-profile", - "value": "public" - }, - { - "name": "apikey", + "name": "Authorization", "value": "STUBBED_VALUE" }, { - "name": "authorization", - "value": "STUBBED_VALUE" + "name": "Connection", + "value": "keep-alive" }, { - "name": "origin", - "value": "http://host.docker.internal:5173" + "name": "Content-Type", + "value": "application/json" }, { - "name": "priority", - "value": "u=1, i" - }, - { - "name": "referer", - "value": "http://host.docker.internal:5173/" - }, - { - "name": "sec-ch-ua", - "value": "\"Chromium\";v=\"136\", \"HeadlessChrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"" - }, - { - "name": "sec-ch-ua-mobile", - "value": "?0" - }, - { - "name": "sec-ch-ua-platform", - "value": "\"Windows\"" - }, - { - "name": "sec-fetch-dest", - "value": "empty" + "name": "Cookie", + "value": "STUBBED_VALUE" }, { - "name": "sec-fetch-mode", - "value": "cors" + "name": "Host", + "value": "host.docker.internal:5173" }, { - "name": "sec-fetch-site", - "value": "cross-site" + "name": "Referer", + "value": "http://mocked.local/en/office/profile" }, { - "name": "user-agent", + "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.7103.25 Safari/537.36" - }, - { - "name": "x-client-info", - "value": "supabase-ssr/0.6.1 createBrowserClient" - } - ], - "queryString": [ - { - "name": "select", - "value": "*" } ], + "queryString": [], "headersSize": -1, "bodySize": -1 }, "response": { "status": 200, - "statusText": "", - "httpVersion": "HTTP/2.0", + "statusText": "OK", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": "access-control-allow-origin", - "value": "http://host.docker.internal:5173" + "name": "X-Powered-By", + "value": "Express" }, { - "name": "access-control-expose-headers", - "value": "Content-Encoding, Content-Location, Content-Range, Content-Type, Date, Location, Server, Transfer-Encoding, Range-Unit" + "name": "access-control-allow-credentials", + "value": "true" }, { - "name": "alt-svc", - "value": "h3=\":443\"; ma=86400" + "name": "connection", + "value": "close" }, { - "name": "cf-cache-status", - "value": "DYNAMIC" + "name": "content-length", + "value": "143" }, { - "name": "cf-ray", - "value": "9a6970eb38909893-HEL" + "name": "content-security-policy", + "value": "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" }, { - "name": "content-encoding", - "value": "gzip" + "name": "content-type", + "value": "application/json; charset=utf-8" }, { - "name": "content-location", - "value": "/user?select=%2A" + "name": "cross-origin-opener-policy", + "value": "same-origin" }, { - "name": "content-profile", - "value": "public" + "name": "cross-origin-resource-policy", + "value": "same-origin" }, { - "name": "content-range", - "value": "0-0/*" + "name": "date", + "value": "Fri, 24 Jul 2026 20:24:57 GMT" }, { - "name": "content-type", - "value": "application/vnd.pgrst.object+json; charset=utf-8" + "name": "etag", + "value": "W/\"8f-lLK5dkCjq4wwJth0G2K/U1kCN94\"" }, { - "name": "date", - "value": "Sun, 30 Nov 2025 09:50:32 GMT" + "name": "origin-agent-cluster", + "value": "?1" }, { - "name": "sb-gateway-version", - "value": "1" + "name": "referrer-policy", + "value": "no-referrer" }, { - "name": "sb-project-ref", - "value": "STUBBED_VALUE" + "name": "strict-transport-security", + "value": "max-age=31536000; includeSubDomains" + }, + { + "name": "vary", + "value": "Origin" }, { - "name": "sb-request-id", - "value": "019ad42b-cf1b-7ae1-96e4-0a0af87b91ea" + "name": "x-content-type-options", + "value": "nosniff" }, { - "name": "server", - "value": "cloudflare" + "name": "x-dns-prefetch-control", + "value": "off" }, { - "name": "set-cookie", - "value": "STUBBED_VALUE" + "name": "x-download-options", + "value": "noopen" }, { - "name": "strict-transport-security", - "value": "max-age=31536000; includeSubDomains; preload" + "name": "x-frame-options", + "value": "SAMEORIGIN" }, { - "name": "vary", - "value": "Accept-Encoding" + "name": "x-permitted-cross-domain-policies", + "value": "none" }, { - "name": "x-content-type-options", - "value": "nosniff" + "name": "x-ratelimit-limit", + "value": "120" + }, + { + "name": "x-ratelimit-remaining", + "value": "114" }, { - "name": "x-envoy-attempt-count", - "value": "1" + "name": "x-ratelimit-reset", + "value": "59" }, { - "name": "x-envoy-upstream-service-time", - "value": "3" + "name": "x-xss-protection", + "value": "0" } ], "content": { "size": -1, - "mimeType": "application/vnd.pgrst.object+json; charset=utf-8", - "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.dat" + "mimeType": "application/json; charset=utf-8", + "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.json" }, "headersSize": -1, "bodySize": -1, @@ -217,7 +176,7 @@ "timings": { "send": -1, "wait": -1, - "receive": 82.847 + "receive": 1.5 } } ] diff --git a/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-dark-chromium-linux.png b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-dark-chromium-linux.png index 87a1bd3..4fa6ef0 100644 Binary files a/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-dark-chromium-linux.png and b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-dark-chromium-linux.png differ diff --git a/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-light-chromium-linux.png b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-light-chromium-linux.png index ea86484..d9ced4b 100644 Binary files a/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-light-chromium-linux.png and b/apps/web-e2e/integration/tests/office/officeProfile.spec.ts-snapshots/office-profile-page-office-visual-light-chromium-linux.png differ diff --git a/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json new file mode 100644 index 0000000..b39e598 --- /dev/null +++ b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/94b2b97640a3ab8c3026d8741b62bf53590237de.json @@ -0,0 +1 @@ +{"id":"0139901a-f520-4b67-a1ba-c75e1a8ddaaa","active_learning_language":"en","interface_language":"en","native_language":"en","theme":"system"} \ No newline at end of file diff --git a/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-chromium-linux.har b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-chromium-linux.har index 574d2fa..801a5ed 100644 --- a/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-chromium-linux.har +++ b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-chromium-linux.har @@ -11,203 +11,162 @@ }, "entries": [ { - "startedDateTime": "2025-11-30T09:50:34.612Z", - "time": 58.904, + "startedDateTime": "2026-07-24T20:24:56.868Z", + "time": 0.87, "request": { "method": "GET", - "url": "https://xvqmpesikdezfqklonvs.supabase.co/rest/v1/user?select=*", - "httpVersion": "HTTP/2.0", + "url": "http://mocked.local/api/user", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": ":authority", - "value": "xvqmpesikdezfqklonvs.supabase.co" + "name": "Accept", + "value": "*/*" }, { - "name": ":method", - "value": "GET" + "name": "Accept-Encoding", + "value": "gzip, deflate" }, { - "name": ":path", - "value": "/rest/v1/user?select=*" - }, - { - "name": ":scheme", - "value": "https" - }, - { - "name": "accept", - "value": "application/vnd.pgrst.object+json" - }, - { - "name": "accept-encoding", - "value": "gzip, deflate, br, zstd" - }, - { - "name": "accept-language", + "name": "Accept-Language", "value": "en-US" }, { - "name": "accept-profile", - "value": "public" - }, - { - "name": "apikey", + "name": "Authorization", "value": "STUBBED_VALUE" }, { - "name": "authorization", - "value": "STUBBED_VALUE" + "name": "Connection", + "value": "keep-alive" }, { - "name": "origin", - "value": "http://host.docker.internal:5173" + "name": "Content-Type", + "value": "application/json" }, { - "name": "priority", - "value": "u=1, i" - }, - { - "name": "referer", - "value": "http://host.docker.internal:5173/" - }, - { - "name": "sec-ch-ua", - "value": "\"Chromium\";v=\"136\", \"HeadlessChrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"" - }, - { - "name": "sec-ch-ua-mobile", - "value": "?0" - }, - { - "name": "sec-ch-ua-platform", - "value": "\"Windows\"" - }, - { - "name": "sec-fetch-dest", - "value": "empty" + "name": "Cookie", + "value": "STUBBED_VALUE" }, { - "name": "sec-fetch-mode", - "value": "cors" + "name": "Host", + "value": "host.docker.internal:5173" }, { - "name": "sec-fetch-site", - "value": "cross-site" + "name": "Referer", + "value": "http://mocked.local/en/office/settings" }, { - "name": "user-agent", + "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.7103.25 Safari/537.36" - }, - { - "name": "x-client-info", - "value": "supabase-ssr/0.6.1 createBrowserClient" - } - ], - "queryString": [ - { - "name": "select", - "value": "*" } ], + "queryString": [], "headersSize": -1, "bodySize": -1 }, "response": { "status": 200, - "statusText": "", - "httpVersion": "HTTP/2.0", + "statusText": "OK", + "httpVersion": "HTTP/1.1", "cookies": [], "headers": [ { - "name": "access-control-allow-origin", - "value": "http://host.docker.internal:5173" + "name": "X-Powered-By", + "value": "Express" }, { - "name": "access-control-expose-headers", - "value": "Content-Encoding, Content-Location, Content-Range, Content-Type, Date, Location, Server, Transfer-Encoding, Range-Unit" + "name": "access-control-allow-credentials", + "value": "true" }, { - "name": "alt-svc", - "value": "h3=\":443\"; ma=86400" + "name": "connection", + "value": "close" }, { - "name": "cf-cache-status", - "value": "DYNAMIC" + "name": "content-length", + "value": "143" }, { - "name": "cf-ray", - "value": "9a6970ff79420c03-HEL" + "name": "content-security-policy", + "value": "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" }, { - "name": "content-encoding", - "value": "gzip" + "name": "content-type", + "value": "application/json; charset=utf-8" }, { - "name": "content-location", - "value": "/user?select=%2A" + "name": "cross-origin-opener-policy", + "value": "same-origin" }, { - "name": "content-profile", - "value": "public" + "name": "cross-origin-resource-policy", + "value": "same-origin" }, { - "name": "content-range", - "value": "0-0/*" + "name": "date", + "value": "Fri, 24 Jul 2026 20:24:57 GMT" }, { - "name": "content-type", - "value": "application/vnd.pgrst.object+json; charset=utf-8" + "name": "etag", + "value": "W/\"8f-lLK5dkCjq4wwJth0G2K/U1kCN94\"" }, { - "name": "date", - "value": "Sun, 30 Nov 2025 09:50:35 GMT" + "name": "origin-agent-cluster", + "value": "?1" }, { - "name": "sb-gateway-version", - "value": "1" + "name": "referrer-policy", + "value": "no-referrer" }, { - "name": "sb-project-ref", - "value": "STUBBED_VALUE" + "name": "strict-transport-security", + "value": "max-age=31536000; includeSubDomains" + }, + { + "name": "vary", + "value": "Origin" }, { - "name": "sb-request-id", - "value": "019ad42b-dbba-73bb-8775-b81f3e17c583" + "name": "x-content-type-options", + "value": "nosniff" }, { - "name": "server", - "value": "cloudflare" + "name": "x-dns-prefetch-control", + "value": "off" }, { - "name": "set-cookie", - "value": "STUBBED_VALUE" + "name": "x-download-options", + "value": "noopen" }, { - "name": "strict-transport-security", - "value": "max-age=31536000; includeSubDomains; preload" + "name": "x-frame-options", + "value": "SAMEORIGIN" }, { - "name": "vary", - "value": "Accept-Encoding" + "name": "x-permitted-cross-domain-policies", + "value": "none" }, { - "name": "x-content-type-options", - "value": "nosniff" + "name": "x-ratelimit-limit", + "value": "120" + }, + { + "name": "x-ratelimit-remaining", + "value": "113" }, { - "name": "x-envoy-attempt-count", - "value": "1" + "name": "x-ratelimit-reset", + "value": "59" }, { - "name": "x-envoy-upstream-service-time", - "value": "4" + "name": "x-xss-protection", + "value": "0" } ], "content": { "size": -1, - "mimeType": "application/vnd.pgrst.object+json; charset=utf-8", - "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.dat" + "mimeType": "application/json; charset=utf-8", + "_file": "94b2b97640a3ab8c3026d8741b62bf53590237de.json" }, "headersSize": -1, "bodySize": -1, @@ -217,7 +176,7 @@ "timings": { "send": -1, "wait": -1, - "receive": 58.904 + "receive": 0.87 } } ] diff --git a/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-dark-chromium-linux.png b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-dark-chromium-linux.png index 376de6a..825558f 100644 Binary files a/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-dark-chromium-linux.png and b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-dark-chromium-linux.png differ diff --git a/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-light-chromium-linux.png b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-light-chromium-linux.png index 7c22a8c..a1875fd 100644 Binary files a/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-light-chromium-linux.png and b/apps/web-e2e/integration/tests/office/officeSettings.spec.ts-snapshots/office-settings-page-office-visual-light-chromium-linux.png differ diff --git a/apps/web-e2e/package.json b/apps/web-e2e/package.json index a7fa348..e8d773d 100644 --- a/apps/web-e2e/package.json +++ b/apps/web-e2e/package.json @@ -5,6 +5,20 @@ "description": "End-to-end and integration tests for the Languages Learner web app.", "license": "MIT", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "lint": "eslint --concurrency=auto .", + "lint:fix": "npm run lint -- --fix", + "typecheck": "tsgo --noEmit", + "test:integration": "playwright test -c integration/playwright.config.ts", + "test:integration:update": "cross-env UPDATE=1 playwright test -c integration/playwright.config.ts --update-snapshots", + "test:integration:docker": "pnpm -w run component-tests:test web-e2e test:integration", + "test:integration:update:docker": "pnpm -w run component-tests:test web-e2e test:integration:update" + }, + "devDependencies": { + "@languages-learner/app-core-tests-utils": "workspace:*", + "@languages-learner/app-integration-tests-utils": "workspace:*", + "@languages-learner/playwright-utils": "workspace:*", + "@playwright/test": "1.52.0", + "cross-env": "^7.0.3", + "dotenv": "^16.5.0" } } diff --git a/apps/web-e2e/pages/dictionary/DictionaryPage.ts b/apps/web-e2e/pages/dictionary/DictionaryPage.ts index bc08c87..06942d7 100644 --- a/apps/web-e2e/pages/dictionary/DictionaryPage.ts +++ b/apps/web-e2e/pages/dictionary/DictionaryPage.ts @@ -1,12 +1,9 @@ -import { type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { workspaceRoutes } from "@/shared/routes"; export class DictionaryPage { - constructor( - // eslint-disable-next-line @typescript-eslint/parameter-properties - private readonly page: Page, - ) {} + constructor(private readonly page: Page) {} async open() { await this.page.goToWithLocale(workspaceRoutes.dictionary); diff --git a/apps/web-e2e/pages/landing/LandingPage.ts b/apps/web-e2e/pages/landing/LandingPage.ts index 2c9d158..1d9c961 100644 --- a/apps/web-e2e/pages/landing/LandingPage.ts +++ b/apps/web-e2e/pages/landing/LandingPage.ts @@ -1,9 +1,9 @@ -import { type Page, expect } from "@playwright/test"; +import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { landingRoutes } from "@/shared/routes"; export class LandingPage { - // eslint-disable-next-line @typescript-eslint/parameter-properties constructor(private readonly page: Page) {} async open() { diff --git a/apps/web-e2e/pages/office/OfficeProfilePage.ts b/apps/web-e2e/pages/office/OfficeProfilePage.ts index 03824e2..2be2b6a 100644 --- a/apps/web-e2e/pages/office/OfficeProfilePage.ts +++ b/apps/web-e2e/pages/office/OfficeProfilePage.ts @@ -1,9 +1,8 @@ -import { type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { officeRoutes } from "@/shared/routes"; export class OfficeProfilePage { - // eslint-disable-next-line @typescript-eslint/parameter-properties constructor(private readonly page: Page) {} async open() { diff --git a/apps/web-e2e/tsconfig.json b/apps/web-e2e/tsconfig.json index 803f44e..89b5759 100644 --- a/apps/web-e2e/tsconfig.json +++ b/apps/web-e2e/tsconfig.json @@ -7,6 +7,16 @@ "../../packages/playwright-utils/src/types/goToWithLocaleFixture.d.ts" ], "compilerOptions": { - "baseUrl": ".", + "paths": { + "@/tests/app/*": [ + "./*" + ], + "@/shared/*": [ + "../web/src/ui/shared/*" + ], + "@@/*": [ + "../../*" + ] + } } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 66d9f83..3c16ecd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,4 @@ services: - playwright: - image: mcr.microsoft.com/playwright:v1.52.0-noble - working_dir: /app - volumes: - - .:/app - extra_hosts: - - "host.docker.internal:host-gateway" - environment: - BASE_URL: http://host.docker.internal:5173 - entrypoint: ["npm", "run"] - command: ["test:integration"] - playwright-ct: build: context: . @@ -25,6 +13,7 @@ services: # Node_modules volumes are generated automatically for all workspace packages - /app/node_modules # Node_modules volumes (auto-generated for all workspace packages) + - /app/apps/backend/node_modules - /app/apps/storybook/node_modules - /app/apps/web-e2e/node_modules - /app/apps/web/node_modules @@ -37,8 +26,6 @@ services: - /app/packages/error-utils/node_modules - /app/packages/form-components/node_modules - /app/packages/har-sanitizer/node_modules - - /app/packages/i18n-core/node_modules - - /app/packages/locale/node_modules - /app/packages/playwright-utils/node_modules - /app/packages/react-router-utils/node_modules - /app/packages/react-utils/node_modules @@ -51,3 +38,18 @@ services: entrypoint: ["pnpm"] + # The keys below live AFTER `entrypoint:` on purpose: generate-volumes.ts only + # rewrites the region between `volumes:` and `entrypoint:`, so anything past it + # survives volume regeneration. + # + # They let this same CI-matched image also run the apps/web-e2e screenshot tests + # (test:integration) against the app running natively on the host. `apps/web` must + # NOT be containerised (see docs/architecture/web-ssr.md), so the container reaches + # the host dev server via host.docker.internal. BASE_URL overrides the value in + # apps/web-e2e/integration/.env for in-container runs (dotenv uses override: false); + # AUTH_LOGIN / AUTH_PASSWORD are still read from that .env file. HAR mocks are updated + # by the test:integration:update script itself (it sets UPDATE=1 via cross-env). + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + BASE_URL: ${E2E_BASE_URL:-http://host.docker.internal:5173} diff --git a/docs/conventions.md b/docs/conventions.md index bba5bfe..7cd6c17 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -18,6 +18,7 @@ pnpm circular-deps # madge, UI code only pnpm knip # unused files/exports/deps pnpm deps:check # syncpack — dependency versions consistent across packages pnpm docs:check # docs/packages coverage + link validity +pnpm har:check # e2e HAR mocks carry no secrets (re-runs the sanitizer + scans for tokens) ``` Single package — use a pnpm filter with the **package name**, not the folder: diff --git a/docs/packages/web-e2e.md b/docs/packages/web-e2e.md index fb18dc8..b16a724 100644 --- a/docs/packages/web-e2e.md +++ b/docs/packages/web-e2e.md @@ -4,29 +4,76 @@ End-to-end and integration tests for the Languages Learner web app. ## Purpose -Exercises `apps/web` as a whole — browser-level e2e flows and integration tests — separate from the -unit tests that live inside individual packages and the Playwright component tests in `uikit`. +Exercises `apps/web` as a whole — browser-level flows and integration tests — separate from the +unit tests that live inside individual packages and the Playwright component tests in `uikit`. In +this repo **"integration tests" are full-page screenshot (visual regression) tests**: each spec +opens a page and calls the `expectScreenshot` fixture, which captures the page in both `light` and +`dark` themes and compares against committed `-linux` baselines. ## Public API / exports -Not a library — it only defines a `test` target. +Not a library — it defines the `test:integration` target (and `test:integration:update` for +refreshing baselines). It does not export anything for other packages to import. + +## Layout + +- `integration/playwright.config.ts` — Playwright config. `testDir` is `integration/tests`; a + `global_setup` project logs in once and persists a storage state, on which the `chromium` project + depends. Snapshots are stored as `…-{theme}-chromium-linux.png` next to each spec. +- `integration/tests/**` — the specs (`landing`, `office`, `dictionary`) and their `*-snapshots/`. +- `pages/**` — Page Object Models; they navigate via `page.goToWithLocale(...)` and reuse the web + app's route constants from `@/shared/routes` (→ `apps/web/src/ui/shared/routes`). +- `core/**` — shared setup (`commonSetup`, `login`) and barrels. The two barrels export + **different** `test` objects on purpose: `core/index.ts` re-exports the plain `test` from + `app-core-tests-utils` (used by `global.setup` for a real login, no network mocking), while + `integration/core/index.ts` re-exports the `test` from `app-integration-tests-utils`, which adds + the auto `mockNetwork` fixture. +- Each spec's `*-snapshots/` folder also holds committed `…-chromium-linux.har` (+ `.dat` bodies) + next to the PNGs: the `mockNetwork` fixture records/replays backend traffic (`/api/**`) + through `routeFromHAR` and **sanitizes** the HAR (`har-sanitizer`) in an `afterAll` hook, so + screenshots don't depend on live backend data. Because `routeFromHAR` matches by exact URL and the + `/api` origin varies between local recording and the dynamic CI stage domain, the committed HAR is + **host-normalized** to `http://mocked.local` (via the fixture's `urlsToReplace`) and rewritten back + to the live `baseURL` in a temp `*.replay.har` on replay. + +Path aliases (see `tsconfig.json`): `@/tests/app/*` → package root, `@/shared/*` → the web app's +`src/ui/shared`, `@@/*` → repo root. ## Depends on -- No workspace packages declared directly; it drives the running `apps/web`. Test helpers generally - come from the `*-tests-utils` packages and `@languages-learner/playwright-utils`. +- `@languages-learner/app-core-tests-utils` — the plain `test` fixture (`expectScreenshot` + + `goToWithLocale`), used for login/`global.setup`. +- `@languages-learner/app-integration-tests-utils` — the integration `test` (core + `mockNetwork`), + used by the specs; records/replays and sanitizes the backend (`/api`) HAR. Committed HARs are kept + secret-free by `har-sanitizer`, enforced in CI by `pnpm har:check`. +- `@languages-learner/playwright-utils` — auth-storage helpers and fixture factories. +- `@playwright/test`, `dotenv`. ## Used in - Nothing imports it; it is a test app. +- CI: the reusable `.github/workflows/e2e-tests.yml` runs `test:integration` on **pull requests, + after the staging deploy**, gated on `check-deploy-affected` (i.e. when `app-web`/`app-backend` + is affected). It receives the freshly-deployed stage URL as `BASE_URL` and the test account from + the `TEST_USER_AUTH_LOGIN` / `TEST_USER_AUTH_PASSWORD` secrets (mapped to `AUTH_LOGIN` / + `AUTH_PASSWORD` at run time). ## Notes -- API-dependent flows need a real Supabase project (see root `.env.example`) or mocks — the public - tree ships no turnkey backend or DB migrations. +- `global_setup` performs a **real UI login** against the staging Supabase, so a seeded, confirmed + test user must exist. Locally, provide `BASE_URL`, `AUTH_LOGIN`, `AUTH_PASSWORD` via + `integration/.env` (see `integration/.env.example`) and point `BASE_URL` at a running `apps/web`. +- Screenshot baselines are Linux-rendered (`…-linux.png`, hardcoded in the config). Regenerate them + with the Docker flow `test:integration:update:docker` (which mirrors the `uikit` component-test + setup: a CI-matched Ubuntu image via `Dockerfile.tests.component`), **not** a native + `test:integration:update` on Windows/macOS, whose font rendering differs from the CI runner. The + container reaches the natively-running `apps/web` through `host.docker.internal` — see the + [README](../../apps/web-e2e/README.md). +- API-dependent flows need a real Supabase project or route mocks (e.g. `dictionary.spec.ts` stubs + `rest/v1/words` via `page.route`) — the public tree ships no turnkey backend or DB migrations. ## Links - Scripts and how to run: [README](../../apps/web-e2e/README.md) - Test utilities: [playwright-utils](./playwright-utils.md), - [app-integration-tests-utils](./app-integration-tests-utils.md) + [app-core-tests-utils](./app-core-tests-utils.md) diff --git a/package.json b/package.json index 4c63a97..0e7f9c7 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "knip": "knip --config ./knip.config.ts", "docs:check": "ts-node ./scripts/check-docs.ts", "sanitize-har": "ts-node ./scripts/sanitize-har-files.ts", + "har:check": "ts-node ./scripts/check-har-sanitized.ts", "component-tests:build-image": "ts-node ./scripts/component-tests/build-image.ts", "component-tests:test": "ts-node ./scripts/component-tests/run-tests.ts", "deps:check": "syncpack lint --dependency-types prod,dev,peer", diff --git a/packages/app-integration-tests-utils/src/fixtures.ts b/packages/app-integration-tests-utils/src/fixtures.ts index 98c5d1c..3b0d057 100644 --- a/packages/app-integration-tests-utils/src/fixtures.ts +++ b/packages/app-integration-tests-utils/src/fixtures.ts @@ -1,12 +1,21 @@ import { testWithMockedNetworkFactory } from "@languages-learner/playwright-utils"; +// Stable, environment-independent host the committed HAR is normalized to. The app's backend is +// served under its own origin (localhost / host.docker.internal locally, a dynamic stage domain in +// CI); rewriting that origin to this placeholder lets a locally recorded HAR replay everywhere. +const MOCKED_ORIGIN = "http://mocked.local"; + export const testWithMockedNetwork = testWithMockedNetworkFactory({ routeFromHarOptions: { update: Boolean(process.env.UPDATE), }, forceUpdateIfHarMissing: !process.env.CI, - url: () => { - return "**/*supabase.co/rest/*/**"; + // Data now flows through the backend (`/api/*`), not directly to Supabase REST. + url: (baseUrl) => { + return `${baseUrl}/api/**`; + }, + urlsToReplace: (baseUrl) => { + return [{ fromUrl: baseUrl, toUrl: MOCKED_ORIGIN }]; }, dumpsFilePath: ({ testInfo }) => { const filePath = testInfo.snapshotPath( diff --git a/packages/har-sanitizer/src/lib.ts b/packages/har-sanitizer/src/lib.ts index e099cb0..67f1e0f 100644 --- a/packages/har-sanitizer/src/lib.ts +++ b/packages/har-sanitizer/src/lib.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; -import type { Har } from "har-format"; +import type { Har, Header } from "har-format"; export const defaultWordList = [ "Authorization", @@ -17,6 +17,7 @@ export const defaultWordList = [ "code", "code_challenge", "code_verifier", + "cookie", "email", "facetID", "fcParams", @@ -46,28 +47,27 @@ const DEFAULT_SANITIZE_OPTIONS: SanitizeHarOptions = { stubValue: "STUBBED_VALUE", }; -export const sanitize = (har: Har) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (har.log && har.log.entries) { - har.log.entries.forEach((entry) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (entry.request && entry.request.headers) { - entry.request.headers.forEach((header) => { - if (DEFAULT_SANITIZE_OPTIONS.words.includes(header.name)) { - header.value = DEFAULT_SANITIZE_OPTIONS.stubValue; - } - }); - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (entry.response && entry.response.headers) { - entry.response.headers.forEach((header) => { - if (DEFAULT_SANITIZE_OPTIONS.words.includes(header.name)) { - header.value = DEFAULT_SANITIZE_OPTIONS.stubValue; - } - }); - } - }); - } +const stubHeaders = (headers: Header[], sensitiveNames: Set, stubValue: string) => { + headers.forEach((header) => { + // HTTP header names are case-insensitive, and Chromium usually emits them lower-cased. + if (sensitiveNames.has(header.name.toLowerCase())) { + header.value = stubValue; + } + }); +}; + +export const sanitize = (har: Har, options: SanitizeHarOptions = DEFAULT_SANITIZE_OPTIONS) => { + const sensitiveNames = new Set(options.words.map((word) => word.toLowerCase())); + + har.log.entries.forEach((entry) => { + stubHeaders(entry.request.headers, sensitiveNames, options.stubValue); + stubHeaders(entry.response.headers, sensitiveNames, options.stubValue); + + // The structured cookie arrays carry the session too, and routeFromHAR never uses them for + // replay (it matches by URL + method). Drop them entirely rather than commit auth material. + entry.request.cookies = []; + entry.response.cookies = []; + }); return har; }; diff --git a/packages/playwright-utils/src/fixtures/mockNetworkFixture.ts b/packages/playwright-utils/src/fixtures/mockNetworkFixture.ts index d66863f..b4865db 100644 --- a/packages/playwright-utils/src/fixtures/mockNetworkFixture.ts +++ b/packages/playwright-utils/src/fixtures/mockNetworkFixture.ts @@ -1,5 +1,5 @@ -import { access } from "node:fs/promises"; -import { resolve } from "node:path"; +import { access, readFile, unlink, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; import { sanitizeHarFile } from "@languages-learner/har-sanitizer"; import { test as baseTest } from "@playwright/test"; @@ -16,10 +16,40 @@ export type MockedNetworkFixtureFactoryParams = { routeFromHarOptions?: Omit; url: (baseUrl: string) => RouteFromHarOptionsType["url"]; dumpsFilePath: (params: { testInfo: TestInfo }) => string; + /** + * Normalizes environment-specific URLs so a HAR recorded in one environment replays in another. + * Playwright matches HAR entries by exact request URL (host included), but the backend is served + * under the app's own origin, which differs between local recording and CI. Return the mapping + * `{ fromUrl: , toUrl: }`: on record the HAR is rewritten + * real → placeholder before it is committed; on replay a temp copy is rewritten placeholder → + * real so the entries match the live requests. + */ urlsToReplace?: (baseUrl: string) => UrlToReplace[]; forceUpdateIfHarMissing?: boolean; }; +const rewriteHarFile = async ( + sourcePath: string, + targetPath: string, + replace: Array<[from: string, to: string]>, +): Promise => { + let text = await readFile(sourcePath, "utf-8"); + for (const [from, to] of replace) { + text = text.split(from).join(to); + } + await writeFile(targetPath, text, "utf-8"); +}; + +const fileExists = async (path: string): Promise => { + try { + await access(path); + + return true; + } catch { + return false; + } +}; + export const testWithMockedNetworkFactory = (factoryParams: MockedNetworkFixtureFactoryParams) => { const testWithGoToWithLocale = baseTest.extend<{ mockNetwork: void }>({ mockNetwork: [ @@ -28,27 +58,47 @@ export const testWithMockedNetworkFactory = (factoryParams: MockedNetworkFixture throw new Error("baseURL should be specified in playwright config"); } - let harPath = factoryParams.dumpsFilePath({ testInfo }); - harPath = resolve(harPath); + const harPath = resolve(factoryParams.dumpsFilePath({ testInfo })); + const replacements = factoryParams.urlsToReplace?.(baseURL) ?? []; let update = factoryParams.routeFromHarOptions?.update; if (!update && factoryParams.forceUpdateIfHarMissing) { - try { - await access(harPath); - } catch { + if (!(await fileExists(harPath))) { update = true; } } const url = factoryParams.url(baseURL); - await page.context().routeFromHAR(harPath, { + // On replay, the committed HAR holds placeholder URLs; rewrite a temp copy back to + // the live baseURL so exact-URL matching works. The temp file lives next to the + // original so the relative external content (.dat) still resolves. + let harToRoute = harPath; + let tempHarPath: string | undefined; + if (!update && replacements.length > 0 && (await fileExists(harPath))) { + tempHarPath = join( + dirname(harPath), + `.${basename(harPath, ".har")}.replay.har`, + ); + await rewriteHarFile( + harPath, + tempHarPath, + replacements.map(({ fromUrl, toUrl }) => [toUrl, fromUrl]), + ); + harToRoute = tempHarPath; + } + + await page.context().routeFromHAR(harToRoute, { update, notFound: factoryParams.routeFromHarOptions?.notFound, url, }); await use(); + + if (tempHarPath) { + await unlink(tempHarPath).catch(() => {}); + } }, { auto: true }, ], @@ -59,18 +109,24 @@ export const testWithMockedNetworkFactory = (factoryParams: MockedNetworkFixture return; } - let harPath = factoryParams.dumpsFilePath({ testInfo }); - harPath = resolve(harPath); + const harPath = resolve(factoryParams.dumpsFilePath({ testInfo })); - let fileExist = true; - try { - await access(harPath); - } catch { - fileExist = false; + if (!(await fileExists(harPath))) { + return; } - if (fileExist) { - await sanitizeHarFile(harPath); + await sanitizeHarFile(harPath); + + // Normalize the freshly recorded HAR to placeholder URLs before it is committed. On a + // replay run the committed HAR already holds placeholders, so there is nothing to replace + // and the file stays untouched. + const replacements = factoryParams.urlsToReplace?.(baseURL) ?? []; + if (replacements.length > 0) { + await rewriteHarFile( + harPath, + harPath, + replacements.map(({ fromUrl, toUrl }) => [fromUrl, toUrl]), + ); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a891c05..5e5b539 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,7 +176,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.8 - version: 10.4.9(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12) + version: 10.4.9(@swc/core@1.15.46(@swc/helpers@0.5.17)) '@nestjs/schematics': specifier: ^10.1.1 version: 10.2.3(chokidar@3.6.0)(typescript@5.7.3) @@ -447,7 +447,26 @@ importers: specifier: ^6.3.1 version: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.94.2)(sugarss@2.0.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - apps/web-e2e: {} + apps/web-e2e: + devDependencies: + '@languages-learner/app-core-tests-utils': + specifier: workspace:* + version: link:../../packages/app-core-tests-utils + '@languages-learner/app-integration-tests-utils': + specifier: workspace:* + version: link:../../packages/app-integration-tests-utils + '@languages-learner/playwright-utils': + specifier: workspace:* + version: link:../../packages/playwright-utils + '@playwright/test': + specifier: 1.52.0 + version: 1.52.0 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + dotenv: + specifier: ^16.5.0 + version: 16.6.1 packages/api: dependencies: @@ -9365,7 +9384,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/cli@10.4.9(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12)': + '@nestjs/cli@10.4.9(@swc/core@1.15.46(@swc/helpers@0.5.17))': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) @@ -9375,7 +9394,7 @@ snapshots: chokidar: 3.6.0 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12)) + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))) glob: 10.4.5 inquirer: 8.2.6 node-emoji: 1.11.0 @@ -9384,7 +9403,7 @@ snapshots: tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.7.2 - webpack: 5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12) + webpack: 5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: '@swc/core': 1.15.46(@swc/helpers@0.5.17) @@ -13013,7 +13032,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12)): + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -13028,7 +13047,7 @@ snapshots: semver: 7.7.3 tapable: 2.3.0 typescript: 5.7.2 - webpack: 5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12) + webpack: 5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17)) form-data@4.0.5: dependencies: @@ -15942,16 +15961,15 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - terser-webpack-plugin@5.3.17(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12)(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12)): + terser-webpack-plugin@5.3.17(@swc/core@1.15.46(@swc/helpers@0.5.17))(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.0 - webpack: 5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12) + webpack: 5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17)) optionalDependencies: '@swc/core': 1.15.46(@swc/helpers@0.5.17) - esbuild: 0.25.12 terser@5.46.0: dependencies: @@ -16476,7 +16494,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12): + webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -16498,7 +16516,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.3.0 - terser-webpack-plugin: 5.3.17(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12)(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))(esbuild@0.25.12)) + terser-webpack-plugin: 5.3.17(@swc/core@1.15.46(@swc/helpers@0.5.17))(webpack@5.97.1(@swc/core@1.15.46(@swc/helpers@0.5.17))) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: diff --git a/scripts/check-har-sanitized.ts b/scripts/check-har-sanitized.ts new file mode 100644 index 0000000..77b45dc --- /dev/null +++ b/scripts/check-har-sanitized.ts @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { sanitize } from "@languages-learner/har-sanitizer"; + +/** + * Guards against committing secrets (session cookies, bearer tokens, API keys) inside the e2e + * screenshot HAR mocks. Two independent checks: + * + * 1. Idempotency — re-run the production sanitizer (`packages/har-sanitizer`) over each committed + * HAR. If the output differs from what is on disk, the file was committed with data the + * sanitizer would have stripped (a leak, or a HAR that skipped sanitizing). Fix with + * `pnpm sanitize-har`. + * 2. Token scan — a raw regex sweep over the `.har` files and their sibling `.dat` response + * bodies, as a backstop for secrets the sanitizer does not touch (e.g. response bodies). + */ + +const projectRoot = path.resolve(__dirname, ".."); +const IGNORED_FOLDERS = new Set(["node_modules", ".git", "dist"]); + +// High-signal secret shapes. STUBBED_VALUE (what the sanitizer writes) intentionally matches none. +const TOKEN_PATTERNS: { name: string; regex: RegExp }[] = [ + { name: "JWT", regex: /eyJ[A-Za-z0-9_-]{6,}\.eyJ[A-Za-z0-9_-]{6,}\./ }, + { name: "Supabase auth token", regex: /sb-[a-z0-9]+-auth-token/ }, +]; + +type Problem = string; + +function walk(dir: string, extension: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return IGNORED_FOLDERS.has(entry.name) ? [] : walk(full, extension); + } + + return entry.name.endsWith(extension) ? [full] : []; + }); +} + +function rel(file: string): string { + return path.relative(projectRoot, file).split(path.sep).join("/"); +} + +function checkSanitizeIdempotent(harPath: string, problems: Problem[]): void { + const raw = fs.readFileSync(harPath, "utf8"); + + let reSanitized: string; + try { + reSanitized = JSON.stringify(sanitize(JSON.parse(raw)), null, 2); + } catch (error) { + problems.push(`${rel(harPath)}: not valid JSON (${String(error)})`); + + return; + } + + if (reSanitized !== raw) { + problems.push( + `${rel(harPath)}: contains data the sanitizer would strip (cookies / sensitive headers).`, + ); + } +} + +function checkNoTokens(file: string, problems: Problem[]): void { + const content = fs.readFileSync(file, "utf8"); + for (const { name, regex } of TOKEN_PATTERNS) { + if (regex.test(content)) { + problems.push(`${rel(file)}: looks like it contains a secret (${name}).`); + } + } +} + +function main(): void { + const problems: Problem[] = []; + + const harFiles = walk(projectRoot, ".har"); + for (const har of harFiles) { + checkSanitizeIdempotent(har, problems); + checkNoTokens(har, problems); + } + + // Scan the external response bodies that live next to each HAR. + const harDirs = new Set(harFiles.map((har) => path.dirname(har))); + for (const dir of harDirs) { + for (const dat of walk(dir, ".dat")) { + checkNoTokens(dat, problems); + } + } + + if (problems.length > 0) { + console.error("har:check — found unsanitized HAR mocks:\n"); + for (const problem of problems) { + console.error(` - ${problem}`); + } + console.error( + "\nRun `pnpm sanitize-har` (or re-record with " + + "`pnpm --filter web-e2e test:integration:update:docker`)," + + "\nand extend packages/har-sanitizer if a new field is leaking.", + ); + process.exit(1); + } + + console.log(`har:check — ok: ${harFiles.length} HAR file(s) are sanitized and token-free.`); +} + +main(); diff --git a/scripts/component-tests/generate-volumes.ts b/scripts/component-tests/generate-volumes.ts index 0cb2c30..a747627 100644 --- a/scripts/component-tests/generate-volumes.ts +++ b/scripts/component-tests/generate-volumes.ts @@ -85,8 +85,15 @@ function generateVolumesForAllPackages(): string[] { const volumes: string[] = []; const allPackages = getAllWorkspacePackages(); - // Add node_modules for all workspace packages + // Add a mask volume only for packages that actually have a node_modules directory on the host. + // Dependency-less packages (e.g. locale, i18n-core) have none, so there is nothing to shadow — + // and mounting an anonymous volume at a path missing from the bind mount makes Docker Desktop + // (Windows/overlay2) fail with "mkdir …/node_modules: file exists". for (const pkgPath of allPackages) { + const nodeModulesFullPath = path.join(projectRoot, pkgPath, "node_modules"); + if (!fs.existsSync(nodeModulesFullPath)) { + continue; + } const nodeModulesPath = `/app/${pkgPath}/node_modules`; volumes.push(nodeModulesPath); }