diff --git a/.github/workflows/ci-nym-wallet-frontend.yml b/.github/workflows/ci-nym-wallet-frontend.yml index cda1ae8512..a41f570b9d 100644 --- a/.github/workflows/ci-nym-wallet-frontend.yml +++ b/.github/workflows/ci-nym-wallet-frontend.yml @@ -72,7 +72,24 @@ jobs: run: | cp -R nym-wallet/storybook-static "$OUTPUT_DIR" - - name: Upload storybook to CI build server + - name: Stage e2e visual flow report + # Generated by the Playwright run (globalTeardown) regardless of pass/fail; `always()` + # so a failing run still uploads the filmstrip for inspection. mkdir covers the + # e2e-failure path where "Prepare build output directory" was skipped. + if: always() + shell: bash + env: + REPORT_DIR: ci-builds/${{ github.ref_name }}/nym-wallet/e2e-report/ + run: | + if [ -d nym-wallet/e2e-report ]; then + mkdir -p "$REPORT_DIR" + cp -R nym-wallet/e2e-report/. "$REPORT_DIR" + else + echo "no e2e-report produced" + fi + + - name: Upload CI artifacts (storybook + e2e visual report) + if: always() continue-on-error: true uses: easingthemes/ssh-deploy@main env: diff --git a/nym-wallet/.gitignore b/nym-wallet/.gitignore index 096bf4ea79..50f385d49a 100644 --- a/nym-wallet/.gitignore +++ b/nym-wallet/.gitignore @@ -1,2 +1,4 @@ test-results/ .tsbuild/ +e2e-report/ +playwright-report/ diff --git a/nym-wallet/e2e/README.md b/nym-wallet/e2e/README.md index 8ba6603fcd..3e2b21e177 100644 --- a/nym-wallet/e2e/README.md +++ b/nym-wallet/e2e/README.md @@ -28,6 +28,11 @@ pnpm test:e2e # launches webpack:dev:mock + replays the journ Config: [`playwright.config.ts`](../playwright.config.ts). Specs: [`families.spec.ts`](./families.spec.ts). +**Visual flow report:** each journey step captures a captioned screenshot (`shot()` in +[`shared/report.ts`](./shared/report.ts)), stitched into a static `e2e-report/index.html` +filmstrip (via global setup/teardown) for smoke inspection. The `build` CI job stages + uploads +`e2e-report/` alongside Storybook (`if: always()`, so a failing run still publishes it). + ## Tier 2 — WebdriverIO + tauri-driver (optional, Linux CI only) Drives the **packaged Tauri binary** in the native WebKitGTK webview. macOS is unsupported diff --git a/nym-wallet/e2e/families.spec.ts b/nym-wallet/e2e/families.spec.ts index 6da3286b2d..f2d910bbd8 100644 --- a/nym-wallet/e2e/families.spec.ts +++ b/nym-wallet/e2e/families.spec.ts @@ -1,5 +1,6 @@ import { test, expect, Page } from '@playwright/test'; import { FAMILY_IDS, FAMILY_NODES, familyMockUrl, TID } from './shared/families'; +import { shot } from './shared/report'; /** * Primary e2e of the owner + operator Node Families journeys (design D1), driven against @@ -7,6 +8,9 @@ import { FAMILY_IDS, FAMILY_NODES, familyMockUrl, TID } from './shared/families' * shared with the optional WebdriverIO leg via `./shared/families` (parity requirement) * and target the ids that actually render (see the note in that file). * + * Each step also captures a screenshot via `shot()` → assembled into a static visual flow + * report (`e2e-report/index.html`) for smoke inspection, uploaded by the CI build job. + * * Confirmation dialogs portal to the document body; in a single browser DOM they're reached * at page scope. Per-node invite content is scoped via the `operator-node-` wrapper, and * invite buttons are keyed by family id. @@ -17,22 +21,25 @@ const { ownerFlow, operatorAccept, operatorReject, operatorNone } = FAMILY_NODES const openOperatorTab = (page: Page) => page.getByTestId(TID.tabOperator).click(); test.describe('Families flows (mock-wired app shell)', () => { - test('owner lifecycle: create → invite → accept → kick → disband', async ({ page }) => { + test('owner lifecycle: create → invite → accept → kick → disband', async ({ page }, testInfo) => { const fid = FAMILY_IDS.ownerFlow; await page.goto(familyMockUrl('owner')); // create await expect(page.getByTestId(TID.createFamilyName)).toBeVisible({ timeout: 30_000 }); + await shot(page, testInfo, 'create family entry'); await page.getByTestId(TID.createFamilyName).fill('Flow Family'); await page.getByTestId(TID.createFamilyDescription).fill('A family created in a flow test.'); await page.getByTestId(TID.createFamilySubmit).click(); await expect(page.getByTestId(TID.ownerManagementPage)).toBeVisible(); + await shot(page, testInfo, 'family created'); // invite the self-controlled node await page.getByTestId(TID.inviteNodeId).fill(String(ownerFlow)); await page.getByTestId(TID.inviteNodeSubmit).click(); await page.getByTestId(TID.inviteNodeConfirm).click(); await expect(page.getByTestId(TID.pendingInvite(ownerFlow))).toBeVisible(); + await shot(page, testInfo, 'node invited (pending)'); // accept it from the operator tab (same account controls the node) await openOperatorTab(page); @@ -42,20 +49,24 @@ test.describe('Families flows (mock-wired app shell)', () => { // kick it from the owner tab — the joined member appears, then is removed await page.getByTestId(TID.tabOwner).click(); await expect(page.getByTestId(TID.memberJoined(ownerFlow))).toBeVisible(); + await shot(page, testInfo, 'member joined'); await page.getByTestId(TID.memberJoinedKick(ownerFlow)).click(); await page.getByTestId(TID.memberJoinedKickConfirm(ownerFlow)).click(); await expect(page.getByTestId(TID.memberJoined(ownerFlow))).toHaveCount(0); + await shot(page, testInfo, 'member kicked'); // disband the now-empty family await page.getByTestId(TID.deleteButton).click(); await page.getByTestId(TID.deleteConfirm).click(); await expect(page.getByTestId(TID.createFamilyName)).toBeVisible(); + await shot(page, testInfo, 'family disbanded'); }); - test('operator lifecycle: accept → leave, then reject', async ({ page }) => { + test('operator lifecycle: accept → leave, then reject', async ({ page }, testInfo) => { const fid = FAMILY_IDS.operatorFlow; await page.goto(familyMockUrl('operator')); await openOperatorTab(page); + await shot(page, testInfo, 'pending node invites'); // accept the invite on the accept-node (scope by node; invite buttons are keyed by family id) const acceptSection = page.getByTestId(TID.operatorNodeSection(operatorAccept)); @@ -63,22 +74,26 @@ test.describe('Families flows (mock-wired app shell)', () => { await page.getByTestId(TID.acceptConfirm(fid)).click(); // joined → a Leave action appears for that node await expect(acceptSection.getByTestId(TID.leaveButton)).toBeVisible(); + await shot(page, testInfo, 'invite accepted'); // leave the family await acceptSection.getByTestId(TID.leaveButton).click(); await page.getByTestId(TID.leaveConfirm).click(); + await shot(page, testInfo, 'family left'); // reject the invite on the reject-node → its group ends empty await page.getByTestId(TID.operatorNodeSection(operatorReject)).getByTestId(TID.rejectCard(fid)).click(); await page.getByTestId(TID.rejectConfirm(fid)).click(); await expect(page.getByTestId(TID.inviteGroupEmpty(operatorReject))).toBeVisible(); + await shot(page, testInfo, 'invite rejected'); }); - test('operator page shows multi-node invite states', async ({ page }) => { + test('operator page shows multi-node invite states', async ({ page }, testInfo) => { await page.goto(familyMockUrl('operator-seeded')); await openOperatorTab(page); // node with an active invite renders its section; node with none shows the empty state await expect(page.getByTestId(TID.operatorNodeSection(operatorAccept))).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(TID.inviteGroupEmpty(operatorNone))).toBeVisible(); + await shot(page, testInfo, 'multi-node invite states'); }); }); diff --git a/nym-wallet/e2e/report.globalSetup.ts b/nym-wallet/e2e/report.globalSetup.ts new file mode 100644 index 0000000000..f3f518f3fd --- /dev/null +++ b/nym-wallet/e2e/report.globalSetup.ts @@ -0,0 +1,6 @@ +import { resetReport } from './shared/report'; + +// Clears the previous visual report before a run so the uploaded artifact is current. +export default async function globalSetup(): Promise { + resetReport(); +} diff --git a/nym-wallet/e2e/report.globalTeardown.ts b/nym-wallet/e2e/report.globalTeardown.ts new file mode 100644 index 0000000000..ac29963612 --- /dev/null +++ b/nym-wallet/e2e/report.globalTeardown.ts @@ -0,0 +1,6 @@ +import { buildGallery } from './shared/report'; + +// Stitches the per-step screenshots into e2e-report/index.html after the run. +export default async function globalTeardown(): Promise { + buildGallery(); +} diff --git a/nym-wallet/e2e/shared/report.ts b/nym-wallet/e2e/shared/report.ts new file mode 100644 index 0000000000..73f872bb9e --- /dev/null +++ b/nym-wallet/e2e/shared/report.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Page, TestInfo } from '@playwright/test'; + +/** + * Visual flow report for the Node Families e2e journeys. Each `shot()` writes a numbered, + * captioned PNG into `e2e-report/screenshots//` (and attaches it to the Playwright HTML + * report). `buildGallery()` (run from globalTeardown) stitches them into a static + * `e2e-report/index.html` filmstrip for visual inspection / smoke checks — uploaded by CI. + */ + +export const REPORT_DIR = path.resolve(__dirname, '..', '..', 'e2e-report'); +const SCREENSHOTS_DIR = path.join(REPORT_DIR, 'screenshots'); + +const slug = (s: string) => + s + .toLowerCase() + .replace(/[^\w]+/g, '-') + .replace(/^-+|-+$/g, ''); + +// Per-test step counters (workers run one test each, so this stays consistent per test). +const counters = new Map(); + +/** Clear any previous report so the upload reflects only the latest run. */ +export function resetReport(): void { + fs.rmSync(REPORT_DIR, { recursive: true, force: true }); + fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + counters.clear(); +} + +/** Capture a full-page, captioned step screenshot into the report (and the Playwright report). */ +export async function shot(page: Page, testInfo: TestInfo, label: string): Promise { + const testSlug = slug(testInfo.title); + const dir = path.join(SCREENSHOTS_DIR, testSlug); + fs.mkdirSync(dir, { recursive: true }); + const n = (counters.get(testSlug) ?? 0) + 1; + counters.set(testSlug, n); + const file = `${String(n).padStart(2, '0')}-${slug(label)}.png`; + const body = await page.screenshot({ fullPage: true }); + fs.writeFileSync(path.join(dir, file), body); + await testInfo.attach(label, { body, contentType: 'image/png' }); +} + +/** Stitch the captured screenshots into a static index.html filmstrip. */ +export function buildGallery(): void { + if (!fs.existsSync(SCREENSHOTS_DIR)) return; + const tests = fs + .readdirSync(SCREENSHOTS_DIR) + .filter((d) => fs.statSync(path.join(SCREENSHOTS_DIR, d)).isDirectory()) + .sort(); + + const sections = tests + .map((t) => { + const imgs = fs + .readdirSync(path.join(SCREENSHOTS_DIR, t)) + .filter((f) => f.endsWith('.png')) + .sort(); + const frames = imgs + .map((f) => { + const caption = f.replace(/^\d+-/, '').replace(/\.png$/, '').replace(/-/g, ' '); + return `
${caption}
${caption}
`; + }) + .join('\n'); + return `

