Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@
# SimpleCov code coverage reports
/coverage


# Playwright demo recorder
/script/demo/node_modules
/script/demo/output
39 changes: 39 additions & 0 deletions script/demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Journey walkthrough video

Records the member happy path (sign up → confirm email → About You → build and
share a scenario → public view) as a video using Playwright against the local
dev server. Same flow as `test/system/user_journey_test.rb`, slowed down for
viewing.

## One-time setup

```sh
cd script/demo
npm install
npm run setup # downloads Chromium for Playwright
```

## Record

In one terminal, from the app root, start the dev server without letter_opener
popping browser tabs for each email:

```sh
LAUNCHY_DRY_RUN=true bin/dev
```

In another:

```sh
cd script/demo
npm run record
```

Output lands in `script/demo/output/`: `journey.mp4` (if `ffmpeg` is installed)
plus the raw `part-N.webm` clips.

Each run signs up a fresh `demo-<timestamp>@example.com` user in the dev
database; the confirmation link is read from `tmp/letter_opener/`.

Tunables: `DEMO_BASE_URL` (default `http://arlington.localhost:3000`),
`DEMO_PAUSE_MS` (default 1200), `DEMO_TYPE_DELAY_MS` (default 45).
60 changes: 60 additions & 0 deletions script/demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions script/demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "community-foundation-demo",
"private": true,
"type": "module",
"description": "Records a walkthrough video of the member journey with Playwright",
"scripts": {
"setup": "npx playwright install chromium",
"record": "node record.mjs"
},
"devDependencies": {
"playwright": "^1.50.0"
}
}
185 changes: 185 additions & 0 deletions script/demo/record.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Records a slowed-down walkthrough of the member journey against a running
// dev server and writes output/journey.webm (plus journey.mp4 when ffmpeg is
// available). See README.md in this directory.

import { chromium } from "playwright";
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync, statSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const appRoot = join(here, "..", "..");
const outDir = join(here, "output");

const BASE_URL = process.env.DEMO_BASE_URL ?? "http://arlington.localhost:3000";
const LETTER_OPENER_DIR = join(appRoot, "tmp", "letter_opener");
const PAUSE = Number(process.env.DEMO_PAUSE_MS ?? 1200);
const TYPE_DELAY = Number(process.env.DEMO_TYPE_DELAY_MS ?? 45);
const SIZE = { width: 1280, height: 800 };

const email = `demo-${Date.now()}@example.com`;
const password = "password123";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const pause = (ms = PAUSE) => sleep(ms);

async function type(page, selector, text) {
const field = page.locator(selector);
await field.click();
await field.pressSequentially(text, { delay: TYPE_DELAY });
await pause(400);
}

function newestConfirmationPath() {
const dirs = readdirSync(LETTER_OPENER_DIR)
.map((name) => join(LETTER_OPENER_DIR, name))
.filter((path) => statSync(path).isDirectory())
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);

for (const dir of dirs) {
const files = readdirSync(dir).filter((f) => f.endsWith(".html"));
for (const file of files) {
const html = readFileSync(join(dir, file), "utf8");
const match = html.match(/\/email_confirmation\?token=[^"'\s<]+/);
if (match) return match[0].replace(/&amp;/g, "&");
}
}
throw new Error(`No confirmation email found under ${LETTER_OPENER_DIR}`);
}

async function run() {
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });

const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: SIZE,
recordVideo: { dir: outDir, size: SIZE },
});
const page = await context.newPage();

