NYM-1199: Add visual flow report for Node Families E2E tests

Enhances Playwright E2E tests by capturing full-page screenshots at each
journey step, which are then stitched into a static `e2e-report/index.html`
filmstrip.

This report is uploaded as a CI artifact (even on failure) to provide a
visual overview of test execution, aiding in the detection of UI regressions
and expediting debugging of test failures.
This commit is contained in:
Yana Matrosova
2026-06-09 14:27:56 +03:00
parent 2007894ee6
commit c9e2d18ef3
9 changed files with 159 additions and 4 deletions
+18 -1
View File
@@ -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:
+2
View File
@@ -1,2 +1,4 @@
test-results/
.tsbuild/
e2e-report/
playwright-report/
+5
View File
@@ -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
+18 -3
View File
@@ -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-<n>` 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');
});
});
+6
View File
@@ -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<void> {
resetReport();
}
+6
View File
@@ -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<void> {
buildGallery();
}
+94
View File
@@ -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/<test>/` (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<string, number>();
/** 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<void> {
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 `<figure><img loading="lazy" src="screenshots/${t}/${f}" alt="${caption}"><figcaption>${caption}</figcaption></figure>`;
})
.join('\n');
return `<section><h2>${t.replace(/-/g, ' ')}</h2><div class="strip">${frames}</div></section>`;
})
.join('\n');
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Node Families e2e — visual flow report</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; background: #0a0a0a; color: #eee; }
h1 { font-size: 20px; }
section { margin: 32px 0; }
h2 { font-size: 16px; color: #5bf0a0; text-transform: capitalize; border-bottom: 1px solid #333; padding-bottom: 6px; }
.strip { display: flex; gap: 16px; overflow-x: auto; padding: 12px 0; }
figure { margin: 0; flex: 0 0 auto; width: 320px; }
img { width: 320px; border: 1px solid #333; border-radius: 6px; background: #1a1a1c; cursor: zoom-in; }
img:target, img:active { transform: scale(2.4); transform-origin: top left; position: relative; z-index: 10; }
figcaption { font-size: 12px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
</style>
</head>
<body>
<h1>Node Families e2e — visual flow report</h1>
<p style="color:#888">Per-step screenshots from the mock-wired Playwright journeys (design D1/D2). Hover/scroll each filmstrip; click an image to zoom.</p>
${sections}
</body>
</html>`;
fs.writeFileSync(path.join(REPORT_DIR, 'index.html'), html);
}
@@ -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/<test>/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.
+3
View File
@@ -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',