Guides

E2E tests & demo videos

One Playwright harness drives the real Redelay / AsyncShop stack for two jobs — browser E2E tests and branded demo videos for the docs and website. How it works, how to run it in Docker, and how to add a test or a video for FlowDSL, Redelay, or AsyncShop/AsyncCart.

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:

  1. E2E tests — real browser assertions against the live app (playwright test).
  2. 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

text
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.

infra/ ships an e2e service (Playwright image + ffmpeg) behind the qa profile, so it never starts with make up. With the stack running:

shell
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

shell
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 one e2e@yourdomain mailbox) 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:

ts
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.

js
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.

FrameworkSurfaceURLSeeding / tips
FlowDSLFlowDSL StudioSTUDIO_URL (:5174) + admin /flowsPublish flows via the admin API; drive the canvas / run button
RedelayAdmin (flows, taps, alerts, lifecycle, users)ADMIN_URL (:3001)Deep-link where supported (e.g. /flows/debug?flow=<id>); seed with admin-api
AsyncShop / AsyncCartStorefront + 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 USelect popovers 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.js is the model.
  • Pace for humans in demos — slowMo on launch + explicit waitForTimeout dwell on each state. Tests want the opposite (no slowMo, expect auto-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:

html
<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.