diff --git a/.github/workflows/ci-quality-gate.yml b/.github/workflows/ci-quality-gate.yml new file mode 100644 index 0000000..7ef8a49 --- /dev/null +++ b/.github/workflows/ci-quality-gate.yml @@ -0,0 +1,47 @@ +name: CI Quality Gate + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + quality-gate: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build frontend workspaces + run: | + npm run build --workspace @flix/web + npm run build --workspace @flix/admin + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run browser smoke gate + run: npm run e2e:smoke:gate + + - name: Upload Playwright diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-diagnostics + path: | + playwright-report + test-results + if-no-files-found: ignore + diff --git a/.gitignore b/.gitignore index 038895f..7055e8c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ Thumbs.db # Local database artifacts services/api/.data/ +.data/ + +# E2E artifacts +playwright-report/ +test-results/ diff --git a/README.md b/README.md index da1b24b..094f0dd 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,15 @@ npm run dev:admin npm run dev:api ``` +## Quality Gates + +```bash +npm run ci:local +npm run e2e:smoke +npm run e2e:smoke:gate +npm run release:readiness +``` + ## Documentation - `docs/design-system-extraction.md` (what can be migrated from legacy DS) diff --git a/docs/stories/8.3.story.md b/docs/stories/8.3.story.md index 0f1454f..f2e44e4 100644 --- a/docs/stories/8.3.story.md +++ b/docs/stories/8.3.story.md @@ -1,7 +1,7 @@ # Story 8.3: Browser E2E Smoke and CI Quality Gate Expansion ## Status -Draft +Review ## Executor Assignment executor: "@devops" @@ -19,15 +19,15 @@ quality_gate_tools: ["manual-review"] 3. Gate fails explicitly on critical flow breakage with actionable diagnostics. ## Tasks / Subtasks -- [ ] Task 1 (AC: 1) E2E smoke suite covers core admin and learner happy paths in browser runtime. - - [ ] Implement technical changes required for AC 1 - - [ ] Add validation/tests for AC 1 -- [ ] Task 2 (AC: 2) CI/release gate includes frontend build checks and E2E smoke execution. - - [ ] Implement technical changes required for AC 2 - - [ ] Add validation/tests for AC 2 -- [ ] Task 3 (AC: 3) Gate fails explicitly on critical flow breakage with actionable diagnostics. - - [ ] Implement technical changes required for AC 3 - - [ ] Add validation/tests for AC 3 +- [x] Task 1 (AC: 1) E2E smoke suite covers core admin and learner happy paths in browser runtime. + - [x] Implement technical changes required for AC 1 + - [x] Add validation/tests for AC 1 +- [x] Task 2 (AC: 2) CI/release gate includes frontend build checks and E2E smoke execution. + - [x] Implement technical changes required for AC 2 + - [x] Add validation/tests for AC 2 +- [x] Task 3 (AC: 3) Gate fails explicitly on critical flow breakage with actionable diagnostics. + - [x] Implement technical changes required for AC 3 + - [x] Add validation/tests for AC 3 ## Dev Notes ### Previous Story Insights @@ -79,16 +79,44 @@ quality_gate_tools: ["manual-review"] ## Dev Agent Record ### Agent Model Used -_To be populated by dev agent_ +GPT-5 Codex ### Debug Log References -- _To be populated by dev agent_ +- `npm run e2e:smoke` +- `npm run e2e:smoke:gate` +- `npm run build --workspace @flix/web` +- `npm run build --workspace @flix/admin` +- `npm run release:readiness` ### Completion Notes List -- _To be populated by dev agent_ +- Added Playwright browser smoke setup with runtime orchestration for API + admin + learner apps. +- Added learner smoke scenario covering catalog-to-playback critical journey. +- Added admin smoke scenario covering browser login and event creation. +- Added CI workflow (`.github/workflows/ci-quality-gate.yml`) executing frontend builds and E2E smoke gate on PR/push to `main`. +- Added explicit E2E gate script with actionable diagnostics (`playwright-report`, `test-results`, rerun command) on failure. +- Expanded release readiness gate to enforce: + - browser smoke gate; + - frontend production builds; + - API integration regression checks. ### File List -- _To be populated by dev agent_ +- `.github/workflows/ci-quality-gate.yml` +- `.gitignore` +- `e2e/playwright.config.mjs` +- `e2e/tests/admin-smoke.spec.mjs` +- `e2e/tests/learner-smoke.spec.mjs` +- `package.json` +- `package-lock.json` +- `scripts/e2e-smoke-gate.mjs` +- `scripts/release-readiness-gate.mjs` +- `README.md` +- `docs/stories/8.3.story.md` ## QA Results -_To be populated after implementation_ +PASS + +- Browser smoke suite passed (`2` tests, chromium): + - admin login + create event + - learner catalog + playback navigation +- Frontend builds passed for both apps (`@flix/web`, `@flix/admin`). +- `release:readiness` gate passed with final status `GO`. diff --git a/e2e/playwright.config.mjs b/e2e/playwright.config.mjs new file mode 100644 index 0000000..aa89e65 --- /dev/null +++ b/e2e/playwright.config.mjs @@ -0,0 +1,54 @@ +import { defineConfig, devices } from '@playwright/test'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const isCi = Boolean(process.env.CI); +const configDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(configDir, '..'); +const dbFilePath = `${repoRoot}/services/api/.data/flix.e2e.sqlite`; +const dbUrl = `file:${dbFilePath}`; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: isCi, + retries: isCi ? 1 : 0, + workers: isCi ? 1 : undefined, + timeout: 45_000, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: [ + { + command: + `sh -c "cd .. && DATABASE_URL=${dbUrl} npm run db:reset --workspace @flix/api && DATABASE_URL=${dbUrl} API_PORT=3001 PERSISTENCE_ADAPTER=sqlite CORS_ORIGIN=http://127.0.0.1:4173,http://127.0.0.1:4174,http://localhost:4173,http://localhost:4174 node services/api/src/server.js"`, + url: 'http://127.0.0.1:3001/health', + reuseExistingServer: !isCi, + timeout: 120_000, + }, + { + command: + 'sh -c "cd .. && VITE_API_BASE_URL=http://127.0.0.1:3001 npm run dev --workspace @flix/admin -- --host 127.0.0.1 --port 4174"', + url: 'http://127.0.0.1:4174/login', + reuseExistingServer: !isCi, + timeout: 120_000, + }, + { + command: + 'sh -c "cd .. && VITE_API_BASE_URL=http://127.0.0.1:3001 npm run dev --workspace @flix/web -- --host 127.0.0.1 --port 4173"', + url: 'http://127.0.0.1:4173/events/flix-mvp-launch-event', + reuseExistingServer: !isCi, + timeout: 120_000, + }, + ], + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/e2e/tests/admin-smoke.spec.mjs b/e2e/tests/admin-smoke.spec.mjs new file mode 100644 index 0000000..1717384 --- /dev/null +++ b/e2e/tests/admin-smoke.spec.mjs @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; + +test('admin happy path smoke: login and create event', async ({ page }) => { + await page.goto('http://127.0.0.1:4174/login'); + + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('heading', { name: 'Admin Content Operations' })).toBeVisible(); + + const randomSuffix = `${Date.now()}`; + await page.getByPlaceholder('Event title').fill(`E2E Event ${randomSuffix}`); + await page.getByRole('textbox', { name: 'Slug', exact: true }).fill(`e2e-event-${randomSuffix}`); + await page.getByPlaceholder('Description').fill('Smoke test event created via browser runtime'); + await page.getByPlaceholder('Hero title').fill(`Hero ${randomSuffix}`); + await page.getByPlaceholder('Hero subtitle').fill('Subtitle for smoke validation'); + await page.getByPlaceholder('Hero CTA text').fill('Start now'); + + await page.getByRole('button', { name: 'Create event' }).click(); + await expect(page.getByText('Event created successfully.')).toBeVisible(); + + await expect(page.getByRole('button', { name: new RegExp(`E2E Event ${randomSuffix}`) })).toBeVisible(); +}); diff --git a/e2e/tests/learner-smoke.spec.mjs b/e2e/tests/learner-smoke.spec.mjs new file mode 100644 index 0000000..efc54c5 --- /dev/null +++ b/e2e/tests/learner-smoke.spec.mjs @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; + +test('learner happy path smoke: catalog to playback', async ({ page }) => { + await page.goto('/events/flix-mvp-launch-event'); + + await expect(page.getByRole('heading', { name: 'Flix Learner' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Catalog Access' })).toBeVisible(); + + const lessonLink = page.locator('a[href="/events/flix-mvp-launch-event/lessons/kickoff-do-mvp"]'); + for (let attempt = 1; attempt <= 3; attempt += 1) { + await page.getByRole('button', { name: 'Load catalog' }).click(); + try { + await expect(lessonLink).toBeVisible({ timeout: 8_000 }); + break; + } catch (error) { + if (attempt === 3) { + throw error; + } + await page.waitForTimeout(1_000); + } + } + + await lessonLink.click(); + await expect(page.getByRole('heading', { name: 'Lesson Playback' })).toBeVisible(); + + await expect(page.getByRole('heading', { name: 'Lesson Materials' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Lesson Quiz' })).toBeVisible(); +}); diff --git a/package-lock.json b/package-lock.json index 708c0ea..054b2c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,10 @@ "apps/*", "packages/*", "services/*" - ] + ], + "devDependencies": { + "@playwright/test": "^1.51.1" + } }, "apps/admin": { "name": "@flix/admin", @@ -1276,6 +1279,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3026,6 +3045,53 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index c23e1c0..fae3e89 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,13 @@ "typecheck": "npm run typecheck --workspaces --if-present", "dev:web": "npm --workspace @flix/web run dev", "dev:admin": "npm --workspace @flix/admin run dev", - "dev:api": "npm --workspace @flix/api run dev" - , - "release:readiness": "node scripts/release-readiness-gate.mjs" + "dev:api": "npm --workspace @flix/api run dev", + "release:readiness": "node scripts/release-readiness-gate.mjs", + "e2e:smoke": "playwright test --config e2e/playwright.config.mjs", + "e2e:smoke:gate": "node scripts/e2e-smoke-gate.mjs", + "ci:local": "node scripts/ci-local-gate.mjs" + }, + "devDependencies": { + "@playwright/test": "^1.51.1" } } diff --git a/scripts/ci-local-gate.mjs b/scripts/ci-local-gate.mjs new file mode 100644 index 0000000..1883a12 --- /dev/null +++ b/scripts/ci-local-gate.mjs @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process'; + +const checks = [ + { + name: 'Build web', + command: 'npm', + args: ['run', 'build', '--workspace', '@flix/web'], + }, + { + name: 'Build admin', + command: 'npm', + args: ['run', 'build', '--workspace', '@flix/admin'], + }, + { + name: 'API regression tests', + command: 'npm', + args: ['run', 'test', '--workspace', '@flix/api'], + }, + { + name: 'Browser smoke gate', + command: 'npm', + args: ['run', 'e2e:smoke:gate'], + }, +]; + +const runCheck = ({ name, command, args }) => { + console.log(`\n[CI LOCAL] ${name}`); + const result = spawnSync(command, args, { + stdio: 'inherit', + env: process.env, + }); + + if (result.status !== 0) { + console.error(`\n[CI LOCAL][FAIL] ${name}`); + process.exit(result.status ?? 1); + } + + console.log(`[CI LOCAL][PASS] ${name}`); +}; + +for (const check of checks) { + runCheck(check); +} + +console.log('\n[CI LOCAL] All checks passed.'); + diff --git a/scripts/e2e-smoke-gate.mjs b/scripts/e2e-smoke-gate.mjs new file mode 100644 index 0000000..0c60097 --- /dev/null +++ b/scripts/e2e-smoke-gate.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from 'node:child_process'; + +const run = (command, args) => + spawnSync(command, args, { + stdio: 'inherit', + env: process.env, + }); + +const result = run('npx', ['playwright', 'test', '--config', 'e2e/playwright.config.mjs']); + +if (result.status !== 0) { + console.error('\nE2E smoke gate failed.'); + console.error('Diagnostics:'); + console.error('- HTML report: playwright-report/index.html'); + console.error('- Traces/screenshots/videos: test-results/'); + console.error('- Re-run locally: npm run e2e:smoke'); + process.exit(result.status ?? 1); +} + +console.log('\nE2E smoke gate passed.'); + diff --git a/scripts/release-readiness-gate.mjs b/scripts/release-readiness-gate.mjs index 8aa2378..b68ab1f 100644 --- a/scripts/release-readiness-gate.mjs +++ b/scripts/release-readiness-gate.mjs @@ -13,6 +13,17 @@ const runCheck = (name, fn) => { }; runCheck('Admin + learner smoke test suite', () => { + execSync('npm run e2e:smoke:gate', { stdio: 'inherit' }); + return 'Browser smoke suite passed'; +}); + +runCheck('Frontend build checks', () => { + execSync('npm run build --workspace @flix/web', { stdio: 'pipe' }); + execSync('npm run build --workspace @flix/admin', { stdio: 'pipe' }); + return 'Web and admin production builds passed'; +}); + +runCheck('API integration regression checks', () => { execSync('npm run test --workspace @flix/api', { stdio: 'pipe' }); return 'API integration suite passed'; });