0775537d4c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
121 lines
5.6 KiB
TypeScript
121 lines
5.6 KiB
TypeScript
// 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, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
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));
|
|
}
|
|
|
|
// Import good.csv and confirm it, leaving a 2-product catalog. Returns nothing;
|
|
// callers continue from the run-detail screen.
|
|
export async function importGoodCsv(page: Page) {
|
|
await uploadFixture(page, "good.csv");
|
|
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Import 2 products" }).click();
|
|
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
|
|
}
|
|
|
|
// Import with-images.csv (3 products, each with one Image Src) and confirm it.
|
|
// Mirrors importGoodCsv; callers continue from the run-detail screen, where the
|
|
// background image fetch progresses fetching_images → terminal.
|
|
export async function importWithImages(page: Page) {
|
|
await uploadFixture(page, "with-images.csv");
|
|
await expect(page.getByRole("heading", { name: "Import preview — with-images.csv" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Import 3 products" }).click();
|
|
await expect(page.getByRole("heading", { level: 1, name: "with-images.csv" })).toBeVisible();
|
|
}
|
|
|
|
// Click an Export status option and capture the downloaded CSV's text + path.
|
|
export async function exportCatalog(page: Page, label: string): Promise<{ text: string; path: string }> {
|
|
// Open the <details> menu, then click the status option, capturing the download.
|
|
// The menu is a native <details> that *toggles* on each summary click and is
|
|
// NOT closed by clicking a download <a> (a download doesn't navigate). So on a
|
|
// second export the menu may already be open — deterministically open it
|
|
// rather than blind-toggling, and wait for the status link to be visible.
|
|
const details = page.locator("details.products__export");
|
|
if (!(await details.evaluate((el: HTMLDetailsElement) => el.open))) {
|
|
await details.locator("> summary").click();
|
|
}
|
|
const link = page.getByRole("link", { name: label, exact: true });
|
|
await expect(link).toBeVisible();
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent("download"),
|
|
link.click(),
|
|
]);
|
|
const stream = await download.createReadStream();
|
|
const chunks: Buffer[] = [];
|
|
for await (const c of stream) chunks.push(c as Buffer);
|
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
const path = join(tmpdir(), `export-${Date.now()}.csv`);
|
|
await writeFile(path, text, "utf8");
|
|
return { text, path };
|
|
}
|