Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- "components/**"
- "lib/**"
- "tests/**"
- "e2e/**"
- "scripts/**"
- "public/**"
- "supabase/**"
Expand All @@ -15,6 +16,7 @@ on:
- "next.config.ts"
- "tsconfig.json"
- "eslint.config.*"
- "playwright.config.ts"
- ".github/workflows/ci.yml"
push:
branches: [main]
Expand All @@ -23,6 +25,7 @@ on:
- "components/**"
- "lib/**"
- "tests/**"
- "e2e/**"
- "scripts/**"
- "public/**"
- "supabase/**"
Expand All @@ -31,6 +34,7 @@ on:
- "next.config.ts"
- "tsconfig.json"
- "eslint.config.*"
- "playwright.config.ts"
- ".github/workflows/ci.yml"

permissions:
Expand All @@ -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 }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion components/data-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@ export function DataControls() {
setMessage("Source deleted.");
}

return <Card><CardHeader><CardTitle>Data controls</CardTitle><CardDescription>Export your structured workspace or permanently remove source material.</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><Button type="button" variant="outline" onClick={() => void exportData()}>Export my data</Button><div className="flex flex-col gap-2">{sources.map((source) => <div key={source.id} className="flex items-center justify-between gap-3 rounded-md border p-3"><div><p className="text-sm font-medium">{source.title}</p><p className="text-xs text-muted-foreground">{source.signals.length} extracted signals</p></div><Button type="button" variant="outline" size="sm" onClick={() => void deleteSource(source.id, source.title)}>Delete</Button></div>)}{!sources.length && <p className="text-sm text-muted-foreground">No source material to manage yet.</p>}</div>{message ? <p className="text-sm text-muted-foreground">{message}</p> : null}</CardContent></Card>;
return <Card><CardHeader><CardTitle>Data controls</CardTitle><CardDescription>Export your structured workspace or permanently remove source material.</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><Button type="button" variant="outline" onClick={() => void exportData()}>Export my data</Button><div className="flex flex-col gap-2">{sources.map((source) => <div key={source.id} className="flex items-center justify-between gap-3 rounded-md border p-3"><div><p className="text-sm font-medium">{source.title}</p><p className="text-xs text-muted-foreground">{source.signals.length} extracted signals</p></div><Button type="button" variant="outline" size="sm" aria-label={`Delete ${source.title}`} onClick={() => void deleteSource(source.id, source.title)}>Delete</Button></div>)}{!sources.length && <p className="text-sm text-muted-foreground">No source material to manage yet.</p>}</div>{message ? <p className="text-sm text-muted-foreground">{message}</p> : null}</CardContent></Card>;
}
2 changes: 1 addition & 1 deletion components/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement>) { 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<HTMLFormElement>) { 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<ContentExperiment>) {
setExperiments((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item));
}
Expand Down
30 changes: 30 additions & 0 deletions docs/e2e-smoke-tests.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions e2e/beta-journey.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
26 changes: 26 additions & 0 deletions e2e/readiness.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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"] } }],
});
Loading