diff --git a/.env.example b/.env.example index 4c342c1..61b1f8d 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,26 @@ VITE_API_TIMEOUT_MS=10000 # Optional override for local Vite proxy target VITE_DEV_PROXY_TARGET=http://localhost:8080 +# Live backend and release-readiness defaults for Playwright runs +LIVE_API_BASE_URL=http://127.0.0.1:8080 +LIVE_REGISTER_PASSWORD=LiveTest1! +LIVE_INVALID_PASSWORD=DefinitelyWrong1! +LIVE_RESET_TOKEN= +LIVE_RESET_PASSWORD=FreshLive1! +LIVE_LOGIN_EMAIL= +LIVE_LOGIN_PASSWORD= +LIVE_LOGIN_OTP= +LIVE_LOGIN_TOTP_SECRET= +LIVE_ADMIN_EMAIL= +LIVE_ADMIN_PASSWORD= +LIVE_ADMIN_OTP= +LIVE_ADMIN_TOTP_SECRET= +LIVE_CHANNEL_DB_CONTAINER=mysql +LIVE_CHANNEL_DB_USER=fix +LIVE_CHANNEL_DB_PASSWORD=fix +LIVE_CHANNEL_DB_NAME=channel_db +PLAYWRIGHT_FE_PORT=4173 + # Optional Grafana-backed admin monitoring descriptor. # Generate a real local value after Prometheus and Grafana are up: # node ../scripts/observability/generate-monitoring-panels.mjs --write-env-file .env.local diff --git a/README.md b/README.md index 9527e84..0ef1eb5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,18 @@ Run the suite only when the live backend is already healthy: pnpm run e2e:live:if-healthy ``` +Run only the release-critical live journeys: + +```bash +pnpm run e2e:release +``` + +Run the full FE release gate bundle: + +```bash +pnpm run release:check +``` + Optional variables: - `LIVE_REGISTER_PASSWORD`: password used for the fresh-account register/login flow. Default: `LiveTest1!` @@ -99,6 +111,32 @@ The suite covers: - suite-local admin-monitoring fixtures are cleaned up through the local MySQL fixture after the live run completes - live monitoring behavior driven by the active `VITE_ADMIN_MONITORING_PANELS_JSON` environment, or deterministic config-unavailable guidance when that env is absent +## Release readiness pack + +Story 10.5 uses checked-in release-pack files so the same evidence path works for both developers and reviewers: + +- `docs/release/web-readiness-checklist.md`: guide and scaffold entry point for versioned candidate checklists +- `docs/release/web-test-matrix.md`: critical FE release lanes and exact commands/specs +- `docs/release/web-release-notes.md`: guide and scaffold entry point for versioned candidate notes +- `docs/release/candidates/v/web-readiness-checklist.md`: candidate-specific checklist, signoff, and evidence links +- `docs/release/candidates/v/web-release-notes.md`: candidate-specific notes, risks, and linked proof +- `docs/release/candidates/v/playwright-release-summary.md`: reviewer-stable summary of the release-critical Playwright run +- `docs/release/candidates/v/upstream-story-10.1-evidence.md`: acceptance-CI evidence record slot for the exact candidate +- `docs/release/candidates/v/upstream-story-10.4-evidence.md`: smoke/rehearsal evidence record slot for the exact candidate + +Suggested flow: + +```bash +cp .env.example .env.local +pnpm run e2e:live:install +pnpm run release:check +pnpm run release:notes +``` + +`playwright.config.ts` and `scripts/check-live-auth-contract.mjs` load `.env.local` automatically, so the copied `LIVE_*`, `PLAYWRIGHT_FE_PORT`, and `VITE_*` values are visible to `pnpm run release:check`, the live preflight step, and the candidate pack generator. + +After the commands pass, update the generated candidate files under `docs/release/candidates/v/` with the checked-in Playwright summary, CI run URL, README/local-setup verification notes, and upstream evidence records, then finalize the candidate release notes for the approved build. + ## Email-first auth contract - Login uses `email + password`. @@ -118,9 +156,13 @@ When FE is pointed at `MOB/scripts/mock-auth-server.mjs` through `VITE_DEV_PROXY ## Environment variables -Copy `.env.example` to `.env.local` when needed. +Copy `.env.example` to `.env.local` when needed. The example file includes both Vite runtime variables and the `LIVE_*` values consumed by the release-readiness suite, and the FE live preflight plus Playwright config both load `.env.local` automatically. - `VITE_API_BASE_URL`: absolute API URL (typically for deployed environments) - `VITE_API_TIMEOUT_MS`: optional FE axios timeout override in milliseconds for slow live demos or diagnostics - `VITE_DEV_PROXY_TARGET`: backend target for local Vite proxy (`/api`, `/actuator`) +- `LIVE_API_BASE_URL`: live backend used by release-critical Playwright runs +- `LIVE_REGISTER_PASSWORD`: password for the fresh-account registration/login lane in release validation +- `LIVE_LOGIN_EMAIL`, `LIVE_LOGIN_PASSWORD`: optional reusable live account for non-mutating portfolio parity checks +- `LIVE_LOGIN_OTP`, `LIVE_LOGIN_TOTP_SECRET`: MFA inputs for the reusable live account when it challenges during portfolio parity or notification smoke - `VITE_ADMIN_MONITORING_PANELS_JSON`: Grafana-backed `/admin` monitoring descriptor generated by `../scripts/observability/generate-monitoring-panels.mjs --write-env-file .env.local` diff --git a/docs/release/candidates/v0.1.0/playwright-release-summary.md b/docs/release/candidates/v0.1.0/playwright-release-summary.md new file mode 100644 index 0000000..1e2cc1c --- /dev/null +++ b/docs/release/candidates/v0.1.0/playwright-release-summary.md @@ -0,0 +1,29 @@ + + +# FE Release-Critical Playwright Evidence Summary + +## Candidate + +- Version: `0.1.0` +- Command: `pnpm run e2e:release` +- Validation date: `` +- Environment: `` + +## Covered Specs + +- `e2e/live/auth-live.spec.ts` +- `e2e/live/order-session-live.spec.ts` +- `e2e/live/notification-center-live.spec.ts` +- `e2e/live/notification-stream-live.spec.ts` +- `e2e/live/portfolio-dashboard-live.spec.ts` + +## Reviewer-Facing Evidence + +- Result summary: `` +- This markdown file is the checked-in, repository-stable evidence record for reviewers. +- External artifact URL or attachment path: `` + +## Notes + +- Raw `playwright-report/` output is intentionally not tracked in git. +- Attach the CI artifact URL or an exported bundle in the candidate release notes when finalizing approval. diff --git a/docs/release/candidates/v0.1.0/upstream-story-10.1-evidence.md b/docs/release/candidates/v0.1.0/upstream-story-10.1-evidence.md new file mode 100644 index 0000000..239c377 --- /dev/null +++ b/docs/release/candidates/v0.1.0/upstream-story-10.1-evidence.md @@ -0,0 +1,21 @@ + + +# Story 10.1 Upstream Evidence Record + +## Expected Evidence + +- Acceptance CI gate run URL +- Scenario traceability artifact or report +- Owner acknowledgement for the exact FE candidate + +## Current Status + +- Status: `Pending upstream completion` +- This file is the candidate-specific evidence record linked from the FE release checklist. +- Replace the placeholders below with actual evidence links before final shipment. + +## Evidence Links + +- CI gate run URL: `` +- Scenario traceability artifact: `` +- Reviewer note: `` diff --git a/docs/release/candidates/v0.1.0/upstream-story-10.4-evidence.md b/docs/release/candidates/v0.1.0/upstream-story-10.4-evidence.md new file mode 100644 index 0000000..74ac1fe --- /dev/null +++ b/docs/release/candidates/v0.1.0/upstream-story-10.4-evidence.md @@ -0,0 +1,21 @@ + + +# Story 10.4 Upstream Evidence Record + +## Expected Evidence + +- Smoke rehearsal execution record +- Rollback verification evidence +- Owner acknowledgement for the exact FE candidate + +## Current Status + +- Status: `Pending upstream completion` +- This file is the candidate-specific evidence record linked from the FE release checklist. +- Replace the placeholders below with actual evidence links before final shipment. + +## Evidence Links + +- Smoke rehearsal artifact: `` +- Rollback verification artifact: `` +- Reviewer note: `` diff --git a/docs/release/candidates/v0.1.0/web-readiness-checklist.md b/docs/release/candidates/v0.1.0/web-readiness-checklist.md new file mode 100644 index 0000000..41f8297 --- /dev/null +++ b/docs/release/candidates/v0.1.0/web-readiness-checklist.md @@ -0,0 +1,61 @@ + + +# Web Release Readiness Checklist + +## Candidate Metadata + +| Field | Value | +| --- | --- | +| Candidate version | `0.1.0` | +| Commit SHA | `` (FE), `` (repo) | +| Reviewer / release owner | `` | +| Validation date | `` | +| Environment | `` | + +## Automated Release Gate + +| Gate | Command | Evidence | +| --- | --- | --- | +| FE type-check | `pnpm run type-check` | `` | +| FE lint | `pnpm run lint` | `` | +| FE unit/integration | `pnpm run test` | `` | +| FE build | `pnpm run build` | `` | +| FE live preflight | `pnpm run e2e:live:preflight` | `` | +| FE release-critical Playwright | `pnpm run e2e:release` | See `./playwright-release-summary.md` | + +## Critical Journey Evidence + +| Journey | Primary specs | Evidence | +| --- | --- | --- | +| Auth register/login/reset guidance | `e2e/live/auth-live.spec.ts` | See `./playwright-release-summary.md` | +| Order create/execute/result | `e2e/live/order-session-live.spec.ts` | See `./playwright-release-summary.md` | +| Notification feed and mark-read | `e2e/live/notification-center-live.spec.ts` | See `./playwright-release-summary.md` | +| Notification stream reconnect | `e2e/live/notification-stream-live.spec.ts` | See `./playwright-release-summary.md` | +| Portfolio boundary and bootstrap | `e2e/live/portfolio-dashboard-live.spec.ts` | See `./playwright-release-summary.md` | + +## Documentation Consistency + +| Artifact | Check | Evidence | +| --- | --- | --- | +| `README.md` | Dual-audience path, Quick Start, Architecture Decisions, Environment Variables, and security narrative remain accurate | Verified by `tests/unit/release/web-release-readiness-pack.test.ts` and manual README pass | +| `BE/README.md` | Backend setup guidance still matches shipped runtime | `` | +| `FE/README.md` | FE local setup and release commands match actual scripts | Verified by `tests/unit/release/web-release-readiness-pack.test.ts` | +| `.env.example` | Root infra variables remain accurate | `` | +| `FE/.env.example` | FE runtime and `LIVE_*` release variables remain accurate | Verified by `tests/unit/release/web-release-readiness-pack.test.ts` | +| `BE/application-local.yml.template` | Reviewer-facing backend local profile guidance still matches service-local files | `` | + +## Upstream Release Evidence + +| Upstream story | Expected evidence | Link | +| --- | --- | --- | +| Story 10.1 | Acceptance CI gate report / scenario traceability | [upstream-story-10.1-evidence.md](./upstream-story-10.1-evidence.md) | +| Story 10.4 | Smoke rehearsal / rollback evidence | [upstream-story-10.4-evidence.md](./upstream-story-10.4-evidence.md) | + +## Signoff + +| Question | Result | +| --- | --- | +| Critical FE journeys pass? | `` | +| Core auth/order regressions block release? | `` | +| Documentation set mutually consistent? | `` | +| Candidate approved for release notes finalization? | `` | diff --git a/docs/release/candidates/v0.1.0/web-release-notes.md b/docs/release/candidates/v0.1.0/web-release-notes.md new file mode 100644 index 0000000..aaf4fcb --- /dev/null +++ b/docs/release/candidates/v0.1.0/web-release-notes.md @@ -0,0 +1,41 @@ + + +# Web Release Notes + +> Candidate-specific FE release notes for version `0.1.0`. +> Generated from `docs/release/web-release-notes.md`. Draft candidate files are safe to regenerate until the approval status changes from draft. + +## Candidate + +- Version: `0.1.0` +- FE commit SHA: `` +- Repo commit SHA: `` +- Release owner: `` +- Validation date: `` + +## Included Scope + +- Story 10.5 web release readiness packaging +- README / local setup consistency verification +- FE live auth, order, and notification critical-journey validation +- FE live portfolio dashboard boundary and bootstrap validation +- Additional candidate-specific scope: `` + +## Evidence Summary + +- Release checklist: `./web-readiness-checklist.md` +- Test matrix: `../../web-test-matrix.md` +- Playwright evidence summary: `./playwright-release-summary.md` +- CI run URL: `` +- Story 10.1 upstream evidence: `./upstream-story-10.1-evidence.md` +- Story 10.4 upstream evidence: `./upstream-story-10.4-evidence.md` + +## Known Risks / Follow-ups + +- Final shipment still depends on upstream Story 10.1 and Story 10.4 evidence closing in the shared release gate. +- Candidate-specific residual risks: `` + +## Approval + +- Approval status: `Draft - pending validation evidence` +- Reviewer notes: `` diff --git a/docs/release/web-readiness-checklist.md b/docs/release/web-readiness-checklist.md new file mode 100644 index 0000000..4d6b15d --- /dev/null +++ b/docs/release/web-readiness-checklist.md @@ -0,0 +1,40 @@ +# Web Release Readiness Checklist Guide + +This file is the checked-in entry point for FE release checklists. Candidate-specific completed checklists must live under `docs/release/candidates/v/web-readiness-checklist.md` so release metadata and evidence stay scoped to the exact FE candidate. + +Do not record candidate-specific SHAs, dates, or approval state in this guide. Put those details in the candidate file instead. + +## Generate Candidate Pack + +Run the scaffold command after the FE release gate passes: + +```bash +pnpm run release:notes +``` + +The command reads the FE version from `package.json`, creates the candidate directory under `docs/release/candidates/v/`, and refreshes the draft candidate pack until the generated release notes are finalized with a non-draft approval status. + +Current FE package version path: + +- `docs/release/candidates/v0.1.0/web-readiness-checklist.md` + +## Candidate Companion Files + +Each candidate directory should keep these reviewer-facing evidence records together: + +- `web-readiness-checklist.md` +- `web-release-notes.md` +- `playwright-release-summary.md` +- `upstream-story-10.1-evidence.md` +- `upstream-story-10.4-evidence.md` + +## Checklist Contract + +Every candidate checklist should include these sections: + +- `## Candidate Metadata` +- `## Automated Release Gate` +- `## Critical Journey Evidence` +- `## Documentation Consistency` +- `## Upstream Release Evidence` +- `## Signoff` diff --git a/docs/release/web-release-notes.md b/docs/release/web-release-notes.md new file mode 100644 index 0000000..af0a6b7 --- /dev/null +++ b/docs/release/web-release-notes.md @@ -0,0 +1,37 @@ +# Web Release Notes Guide + +This file is the checked-in entry point for FE release notes. Candidate-specific notes must live under `docs/release/candidates/v/web-release-notes.md` so each FE candidate keeps its own immutable evidence trail. + +Do not record candidate-specific approval state, dates, or commit SHAs in this file. Put those details in the generated candidate file instead. + +## Generate Candidate Notes + +Run the scaffold command after the FE release gate passes: + +```bash +pnpm run release:notes +``` + +The command reads the FE version from `package.json`, creates the candidate directory under `docs/release/candidates/v/`, and refreshes the draft candidate pack until the release notes are finalized with a non-draft approval status. + +Current FE package version path: + +- `docs/release/candidates/v0.1.0/web-release-notes.md` + +## What Belongs In The Generated File + +- candidate metadata for the exact FE candidate under review +- links to the candidate checklist and shared test matrix +- links to CI and Playwright evidence for that candidate +- links to candidate-specific upstream Story 10.1 and Story 10.4 evidence records +- approval outcome and reviewer notes for that version only + +## Template Contract + +Every generated candidate file should include these sections: + +- `## Candidate` +- `## Included Scope` +- `## Evidence Summary` +- `## Known Risks / Follow-ups` +- `## Approval` diff --git a/docs/release/web-test-matrix.md b/docs/release/web-test-matrix.md new file mode 100644 index 0000000..18a2aa7 --- /dev/null +++ b/docs/release/web-test-matrix.md @@ -0,0 +1,27 @@ +# Web Release Test Matrix + +## Target Lanes + +| Lane | Purpose | Command / Spec Set | Pass Rule | +| --- | --- | --- | --- | +| `live-auth` | Register, login, password-recovery, and correlation-id behavior against live backend | `pnpm run e2e:release` (`e2e/live/auth-live.spec.ts`) | All auth scenarios pass with no uncaught browser errors | +| `live-order` | Order session create/execute/result against live backend | `pnpm run e2e:release` (`e2e/live/order-session-live.spec.ts`) | Order flow reaches deterministic success or documented guarded result | +| `live-notification-center` | Notification list hydration and mark-read UX | `pnpm run e2e:release` (`e2e/live/notification-center-live.spec.ts`) | Feed renders, updates, and mark-read completes | +| `live-notification-stream` | Reconnect and SSE hydration behavior | `pnpm run e2e:release` (`e2e/live/notification-stream-live.spec.ts`) | Stream reconnect path restores live state without manual browser repair | +| `live-portfolio-dashboard` | Portfolio boundary, bootstrap, and dashboard history rendering against live backend | `pnpm run e2e:release` (`e2e/live/portfolio-dashboard-live.spec.ts`) | Anonymous access is blocked and a live authenticated portfolio session renders summary/history without UI contract drift | + +## Supporting Quality Gates + +| Gate | Command | Purpose | +| --- | --- | --- | +| Type check | `pnpm run type-check` | TS contract integrity | +| Lint | `pnpm run lint` | Static code quality | +| Unit and integration | `pnpm run test` | FE behavior regression coverage | +| Build | `pnpm run build` | Production bundle validity | +| Live preflight | `pnpm run e2e:live:preflight` | Backend auth contract readiness | + +## Notes + +- `pnpm run release:check` is the canonical FE release gate command for Story 10.5. +- `.env.example` is the checked-in source for the `LIVE_*` variables consumed by the FE release gate, and both the Playwright config and live preflight script load `.env.local` automatically. +- Final release approval should attach the checked-in `playwright-release-summary.md` record and CI artifact URL to the candidate checklist under `docs/release/candidates/v/`. diff --git a/e2e/live/notification-center-live.spec.ts b/e2e/live/notification-center-live.spec.ts index 0ced1bc..bb58ab8 100644 --- a/e2e/live/notification-center-live.spec.ts +++ b/e2e/live/notification-center-live.spec.ts @@ -1,11 +1,285 @@ -import { expect, test } from '@playwright/test'; +import { createHmac } from 'node:crypto'; + +import { expect, test, type Page } from '@playwright/test'; + +import { primeLiveBrowserCsrf, requireLiveAuthContractHealthy } from './_shared/liveAuthContract'; const isLiveConfigured = Boolean(process.env.LIVE_API_BASE_URL?.trim()); +const DEFAULT_REGISTER_PASSWORD = 'LiveNotificationCenter1!'; +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const liveLoginEmail = process.env.LIVE_LOGIN_EMAIL?.trim(); const liveLoginPassword = process.env.LIVE_LOGIN_PASSWORD?.trim(); const liveLoginOtp = process.env.LIVE_LOGIN_OTP?.trim(); +const liveLoginTotpSecret = process.env.LIVE_LOGIN_TOTP_SECRET?.trim(); +const protectedStatuses = [401, 403, 410]; + +const createLiveIdentity = () => { + const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`; + + return { + email: `notification_center_live_${suffix}@example.com`, + name: `Notification Center ${suffix}`, + password: process.env.LIVE_REGISTER_PASSWORD ?? DEFAULT_REGISTER_PASSWORD, + }; +}; + +const decodeBase32 = (value: string): Buffer => { + const normalized = value.trim().replace(/[\s=-]/g, '').toUpperCase(); + let buffer = 0; + let bitsLeft = 0; + const output: number[] = []; + + for (const character of normalized) { + const index = BASE32_ALPHABET.indexOf(character); + + if (index < 0) { + throw new Error(`Unsupported base32 character: ${character}`); + } + + buffer = (buffer << 5) | index; + bitsLeft += 5; + + if (bitsLeft >= 8) { + output.push((buffer >> (bitsLeft - 8)) & 0xff); + bitsLeft -= 8; + } + } + + return Buffer.from(output); +}; + +const generateTotp = (manualEntryKey: string, now = Date.now()): string => { + const counter = Math.floor(now / 1000 / 30); + const counterBuffer = Buffer.alloc(8); + + counterBuffer.writeBigUInt64BE(BigInt(counter)); + + const digest = createHmac('sha1', decodeBase32(manualEntryKey)) + .update(counterBuffer) + .digest(); + const offset = digest[digest.length - 1] & 0x0f; + const binary = ((digest[offset] & 0x7f) << 24) + | ((digest[offset + 1] & 0xff) << 16) + | ((digest[offset + 2] & 0xff) << 8) + | (digest[offset + 3] & 0xff); + + return String(binary % 1_000_000).padStart(6, '0'); +}; + +const delay = (milliseconds: number) => + new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + +const millisUntilNextTotpWindow = (now = Date.now()) => 30_000 - (now % 30_000); + +const waitForNextTotp = async ( + manualEntryKey: string, + previousCode: string, +): Promise => { + const startedAt = Date.now(); + let nextCode = generateTotp(manualEntryKey); + + while (nextCode === previousCode || millisUntilNextTotpWindow() < 10_000) { + if (Date.now() - startedAt > 45_000) { + throw new Error('Timed out waiting for the next TOTP window.'); + } + + await delay(250); + nextCode = generateTotp(manualEntryKey); + } + + return nextCode; +}; + +const waitForLoginStep = async (page: Page): Promise<'orders' | 'mfa' | 'error'> => { + const mfaInput = page.getByTestId('login-mfa-input'); + const loginError = page.getByTestId('error-message'); + const startedAt = Date.now(); + + while (Date.now() - startedAt <= 15_000) { + const pathname = new URL(page.url()).pathname; + + if (pathname === '/orders') { + return 'orders'; + } + + if (await mfaInput.isVisible().catch(() => false)) { + return 'mfa'; + } + + if (await loginError.isVisible().catch(() => false)) { + return 'error'; + } + + await delay(250); + } + + throw new Error('Expected login to reach /orders, show MFA challenge, or show a login error message within 15s.'); +}; + +const goToRegister = async (page: Page) => { + await page.goto('/register?redirect=/orders'); + await expect(page.getByTestId('register-email')).toBeVisible(); + await primeLiveBrowserCsrf(page); +}; + +const goToLogin = async (page: Page) => { + await page.goto('/login?redirect=/orders'); + await expect(page.getByTestId('login-email')).toBeVisible(); + await primeLiveBrowserCsrf(page); +}; + +const expectOrdersPath = async (page: Page) => { + await expect.poll(() => { + const url = new URL(page.url()); + return url.pathname; + }, { + timeout: 20_000, + message: 'Expected browser to navigate to /orders pathname.', + }).toBe('/orders'); +}; + +const loginWithLiveAccount = async (page: Page) => { + if (!liveLoginEmail || !liveLoginPassword) { + return false; + } + + await goToLogin(page); + await page.getByTestId('login-email').fill(liveLoginEmail); + await page.getByTestId('login-password').fill(liveLoginPassword); + await page.getByTestId('login-submit').click(); + + const mfaInput = page.getByTestId('login-mfa-input'); + const loginError = page.getByTestId('error-message'); + const loginStep = await waitForLoginStep(page); + + if (loginStep === 'error') { + const message = (await loginError.textContent())?.trim() ?? 'Unknown login error'; + throw new Error(`Live account password login failed before MFA: ${message}`); + } + + if (loginStep === 'mfa') { + if (!liveLoginOtp && !liveLoginTotpSecret) { + throw new Error('LIVE_LOGIN_OTP or LIVE_LOGIN_TOTP_SECRET is required when live account login prompts MFA verification.'); + } + + const maxAttempts = liveLoginTotpSecret ? 3 : 1; + let previousCode = ''; + let mfaPassed = false; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const mfaCode = liveLoginTotpSecret + ? (attempt === 1 + ? generateTotp(liveLoginTotpSecret) + : await waitForNextTotp(liveLoginTotpSecret, previousCode)) + : liveLoginOtp!; + + previousCode = mfaCode; + await mfaInput.fill(mfaCode); + await page.getByTestId('login-mfa-submit').click(); + + const reachedOrders = await expect.poll(() => { + const url = new URL(page.url()); + return url.pathname === '/orders'; + }, { + timeout: 8_000, + message: `Expected browser to navigate to /orders after MFA attempt ${attempt}.`, + }).toBeTruthy().then(() => true).catch(() => false); + + if (reachedOrders) { + mfaPassed = true; + break; + } + } + + if (!mfaPassed) { + const message = await page.getByTestId('login-mfa-error').textContent().catch(() => null); + throw new Error(`Live account MFA verification did not complete. ${message?.trim() ? `Server message: ${message.trim()}` : 'Check LIVE_LOGIN_TOTP_SECRET/LIVE_LOGIN_OTP validity and server clock skew.'}`); + } + } + + await expectOrdersPath(page); + await expect(page.getByTestId('protected-area-title')).toHaveText('Session-based order flow'); + return true; +}; + +const registerEnrollAndLoginToOrders = async (page: Page) => { + const identity = createLiveIdentity(); + + await goToRegister(page); + await page.getByTestId('register-email').fill(identity.email); + await page.getByTestId('register-name').fill(identity.name); + await page.getByTestId('register-password').fill(identity.password); + await page.getByTestId('register-password-confirm').fill(identity.password); + + const registerLoginResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/v1/auth/login') + && response.request().method() === 'POST', + ); + await page.getByTestId('register-submit').click(); + + const registerLoginResponse = await registerLoginResponsePromise; + const registerLoginPayload = await registerLoginResponse.json() as { + data?: { + nextAction?: string; + }; + }; + + expect(registerLoginPayload.data?.nextAction).toBe('ENROLL_TOTP'); + await expect(page).toHaveURL(/\/settings\/totp\/enroll(?:\?.*)?$/); + await expect(page.getByTestId('totp-enroll-manual-key')).toBeVisible(); + + const manualEntryKey = (await page.getByTestId('totp-enroll-manual-key').textContent())?.trim(); + expect(manualEntryKey).toBeTruthy(); + + const enrollmentCode = generateTotp(manualEntryKey!); + await page.getByTestId('totp-enroll-code').fill(enrollmentCode); + await page.getByTestId('totp-enroll-submit').click(); + + await expect(page.getByTestId('protected-area-title')).toBeVisible(); + + await page.context().clearCookies(); + await page.evaluate(() => { + globalThis.localStorage?.clear(); + globalThis.sessionStorage.clear(); + }); + + await goToLogin(page); + await page.getByTestId('login-email').fill(identity.email); + await page.getByTestId('login-password').fill(identity.password); + + const loginChallengeResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/v1/auth/login') + && response.request().method() === 'POST', + ); + await page.getByTestId('login-submit').click(); + + const loginChallengeResponse = await loginChallengeResponsePromise; + const loginChallengePayload = await loginChallengeResponse.json() as { + data?: { + nextAction?: string; + }; + }; + + if (loginChallengePayload.data?.nextAction === 'VERIFY_TOTP') { + await expect(page.getByTestId('login-mfa-input')).toBeVisible(); + const loginCode = await waitForNextTotp(manualEntryKey!, enrollmentCode); + await page.getByTestId('login-mfa-input').fill(loginCode); + await page.getByTestId('login-mfa-submit').click(); + } + + await expectOrdersPath(page); + await expect(page.getByTestId('protected-area-title')).toHaveText('Session-based order flow'); +}; test.describe.serial('live notification center smoke', () => { + test.beforeEach(async ({ request }) => { + await requireLiveAuthContractHealthy(request); + }); + test('enforces auth boundary for notification-center entry points against live backend', async ({ page, }) => { @@ -20,42 +294,51 @@ test.describe.serial('live notification center smoke', () => { const notificationListResponse = await page.request.get('/api/v1/notifications?limit=20'); const notificationStreamResponse = await page.request.get('/api/v1/notifications/stream'); - expect([401, 403]).toContain(notificationListResponse.status()); - expect([401, 403]).toContain(notificationStreamResponse.status()); + expect(protectedStatuses).toContain(notificationListResponse.status()); + expect(protectedStatuses).toContain(notificationStreamResponse.status()); }); - test('renders notification center after a live authenticated login', async ({ page }) => { + test('hydrates notification center and marks a live notification as read after order completion', async ({ + page, + }) => { test.skip( - !isLiveConfigured || !liveLoginEmail || !liveLoginPassword, - 'LIVE_API_BASE_URL, LIVE_LOGIN_EMAIL, and LIVE_LOGIN_PASSWORD are required for live authenticated smoke execution.', + !isLiveConfigured, + 'LIVE_API_BASE_URL is required for live notification-center smoke execution.', ); + test.slow(); + test.setTimeout(180_000); - await page.goto('/login?redirect=/portfolio'); - await expect(page.getByTestId('login-email')).toBeVisible(); + const usedLiveAccount = await loginWithLiveAccount(page); - await page.getByTestId('login-email').fill(liveLoginEmail!); - await page.getByTestId('login-password').fill(liveLoginPassword!); - await page.getByTestId('login-submit').click(); + if (!usedLiveAccount) { + await registerEnrollAndLoginToOrders(page); + } - const mfaInput = page.getByTestId('login-mfa-input'); - const mfaVisible = await mfaInput.isVisible({ timeout: 2_000 }).catch(() => false); + await expect(page.getByTestId('notification-center')).toBeVisible(); - if (mfaVisible) { - if (!liveLoginOtp) { - throw new Error('LIVE_LOGIN_OTP is required when live account login prompts MFA verification.'); - } + const notificationItems = page.locator('[data-testid^="notification-item-"]'); + const initialNotificationCount = await notificationItems.count(); - await mfaInput.fill(liveLoginOtp); - await page.getByTestId('login-mfa-submit').click(); - } + await page.getByTestId('order-session-create').click(); + await expect(page.getByTestId('order-session-summary')).toContainText('상태 AUTHED'); + await page.getByTestId('order-session-execute').click(); + await expect(page.getByTestId('order-session-summary')).toContainText('상태 COMPLETED'); - await expect(page).toHaveURL(/\/portfolio$/); - await expect(page.getByTestId('notification-center')).toBeVisible(); + await expect.poll(async () => notificationItems.count(), { + timeout: 45_000, + message: 'Expected notification center to receive a new live notification after order completion.', + }).toBeGreaterThan(initialNotificationCount); + + const unreadButton = page.locator('[data-testid^="notification-mark-read-"]').first(); + await expect(unreadButton).toBeVisible(); + + const buttonTestId = await unreadButton.getAttribute('data-testid'); + const notificationId = buttonTestId?.match(/^notification-mark-read-(\d+)$/)?.[1]; + + expect(notificationId).toBeTruthy(); - const hasList = await page.getByTestId('notification-center-list').isVisible().catch(() => false); - const hasEmpty = await page.getByTestId('notification-center-empty').isVisible().catch(() => false); - const hasUnavailable = await page.getByTestId('notification-feed-unavailable').isVisible().catch(() => false); + await unreadButton.click(); - expect(hasList || hasEmpty || hasUnavailable).toBe(true); + await expect(page.getByTestId(`notification-read-${notificationId}`)).toHaveText('Read'); }); }); diff --git a/e2e/live/portfolio-dashboard-live.spec.ts b/e2e/live/portfolio-dashboard-live.spec.ts index 831a964..3302d24 100644 --- a/e2e/live/portfolio-dashboard-live.spec.ts +++ b/e2e/live/portfolio-dashboard-live.spec.ts @@ -17,6 +17,7 @@ const LIVE_LOGIN_EMAIL = process.env.LIVE_LOGIN_EMAIL?.trim(); const LIVE_LOGIN_PASSWORD = process.env.LIVE_LOGIN_PASSWORD?.trim(); const LIVE_LOGIN_OTP = process.env.LIVE_LOGIN_OTP?.trim(); const LIVE_LOGIN_TOTP_SECRET = process.env.LIVE_LOGIN_TOTP_SECRET?.trim(); +const PROTECTED_ACCOUNT_BOUNDARY_STATUSES = [401, 403, 410]; const createLiveIdentity = () => { const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`; @@ -630,6 +631,22 @@ test.describe('live backend portfolio dashboard', () => { await requireLiveAuthContractHealthy(request); }); + test('redirects anonymous portfolio access to login and blocks backend account dashboard endpoints', async ({ + page, + }) => { + await page.context().clearCookies(); + await page.goto('/portfolio?tab=positions'); + + await expect(page).toHaveURL(/\/login\?redirect=%2Fportfolio%3Ftab%3Dpositions$/); + await expect(page.getByTestId('login-email')).toBeVisible(); + + const summaryResponse = await page.request.get('/api/v1/accounts/999999/summary'); + const positionsResponse = await page.request.get('/api/v1/accounts/999999/positions/list'); + + expect(PROTECTED_ACCOUNT_BOUNDARY_STATUSES).toContain(summaryResponse.status()); + expect(PROTECTED_ACCOUNT_BOUNDARY_STATUSES).toContain(positionsResponse.status()); + }); + test('renders live portfolio dashboard and history states from the backend contract', async ({ page, }) => { diff --git a/package.json b/package.json index 230cd48..5706f14 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,11 @@ "test": "vitest run", "test:watch": "vitest", "e2e:live": "playwright test e2e/live", + "e2e:release": "playwright test e2e/live/auth-live.spec.ts e2e/live/order-session-live.spec.ts e2e/live/notification-center-live.spec.ts e2e/live/notification-stream-live.spec.ts e2e/live/portfolio-dashboard-live.spec.ts", "e2e:live:preflight": "node scripts/check-live-auth-contract.mjs", "e2e:live:if-healthy": "pnpm e2e:live:preflight && pnpm e2e:live", + "release:notes": "node scripts/generate-release-notes.mjs", + "release:check": "pnpm run type-check && pnpm run lint && pnpm run test && pnpm run build && pnpm run e2e:live:preflight && pnpm run e2e:release", "e2e:live:headed": "playwright test e2e/live --headed", "e2e:live:install": "playwright install chromium", "demo:admin-monitoring:record": "node scripts/record-admin-monitoring-demo.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index e29ce78..a37da68 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,29 @@ import { defineConfig, devices } from '@playwright/test'; +import { loadEnv } from 'vite'; + +const loadedEnv = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), ''); +const configEnv = { + ...loadedEnv, + ...process.env, +}; + +const parseBooleanEnv = (value: string | undefined) => + /^(1|true|yes|on)$/i.test(value ?? ''); + +const parsePortEnv = (value: string | undefined, fallback: number) => { + const parsed = Number.parseInt(value ?? '', 10); + + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535) { + return parsed; + } + + return fallback; +}; const host = '127.0.0.1'; -const port = Number(process.env.PLAYWRIGHT_FE_PORT ?? '4173'); +const port = parsePortEnv(configEnv.PLAYWRIGHT_FE_PORT, 4173); const baseURL = `http://${host}:${port}`; +const isCi = parseBooleanEnv(configEnv.CI); const requestedArgs = process.argv.slice(2); const usesMockedAdminMonitoringFixture = requestedArgs.some((arg) => arg.includes('admin-monitoring.spec.ts') && !arg.includes('/live/'), @@ -76,11 +97,11 @@ const defaultAdminMonitoringPanelsJson = JSON.stringify([ }, }, ]); -const liveBackendBaseUrl = process.env.LIVE_API_BASE_URL - ?? process.env.VITE_DEV_PROXY_TARGET +const liveBackendBaseUrl = configEnv.LIVE_API_BASE_URL + ?? configEnv.VITE_DEV_PROXY_TARGET ?? 'http://127.0.0.1:8080'; -const proxyTarget = process.env.VITE_DEV_PROXY_TARGET - ?? process.env.LIVE_API_BASE_URL +const proxyTarget = configEnv.VITE_DEV_PROXY_TARGET + ?? configEnv.LIVE_API_BASE_URL ?? 'http://127.0.0.1:8080'; export default defineConfig({ @@ -90,7 +111,7 @@ export default defineConfig({ expect: { timeout: 15_000, }, - retries: process.env.CI ? 1 : 0, + retries: isCi ? 1 : 0, reporter: [ ['list'], ['html', { open: 'never' }], @@ -114,9 +135,9 @@ export default defineConfig({ url: baseURL, // Mocked admin-monitoring runs need a fresh Vite env contract, so do not reuse // an already-running local server that may have been started without the fixture. - reuseExistingServer: !process.env.CI && !usesMockedAdminMonitoringFixture, + reuseExistingServer: !isCi && !usesMockedAdminMonitoringFixture, env: { - ...process.env, + ...configEnv, LIVE_API_BASE_URL: liveBackendBaseUrl, VITE_DEV_PROXY_TARGET: proxyTarget, // Force FE API calls to remain relative and flow through Vite proxy. @@ -124,7 +145,7 @@ export default defineConfig({ ...(usesMockedAdminMonitoringFixture ? { VITE_ADMIN_MONITORING_PANELS_JSON: - process.env.VITE_ADMIN_MONITORING_PANELS_JSON ?? defaultAdminMonitoringPanelsJson, + configEnv.VITE_ADMIN_MONITORING_PANELS_JSON ?? defaultAdminMonitoringPanelsJson, } : {}), }, diff --git a/scripts/check-live-auth-contract.mjs b/scripts/check-live-auth-contract.mjs index c2e5cf4..8e5942a 100644 --- a/scripts/check-live-auth-contract.mjs +++ b/scripts/check-live-auth-contract.mjs @@ -1,6 +1,14 @@ +import { loadEnv } from 'vite'; + +const loadedEnv = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), ''); +const configEnv = { + ...loadedEnv, + ...process.env, +}; + const baseUrl = ( - process.env.LIVE_API_BASE_URL - ?? process.env.VITE_DEV_PROXY_TARGET + configEnv.LIVE_API_BASE_URL + ?? configEnv.VITE_DEV_PROXY_TARGET ?? 'http://127.0.0.1:8080' ).replace(/\/$/, ''); diff --git a/scripts/generate-release-notes.mjs b/scripts/generate-release-notes.mjs new file mode 100644 index 0000000..ae5c912 --- /dev/null +++ b/scripts/generate-release-notes.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const feRoot = resolve(scriptDir, '..'); + +const GENERATED_MARKER = ''; + +async function readTextIfExists(path) { + try { + return await readFile(path, 'utf8'); + } catch { + return null; + } +} + +async function main() { + const packageJson = JSON.parse( + await readFile(resolve(feRoot, 'package.json'), 'utf8'), + ); + const version = packageJson.version; + const candidateDir = resolve(feRoot, 'docs/release/candidates', `v${version}`); + const releaseNotesFile = resolve(candidateDir, 'web-release-notes.md'); + const checklistFile = resolve(candidateDir, 'web-readiness-checklist.md'); + const playwrightSummaryFile = resolve( + candidateDir, + 'playwright-release-summary.md', + ); + const story101File = resolve(candidateDir, 'upstream-story-10.1-evidence.md'); + const story104File = resolve(candidateDir, 'upstream-story-10.4-evidence.md'); + const existingReleaseNotes = await readTextIfExists(releaseNotesFile); + + if ( + existingReleaseNotes + && !existingReleaseNotes.includes('Approval status: `Draft - pending validation evidence`') + ) { + console.log(`Preserved approved candidate pack: ${candidateDir}`); + return; + } + + const files = [ + { + path: releaseNotesFile, + label: 'release notes', + contents: `${GENERATED_MARKER} + +# Web Release Notes + +> Candidate-specific FE release notes for version \`${version}\`. +> Generated from \`docs/release/web-release-notes.md\`. Draft candidate files are safe to regenerate until the approval status changes from draft. + +## Candidate + +- Version: \`${version}\` +- FE commit SHA: \`\` +- Repo commit SHA: \`\` +- Release owner: \`\` +- Validation date: \`\` + +## Included Scope + +- Story 10.5 web release readiness packaging +- README / local setup consistency verification +- FE live auth, order, and notification critical-journey validation +- FE live portfolio dashboard boundary and bootstrap validation +- Additional candidate-specific scope: \`\` + +## Evidence Summary + +- Release checklist: \`./web-readiness-checklist.md\` +- Test matrix: \`../../web-test-matrix.md\` +- Playwright evidence summary: \`./playwright-release-summary.md\` +- CI run URL: \`\` +- Story 10.1 upstream evidence: \`./upstream-story-10.1-evidence.md\` +- Story 10.4 upstream evidence: \`./upstream-story-10.4-evidence.md\` + +## Known Risks / Follow-ups + +- Final shipment still depends on upstream Story 10.1 and Story 10.4 evidence closing in the shared release gate. +- Candidate-specific residual risks: \`\` + +## Approval + +- Approval status: \`Draft - pending validation evidence\` +- Reviewer notes: \`\` +`, + }, + { + path: checklistFile, + label: 'checklist', + contents: `${GENERATED_MARKER} + +# Web Release Readiness Checklist + +## Candidate Metadata + +| Field | Value | +| --- | --- | +| Candidate version | \`${version}\` | +| Commit SHA | \`\` (FE), \`\` (repo) | +| Reviewer / release owner | \`\` | +| Validation date | \`\` | +| Environment | \`\` | + +## Automated Release Gate + +| Gate | Command | Evidence | +| --- | --- | --- | +| FE type-check | \`pnpm run type-check\` | \`\` | +| FE lint | \`pnpm run lint\` | \`\` | +| FE unit/integration | \`pnpm run test\` | \`\` | +| FE build | \`pnpm run build\` | \`\` | +| FE live preflight | \`pnpm run e2e:live:preflight\` | \`\` | +| FE release-critical Playwright | \`pnpm run e2e:release\` | See \`./playwright-release-summary.md\` | + +## Critical Journey Evidence + +| Journey | Primary specs | Evidence | +| --- | --- | --- | +| Auth register/login/reset guidance | \`e2e/live/auth-live.spec.ts\` | See \`./playwright-release-summary.md\` | +| Order create/execute/result | \`e2e/live/order-session-live.spec.ts\` | See \`./playwright-release-summary.md\` | +| Notification feed and mark-read | \`e2e/live/notification-center-live.spec.ts\` | See \`./playwright-release-summary.md\` | +| Notification stream reconnect | \`e2e/live/notification-stream-live.spec.ts\` | See \`./playwright-release-summary.md\` | +| Portfolio boundary and bootstrap | \`e2e/live/portfolio-dashboard-live.spec.ts\` | See \`./playwright-release-summary.md\` | + +## Documentation Consistency + +| Artifact | Check | Evidence | +| --- | --- | --- | +| \`README.md\` | Dual-audience path, Quick Start, Architecture Decisions, Environment Variables, and security narrative remain accurate | Verified by \`tests/unit/release/web-release-readiness-pack.test.ts\` and manual README pass | +| \`BE/README.md\` | Backend setup guidance still matches shipped runtime | \`\` | +| \`FE/README.md\` | FE local setup and release commands match actual scripts | Verified by \`tests/unit/release/web-release-readiness-pack.test.ts\` | +| \`.env.example\` | Root infra variables remain accurate | \`\` | +| \`FE/.env.example\` | FE runtime and \`LIVE_*\` release variables remain accurate | Verified by \`tests/unit/release/web-release-readiness-pack.test.ts\` | +| \`BE/application-local.yml.template\` | Reviewer-facing backend local profile guidance still matches service-local files | \`\` | + +## Upstream Release Evidence + +| Upstream story | Expected evidence | Link | +| --- | --- | --- | +| Story 10.1 | Acceptance CI gate report / scenario traceability | [upstream-story-10.1-evidence.md](./upstream-story-10.1-evidence.md) | +| Story 10.4 | Smoke rehearsal / rollback evidence | [upstream-story-10.4-evidence.md](./upstream-story-10.4-evidence.md) | + +## Signoff + +| Question | Result | +| --- | --- | +| Critical FE journeys pass? | \`\` | +| Core auth/order regressions block release? | \`\` | +| Documentation set mutually consistent? | \`\` | +| Candidate approved for release notes finalization? | \`\` | +`, + }, + { + path: playwrightSummaryFile, + label: 'playwright summary', + contents: `${GENERATED_MARKER} + +# FE Release-Critical Playwright Evidence Summary + +## Candidate + +- Version: \`${version}\` +- Command: \`pnpm run e2e:release\` +- Validation date: \`\` +- Environment: \`\` + +## Covered Specs + +- \`e2e/live/auth-live.spec.ts\` +- \`e2e/live/order-session-live.spec.ts\` +- \`e2e/live/notification-center-live.spec.ts\` +- \`e2e/live/notification-stream-live.spec.ts\` +- \`e2e/live/portfolio-dashboard-live.spec.ts\` + +## Reviewer-Facing Evidence + +- Result summary: \`\` +- This markdown file is the checked-in, repository-stable evidence record for reviewers. +- External artifact URL or attachment path: \`\` + +## Notes + +- Raw \`playwright-report/\` output is intentionally not tracked in git. +- Attach the CI artifact URL or an exported bundle in the candidate release notes when finalizing approval. +`, + }, + { + path: story101File, + label: 'Story 10.1 evidence record', + contents: `${GENERATED_MARKER} + +# Story 10.1 Upstream Evidence Record + +## Expected Evidence + +- Acceptance CI gate run URL +- Scenario traceability artifact or report +- Owner acknowledgement for the exact FE candidate + +## Current Status + +- Status: \`Pending upstream completion\` +- This file is the candidate-specific evidence record linked from the FE release checklist. +- Replace the placeholders below with actual evidence links before final shipment. + +## Evidence Links + +- CI gate run URL: \`\` +- Scenario traceability artifact: \`\` +- Reviewer note: \`\` +`, + }, + { + path: story104File, + label: 'Story 10.4 evidence record', + contents: `${GENERATED_MARKER} + +# Story 10.4 Upstream Evidence Record + +## Expected Evidence + +- Smoke rehearsal execution record +- Rollback verification evidence +- Owner acknowledgement for the exact FE candidate + +## Current Status + +- Status: \`Pending upstream completion\` +- This file is the candidate-specific evidence record linked from the FE release checklist. +- Replace the placeholders below with actual evidence links before final shipment. + +## Evidence Links + +- Smoke rehearsal artifact: \`\` +- Rollback verification artifact: \`\` +- Reviewer note: \`\` +`, + }, + ]; + + await mkdir(candidateDir, { recursive: true }); + + for (const file of files) { + await writeFile(file.path, file.contents, 'utf8'); + console.log(`Refreshed ${file.path}`); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/unit/release/web-release-readiness-pack.test.ts b/tests/unit/release/web-release-readiness-pack.test.ts new file mode 100644 index 0000000..7791d02 --- /dev/null +++ b/tests/unit/release/web-release-readiness-pack.test.ts @@ -0,0 +1,146 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const feRoot = resolve(testDir, '../../..'); +const repoRoot = resolve(feRoot, '..'); +const repoRootReadmePath = resolve(repoRoot, 'README.md'); +const hasRepoRootReadme = existsSync(repoRootReadmePath); + +const readFeText = (relativePath: string) => + readFileSync(resolve(feRoot, relativePath), 'utf8'); + +const readRepoText = (relativePath: string) => + readFileSync(resolve(repoRoot, relativePath), 'utf8'); + +describe('web release readiness pack', () => { + it('links the release pack from the FE README and exposes the release gate commands', () => { + const readme = readFeText('README.md'); + + expect(readme).toContain('## Release readiness pack'); + expect(readme).toContain('docs/release/web-readiness-checklist.md'); + expect(readme).toContain('docs/release/web-test-matrix.md'); + expect(readme).toContain('docs/release/web-release-notes.md'); + expect(readme).toContain('docs/release/candidates/v/web-readiness-checklist.md'); + expect(readme).toContain('pnpm run e2e:release'); + expect(readme).toContain('pnpm run release:check'); + expect(readme).toContain('pnpm run release:notes'); + }); + + it('keeps the FE env example aligned with the live release gate variables', () => { + const envExample = readFeText('.env.example'); + + expect(envExample).toContain('LIVE_API_BASE_URL='); + expect(envExample).toContain('LIVE_REGISTER_PASSWORD='); + expect(envExample).toContain('LIVE_INVALID_PASSWORD='); + expect(envExample).toContain('LIVE_LOGIN_EMAIL='); + expect(envExample).toContain('LIVE_LOGIN_PASSWORD='); + expect(envExample).toContain('LIVE_LOGIN_TOTP_SECRET='); + expect(envExample).toContain('LIVE_CHANNEL_DB_CONTAINER='); + expect(envExample).toContain('PLAYWRIGHT_FE_PORT='); + expect(envExample).toContain('VITE_DEV_PROXY_TARGET='); + }); + + it('defines the release matrix and checklist links for the critical FE journeys', () => { + const { version } = JSON.parse(readFeText('package.json')) as { + version: string; + }; + const matrix = readFeText('docs/release/web-test-matrix.md'); + const checklistGuide = readFeText('docs/release/web-readiness-checklist.md'); + const candidateChecklist = readFeText( + `docs/release/candidates/v${version}/web-readiness-checklist.md`, + ); + const notesGuide = readFeText('docs/release/web-release-notes.md'); + const candidateNotes = readFeText( + `docs/release/candidates/v${version}/web-release-notes.md`, + ); + const playwrightSummary = readFeText( + `docs/release/candidates/v${version}/playwright-release-summary.md`, + ); + const story101Evidence = readFeText( + `docs/release/candidates/v${version}/upstream-story-10.1-evidence.md`, + ); + const story104Evidence = readFeText( + `docs/release/candidates/v${version}/upstream-story-10.4-evidence.md`, + ); + + expect(matrix).toContain('pnpm run release:check'); + expect(matrix).toContain('e2e/live/auth-live.spec.ts'); + expect(matrix).toContain('e2e/live/order-session-live.spec.ts'); + expect(matrix).toContain('e2e/live/notification-center-live.spec.ts'); + expect(matrix).toContain('e2e/live/notification-stream-live.spec.ts'); + expect(matrix).toContain('e2e/live/portfolio-dashboard-live.spec.ts'); + + expect(checklistGuide).toContain('docs/release/candidates/v/web-readiness-checklist.md'); + expect(checklistGuide).toContain('playwright-release-summary.md'); + expect(checklistGuide).toContain('upstream-story-10.1-evidence.md'); + expect(checklistGuide).toContain('upstream-story-10.4-evidence.md'); + + expect(candidateChecklist).toContain('README.md'); + expect(candidateChecklist).toContain('BE/README.md'); + expect(candidateChecklist).toContain('FE/README.md'); + expect(candidateChecklist).toContain('FE/.env.example'); + expect(candidateChecklist).toContain('BE/application-local.yml.template'); + expect(candidateChecklist).toContain('Portfolio boundary and bootstrap'); + expect(candidateChecklist).toContain('[upstream-story-10.1-evidence.md](./upstream-story-10.1-evidence.md)'); + expect(candidateChecklist).toContain('[upstream-story-10.4-evidence.md](./upstream-story-10.4-evidence.md)'); + expect(candidateChecklist).not.toContain('playwright-report/index.html'); + expect(candidateChecklist).not.toContain('_bmad-output/implementation-artifacts/10-1-7-plus-1-acceptance-ci-gate.md'); + expect(candidateChecklist).not.toContain('_bmad-output/implementation-artifacts/10-4-full-stack-smoke-and-rehearsal.md'); + + expect(notesGuide).toContain('pnpm run release:notes'); + expect(notesGuide).toContain('docs/release/candidates/v/web-release-notes.md'); + expect(notesGuide).toContain(`docs/release/candidates/v${version}/web-release-notes.md`); + + expect(candidateNotes).toContain(`Version: \`${version}\``); + expect(candidateNotes).toContain('Release checklist: `./web-readiness-checklist.md`'); + expect(candidateNotes).toContain('Test matrix: `../../web-test-matrix.md`'); + expect(candidateNotes).toContain('Playwright evidence summary: `./playwright-release-summary.md`'); + expect(candidateNotes).toContain('Story 10.1 upstream evidence: `./upstream-story-10.1-evidence.md`'); + expect(candidateNotes).toContain('Story 10.4 upstream evidence: `./upstream-story-10.4-evidence.md`'); + expect(candidateNotes).toContain('Approval status: `Draft - pending validation evidence`'); + + expect(playwrightSummary).toContain('This markdown file is the checked-in, repository-stable evidence record for reviewers.'); + expect(playwrightSummary).toContain('Raw `playwright-report/` output is intentionally not tracked in git.'); + expect(playwrightSummary).toContain('`e2e/live/portfolio-dashboard-live.spec.ts`'); + expect(story101Evidence).toContain('Status: `Pending upstream completion`'); + expect(story101Evidence).not.toContain('_bmad-output/implementation-artifacts/10-1-7-plus-1-acceptance-ci-gate.md'); + expect(story104Evidence).toContain('Status: `Pending upstream completion`'); + expect(story104Evidence).not.toContain('_bmad-output/implementation-artifacts/10-4-full-stack-smoke-and-rehearsal.md'); + + if (hasRepoRootReadme) { + const rootReadme = readRepoText('README.md'); + + expect(rootReadme).toContain('Frontend release checklist'); + expect(rootReadme).toContain('Frontend release notes template'); + } + }); + + it('guards the root README reviewer contract required by AC5', () => { + if (!hasRepoRootReadme) { + return; + } + + const rootReadme = readRepoText('README.md'); + + expect(rootReadme).toContain('[![API Docs]'); + expect(rootReadme).toContain('[![Docs Publish]'); + expect(rootReadme).toContain('[![Supply Chain Security]'); + + expect(rootReadme).toContain('## Quick Start'); + expect(rootReadme).toContain('## Architecture Diagram'); + expect(rootReadme).toContain('## Reviewer Paths'); + expect(rootReadme).toContain('### Banking interviewer path'); + expect(rootReadme).toContain('### FinTech interviewer path'); + expect(rootReadme).toContain('## Architecture Decisions'); + expect(rootReadme).toContain('## Environment Variables'); + expect(rootReadme).toContain('docker exec channel-service curl -fsS http://localhost:18080/actuator/health'); + expect(rootReadme).not.toContain('curl http://localhost:8080/actuator/health'); + + expect(rootReadme).toContain('BE/application-local.yml.template'); + expect(rootReadme).toContain('[Backend runtime guide](./BE/README.md)'); + expect(rootReadme).toContain('[Frontend runtime guide](./FE/README.md)'); + expect(rootReadme).toContain('[Shared environment defaults](./.env.example)'); + }); +});