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",