Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 16 additions & 1 deletion .github/workflows/precommit-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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?
11 changes: 11 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions apps/web-e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
auth.*.json

# Temporary HAR copies written by the mockNetwork fixture during replay (host de-normalization).
*.replay.har
94 changes: 92 additions & 2 deletions apps/web-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<baseURL>/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.
3 changes: 3 additions & 0 deletions apps/web-e2e/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { test } from "@languages-learner/app-core-tests-utils";

export { commonSetup } from "./setup/commonSetup";
1 change: 1 addition & 0 deletions apps/web-e2e/integration/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { test } from "@languages-learner/app-integration-tests-utils";
2 changes: 1 addition & 1 deletion apps/web-e2e/integration/global.setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as path from "path";
import * as path from "node:path";

import { getAuthStorageStateFileName } from "@@/packages/playwright-utils/src/auth-storage";

Expand Down
12 changes: 4 additions & 8 deletions apps/web-e2e/integration/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down
22 changes: 0 additions & 22 deletions apps/web-e2e/integration/tests/dictionary/dictionary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"id":"0139901a-f520-4b67-a1ba-c75e1a8ddaaa","active_learning_language":"en","interface_language":"en","native_language":"en","theme":"system"}
Loading
Loading