try {
// Landing → sign up
await page.goto(`${BASE_URL}/`);
await pause(2000);
await page.locator("nav").getByRole("link", { name: "Log in" }).click();
await pause();
await page.getByRole("link", { name: "Sign up" }).click();
await pause();

await type(page, "#user_name", "Journey Tester");
await type(page, "#user_email_address", email);
await type(page, "#user_password", password);
await type(page, "#user_password_confirmation", password);
await page.getByRole("button", { name: "Sign up" }).click();
await page.getByText("Check your email to confirm your account").waitFor();
await pause(2000);

// Follow the confirmation link letter_opener wrote to disk
await page.goto(`${BASE_URL}${newestConfirmationPath()}`);
await page.getByRole("heading", { name: "Welcome to your workspace" }).waitFor();
await pause(2000);

// About You (autosaves as you type)
await page.getByRole("link", { name: "About you" }).click();
await page.getByRole("heading", { name: "About you" }).waitFor();
await pause();
await type(page, "#user_biography_birthplace", "Arlington, VA");
await type(page, "#user_biography_background", "Grew up on a family farm just outside town.");
await page.getByText("Saved.").first().waitFor();
await pause(1500);
await page.locator("#user_biography_hobbies").scrollIntoViewIfNeeded();
await type(page, "#user_biography_hobbies", "Gardening, hiking, and mentoring students.");
await page.getByText("Saved.").first().waitFor();
await pause(1500);

// Build a scenario
await page.getByRole("link", { name: "Dashboard" }).click();
await pause();
await page.getByRole("link", { name: "Explore options" }).click();
await pause();
await page.getByRole("link", { name: "Create scenario" }).click();
await pause();
await type(page, "#scenario_name", "Education focus");
await page.getByRole("button", { name: "Create scenario" }).click();
await page.getByRole("heading", { name: "Education focus" }).waitFor();
await pause(1500);

await page.getByRole("link", { name: "Add amount" }).click();
await pause(600);
await type(page, "#scenario_total_giving_amount", "100000");
await page.getByRole("button", { name: "Save total giving amount" }).click();
await page.getByText("$100,000").first().waitFor();
await pause(1500);

const oneTime = page.locator("[data-controller='dialog']", { hasText: /one time giving/i }).first();
await oneTime.getByRole("button", { name: "+ Add allocation" }).click();
await pause();
await oneTime.getByRole("button", { name: "Select a category" }).click();
await pause();
await oneTime.locator("button[data-name='Education']").click();
await pause();
await type(page, "dialog[open] #allocation_amount_one_time", "5000");
await oneTime.getByRole("button", { name: "Create" }).click();
await page.getByText("$5,000").first().waitFor();
await pause(2000);

// Share → open the public link as an anonymous visitor
await page.getByRole("button", { name: "Share" }).click();
await pause();
await page.getByRole("button", { name: "Create share link" }).click();
const shareInput = page.locator("input[readonly][value*='/public/scenarios/']");
await shareInput.waitFor();
await pause(2000);
const shareUrl = new URL(await shareInput.inputValue());
await page.getByRole("button", { name: "Close" }).click();
await pause();

// Sign out, then open the public link as an anonymous visitor
await page.locator("nav").getByRole("button", { name: "Journey Tester" }).click();
await pause(600);
await page.getByRole("button", { name: "Sign out" }).click();
await page.getByRole("heading", { name: "Sign in" }).waitFor();
await pause();
await page.goto(`${BASE_URL}${shareUrl.pathname}`);
await page.getByText("Read-only shared view").waitFor();
await pause(3000);
} finally {
await context.close();
await browser.close();
}

finalize();
}

// Playwright names the video by page id; rename it, then convert to mp4 when a
// working system ffmpeg is available (Playwright's bundled ffmpeg can't write mp4).
function finalize() {
const [video] = readdirSync(outDir).filter((f) => f.endsWith(".webm"));
if (!video) throw new Error(`No video written to ${outDir}`);

const webm = join(outDir, "journey.webm");
renameSync(join(outDir, video), webm);
console.log(`Wrote ${webm}`);

try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
} catch {
console.log("No working ffmpeg on PATH; skipping mp4 conversion.");
return;
}

const mp4 = join(outDir, "journey.mp4");
execFileSync("ffmpeg", [
"-y", "-loglevel", "error", "-i", webm,
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart",
mp4,
]);
console.log(`Wrote ${mp4}`);
}

run().catch((error) => {
console.error(error);
process.exit(1);
});
Loading