test(e2e): stand up Playwright — fresh-DB server harness + sign-up helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:50:59 -07:00
parent d18a84e135
commit a58f42cf86
11 changed files with 240 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.backend.log
test-results/
playwright-report/
+4
View File
@@ -0,0 +1,4 @@
Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty
moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40
star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12
star-tee,,,,,,,M,WG-TEE-M,24.00,18
1 Handle Title Vendor Tags Status Published Option1 Name Option1 Value Variant SKU Variant Price Variant Inventory Qty
2 moon-mug Moon Mug Wiggle Goods kitchen, mugs active TRUE WG-MUG-001 18.00 40
3 star-tee Star Tee Wiggle Goods apparel active TRUE Size S WG-TEE-S 24.00 12
4 star-tee M WG-TEE-M 24.00 18
+2
View File
@@ -0,0 +1,2 @@
Handle,Vendor
mug,Acme
1 Handle Vendor
2 mug Acme
+4
View File
@@ -0,0 +1,4 @@
Handle,Title,Variant Price
good-mug,Good Mug,10.00
bad-tee,Bad Tee,not-a-price
also-good,Also Good,5.00
1 Handle Title Variant Price
2 good-mug Good Mug 10.00
3 bad-tee Bad Tee not-a-price
4 also-good Also Good 5.00
+74
View File
@@ -0,0 +1,74 @@
// Shared E2E helpers — the sign-up journey (SD-0001 §5.1–§5.4) and products-page
// navigation (SD-0002 §5.2). Selectors are role/label-based against the real screens
// (Landing.tsx, SignIn.tsx, CreateStorefront.tsx, Admin.tsx, ProductsPage.tsx).
import { expect, type Page } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const LOG = join(__dirname, ".backend.log");
let seq = 0;
// The storefront name every helper-created merchant uses; tests assert against it.
export const STOREFRONT_NAME = "E2E Test Goods";
export function freshEmail(): string {
return `merchant${Date.now()}-${seq++}@example.com`;
}
// LogMailer's exact line (backend/app/platform/mailer.py):
// INFO:ecomm.mailer: LogMailer -> <to> | Your ecomm code: <6 digits>\n<body>
// The first 6-digit group after the marker is the code.
async function codeFor(email: string): Promise<string> {
for (let i = 0; i < 50; i++) {
const log = await readFile(LOG, "utf8").catch(() => "");
const at = log.lastIndexOf(`LogMailer -> ${email}`);
if (at >= 0) {
const m = log.slice(at).match(/\b(\d{6})\b/);
if (m) return m[1];
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`no code logged for ${email}`);
}
export async function signUpWithStorefront(page: Page, email = freshEmail()): Promise<string> {
// Landing → the sign-up door.
await page.goto("/");
await page.getByRole("button", { name: "Create your storefront →" }).click();
// Sign-in step 1: request the one-time code.
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Send code" }).click();
// Sign-in step 2: read the code from the backend log and verify it.
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
const code = await codeFor(email);
await page.getByLabel("One-time code").fill(code);
await page.getByRole("button", { name: "Continue" }).click();
// Create-storefront screen (a new account has none yet).
await expect(page.getByRole("heading", { name: "Create your storefront" })).toBeVisible();
await page.getByLabel("Storefront name").fill(STOREFRONT_NAME);
await page.getByRole("button", { name: "Create storefront", exact: true }).click();
// Admin shell: nav strip + the storefront identity in the topbar.
await expect(page.getByRole("navigation", { name: "Admin sections" })).toBeVisible();
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
return email;
}
export async function gotoProducts(page: Page) {
await page
.getByRole("navigation", { name: "Admin sections" })
.getByRole("link", { name: "Products" })
.click();
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
}
export async function uploadFixture(page: Page, fixture: string) {
// Two "Import products" links render on the empty products page (header + empty
// state) — same destination, so take the first.
await page.getByRole("link", { name: "Import products" }).first().click();
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles(join(__dirname, "fixtures", fixture));
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "wiggleverse-ecomm-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wiggleverse-ecomm-e2e",
"devDependencies": {
"@playwright/test": "^1.48.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"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.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "wiggleverse-ecomm-e2e",
"private": true,
"scripts": { "test": "playwright test" },
"devDependencies": { "@playwright/test": "^1.48.0" }
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 60_000,
retries: 0,
// One shared backend + log file; parallel sign-ins would interleave codes.
workers: 1,
use: { baseURL: "http://localhost:8765" },
webServer: {
command: "bash ./serve.sh",
url: "http://localhost:8765/healthz",
reuseExistingServer: false,
timeout: 120_000,
},
});
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log),
# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ ! -f "$repo_root/frontend/dist/index.html" ]; then
( cd "$repo_root/frontend" && npm run build )
fi
"$repo_root/.venv/bin/python" - <<'PY'
import psycopg
admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True)
admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()")
admin.execute("DROP DATABASE IF EXISTS ecomm_e2e")
admin.execute("CREATE DATABASE ecomm_e2e")
PY
export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e"
export ECOMM_MAILER=log
cd "$repo_root/backend"
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
> "$repo_root/e2e/.backend.log" 2>&1
+20
View File
@@ -0,0 +1,20 @@
// Harness smoke (Task 15): the full sign-up journey lands in the admin, and the
// products section's empty state renders with its import affordances. The four
// SD-0002 scenario specs build on these helpers.
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME } from "../helpers";
test("smoke: sign up, storefront, products nav", async ({ page }) => {
await signUpWithStorefront(page);
// Admin topbar: storefront identity + the signed-in account chip.
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
await gotoProducts(page);
// Empty state + the import affordances (SD-0002 §5.2).
await expect(page.getByText("No products yet. Bulk import is how product data gets in.")).toBeVisible();
await expect(page.getByRole("link", { name: "Import products" }).first()).toBeVisible();
await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible();
});
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# E2E browser gate (SD-0002 §6.8) — Playwright against a fresh local stack.
# Not yet part of check.sh/CI: the CI runner has no browsers (§10.6 gap).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root/e2e"
if [ ! -d node_modules ]; then
npm install
npx playwright install chromium
fi
npx playwright test "$@"