From 6ded4b06f2762f45d3b4cc8a7e24c7bd30f261ab Mon Sep 17 00:00:00 2001 From: John Paul Ashenfelter Date: Sat, 29 Aug 2026 14:08:00 -0400 Subject: [PATCH] Add system tests and video --- .gitignore | 4 + script/demo/README.md | 39 +++++++ script/demo/package-lock.json | 60 ++++++++++ script/demo/package.json | 13 +++ script/demo/record.mjs | 185 +++++++++++++++++++++++++++++++ test/system/user_journey_test.rb | 112 +++++++++++++++++++ 6 files changed, 413 insertions(+) create mode 100644 script/demo/README.md create mode 100644 script/demo/package-lock.json create mode 100644 script/demo/package.json create mode 100644 script/demo/record.mjs create mode 100644 test/system/user_journey_test.rb diff --git a/.gitignore b/.gitignore index 9afb2f6..5eb7a99 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,7 @@ # SimpleCov code coverage reports /coverage + +# Playwright demo recorder +/script/demo/node_modules +/script/demo/output diff --git a/script/demo/README.md b/script/demo/README.md new file mode 100644 index 0000000..fba4189 --- /dev/null +++ b/script/demo/README.md @@ -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-@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). diff --git a/script/demo/package-lock.json b/script/demo/package-lock.json new file mode 100644 index 0000000..08a5bb3 --- /dev/null +++ b/script/demo/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "community-foundation-demo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "community-foundation-demo", + "devDependencies": { + "playwright": "^1.50.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/script/demo/package.json b/script/demo/package.json new file mode 100644 index 0000000..e663cae --- /dev/null +++ b/script/demo/package.json @@ -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" + } +} diff --git a/script/demo/record.mjs b/script/demo/record.mjs new file mode 100644 index 0000000..f41e072 --- /dev/null +++ b/script/demo/record.mjs @@ -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(/&/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); +}); diff --git a/test/system/user_journey_test.rb b/test/system/user_journey_test.rb new file mode 100644 index 0000000..4382591 --- /dev/null +++ b/test/system/user_journey_test.rb @@ -0,0 +1,112 @@ +require "application_system_test_case" + +# Walks the happy path a brand-new member takes on a tenant: sign up, confirm +# via the emailed link, fill in About You (autosave), build a giving scenario, +# share it, and view the shared page anonymously. +class UserJourneyTest < ApplicationSystemTestCase + include ActiveJob::TestHelper + + EMAIL = "journey@example.com".freeze + TENANT_HOST = "http://arlington.localhost".freeze + + setup do + @original_app_host = Capybara.app_host + Capybara.app_host = TENANT_HOST + end + + teardown do + Capybara.app_host = @original_app_host + end + + test "a new member signs up, confirms, fills out About You, builds and shares a scenario" do + # Landing → sign up + visit "/" + assert_selector "h1", text: "Your Legacy Starts Here" + within("nav") { click_link "Log in" } + click_link "Sign up" + + fill_in "Full name", with: "Journey Tester" + fill_in "user_email_address", with: EMAIL + fill_in "user_password", with: "password123" + fill_in "user_password_confirmation", with: "password123" + click_button "Sign up" + assert_text "Check your email to confirm your account before signing in." + + # Follow the confirmation email; it confirms and signs the user in + perform_enqueued_jobs + mail = ActionMailer::Base.deliveries.last + assert_equal [ EMAIL ], mail.to + visit extract_path(mail, "/email_confirmation") + assert_text "Your email address has been confirmed." + assert_selector "h1", text: "Welcome to your workspace" + + # About You autosaves as you type + click_link "About you" + assert_selector "h1", text: "About you" + fill_in "user_biography_birthplace", with: "Arlington, VA" + fill_in "user_biography_background", with: "Grew up on a farm." + assert_text "Saved." + + user = User.find_by!(email_address: EMAIL) + assert_equal "Arlington, VA", user.biography.birthplace + assert_equal "Grew up on a farm.", user.biography.background + + visit "/users/biography" + assert_field "user_biography_birthplace", with: "Arlington, VA" + + # Build a scenario + visit "/dashboard" + click_link "Explore options" + click_link "Create scenario" + fill_in "Name", with: "Education focus" + click_button "Create scenario" + assert_selector "h1", text: "Education focus" + assert_text "Greatest Community Need" + + click_link "Add amount" + fill_in "Total giving amount", with: 100_000 + find("button[aria-label='Save total giving amount']").click + assert_text "$100,000" + + within one_time_section do + click_button "+ Add allocation" + click_button "Select a category" + find("button[data-name='Education']").click + fill_in "Amount", with: 5_000 + click_button "Create" + end + assert_text "Education" + assert_text "$5,000" + + # Share and view the read-only page without signing in + click_button "Share" + click_button "Create share link" + assert_selector "input[readonly][value*='/public/scenarios/']" + + token = user.scenarios.find_by!(name: "Education focus").share_token + assert token.present? + + Capybara.using_session(:anonymous_visitor) do + visit "/public/scenarios/#{token}" + assert_text "Read-only shared view" + assert_selector "h1", text: "Education focus" + assert_text "Education" + end + end + + private + + def one_time_section + find("h3", text: /one time giving/i).ancestor("[data-controller='dialog']") + end + + # Pull the first link containing `prefix` out of the email and return it as a + # path + query so Capybara routes it through the running test server. + def extract_path(mail, prefix) + body = mail.html_part&.body&.decoded || mail.body.decoded + url = body[%r{https?://[^"'\s<]+#{Regexp.escape(prefix)}[^"'\s<]*}] + assert url, "No #{prefix} link found in email" + uri = URI.parse(url) + [ uri.path, uri.query ].compact.join("?") + end +end