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:
@@ -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:
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
test-results/
|
||||
.tsbuild/
|
||||
e2e-report/
|
||||
playwright-report/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user