diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..cbc6ce8 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.backend.log +test-results/ +playwright-report/ diff --git a/e2e/fixtures/good.csv b/e2e/fixtures/good.csv new file mode 100644 index 0000000..5c54ca4 --- /dev/null +++ b/e2e/fixtures/good.csv @@ -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 diff --git a/e2e/fixtures/missing-title.csv b/e2e/fixtures/missing-title.csv new file mode 100644 index 0000000..e4e06e4 --- /dev/null +++ b/e2e/fixtures/missing-title.csv @@ -0,0 +1,2 @@ +Handle,Vendor +mug,Acme diff --git a/e2e/fixtures/mixed-errors.csv b/e2e/fixtures/mixed-errors.csv new file mode 100644 index 0000000..4b260bd --- /dev/null +++ b/e2e/fixtures/mixed-errors.csv @@ -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 diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..2a1b0a3 --- /dev/null +++ b/e2e/helpers.ts @@ -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 -> | Your ecomm code: <6 digits>\n +// The first 6-digit group after the marker is the code. +async function codeFor(email: string): Promise { + 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 { + // 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)); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..4b6066b --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..c7a9766 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,6 @@ +{ + "name": "wiggleverse-ecomm-e2e", + "private": true, + "scripts": { "test": "playwright test" }, + "devDependencies": { "@playwright/test": "^1.48.0" } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..a809705 --- /dev/null +++ b/e2e/playwright.config.ts @@ -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, + }, +}); diff --git a/e2e/serve.sh b/e2e/serve.sh new file mode 100755 index 0000000..a6db7eb --- /dev/null +++ b/e2e/serve.sh @@ -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 diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts new file mode 100644 index 0000000..4915427 --- /dev/null +++ b/e2e/tests/smoke.spec.ts @@ -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(); +}); diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..373dd01 --- /dev/null +++ b/scripts/e2e.sh @@ -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 "$@"