E2E tests & demo videos
E2E tests & demo videos
The same tool that verifies the product also films it. A single Playwright harness drives the real, running stack — the Redelay admin, FlowDSL Studio, and the AsyncShop storefront — and is used for two things:
- E2E tests — real browser assertions against the live app (
playwright test). - Demo videos — scripted walkthroughs recorded on the real UI, encoded to MP4 + GIF for the docs and website.
Both share the same login, API-seeding, and URL config, so a demo is "an E2E test that also records," and a test is "a demo without the camera." It runs on your host or as a containerized service in the compose stack.
Two demos this harness produces today — recorded on the real UI, nothing staged:
FlowDSL Debug Taps on the Redelay admin (demos/flows-debug.demo.js).
AsyncCart one-page checkout on the go.shop.pl storefront
(demos/storefront-checkout.demo.js).
AsyncCart coupon — apply a code, watch the total drop
(demos/storefront-coupon.demo.js).
Where it lives: the reference implementation is
goshop-pl/e2e/(the app that runs every service). Framework repos ship libraries; E2E happens against a running app, so the harness lives with the deployment.
Layout
e2e/
├── playwright.config.ts # test runner config (env-driven baseURL, viewport)
├── lib/
│ ├── config.js # every URL/credential from env (host OR container)
│ ├── auth.js # apiLogin(request) + loginUI(page)
│ ├── seed.js # ensureDebugFlow / launchRun / waitForHits (real API)
│ ├── cards.js # branded title/outro cards (the only non-UI frames)
│ └── video.js # renderHTMLToPng + encodeDemo (ffmpeg: webm→mp4+gif)
├── tests/ # *.spec.ts — E2E assertions
│ └── flows-debug.spec.ts
├── demos/ # *.demo.js — video generators (+ run-all.js)
│ └── flows-debug.demo.js
└── output/ # generated videos (git-ignored)
Running it
Everything is env-driven, so the identical harness targets localhost ports
on your host and compose service names inside the network — no code change.
In Docker (recommended — pinned browsers + ffmpeg)
infra/ ships an e2e service (Playwright image + ffmpeg) behind the qa
profile, so it never starts with make up. With the stack running:
make e2e # build the image + run all E2E tests
make videos # record every demo → infra/e2e/output/
The container reaches the app by service name (http://admin:3001,
http://admin-api:8080, …) on the default compose network.
On the host
cd e2e
npm install && npx playwright install chromium # one-time
make e2e-local # or: npx playwright test
make videos-local # or: node demos/run-all.js
Defaults target localhost:3001 (admin) / :8081 (admin-api) / :3002
(storefront) / :5174 (Studio). Override any via env
(ADMIN_URL, ADMIN_API_URL, STOREFRONT_URL, STUDIO_URL,
ADMIN_EMAIL, ADMIN_PASSWORD, OUT_DIR).
Post-deploy smoke against a deployed environment
Because every URL is env-driven, the harness runs unchanged against a deployed target — point the API/app envs at staging or prod and run a focused smoke spec. Keep at least one spec API-only (drives the public API, never the UI) so it can run anywhere without a browser-served frontend, and have it register a throwaway user, exercise the core journey, assert the result, then delete its own scratch data. Two rules make "runs anywhere" real:
- Soft-skip anything the target can't observe. Local dev has a mail catcher (Mailpit); prod does not. Gate email assertions on the inbox being reachable so the same spec asserts mail locally and simply skips it on prod, rather than failing.
- Assert real prod email via a catch-all + IMAP (optional). Register users at a
catch-all domain (
e2e-<id>@yourdomain, all delivered to onee2e@yourdomainmailbox) and read the delivered mail over IMAP — the same assertions as Mailpit, against real delivery. Select the backend by env (IMAP when configured, else the local catcher, else skip). Keep IMAP creds in a CI secret / gitignored.env.
GymTracer's e2e/tests/lifecycle.spec.ts (npm run test:smoke) is a worked
example of this pattern.
Writing an E2E test
Compose the shared helpers; assert against the real DOM. This one is the regression guard for the debug-trace render bug:
import { test, expect } from '@playwright/test'
import { apiLogin, loginUI } from '../lib/auth.js'
import { ensureDebugFlow, launchRun, waitForHits } from '../lib/seed.js'
import { cfg } from '../lib/config.js'
test('flow debug: trace renders captured hits', async ({ page, request }) => {
const token = await apiLogin(request) // real admin-API token
const fid = await ensureDebugFlow(request, token) // publish + tap a demo flow
await launchRun(request, token, fid)
await waitForHits(request, token, fid, 2)
await loginUI(page)
await page.goto(`${cfg.adminUrl}/flows/debug?flow=${fid}`) // deep-link, no dropdown
await expect(page.locator('main')).toContainText('gross_minor')
await expect(page.locator('main')).toContainText('1230')
})
Writing a demo video
A demo is a standalone script: seed via the API, record the real UI with
recordVideo, then encodeDemo bookends it with branded title/outro cards.
import { chromium, request as pwRequest } from '@playwright/test'
import { apiLogin, loginUI } from '../lib/auth.js'
import { ensureDebugFlow, launchRun } from '../lib/seed.js'
import { titleCard, outroCard } from '../lib/cards.js'
import { encodeDemo } from '../lib/video.js'
import { cfg, VIEWPORT } from '../lib/config.js'
const api = await pwRequest.newContext()
const token = await apiLogin(api)
const fid = await ensureDebugFlow(api, token)
const ctx = await (await chromium.launch({ slowMo: 320 })) // slowMo = watchable
.newContext({ viewport: VIEWPORT, recordVideo: { dir: rec, size: VIEWPORT } })
const p = await ctx.newPage()
await loginUI(p)
await p.goto(`${cfg.adminUrl}/flows/debug?flow=${fid}`)
// …drive the feature, with pauses so each state is readable…
await ctx.close() // finalizes the webm
await encodeDemo({
webm, outDir: cfg.outDir, name: 'flowdsl-debug-taps',
titleHTML: titleCard({ badge: 'LIVE DEBUG TAPS', title: '…', sub: '…' }),
outroHTML: outroCard({}),
startAt: 4.5, // trim the login lead-in from the GIF
})
Output: output/<name>.mp4 (title → real UI → outro) and a lean <name>.gif.
Demoing each framework
The pattern is the same everywhere — seed state via the real API, deep-link to the surface, drive it, record. What changes is the target and the selectors.
| Framework | Surface | URL | Seeding / tips |
|---|---|---|---|
| FlowDSL | FlowDSL Studio | STUDIO_URL (:5174) + admin /flows | Publish flows via the admin API; drive the canvas / run button |
| Redelay | Admin (flows, taps, alerts, lifecycle, users) | ADMIN_URL (:3001) | Deep-link where supported (e.g. /flows/debug?flow=<id>); seed with admin-api |
| AsyncShop / AsyncCart | Storefront + admin (catalog, checkout, promotions, order-tracking) | STOREFRONT_URL (:3002) | Seed products/coupons via the admin API, then drive the storefront cart → checkout |
Reliability rules (learned the hard way)
- Deep-link instead of driving dropdowns. Nuxt UI
USelectpopovers are flaky to automate; a?flow=<id>-style deep link (add one to the page if it's missing) is deterministic. It's also a real UX win — shareable views. - Seed with the API, assert/record in the UI. Don't click your way to state
you can create in one
POST.lib/seed.jsis the model. - Pace for humans in demos —
slowMoon launch + explicitwaitForTimeoutdwell on each state. Tests want the opposite (noslowMo,expectauto-waits). - Trim the lead-in —
encodeDemo({ startAt })drops the login seconds from the GIF while keeping the full MP4. - Never hardcode a URL — always read
cfg, so host and container both work.
Publishing to the docs / website
Drop the MP4 into website/public/videos/ and embed it:
<video src="/videos/flowdsl-debug-taps.mp4" autoplay loop muted playsinline></video>
Use the MP4 for pages (small, crisp) and the GIF only where inline autoplay
images are the only option. Register a CORS route rule for the new asset the same
way the other website/public/* assets do.
See also
- Debug taps — the feature the first demo showcases.
- Observability — Trace / Live / Lifecycle / Alerts, all demo-able with this harness.
Creating a Go App
Step-by-step guide to building a backend application using the redelay go-framework.
Deploying to production
Ship a Redelay Go backend to Docker Swarm — a Mode B image built with SSH-forwarded private modules, a swarm stack that survives node reboots, and a push-to-deploy GitHub Actions workflow that fails on an automatic rollback.