diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3a7fb0..85ac7da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - "components/**" - "lib/**" - "tests/**" + - "e2e/**" - "scripts/**" - "public/**" - "supabase/**" @@ -15,6 +16,7 @@ on: - "next.config.ts" - "tsconfig.json" - "eslint.config.*" + - "playwright.config.ts" - ".github/workflows/ci.yml" push: branches: [main] @@ -23,6 +25,7 @@ on: - "components/**" - "lib/**" - "tests/**" + - "e2e/**" - "scripts/**" - "public/**" - "supabase/**" @@ -31,6 +34,7 @@ on: - "next.config.ts" - "tsconfig.json" - "eslint.config.*" + - "playwright.config.ts" - ".github/workflows/ci.yml" permissions: @@ -54,3 +58,9 @@ jobs: - run: npm run lint - run: npm test - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e -- e2e/beta-journey.spec.ts + - if: ${{ vars.E2E_BASE_URL != '' }} + run: npm run test:e2e -- e2e/readiness.spec.ts + env: + E2E_BASE_URL: ${{ vars.E2E_BASE_URL }} diff --git a/.gitignore b/.gitignore index be35599..2676995 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* +# browser test artifacts +/playwright-report/ +/test-results/ + # prompt eval artifacts /evaluations/* !/evaluations/.gitkeep diff --git a/components/data-controls.tsx b/components/data-controls.tsx index 17e963e..c8b9544 100644 --- a/components/data-controls.tsx +++ b/components/data-controls.tsx @@ -39,5 +39,5 @@ export function DataControls() { setMessage("Source deleted."); } - return Data controlsExport your structured workspace or permanently remove source material. void exportData()}>Export my data{sources.map((source) => {source.title}{source.signals.length} extracted signals void deleteSource(source.id, source.title)}>Delete)}{!sources.length && No source material to manage yet.}{message ? {message} : null}; + return Data controlsExport your structured workspace or permanently remove source material. void exportData()}>Export my data{sources.map((source) => {source.title}{source.signals.length} extracted signals void deleteSource(source.id, source.title)}>Delete)}{!sources.length && No source material to manage yet.}{message ? {message} : null}; } diff --git a/components/workspace.tsx b/components/workspace.tsx index 000ceab..e42d655 100644 --- a/components/workspace.tsx +++ b/components/workspace.tsx @@ -37,7 +37,7 @@ export function Workspace() { async function saveProfile(event: FormEvent) { event.preventDefault(); setMessage("Saving profile…"); const response = await fetch("/api/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(profile) }); setMessage(response.ok ? "Profile saved." : "Could not save profile."); } async function addSource(event: FormEvent) { event.preventDefault(); setMessage("Saving source…"); const response = await fetch("/api/sources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(source) }); if (response.ok) { const data = await response.json() as { source: SourceDocument }; setSource({ title: "", type: "transcript", content: "" }); await load(); setMessage(`Source added with ${data.source.signals.length} evidence-backed audience signal${data.source.signals.length === 1 ? "" : "s"}.`); } else setMessage("Could not save source."); } - async function addExperiment(event: FormEvent) { event.preventDefault(); const response = await fetch("/api/experiments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "New content experiment", platform: "LinkedIn", format: "Short post", ...Object.fromEntries(new FormData(event.currentTarget)) }) }); if (response.ok) { event.currentTarget.reset(); await load(); setMessage("Experiment added."); } } + async function addExperiment(event: FormEvent) { event.preventDefault(); const form = event.currentTarget; const response = await fetch("/api/experiments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "New content experiment", platform: "LinkedIn", format: "Short post", ...Object.fromEntries(new FormData(form)) }) }); if (response.ok) { form.reset(); await load(); setMessage("Experiment added."); } } function changeExperiment(id: string, patch: Partial) { setExperiments((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item)); } diff --git a/docs/e2e-smoke-tests.md b/docs/e2e-smoke-tests.md new file mode 100644 index 0000000..416ae9d --- /dev/null +++ b/docs/e2e-smoke-tests.md @@ -0,0 +1,30 @@ +# Browser smoke tests + +CreatorOS has one deterministic local browser journey and a small deployment-readiness suite. Both use Playwright and Chromium. + +## Local journey + +Install the browser once, then run: + +```sh +npm run test:e2e:install +npm run test:e2e -- e2e/beta-journey.spec.ts +``` + +The local server runs with `CREATOROS_ALLOW_MOCK=true`, so no credentials or real user data are needed. The journey uses synthetic evidence, saves a profile, creates an experiment, records an outcome, checks learning, downloads an export, and deletes the synthetic source. + +## Preview readiness + +Set `E2E_BASE_URL` to a Vercel preview URL and run: + +```sh +E2E_BASE_URL=https://your-preview.vercel.app npm run test:e2e -- e2e/readiness.spec.ts +``` + +The readiness checks do not log in or mutate workspace data. They verify the health contract, safe login validation, and protected workspace behavior. Vercel deployment protection may return a redirect; provide an accessible preview URL when running these checks in CI. + +For a real authenticated journey, use an isolated Supabase test project and a dedicated test user. Keep its URL, publishable key, service-role key, email credentials, and any mail-capture credentials in CI secrets. Never commit them or use a production user. CAPTCHA should be disabled in the isolated test environment or handled by a documented test-only adapter. + +## CI policy + +The deterministic local journey runs in the existing validation job on every relevant pull request and push to `main`, avoiding a second checkout and dependency install. When the repository variable `E2E_BASE_URL` is configured, the same job also runs the non-mutating deployment-readiness checks against that URL. A missing `E2E_BASE_URL` skips only the remote checks rather than silently testing production. diff --git a/e2e/beta-journey.spec.ts b/e2e/beta-journey.spec.ts new file mode 100644 index 0000000..e422b08 --- /dev/null +++ b/e2e/beta-journey.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +test("local beta journey persists and learns from one experiment", async ({ page }) => { + await page.goto("/workspace"); + await expect(page.getByRole("heading", { name: "Turn content into a learning loop." })).toBeVisible(); + + await page.getByPlaceholder("I help...").fill("I help independent creators build a reliable publishing system."); + await page.getByPlaceholder("Course, consulting, membership, product...").fill("Creator growth coaching"); + await page.getByRole("button", { name: "Save growth profile" }).click(); + await expect(page.getByText("Profile saved.")).toBeVisible(); + + const sourceTitle = "E2E transcript — synthetic beta evidence"; + await page.getByPlaceholder("Podcast episode: audience research").fill(sourceTitle); + await page.getByPlaceholder("Paste a transcript, notes, comments, or research...").fill("Synthetic test evidence: creators need a repeatable weekly system, clearer audience language, and a way to learn from outcomes."); + await page.getByRole("button", { name: "Add evidence" }).click(); + await expect(page.getByText(sourceTitle)).toBeVisible(); + + await page.getByPlaceholder("Experiment title").fill("E2E weekly publishing experiment"); + await page.getByPlaceholder("Hook or angle").fill("Stop guessing what to publish next"); + await page.getByPlaceholder("Hypothesis").fill("A sharper audience problem will increase qualified clicks."); + await page.getByRole("button", { name: "Add experiment" }).click(); + await expect(page.getByLabel("Title for E2E weekly publishing experiment")).toBeVisible(); + + await page.getByLabel("Experiment", { exact: true }).selectOption({ label: "E2E weekly publishing experiment" }); + await page.getByRole("spinbutton", { name: "Views" }).fill("100"); + await page.getByRole("spinbutton", { name: "Clicks" }).fill("12"); + await page.getByRole("spinbutton", { name: "Signups" }).fill("3"); + await page.getByRole("button", { name: "Record outcome" }).click(); + await expect(page.getByText("Outcome recorded.")).toBeVisible(); + await expect(page.getByText("Your strongest signal is Short post on LinkedIn")).toBeVisible(); + + const exportResponse = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export my data" }).click(); + const download = await exportResponse; + expect(download.suggestedFilename()).toBe("creatoros-export.json"); + + await page.reload(); + const deleteSource = page.getByRole("button", { name: `Delete ${sourceTitle}` }); + await expect(deleteSource).toBeVisible(); + page.once("dialog", (dialog) => dialog.accept()); + await deleteSource.click(); + await expect(page.getByText("Source deleted.")).toBeVisible(); + await page.reload(); + await expect(page.getByText(sourceTitle)).toHaveCount(0); +}); + +test("unauthenticated workspace redirects when auth is configured", async ({ page }) => { + test.skip(!process.env.E2E_AUTH_REQUIRED, "Local smoke uses the deterministic demo adapter."); + await page.goto("/workspace"); + await expect(page).toHaveURL(/\/login\?next=%2Fworkspace|\/login\?next=\/workspace/); +}); diff --git a/e2e/readiness.spec.ts b/e2e/readiness.spec.ts new file mode 100644 index 0000000..b681cce --- /dev/null +++ b/e2e/readiness.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; + +test.describe("deployment readiness", () => { + test.skip(!process.env.E2E_BASE_URL, "Set E2E_BASE_URL to run preview/production readiness checks."); + + test("health endpoint is available", async ({ request }) => { + const response = await request.get("/api/health"); + expect([200, 503]).toContain(response.status()); + expect(response.headers()["cache-control"]).toContain("no-store"); + const body = await response.json(); + expect(body).toMatchObject({ service: "creatoros" }); + }); + + test("login endpoint validates input without exposing configuration", async ({ request }) => { + const response = await request.post("/api/auth/login", { data: {} }); + expect([400, 503]).toContain(response.status()); + const body = await response.json(); + expect(JSON.stringify(body)).not.toMatch(/service.role|supabase\.co|api[_-]?key/i); + }); + + test("protected workspace does not expose anonymous workspace data", async ({ request }) => { + const response = await request.get("/workspace", { maxRedirects: 0 }); + expect([302, 307, 308]).toContain(response.status()); + expect(response.headers().location).toMatch(/login|vercel\.com\/sso-api/); + }); +}); diff --git a/package-lock.json b/package-lock.json index 4d4e495..72f1055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "zod": "*" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "4.3.0", "@types/node": "25.6.2", "@types/react": "19.2.14", @@ -1803,6 +1804,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -7564,6 +7581,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/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/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index a7f3c55..5e0fd62 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium", "eval:prompts": "node --import tsx scripts/evaluate-prompts.ts" }, "dependencies": { @@ -33,6 +35,7 @@ "postcss": "8.5.18" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "4.3.0", "@types/node": "25.6.2", "@types/react": "19.2.14", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..8769941 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3001"; +const externalBaseURL = Boolean(process.env.E2E_BASE_URL); + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? [["line"], ["html", { open: "never" }]] : "list", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: externalBaseURL ? undefined : { + command: "APP_ORIGIN=http://127.0.0.1:3001 NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= SUPABASE_SERVICE_ROLE_KEY= CREATOROS_ALLOW_MOCK=true npm run dev -- --hostname 127.0.0.1 --port 3001", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +});
{source.title}
{source.signals.length} extracted signals
No source material to manage yet.
{message}