From 476e8d48457bad1cf451b672d7dd082d807cacc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Thu, 25 Jun 2026 00:04:55 +0100 Subject: [PATCH] tests: set up basic playwright E2E test (#823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): bootstrap Playwright suite with login flow Stand up a dedicated /e2e Playwright package (own package.json/lockfile, matching the per-package repo layout) and the first moderator-critical flow from #485: login + session. Also adds a draft CI workflow that runs the suite on PRs and pushes to main, gated by dorny/paths-filter so it only fires on app-affecting changes, and caching the Playwright browser binaries. The full-stack bring-up mirrors the documented local setup and still needs maintainer review + end-to-end CI validation. Refs #485 Co-Authored-By: Claude Opus 4.8 (1M context) * ci(e2e): fix server/client startup hang Redirect backgrounded server/client output to log files so they don't hold the step's stdout pipe open, and wait on the server's /ready health endpoint (a reliable 200) instead of the GraphQL endpoint, which returns 400 on a bare GET. Dump the logs if readiness times out. Co-Authored-By: Claude Opus 4.8 (1M context) * ci(e2e): wait on server TCP port, not load-gated /ready The /ready endpoint returns 500 when CPU usage exceeds 75% (api.ts), which is always the case while tsc-watch and vite are compiling on a CI runner, so wait-on never saw a 200. Wait on the server's listening TCP port instead (the server only logs readiness after all middleware, including GraphQL, is wired). Co-Authored-By: Claude Opus 4.8 (1M context) * test(e2e): select login email field by input type, not role getByRole('textbox') matched both the email and password inputs in this Ant version (strict-mode violation in CI). The email field is the only type=text input, so select by type to stay unambiguous. Co-Authored-By: Claude Opus 4.8 (1M context) * test(e2e): move Playwright into server/ and self-seed via DI factories Relocate the Playwright suite from a standalone top-level package into server/e2e/ so tests can import the server's DI container and the existing test/fixtureHelpers factories. Each test now seeds the state it needs (committed) and tears it down, instead of depending on a pre-seeded org. - fixtures/coop.ts extends Playwright's test with a worker-scoped `deps` fixture (getBottle(), dynamically imported so the heavy graph defers to run time) and a test-scoped `seed` fixture exposing factory wrappers (seed.orgWithAdmin seeds an org + password-login admin). - login.spec.ts seeds its own admin and logs in; no env credentials. - jest testPathIgnorePatterns excludes /e2e/ so its *.spec.ts files never run under the unit-test runner; eslint test-rules + devDep allowlist extended to e2e/. - CI: drop the create-org seed step; @playwright/test is a server devDep so only the browser binary is installed; cache keyed on server/package-lock.json; report uploaded from server/e2e/playwright-report. Co-Authored-By: Claude Opus 4.8 (1M context) * test(e2e): load server runtime from transpiled/ to dodge esbuild type-import bug Playwright's esbuild loader transpiles each file in isolation and can't elide type-only imports written with value syntax (e.g. `import { JSON }`), so importing the server's TS source graph threw "does not provide an export named 'JSON'" at run time. Load the compiled output (transpiled/, emitted by tsc with those imports correctly elided) instead, via computed specifiers so tsc doesn't resolve transpiled/ statically; types still come from the .ts source through `typeof import()` casts. transpiled/ is present whenever the server runs (tsc-watch / the Docker build emit it). Relax `consistent-type-imports` (disallowTypeAnnotations: false) for test/e2e files so the `typeof import()` casts pass lint. Co-Authored-By: Claude Opus 4.8 (1M context) * test(e2e): drop per-test cleanup; isolate by tenant and run fully parallel Each test seeds a unique-id org, so the app's multi-tenancy isolates tests from one another and no cleanup is needed (the CI database is disposable). This lets the suite run with `fullyParallel: true` — Playwright has no random-order flag, and concurrent execution with no fixed order is the idiomatic way to prevent implicit ordering dependencies. Removes the Seeder's cleanup tracking entirely. Documents the two disciplines that keep this valid (seed your own data; never assert on cross-tenant/global state) and a "Scaling to per-worker databases" note for when the suite grows — that future change is infra-only because the deps fixture is already worker-scoped and tests already self-seed. Co-Authored-By: Claude Opus 4.8 (1M context) * clean up * ci(e2e): cover root deps in path filter; drop runtime wait-on fetch Add .nvmrc, root package manifests/lock, and docker-compose.yaml to the paths-filter so the E2E job runs when files it depends on change. Replace the dynamic `npx --yes wait-on@8` readiness check (an undeclared runtime dependency that bypasses the lockfile) with a built-in bash wait loop polling tcp:8080 and http:3000. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Cassidy James --- .github/workflows/e2e.yaml | 152 ++++++++++++++++++++++++++++++++ server/.eslintrc.cjs | 8 +- server/e2e/.gitignore | 4 + server/e2e/fixtures/coop.ts | 121 +++++++++++++++++++++++++ server/e2e/playwright.config.ts | 42 +++++++++ server/e2e/tests/login.spec.ts | 12 +++ server/jest.config.cjs | 8 +- server/jest.integ.config.cjs | 3 +- server/package-lock.json | 49 ++++++++++ server/package.json | 4 + 10 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/e2e.yaml create mode 100644 server/e2e/.gitignore create mode 100644 server/e2e/fixtures/coop.ts create mode 100644 server/e2e/playwright.config.ts create mode 100644 server/e2e/tests/login.spec.ts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..0daab5a --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,152 @@ +name: E2E + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Only run the (heavy) E2E job when something that can affect the running app + # changed. + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + run: ${{ steps.filter.outputs.app }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + app: + - 'server/**' + - 'client/**' + - 'db/**' + - '.github/workflows/e2e.yaml' + # Root-level files the workflow steps below depend on. + - '.nvmrc' + - 'package.json' + - 'package-lock.json' + - 'docker-compose.yaml' + + e2e: + needs: [changes] + if: ${{ needs.changes.outputs.run == 'true' }} + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + + - name: Configure Docker Hub mirror + # Route docker.io pulls through Google's pull-through cache to avoid + # transient Docker Hub registry/auth timeouts. + run: | + sudo mkdir -p /etc/docker + echo '{"registry-mirrors":["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json + sudo systemctl restart docker + + - name: Pre-pull images (with retry) + run: | + for attempt in 1 2 3; do + if docker compose pull --quiet postgres scylla clickhouse redis; then exit 0; fi + if [ "$attempt" -lt 3 ]; then + echo "::warning::image prep attempt $attempt failed; retrying in $((attempt * 15))s" + sleep $((attempt * 15)) + fi + done + echo "::error::image prep failed after 3 attempts"; exit 1 + + - name: Start backing services + run: docker compose up --detach --quiet-pull postgres clickhouse scylla redis + + - name: Configure env + run: | + cp server/.env.example server/.env + cp db/.env.example db/.env + cp client/.env.example client/.env + + - name: Install dependencies + run: | + npm ci + (cd server && npm ci) + (cd client && npm ci) + (cd db && npm ci) + + - name: Create databases + run: | + npm run db:create -- --env staging --db api-server-pg + npm run db:create -- --env staging --db scylla + npm run db:create -- --env staging --db clickhouse + + - name: Run migrations + run: | + npm run db:update -- --env staging --db api-server-pg + npm run db:update -- --env staging --db scylla + npm run db:update -- --env staging --db clickhouse + + - name: Start server and client + # Redirect output to files so the backgrounded processes don't hold this + # step's stdout pipe open (which would hang the step). + run: | + npm run server:start > /tmp/server.log 2>&1 & + (cd client && npm start) > /tmp/client.log 2>&1 & + # Wait (up to 120s) for the API (tcp 8080) and client (http 3000) to be + # ready. A built-in shell loop avoids fetching an undeclared dependency + # at runtime, which would bypass the committed lockfile. + deadline=$((SECONDS + 120)) + until (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null \ + && curl -sf -o /dev/null http://localhost:3000; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::group::server.log"; cat /tmp/server.log; echo "::endgroup::" + echo "::group::client.log"; cat /tmp/client.log; echo "::endgroup::" + echo "::error::server (8080) / client (3000) not ready within 120s" + exit 1 + fi + sleep 2 + done + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('server/package-lock.json') }} + + - name: Install Playwright system dependencies + # OS-level deps live outside the cached browser dir, so install them every run. + run: cd server && npx playwright install-deps chromium + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd server && npx playwright install chromium + + - name: Run E2E suite + run: cd server && npm run test:e2e + env: + PLAYWRIGHT_BASE_URL: http://localhost:3000 + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: playwright-report + path: server/e2e/playwright-report/ + retention-days: 14 diff --git a/server/.eslintrc.cjs b/server/.eslintrc.cjs index c8fa255..b51c42f 100644 --- a/server/.eslintrc.cjs +++ b/server/.eslintrc.cjs @@ -283,6 +283,7 @@ const correctnessRules = { { devDependencies: [ '**/test/**', + '**/e2e/**', '**/*.{spec,test}.{ts,tsx,js}', '.storybook/**', '**/*.stories.tsx', @@ -674,7 +675,7 @@ module.exports = { }, }, { - files: ['test/**/*.ts', './**/*.{spec,test}.ts'], + files: ['test/**/*.ts', 'e2e/**/*.ts', './**/*.{spec,test}.ts'], rules: { // Match prior test-only mutation policy: allow `this`, class internals, // and `process.env.*`; production code is not in this override. @@ -693,6 +694,11 @@ module.exports = { }, ], 'no-console': 'off', + // Allow `typeof import('...')` annotations; needed for E2E tests. + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', disallowTypeAnnotations: false }, + ], }, }, ], diff --git a/server/e2e/.gitignore b/server/e2e/.gitignore new file mode 100644 index 0000000..c1cd6cf --- /dev/null +++ b/server/e2e/.gitignore @@ -0,0 +1,4 @@ +/test-results/ +/playwright-report/ +/playwright/.cache/ +/blob-report/ diff --git a/server/e2e/fixtures/coop.ts b/server/e2e/fixtures/coop.ts new file mode 100644 index 0000000..b038f84 --- /dev/null +++ b/server/e2e/fixtures/coop.ts @@ -0,0 +1,121 @@ +// Load .env before anything reads process.env (the DI container does, heavily). +import 'dotenv/config'; + +import { test as base } from '@playwright/test'; +import { uid } from 'uid'; + +import { type Dependencies } from '../../iocContainer/index.js'; + +/** + * Server runtime is loaded from the COMPILED output (`transpiled/`), not the TS + * source, so we have to do some ugly type casting here. + */ +const TRANSPILED = '../../transpiled'; + +async function importIocContainer() { + return (await import( + `${TRANSPILED}/iocContainer/index.js` + )) as typeof import('../../iocContainer/index.js'); +} + +async function importSeedHelpers() { + const [createOrg, ums, userPersistence] = await Promise.all([ + import(`${TRANSPILED}/test/fixtureHelpers/createOrg.js`) as Promise< + typeof import('../../test/fixtureHelpers/createOrg.js') + >, + import(`${TRANSPILED}/services/userManagementService/index.js`) as Promise< + typeof import('../../services/userManagementService/index.js') + >, + import( + `${TRANSPILED}/graphql/datasources/userKyselyPersistence.js` + ) as Promise< + typeof import('../../graphql/datasources/userKyselyPersistence.js') + >, + ]); + return { + createOrg: createOrg.default, + hashPassword: ums.hashPassword, + UserRole: ums.UserRole, + kyselyUserInsert: userPersistence.kyselyUserInsert, + }; +} + +export type SeededAdmin = { + /** Org id the admin belongs to. */ + orgId: string; + /** Admin user id. */ + userId: string; + /** Email to log in with. */ + email: string; + /** Plaintext password to log in with. */ + password: string; +}; + +/** + * Seeds DB state for a test via the real DI factories (`test/fixtureHelpers`). + * + * There is intentionally no cleanup: every seeded org gets a unique id, so the + * app's own multi-tenancy isolates tests from each other. + * This keeps tests parallelizable. + */ +class Seeder { + constructor(private readonly deps: Dependencies) {} + + /** + * Create an organization with a password-login admin user. The returned + * credentials can be used to log in through the UI. + */ + async orgWithAdmin(opts: { password?: string } = {}): Promise { + const password = opts.password ?? 'e2e-password'; + const { createOrg, hashPassword, UserRole, kyselyUserInsert } = + await importSeedHelpers(); + + const org = await createOrg(this.deps); + + const userId = uid(); + const email = `e2e-${userId}@example.com`; + const user = await kyselyUserInsert({ + db: this.deps.KyselyPg, + id: userId, + orgId: org.org.id, + email, + password: await hashPassword(password), + firstName: 'E2E', + lastName: 'Admin', + role: UserRole.ADMIN, + approvedByAdmin: true, + loginMethods: ['password'], + }); + + return { orgId: org.org.id, userId: user.id, email, password }; + } +} + +export type { Seeder }; + +type TestFixtures = { seed: Seeder }; +type WorkerFixtures = { deps: Dependencies }; + +/** + * Playwright test extended with server-side seeding. Each test creates the + * state it needs. + */ +export const test = base.extend({ + deps: [ + // eslint-disable-next-line no-empty-pattern -- worker fixtures take no test args + async ({}, use) => { + const { default: getBottle } = await importIocContainer(); + const bottle = await getBottle(); + const deps = bottle.container as Dependencies; + await use(deps); + await deps.closeSharedResourcesForShutdown(); + }, + { scope: 'worker' }, + ], + + seed: async ({ deps }, use) => { + await use(new Seeder(deps)); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/server/e2e/playwright.config.ts b/server/e2e/playwright.config.ts new file mode 100644 index 0000000..be509c5 --- /dev/null +++ b/server/e2e/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * https://playwright.dev/docs/test-configuration + */ +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'; + +export default defineConfig({ + testDir: './tests', + // Configured paths resolve relative to this config file's dir (server/e2e/), + // so these land at server/e2e/{test-results,playwright-report}. + outputDir: 'test-results', + // Run every test concurrently. This helps ensure that we do + // not get implicit dependencies between tests. + fullyParallel: true, + // Fail the build on CI if test.only was left in the source. + forbidOnly: Boolean(process.env.CI), + // Retry flaky flows on CI; fail fast locally. + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI + ? [ + ['github'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['list'], + ] + : [ + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['list'], + ], + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/server/e2e/tests/login.spec.ts b/server/e2e/tests/login.spec.ts new file mode 100644 index 0000000..acc5b9f --- /dev/null +++ b/server/e2e/tests/login.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../fixtures/coop.js'; + +test('a user can log in', async ({ page, seed }) => { + const admin = await seed.orgWithAdmin(); + + await page.goto('/login'); + await page.locator('input[type="text"]').fill(admin.email); + await page.locator('input[type="password"]').fill(admin.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + + await expect(page).toHaveURL(/\/dashboard/); +}); diff --git a/server/jest.config.cjs b/server/jest.config.cjs index ccdd008..b1f4022 100644 --- a/server/jest.config.cjs +++ b/server/jest.config.cjs @@ -119,7 +119,13 @@ module.exports = { testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - testPathIgnorePatterns: ['/node_modules/', '/transpiled/', '.integ.test.ts$'], + // `/e2e/` holds Playwright specs (their own runner); never run them under jest. + testPathIgnorePatterns: [ + '/node_modules/', + '/transpiled/', + '/e2e/', + '.integ.test.ts$', + ], // This option allows the use of a custom results processor // testResultsProcessor: undefined, diff --git a/server/jest.integ.config.cjs b/server/jest.integ.config.cjs index b5223f7..a4a6170 100644 --- a/server/jest.integ.config.cjs +++ b/server/jest.integ.config.cjs @@ -120,7 +120,8 @@ module.exports = { // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // We ignore integration tests here, as they should only be run explicitly. - testPathIgnorePatterns: ['/node_modules/', '/transpiled/'], + // `/e2e/` holds Playwright specs (their own runner); never run them under jest. + testPathIgnorePatterns: ['/node_modules/', '/transpiled/', '/e2e/'], // This option allows the use of a custom results processor // testResultsProcessor: undefined, diff --git a/server/package-lock.json b/server/package-lock.json index 3b55d90..c46fbb8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -79,6 +79,7 @@ "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@faker-js/faker": "^7.5.0", + "@playwright/test": "^1.61.0", "@types/bcryptjs": "^2.4.2", "@types/connect-pg-simple": "^7.0.0", "@types/cookie-parser": "^1.4.2", @@ -4187,6 +4188,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -19355,6 +19372,38 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/server/package.json b/server/package.json index b62a85b..aff7cce 100644 --- a/server/package.json +++ b/server/package.json @@ -14,6 +14,9 @@ "test:ci": "NODE_OPTIONS=\"--loader ts-node/esm\" jest --ci --reporters=default --silent=false --reporters=jest-junit --no-cache --forceExit --runInBand", "test:integ": "NODE_OPTIONS=\"--loader ts-node/esm\" jest --ci --reporters=default --silent=false --reporters=jest-junit --detectOpenHandles --no-cache --forceExit --runInBand --config jest.integ.config.cjs", "typecheck": "tsc --noEmit", + "test:e2e": "playwright test --config e2e/playwright.config.ts", + "test:e2e:ui": "playwright test --config e2e/playwright.config.ts --ui", + "e2e:install-browsers": "playwright install --with-deps chromium", "check:prepush": "npm run typecheck && npm run test:prepush", "lint": "eslint \"./**/*.{ts,tsx,js}\"", "runWorkerOrJob": "node --loader ts-node/esm --require dotenv/config bin/run-worker-or-job.ts", @@ -95,6 +98,7 @@ "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@faker-js/faker": "^7.5.0", + "@playwright/test": "^1.61.0", "@types/bcryptjs": "^2.4.2", "@types/connect-pg-simple": "^7.0.0", "@types/cookie-parser": "^1.4.2", -- 2.51.2