${t.replace(/-/g, ' ')}

${frames}
`; + }) + .join('\n'); + + const html = ` + + + + +Node Families e2e — visual flow report + + + +

Node Families e2e — visual flow report

+

Per-step screenshots from the mock-wired Playwright journeys (design D1/D2). Hover/scroll each filmstrip; click an image to zoom.

+${sections} + +`; + fs.writeFileSync(path.join(REPORT_DIR, 'index.html'), html); +} diff --git a/nym-wallet/openspec/changes/node-families-tauri-webdriver-e2e/tasks.md b/nym-wallet/openspec/changes/node-families-tauri-webdriver-e2e/tasks.md index b5d09d1aa8..44b5b6cda3 100644 --- a/nym-wallet/openspec/changes/node-families-tauri-webdriver-e2e/tasks.md +++ b/nym-wallet/openspec/changes/node-families-tauri-webdriver-e2e/tasks.md @@ -45,3 +45,10 @@ - [x] 6.3 Confirm `tsc` + eslint stay clean and the production build is unaffected — verified: after `pnpm install`, `tsc` is fully clean (exit 0); `main.mock.tsx` + `utils/common.ts` lint clean; webpack prod-safe (no `mainMock` entry with flag off). - [x] 6.4 Confirm the provider seam didn't break Code Connect (seam is a separate entry; `FamilyPage.tsx` + `FamilyPageRoute.tsx` untouched; `FamilyPage.figma.tsx` now type-checks once `@figma/code-connect` is installed) and the Nym 2.0 theme swap left journey `data-testid`s intact (color-only). - [x] 6.5 Document the tiered setup + mock-flag usage (`e2e/README.md`). + +## 7. Visual flow report (bonus) + +- [x] 7.1 Capture a full-page, captioned screenshot at each journey step (`shot()` in `e2e/shared/report.ts`) — written to `e2e-report/screenshots//NN-label.png` and attached to the Playwright report. +- [x] 7.2 Assemble a static `e2e-report/index.html` filmstrip (per-test, ordered, captioned) via Playwright `globalSetup`/`globalTeardown` (`report.globalSetup.ts` resets, `report.globalTeardown.ts` builds). +- [x] 7.3 Stage + upload `e2e-report/` in the CI `build` job alongside Storybook, with `if: always()` so a failing run still publishes the filmstrip for inspection; gitignore `e2e-report/` + `playwright-report/`. +- [x] 7.4 Verified locally: `pnpm test:e2e` green (3/3), report renders 11 frames across the owner/operator/multi-node journeys. diff --git a/nym-wallet/playwright.config.ts b/nym-wallet/playwright.config.ts index 4dd99dd964..36444868e5 100644 --- a/nym-wallet/playwright.config.ts +++ b/nym-wallet/playwright.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: 'list', + // Build the static visual flow report (e2e-report/) from per-step screenshots. + globalSetup: './e2e/report.globalSetup.ts', + globalTeardown: './e2e/report.globalTeardown.ts', timeout: 60_000, use: { baseURL: 'http://localhost:9000',