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
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,56 @@ jobs:
# so it must pass with NO env vars set (mirrors the build invariant).
run: npm run test:run

e2e:
name: Playwright E2E (mock-driver, public + simulated) — optional, non-blocking
runs-on: ubuntu-latest
# This job builds the app and runs the Playwright suite against a locally
# started production server. It is OPTIONAL and MUST NOT block a PR: it needs
# a running app (and, for the real-auth flows, live Supabase + bootstrapped
# credentials supplied out-of-band as E2E_* secrets). With NO env vars the app
# runs in simulated-auth / mock-driver mode, so the PUBLIC + simulated flows
# (storefront, terminal, KDS, shop, back-office, onboarding) still run
# end-to-end here; the real-login / gating specs skip gracefully. The REQUIRED
# gates are `build` + `test` above, which never depend on this job.
continue-on-error: true
env:
# Real-auth specs read these; absent here, those specs skip. An orchestrator
# running E2E against a live preview supplies them (and BASE_URL).
E2E_OWNER_EMAIL: ${{ secrets.E2E_OWNER_EMAIL }}
E2E_OWNER_PASSWORD: ${{ secrets.E2E_OWNER_PASSWORD }}
E2E_PLATFORM_EMAIL: ${{ secrets.E2E_PLATFORM_EMAIL }}
E2E_PLATFORM_PASSWORD: ${{ secrets.E2E_PLATFORM_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright browser (chromium)
run: npx playwright install --with-deps chromium

- name: Build (zero env — mock driver / simulated auth)
run: npm run build

- name: Run Playwright E2E
# webServer in playwright.config.ts starts `npm run start` on :3100.
run: npm run e2e

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

rls-isolation:
name: RLS isolation (Postgres) — optional, non-blocking
runs-on: ubuntu-latest
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ Thumbs.db

# testing
coverage/

# playwright (E2E) — separate from Vitest
/test-results/
/playwright-report/
/playwright/.cache/
/e2e/.auth/
85 changes: 85 additions & 0 deletions e2e/auth-gating.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Auth + route-gating E2E.
*
* REAL-AUTH assertions (unauthenticated redirects, owner→/admin,
* platform-admin→/platform) only run when the target deployment uses real
* Supabase Auth AND the orchestrator supplied `E2E_*` creds. In simulated/mock
* mode (zero env), middleware is a pass-through and there is no real login, so
* those specs `test.skip()` — the public storefront spec always runs.
*/
import { test, expect } from "@playwright/test";
import { detectRealAuth, signInWithPassword } from "./support/auth";
import {
OWNER_EMAIL,
OWNER_PASSWORD,
PLATFORM_EMAIL,
PLATFORM_PASSWORD,
SHOP_SLUG_PICKUP_DELIVERY,
} from "./support/env";

test.describe("public storefront is reachable without auth", () => {
test("GET /shop/<slug> renders the storefront", async ({ page }) => {
await page.goto(`/shop/${SHOP_SLUG_PICKUP_DELIVERY}`, {
waitUntil: "domcontentloaded",
});
// Storefront header copy + the menu category tab prove a public render.
await expect(page.getByText("Order online")).toBeVisible();
await expect(page.getByRole("button", { name: "Pizzas" })).toBeVisible({
timeout: 20_000,
});
// It must NOT have bounced us to a login.
expect(page.url()).toContain(`/shop/${SHOP_SLUG_PICKUP_DELIVERY}`);
});
});

test.describe("real-auth gating", () => {
test("unauthenticated /admin redirects to /login", async ({ page }) => {
const real = await detectRealAuth(page);
test.skip(!real, "Simulated auth: middleware is a pass-through.");

await page.context().clearCookies();
await page.goto("/admin", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/login(\?|$)/);
});

test("unauthenticated /platform redirects to /platform/login", async ({
page,
}) => {
const real = await detectRealAuth(page);
test.skip(!real, "Simulated auth: middleware is a pass-through.");

await page.context().clearCookies();
await page.goto("/platform", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/platform\/login(\?|$)/);
});

test("owner can log in and reach /admin", async ({ page }) => {
const real = await detectRealAuth(page);
test.skip(!real, "Simulated auth: no real login.");
test.skip(!OWNER_PASSWORD, "No E2E_OWNER_PASSWORD supplied.");

await signInWithPassword(page, {
loginPath: "/login?redirect=/admin",
email: OWNER_EMAIL,
password: OWNER_PASSWORD,
expectPath: /\/admin(\?|$)/,
});
await expect(page.getByText("Back office")).toBeVisible();
});

test("platform admin can log in and reach /platform", async ({ page }) => {
const real = await detectRealAuth(page);
test.skip(!real, "Simulated auth: no real login.");
test.skip(!PLATFORM_PASSWORD, "No E2E_PLATFORM_PASSWORD supplied.");

await signInWithPassword(page, {
loginPath: "/platform/login",
email: PLATFORM_EMAIL,
password: PLATFORM_PASSWORD,
expectPath: /\/platform(\?|$)/,
});
await expect(
page.getByRole("heading", { name: "Platform admin" }),
).toBeVisible();
});
});
80 changes: 80 additions & 0 deletions e2e/back-office.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Back office (/admin) E2E: reports render with data, and an item can be 86'd
* (marked unavailable at a location) then un-86'd.
*
* Runs against real auth when the owner logs in (E2E_OWNER_PASSWORD), else the
* simulated demo owner session (mock driver). The demo tenant is on the Pro
* plan, so advanced reports are unlocked.
*/
import { test, expect, type Page } from "@playwright/test";
import { detectRealAuth, signInWithPassword } from "./support/auth";
import { OWNER_EMAIL, OWNER_PASSWORD } from "./support/env";

async function enterAdmin(page: Page) {
const real = await detectRealAuth(page);
if (real) {
test.skip(!OWNER_PASSWORD, "Real auth but no E2E_OWNER_PASSWORD.");
await signInWithPassword(page, {
loginPath: "/login?redirect=/admin",
email: OWNER_EMAIL,
password: OWNER_PASSWORD,
expectPath: /\/admin(\?|$)/,
});
} else {
await page.goto("/admin", { waitUntil: "domcontentloaded" });
}
await expect(page.getByText("Back office")).toBeVisible({ timeout: 30_000 });
}

test("reports tab renders KPIs and payment mix", async ({ page }) => {
await enterAdmin(page);

await page.getByRole("button", { name: "Reports" }).click();
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();

// KPI cards + the payment-mix section render once data loads.
await expect(page.getByText("Orders").first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("Gross")).toBeVisible();
await expect(
page.getByText("Payment mix (cash / card / crypto)"),
).toBeVisible();
// By-channel / by-item rollups render.
await expect(page.getByText("By channel")).toBeVisible();
await expect(page.getByText("Top items")).toBeVisible();
});

test("86 a menu item at a location, then un-86", async ({ page }) => {
await enterAdmin(page);

// Menu is the default tab. Operate on a SPECIFIC item ("Pepperoni") so we can
// scope the 86/Un-86 controls to that item's row and reliably restore it —
// never leaving a menu item hidden for other specs sharing the mock state.
await page.getByRole("button", { name: "Menu" }).first().click();

// The item row's name button reads e.g. "Pepperonioven · ½&½ · 3 sizes". Find
// the smallest row container holding that button plus its 86 control.
const row = page
.locator("div.px-4.py-3")
.filter({
has: page.getByRole("button", { name: /^Pepperoni/ }),
})
.first();
await expect(row).toBeVisible({ timeout: 30_000 });

// 86 it (mark unavailable at this location).
await row.getByRole("button", { name: "86", exact: true }).click();

// The row now shows the "86'd here" badge and a Un-86 control.
await expect(row.getByText("86'd here")).toBeVisible({ timeout: 30_000 });
const un86 = row.getByRole("button", { name: "Un-86", exact: true });
await expect(un86).toBeVisible();

// Restore so the run is idempotent against the shared (mock/live) state.
await un86.click();
await expect(row.getByText("86'd here")).toBeHidden({ timeout: 30_000 });
await expect(
row.getByRole("button", { name: "86", exact: true }),
).toBeVisible();
});
80 changes: 80 additions & 0 deletions e2e/kds.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* KDS (kitchen display) E2E: a placed order appears on the board and can be
* bumped through statuses (placed → … → ready → recall).
*
* We first place an order in the terminal (so there is a deterministic ticket),
* then open the KDS, find a bumpable ticket, and advance it. Runs against real
* auth when logged in, else the simulated demo session (mock driver).
*/
import { test, expect } from "@playwright/test";
import { detectRealAuth, signInWithPassword } from "./support/auth";
import { buildHalfAndHalf } from "./support/terminal";
import { OWNER_EMAIL, OWNER_PASSWORD } from "./support/env";

async function enterTerminal(page: import("@playwright/test").Page) {
const real = await detectRealAuth(page);
if (real) {
test.skip(!OWNER_PASSWORD, "Real auth but no E2E_OWNER_PASSWORD.");
await signInWithPassword(page, {
loginPath: "/login?redirect=/terminal",
email: OWNER_EMAIL,
password: OWNER_PASSWORD,
expectPath: /\/terminal(\?|$)/,
});
} else {
await page.goto("/terminal", { waitUntil: "domcontentloaded" });
}
await expect(page.getByRole("button", { name: "Pizzas" })).toBeVisible({
timeout: 30_000,
});
}

test("placed order appears on the KDS and can be bumped", async ({ page }) => {
// 1) Place an order in the terminal.
await enterTerminal(page);
await buildHalfAndHalf(page, {
itemName: "Margherita",
leftTopping: "Mushrooms",
rightTopping: "Sausage",
addButtonName: "Add to order",
});
await page.getByRole("button", { name: "Place order" }).click();
// Either the payment screen (online) or the offline confirmation appears;
// the order is on the board regardless. Capture the order number if shown.
await expect(
page
.getByText("Take payment")
.or(page.getByText(/Order .* placed|Order placed/i))
.first(),
).toBeVisible({ timeout: 30_000 });

// 2) Open the KDS (login is already established if real auth).
await page.goto("/kitchen", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Kitchen Display" }),
).toBeVisible({ timeout: 30_000 });

// 3) There should be at least one active ticket with a Bump button.
const bump = page.getByRole("button", { name: "Bump" }).first();
await expect(bump).toBeVisible({ timeout: 30_000 });

// Count tickets, bump one, and expect a status change (Bump → Recall once
// the ticket reaches a bumped state). We click Bump until it becomes Recall
// (the order may need more than one bump to reach ready, depending on the
// starting status).
for (let i = 0; i < 4; i++) {
const recallVisible = await page
.getByRole("button", { name: "Recall" })
.first()
.isVisible()
.catch(() => false);
if (recallVisible) break;
await page.getByRole("button", { name: "Bump" }).first().click();
// Let the optimistic update / poll settle.
await page.waitForTimeout(800);
}

await expect(
page.getByRole("button", { name: "Recall" }).first(),
).toBeVisible({ timeout: 30_000 });
});
Loading
Loading