diff --git a/apps/web/e2e-hosted/.gitignore b/apps/web/e2e-hosted/.gitignore new file mode 100644 index 00000000..aaa9103e --- /dev/null +++ b/apps/web/e2e-hosted/.gitignore @@ -0,0 +1,2 @@ +test-results/ +playwright-report/ diff --git a/apps/web/e2e-hosted/README.md b/apps/web/e2e-hosted/README.md new file mode 100644 index 00000000..7d2ec577 --- /dev/null +++ b/apps/web/e2e-hosted/README.md @@ -0,0 +1,68 @@ +# Hosted E2E (production / staging) + +Browser-driven tests that exercise a **deployed** StackPanel studio (prod or +staging), as opposed to `apps/web/e2e/` which spins up a local web server + a +local `go run . agent` and drives them together. + +This split exists because the production architecture is +`hosted web ↔ local agent ↔ local repo`: the studio is served from +`stackpanel.com`, pairs with an agent running on `localhost:9876`, and that +agent manages a real project on disk. + +## Prerequisites + +```bash +bun install # workspace deps (incl. @playwright/test) +bunx playwright install chromium # browser binary +``` + +> In a bare container without Nix you can still run everything here: the agent +> side uses plain `go run` (see the studio spec), no devshell required. + +## Read-only smoke (safe against any environment) + +`*.smoke.spec.ts` only loads public pages and asserts the app renders + gates +auth correctly. **No login, no signup, no writes** — safe to point at prod. + +```bash +# staging (preferred once healthy) +SMOKE_BASE_URL=https://staging.stackpanel.com \ + bunx playwright test --config apps/web/e2e-hosted/playwright.hosted.config.ts + +# production (default) +bunx playwright test --config apps/web/e2e-hosted/playwright.hosted.config.ts +``` + +What it checks: + +- `/` renders with no uncaught page errors (catches white-screen / JS-crash regressions) +- `/dashboard` redirects an unauthenticated visitor to `/login` +- `/login` presents a sign-in affordance (client-rendered form) +- `/studio` responds without a 5xx and without uncaught errors + +## Full studio ↔ agent E2E (authenticated) — `*.studio.spec.ts` + +Drives the real happy path: log in, pair the hosted studio to a **local** agent +serving `examples/multi-app`, and verify apps/services/ports/secrets render from +the live agent. Requires: + +| Env var | Purpose | +| --- | --- | +| `SMOKE_BASE_URL` | Hosted studio to drive (use **staging**, not prod) | +| `STUDIO_TEST_EMAIL` / `STUDIO_TEST_PASSWORD` | Dedicated test account on that env | +| `STACKPANEL_AGENT_PORT` | Local agent port (default 9876) | +| `STACKPANEL_TEST_PAIRING_TOKEN` | Pre-shared pairing token so the agent auto-pairs | + +The spec boots the agent with: + +```bash +( cd apps/stackpanel-go && \ + STACKPANEL_TEST_PAIRING_TOKEN=$TOKEN \ + go run . agent --port $STACKPANEL_AGENT_PORT --project-root ../../examples/multi-app ) +``` + +> **Status:** authored against `staging.stackpanel.com`. As of this writing +> staging is returning no response (web) / 503 (api), so this spec is skipped +> unless `SMOKE_BASE_URL` resolves AND the test-account env vars are set. Run it +> once staging is back up, or against a dedicated test account on prod with +> explicit sign-off. diff --git a/apps/web/e2e-hosted/hosted.smoke.spec.ts b/apps/web/e2e-hosted/hosted.smoke.spec.ts new file mode 100644 index 00000000..75d5f0be --- /dev/null +++ b/apps/web/e2e-hosted/hosted.smoke.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * Read-only hosted smoke. NO authentication, NO mutations — safe against prod or staging. + * + * The deployed studio is a client-rendered SPA, so this is intentionally + * selector-light: the strongest, most durable signal is "no uncaught page + * errors", which catches white-screen / hydration-crash regressions that a + * status-code check alone would miss. + */ + +function collectPageErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message ?? String(err))); + return errors; +} + +test.describe("hosted smoke (read-only, unauthenticated)", () => { + test("landing renders without uncaught page errors", async ({ page }) => { + const errors = collectPageErrors(page); + const res = await page.goto("/", { waitUntil: "domcontentloaded" }); + expect(res?.status() ?? 0, "GET / status").toBeLessThan(400); + await page.waitForLoadState("load"); + const text = (await page.locator("body").innerText().catch(() => "")).trim(); + expect(text.length, "landing shows visible text").toBeGreaterThan(0); + expect(errors, `uncaught errors on /:\n${errors.join("\n")}`).toEqual([]); + }); + + test("/dashboard gates unauthenticated visitors to /login", async ({ page }) => { + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await page.waitForURL(/\/login/, { timeout: 15_000 }).catch(() => {}); + expect(page.url(), "expected redirect to /login").toMatch(/\/login/); + }); + + test("/login presents a sign-in affordance", async ({ page }) => { + const res = await page.goto("/login", { waitUntil: "domcontentloaded" }); + expect(res?.status() ?? 0, "GET /login status").toBeLessThan(400); + const emailField = page.locator('input[type="email"], input[name="email"]'); + const signInControl = page + .getByRole("button", { name: /sign ?in|log ?in|continue|sign ?up/i }) + .or(page.getByText(/sign ?in|log ?in/i)); + await expect( + emailField.or(signInControl).first(), + "a login form or sign-in control is visible", + ).toBeVisible({ timeout: 15_000 }); + }); + + test("/studio responds without a server error or crash", async ({ page }) => { + const errors = collectPageErrors(page); + const res = await page.goto("/studio", { waitUntil: "domcontentloaded" }); + expect(res?.status() ?? 0, "GET /studio status (no 5xx)").toBeLessThan(500); + await page.waitForLoadState("load"); + expect(errors, `uncaught errors on /studio:\n${errors.join("\n")}`).toEqual([]); + // Observed 2026-06: /studio returns 200 with no server-side redirect for + // unauthenticated users (client-gated), unlike /dashboard. Recorded for the audit. + // eslint-disable-next-line no-console + console.log(`[hosted-smoke] /studio settled at: ${page.url()}`); + }); +}); diff --git a/apps/web/e2e-hosted/playwright.hosted.config.ts b/apps/web/e2e-hosted/playwright.hosted.config.ts new file mode 100644 index 00000000..7449851c --- /dev/null +++ b/apps/web/e2e-hosted/playwright.hosted.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Hosted-environment Playwright config. + * + * Unlike `apps/web/playwright.config.ts` (which boots a LOCAL agent + local web + * dev server), this config drives a REMOTE deployment — production or staging — + * so there is intentionally NO `webServer` block. + * + * # read-only smoke against staging (preferred once it's healthy) + * SMOKE_BASE_URL=https://staging.stackpanel.com \ + * bunx playwright test --config apps/web/e2e-hosted/playwright.hosted.config.ts + * + * # read-only smoke against production (default) + * bunx playwright test --config apps/web/e2e-hosted/playwright.hosted.config.ts + * + * Smoke specs (*.smoke.spec.ts) are READ-ONLY: no login, no signup, no mutations. + * The authenticated studio<->agent flow lives in *.studio.spec.ts and requires + * extra env (see README.md); it is gated so it only runs when configured. + */ + +const baseURL = process.env.SMOKE_BASE_URL ?? "https://stackpanel.com"; + +export default defineConfig({ + testDir: ".", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: process.env.CI ? 2 : 3, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [["list"]], + outputDir: "test-results", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "off", + // Some sandboxed/CI environments route egress through a TLS-intercepting + // proxy whose CA the browser doesn't trust (curl succeeds, but Chromium + // reports ERR_CERT_AUTHORITY_INVALID). Opt into ignoring cert errors there + // via SMOKE_IGNORE_HTTPS_ERRORS=1. Leave it OFF in clean CI so a genuine + // production certificate regression still fails the smoke. + ignoreHTTPSErrors: process.env.SMOKE_IGNORE_HTTPS_ERRORS === "1", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +});