| name | journey-run |
| description | Drive a PrairieLearn Playwright journey — persona switching (student/instructor/admin), render-gating (never screenshot a blank/500 and call it fine), numbered before/after screenshots, socket-safe waits, and cold-vs-warm diffing. Spins the journey's containers, runs scenario.ts, captures evidence. Runs inline. Use when the user says "run the journey", "run the scenario", "screenshot this flow", "drive it as instructor/student", "check for regressions across personas", or after editing a fix to re-verify. |
journey-run
Execute a journey's Playwright scenario and capture evidence. Helpers live in @pldebug/dev-tools/journey; surfaces are mapped in reference/control-surfaces.md Part B.
Invoke: /journey-run 12345 · /journey-run 12345 --variant after-fix · /journey-run 12345 --teardown
Before running
- The journey must exist (
spawn-journey.sh) with a real scenario.ts.
- Decide the persona and route from the issue — most PL bugs are role-specific.
Writing scenario.ts (use the helpers, don't hand-roll)
import { test } from '@playwright/test';
import { setPersona, gotoRendered, shot, resetSteps } from '@pldebug/dev-tools/journey';
test('reproduce', async ({ page, context }) => {
resetSteps();
await setPersona(page, context, 'instructor');
const r = await gotoRendered(page, '/pl/course_instance/1/instructor');
if (!r.rendered) throw new Error(`page broken: ${r.reason} (http ${r.status})`);
await shot(page, 'dashboard');
await shot(page, 'broken-state');
});
- Never use
networkidle — PL holds a socket.io connection open, so it never fires. gotoRendered waits on domcontentloaded + settle and checks for error pages.
- Wait for hydration before interacting. PL server-renders HTML and then hydrates client components asynchronously (
hydrateHtml / registerHydratedComponent). A click or keystroke fired before the target component hydrates is silently lost or desyncs its state — the #1 cause of flaky scenarios that "work when I add a sleep." Wait for the component to be interactive (its hydration bundle loaded / a known post-hydration marker present), and retry the triggering action until its reactive effect appears before asserting — don't trust a single pre-hydration click.
- Render-gate every screenshot — on the subject itself, not just "a page loaded". The capture must show the actual thing you're claiming, drawn by the running app: if it's a drawing/canvas/plot/chart/diagram, wait until that artifact has actually drawn (the
<canvas> has non-blank pixels, the expected shape / SVG / DOM node is present) and screenshot that element. Reconstructing the markup in a static page, stubbing/blanking the canvas, or screenshotting a text description of a visual change is a false positive — forbidden. A blank page that "looks fine" is the #1 false repro; a "rendered" page where the subject never drew is the #2.
- You have total license to force the real render (see CLAUDE.md Total license → observability). If the genuine artifact won't draw in your capture, don't fall back to a mockup — edit code until it draws: hard-code the inputs/params, enable the panel or feature flag that renders it, inline or mount the client asset/package, disable a guard that suppresses it, add a tiny debug route that mounts the component. The worktree is disposable and reverted; the only acceptable screenshot is of the real thing rendered.
- Numbered shots tell the before/after story:
01-…, 02-…, then …-after-fix on the verify run. shot() is token-optimized — viewport JPEG downscaled to ~1024px at low quality (Claude tokenizes by dimensions), so reading a screenshot is cheap. Pass { fullPage: true } only when the bug needs the whole page.
Run
./scripts/run-journey.sh --issue N [--variant after-fix] [--ring 1] [--teardown]
(In a self-contained workspace — in-tree PL + issue.md, no GitHub number — run it with no args: local mode, same behavior.)
Baseline content: a fresh PL dev boot has no course. run-journey.sh loads PL's bundled exampleCourse into the baseline (and bakes it into the golden snapshot, so resets keep it) — so /pl/course_instance/1 is its SectionA instance. Need a different/extra course, or runtime state like a student submission to grade? Seed it in setup.sh (re-run each reset) — or call ./scripts/load-course.sh --path <container-dir> for another course.
Ensures the warm stack is up, resets state from the golden template (~14s), seeds via setup.sh (in-container), runs the scenario with host-side Playwright against 127.0.0.1:HOST_PORT, and captures token-optimized screenshots + logs + the merged trace.
Persona × route matrix (for "regressions across roles")
Loop the key routes as student / instructor / admin; diff the screenshots. Access-control and render bugs surface as a cell that differs from the others. Pair with trace to see why a cell differs.
Cold vs warm
- Cold (first run): discover what's broken — log it, exit without asserting hard.
- Warm (after fix,
--variant after-fix): assert the fixed behavior and diff against the cold screenshots.
Output
Per-step screenshot list + render-gate status per route + the failing/now-passing assertion. Point at /tmp/prairielearn-debug/<id>/.
Cleanup
The stack stays warm between runs (boot once; state resets from the golden template in ~14s). Pass --teardown to stop it when you're done with the journey. The Stop hook reaps any tracked background procs.