09d6842b67caf68ebb4ee7e94814c13d7123380b diff --git a/e2e/blog-cms-meta.spec.ts b/e2e/blog-cms-meta.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c82d047a27cc6c84a8c2ea0c8b1f666de41bb41b --- /dev/null +++ b/e2e/blog-cms-meta.spec.ts @@ -0,0 +1,61 @@ +// E2E: the meta-demonstration. The blog post that describes the +// CMS gets edited THROUGH the CMS, the resulting commit is observed +// in Forgejo, and the live page reflects it. If this test passes, +// the blog post's central claim ("you can verify this post is real") +// is literally true. + +import { test, expect } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +const SLUG = "sama-meets-git-cms"; +const SCREENSHOT_DIR = "test-results/blog-cms-meta"; + +test.beforeAll(() => { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); +}); + +test.describe("blog post is the CMS demo", () => { + test("post is reachable + announces it's editable via the CMS", async ({ page }) => { + const res = await page.goto(`/blog/${SLUG}`); + expect(res?.status()).toBe(200); + await expect(page.getByRole("heading", { name: /SAMA meets git/i }).first()).toBeVisible(); + + // The "you can verify this post is real" section links the editor, + // raw markdown and Forgejo source. + await expect(page.locator(`a[href="/edit/blog/${SLUG}"]`).first()).toBeVisible(); + await expect(page.locator(`a[href="/content/blog/${SLUG}.md"]`).first()).toBeVisible(); + await expect( + page.locator(`a[href*="git.tdd.md/syntaxai/tdd.md/src/branch/main/content/blog/${SLUG}.md"]`).first(), + ).toBeVisible(); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "1-blog-post-live.png"), + fullPage: true, + }); + }); + + test("raw markdown serves from tdd.md (no git.tdd.md dependency)", async ({ request }) => { + const res = await request.get(`/content/blog/${SLUG}.md`); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/text\/plain/); + const body = await res.text(); + expect(body).toMatch(/^# SAMA meets git/m); + expect(body).toContain("Forgejo"); + }); + + test("/edit/blog/ renders the login wall when not authed", async ({ page }) => { + const res = await page.goto(`/edit/blog/${SLUG}`); + expect(res?.status()).toBe(401); + await expect(page.getByRole("heading", { name: /edit · /i })).toBeVisible(); + await expect(page.getByRole("link", { name: /sign in with github/i })).toBeVisible(); + }); +}); + +// Note: the admin-can-edit-meta-blog test that used to live here was +// retired — it asserted on a git.tdd.md commit link the applied-live +// page no longer emits (we link to /GIT/... now), and the broader +// admin-edit-lands-in-bare-repo flow is fully covered by +// e2e/git-native-proof.spec.ts. The remaining tests above still prove +// the meta-claim of the blog post (post is reachable, raw markdown +// works, /edit page is gated correctly). diff --git a/e2e/commit-view.spec.ts b/e2e/commit-view.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0624c947dff2b1db44ff2b92ae50cc9299dd2259 --- /dev/null +++ b/e2e/commit-view.spec.ts @@ -0,0 +1,69 @@ +// E2E: SAMA-native commit view at /GIT/:owner/:repo/commit/:sha +// +// The screenshot in this PR was taken against the same SHA the user +// paired in their request — 526fa16... is the most recent meta-edit +// of the blog post that committed itself via the CMS. + +import { test, expect } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +const SCREENSHOT_DIR = "test-results/commit-view"; +const SHA = "526fa16faa56ab82c8f69a143829361365dcc98d"; +const SHORT = SHA.slice(0, 7); + +test.beforeAll(() => { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); +}); + +test.describe("/GIT/:owner/:repo/commit/:sha", () => { + test("renders commit subject + author + parent + diff", async ({ page }) => { + const res = await page.goto(`/GIT/syntaxai/tdd.md/commit/${SHA}`); + expect(res?.status()).toBe(200); + + // Subject + body block + await expect(page.locator(".commit-subject")).toContainText(/edit content\/blog\/sama-meets-git-cms\.md via web/); + + // Metadata block has author, date, parent, full SHA + const meta = page.locator(".commit-meta"); + await expect(meta).toContainText("syntaxai"); + await expect(meta).toContainText(SHA); + + // At least one file pane with the right path + const fileHeader = page.locator(".commit-file-header").first(); + await expect(fileHeader).toBeVisible(); + await expect(fileHeader).toContainText(/content\/blog\/sama-meets-git-cms\.md/); + + // At least one added line and one removed line — every meta edit + // adds an HTML comment and that's at minimum one + line. + await expect(page.locator(".commit-line-added").first()).toBeVisible(); + + // Footer link to raw .diff (still on tdd.md) + const rawLink = page.locator('a[href$=".diff"]').first(); + await expect(rawLink).toBeVisible(); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, `1-commit-${SHORT}.png`), + fullPage: true, + }); + }); + + test(".diff suffix returns raw unified-diff text (Bun-native, served from tdd.md)", async ({ request }) => { + const res = await request.get(`/GIT/syntaxai/tdd.md/commit/${SHA}.diff`); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/text\/plain/); + const body = await res.text(); + expect(body).toContain("diff --git"); + expect(body).toContain("content/blog/sama-meets-git-cms.md"); + }); + + test("invalid SHA shape 404s", async ({ request }) => { + const res = await request.get(`/GIT/syntaxai/tdd.md/commit/not-a-sha`); + expect(res.status()).toBe(404); + }); + + test("non-existent commit 404s with our own not-found page", async ({ request }) => { + const res = await request.get(`/GIT/syntaxai/tdd.md/commit/0000000000000000000000000000000000000000`); + expect(res.status()).toBe(404); + }); +}); diff --git a/e2e/editor-flow.spec.ts b/e2e/editor-flow.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e68bd28158041e1b40818ec8b04c049439a4044c --- /dev/null +++ b/e2e/editor-flow.spec.ts @@ -0,0 +1,142 @@ +// E2E: the self-hosted editor flow that replaces "edit on GitHub". +// +// Covers the unauthenticated paths only — full GitHub OAuth is not +// scripted here because it requires a real user account and adds +// flakiness against the live site. Authenticated paths (admin direct- +// write, proposal submission) are smoke-tested via the login-wall +// behavior; the actual write paths are unit-tested in src/*.test.ts. + +import { test, expect } from "@playwright/test"; + +test.describe("editor flow (unauthenticated)", () => { + test("docs page exposes propose-an-edit + view-source links", async ({ page }) => { + const res = await page.goto("/sama/sorted"); + expect(res?.status()).toBe(200); + + const editLink = page.locator('a[href="/edit/sama/sorted"]', { hasText: /propose an edit/i }); + await expect(editLink).toBeVisible(); + + const sourceLink = page.locator('a[href="/content/sama/sorted.md"]', { hasText: /view source/i }); + await expect(sourceLink).toBeVisible(); + + // The link must NOT point at the git.tdd.md subdomain anymore. + const gitLinks = await page.locator('a[href*="git.tdd.md"]').count(); + expect(gitLinks, "no docs link should still target git.tdd.md").toBe(0); + }); + + test("clicking 'view source' returns the raw markdown from tdd.md", async ({ request }) => { + const res = await request.get("/content/sama/sorted.md"); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/text\/plain/); + const body = await res.text(); + // The .md file is the actual source — should contain the SAMA + // Sorted page's frontmatter / heading, never raw HTML chrome. + expect(body).toMatch(/sorted/i); + expect(body).not.toContain(" { + const res = await request.get("/content/sama/does-not-exist.md"); + expect(res.status()).toBe(404); + }); + + test("raw source 404s for non-editable section", async ({ request }) => { + const res = await request.get("/content/etc/passwd.md"); + expect(res.status()).toBe(404); + }); + + test("raw source 404s without .md suffix", async ({ request }) => { + const res = await request.get("/content/sama/sorted"); + expect(res.status()).toBe(404); + }); + + test("/edit/:section/:slug shows the GitHub login wall when unauthenticated", async ({ page }) => { + const res = await page.goto("/edit/sama/sorted"); + expect(res?.status()).toBe(401); + await expect(page.getByRole("heading", { name: /edit · S — Sorted/i })).toBeVisible(); + const signIn = page.getByRole("link", { name: /sign in with github/i }); + await expect(signIn).toBeVisible(); + const href = await signIn.getAttribute("href"); + expect(href).toContain("/auth/github/start"); + expect(href).toContain("to=%2Fedit%2Fsama%2Fsorted"); + }); + + test("/edit returns 404 for unknown slug", async ({ page }) => { + const res = await page.goto("/edit/sama/nonexistent-slug-9999"); + expect(res?.status()).toBe(404); + }); + + test("/edit returns 404 for non-editable section", async ({ page }) => { + const res = await page.goto("/edit/notreal/whatever"); + expect(res?.status()).toBe(404); + }); +}); + +test.describe("admin routes are gone (proposal queue retired)", () => { + // The /admin/proposals queue was removed when admin edits started + // committing directly to Forgejo via c14_forgejo.commitFile. The + // routes should now 404 (no fallthrough handler claims them). + test("/admin/proposals returns 404", async ({ request }) => { + const res = await request.get("/admin/proposals", { maxRedirects: 0 }); + expect(res.status()).toBe(404); + }); + + test("/admin/proposals/1/approve returns 404", async ({ request }) => { + const res = await request.post("/admin/proposals/1/approve", { maxRedirects: 0 }); + expect(res.status()).toBe(404); + }); +}); + +test.describe("OAuth start preserves return URL", () => { + // Use raw fetch with redirect:"manual" — Playwright's request fixture + // either follows redirects or throws on maxRedirects:0 unreliably, + // which makes the 302 cookie inspection awkward. + const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "https://tdd.md"; + + test("?to= is honored as a cookie + GitHub redirect", async () => { + const res = await fetch(`${baseURL}/auth/github/start?to=/edit/sama/sorted`, { + redirect: "manual", + }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("github.com/login/oauth/authorize"); + const setCookies = res.headers.getSetCookie?.() ?? [res.headers.get("set-cookie") ?? ""]; + const joined = setCookies.join(" | "); + expect(joined).toContain("tdd_oauth_state="); + expect(joined).toContain("tdd_oauth_return=%2Fedit%2Fsama%2Fsorted"); + }); + + test("?to= rejects unsafe return URLs (no scheme jumps)", async () => { + const unsafe = encodeURIComponent("https://evil.example.com/steal"); + const res = await fetch(`${baseURL}/auth/github/start?to=${unsafe}`, { + redirect: "manual", + }); + expect(res.status).toBe(302); + const setCookies = res.headers.getSetCookie?.() ?? [res.headers.get("set-cookie") ?? ""]; + const joined = setCookies.join(" | "); + // State cookie is always set; the return cookie must NOT be set + // for an unsafe URL — that's the open-redirect guard. + expect(joined).toContain("tdd_oauth_state="); + expect(joined).not.toContain("tdd_oauth_return="); + expect(joined).not.toContain("evil.example.com"); + }); +}); + +test.describe("docs site smoke", () => { + test("homepage loads", async ({ page }) => { + const res = await page.goto("/"); + expect(res?.status()).toBe(200); + await expect(page).toHaveTitle(/tdd\.md/i); + }); + + test("sama landing loads with sidebar nav", async ({ page }) => { + const res = await page.goto("/sama"); + expect(res?.status()).toBe(200); + await expect(page.locator(".docs-sidebar")).toBeVisible(); + }); + + test("blog index lists at least one post", async ({ page }) => { + const res = await page.goto("/blog"); + expect(res?.status()).toBe(200); + }); +}); diff --git a/e2e/git-native-forgejo-down.spec.ts b/e2e/git-native-forgejo-down.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c54331b8096b05fcef16609d0c7cec20f3cdc61 --- /dev/null +++ b/e2e/git-native-forgejo-down.spec.ts @@ -0,0 +1,117 @@ +// E2E DRAMATIC PROOF: kill Forgejo, the CMS still commits. +// +// Stops the forgejo service on p620, performs an admin web-edit, +// asserts that the commit lands in our local bare repo, then brings +// Forgejo back up. If Forgejo were on tdd.md's edit path this would +// fail — the test passes only because c14_git owns the commit path +// and Forgejo is genuinely unused for syntaxai/tdd.md. +// +// Run this only when you can tolerate a few seconds of Forgejo +// downtime (agent kata pushes will fail during the test). It's a +// separate spec file so you can opt in: bunx playwright test +// e2e/git-native-forgejo-down.spec.ts. + +import { test, expect } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; +import { execSync } from "child_process"; + +const SCREENSHOT_DIR = "test-results/git-native-forgejo-down"; + +const sshExec = (cmd: string): string => + execSync(`flatpak-spawn --host ssh p620 '${cmd}'`, { encoding: "utf8" }).trim(); + +const sshGit = (args: string): string => + sshExec(`git --git-dir=/home/scri/repos/tdd.md.git ${args}`); + +const forgejoIsUp = (): boolean => { + try { + // The container's HTTP API answers when Forgejo is up; returns + // empty/error when stopped. We check the published port from the + // host's perspective via SSH — same network path the tdd-md + // container would use. + const out = sshExec( + "curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://localhost:44400/api/v1/version || echo 000", + ); + return out === "200"; + } catch { + return false; + } +}; + +test.beforeAll(() => { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); +}); + +const ADMIN_AUTH_FILE = ".auth/admin.json"; + +test.describe("CMS works with Forgejo stopped", () => { + test.skip(!fs.existsSync(ADMIN_AUTH_FILE), `no ${ADMIN_AUTH_FILE} found`); + test.use({ storageState: ADMIN_AUTH_FILE }); + + // The teardown re-starts forgejo even if the test fails halfway. + test.afterAll(async () => { + try { + sshExec("systemctl --user start forgejo.service 2>/dev/null || true"); + sshExec("sleep 3"); + } catch { + // Best effort. + } + }); + + test("admin edit succeeds while forgejo container is stopped", async ({ page, request }) => { + // Confirm forgejo is currently up before we start. + expect(forgejoIsUp()).toBe(true); + + // STOP FORGEJO via systemd — the .service has Restart=always, so + // `podman stop` would just trigger a restart. Stopping the unit + // keeps it down for the duration of the test. + sshExec("systemctl --user stop forgejo.service"); + sshExec("sleep 2"); + expect(forgejoIsUp()).toBe(false); + + try { + const headBefore = sshGit("rev-parse refs/heads/main"); + const before = await (await request.get("/content/sama/skill.md")).text(); + const marker = ``; + const newBody = before + "\n" + marker + "\n"; + + await page.goto("/edit/sama/skill"); + const textarea = page.locator("textarea[name='body']"); + await expect(textarea).toBeVisible(); + await textarea.fill(newBody); + await page.locator("button[type='submit']").click(); + + // The save must succeed despite Forgejo being down. If it + // didn't, something in the edit path is still calling Forgejo + // and we have not actually decoupled. + await expect(page.getByRole("heading", { name: /applied live/i })).toBeVisible({ timeout: 8000 }); + const commitLink = page.locator('a[href^="/GIT/syntaxai/tdd.md/commit/"]'); + await expect(commitLink).toBeVisible(); + const newSha = (await commitLink.getAttribute("href"))! + .replace("/GIT/syntaxai/tdd.md/commit/", ""); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "1-applied-live-while-forgejo-down.png"), + fullPage: true, + }); + + // Bare repo advanced. + const headAfter = sshGit("rev-parse refs/heads/main"); + expect(headAfter).toBe(newSha); + expect(headAfter).not.toBe(headBefore); + + // /GIT/.../commit/ must also still render — read path is + // also c14_git, no Forgejo involvement. + const commitPage = await page.goto(`/GIT/syntaxai/tdd.md/commit/${newSha}`); + expect(commitPage?.status()).toBe(200); + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "2-commit-view-while-forgejo-down.png"), + fullPage: true, + }); + } finally { + // Restart forgejo so agent kata pushes work again. + sshExec("systemctl --user start forgejo.service"); + } + }); +}); diff --git a/e2e/git-native-proof.spec.ts b/e2e/git-native-proof.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..975eeb5dbeab48633027f036459f671208bdea95 --- /dev/null +++ b/e2e/git-native-proof.spec.ts @@ -0,0 +1,117 @@ +// E2E PROOF: tdd.md's CMS commits to a local bare git repo, not Forgejo. +// +// What this test demonstrates, end-to-end against the live deployed +// container: +// 1. The admin (syntaxai, via storage-state) saves an edit through +// the web editor at /edit/sama/skill. +// 2. The "applied live" page shows a fresh full SHA. +// 3. That SHA matches refs/heads/main of the bare repo on p620 +// (/home/scri/repos/tdd.md.git) — proving the commit landed in +// our own git storage, not via a Forgejo HTTP round-trip. +// 4. /GIT/syntaxai/tdd.md/commit/ renders the same commit +// (read-side also flows through c14_git, not c14_forgejo). +// 5. The commit's parent matches what HEAD was *before* the edit — +// proving we observed the commit graph genuinely advance, not +// a coincidental SHA match. +// +// The dramatic version of this test is in git-native-forgejo-down.spec.ts +// (separate file because it stops the forgejo service on p620 — only +// run that one when you can tolerate brief downtime). + +import { test, expect } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; +import { execSync } from "child_process"; + +const SCREENSHOT_DIR = "test-results/git-native-proof"; + +const sshGit = (args: string): string => + execSync(`flatpak-spawn --host ssh p620 'git --git-dir=/home/scri/repos/tdd.md.git ${args}'`, { + encoding: "utf8", + }).trim(); + +test.beforeAll(() => { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); +}); + +const ADMIN_AUTH_FILE = ".auth/admin.json"; + +test.describe("CMS commits to local bare repo, not Forgejo", () => { + test.skip(!fs.existsSync(ADMIN_AUTH_FILE), `no ${ADMIN_AUTH_FILE} found`); + test.use({ storageState: ADMIN_AUTH_FILE }); + + test("admin web-edit lands as a commit in /home/scri/repos/tdd.md.git on p620", async ({ + page, + request, + }) => { + // Snapshot HEAD before the edit. Read directly from the bare repo + // via SSH — no HTTP, no Forgejo, just `git` on disk. + const headBefore = sshGit("rev-parse refs/heads/main"); + expect(headBefore).toMatch(/^[a-f0-9]{40}$/); + + // Load current source so we can busy-the-no-op-skip with a marker. + const before = await (await request.get("/content/sama/skill.md")).text(); + const marker = ``; + const newBody = before + "\n" + marker + "\n"; + + // Save through the web editor. + await page.goto("/edit/sama/skill"); + const textarea = page.locator("textarea[name='body']"); + await expect(textarea).toBeVisible(); + await textarea.fill(newBody); + await page.locator("button[type='submit']").click(); + + // Land on "applied live" with a 40-char commit SHA. + await expect(page.getByRole("heading", { name: /applied live/i })).toBeVisible(); + const commitLink = page.locator('a[href^="/GIT/syntaxai/tdd.md/commit/"]'); + await expect(commitLink).toBeVisible(); + const href = await commitLink.getAttribute("href"); + const newSha = href!.replace("/GIT/syntaxai/tdd.md/commit/", ""); + expect(newSha).toMatch(/^[a-f0-9]{40}$/); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "1-applied-live-with-bare-repo-sha.png"), + fullPage: true, + }); + + // The bare repo on p620 — read-only check via SSH — must now point + // at this exact SHA. If it doesn't, the commit either didn't land + // or landed somewhere else (e.g. Forgejo). + const headAfter = sshGit("rev-parse refs/heads/main"); + expect(headAfter).toBe(newSha); + expect(headAfter).not.toBe(headBefore); + + // The new commit's parent must be the OLD HEAD — proving the graph + // genuinely advanced and our optimistic-concurrency check held. + const parentOfNew = sshGit(`rev-parse ${newSha}^`); + expect(parentOfNew).toBe(headBefore); + + // The commit message subject must match what c31_commit_meta emits. + const msgSubject = sshGit(`log -1 --format=%s ${newSha}`); + expect(msgSubject).toBe("edit content/sama/skill.md via web"); + + // The author must be syntaxai (set by c21_handlers_edit using viewer). + const authorName = sshGit(`log -1 --format=%an ${newSha}`); + expect(authorName).toBe("syntaxai"); + + // /GIT/.../commit/ reads from the same bare repo via c14_git + // and renders Bun-native. Walk it and check it matches. + const commitPage = await page.goto(`/GIT/syntaxai/tdd.md/commit/${newSha}`); + expect(commitPage?.status()).toBe(200); + await expect(page.locator(".commit-meta")).toContainText(newSha); + await expect(page.locator(".commit-meta")).toContainText(headBefore.slice(0, 7)); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "2-commit-view-served-from-bare-repo.png"), + fullPage: true, + }); + + // The raw .diff endpoint also goes through c14_git (git diff-tree + // against the local bare repo, no Forgejo). + const diffRes = await request.get(`/GIT/syntaxai/tdd.md/commit/${newSha}.diff`); + expect(diffRes.status()).toBe(200); + const diffBody = await diffRes.text(); + expect(diffBody).toContain("diff --git"); + expect(diffBody).toContain(marker); + }); +}); diff --git a/e2e/sama-skill-editable.spec.ts b/e2e/sama-skill-editable.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a771460f736aa516ad2fb0b67d5ba058e20ca154 --- /dev/null +++ b/e2e/sama-skill-editable.spec.ts @@ -0,0 +1,111 @@ +// E2E: prove /sama/skill is end-to-end editable. +// +// Walks the full user journey: +// 1. Land on https://tdd.md/sama/skill (the SKILL.md viewer) +// 2. Spot the "propose an edit →" link in the docs chrome +// 3. Click it → navigate to /edit/sama/skill +// 4. Confirm the editor login wall renders (server side returns 401 +// with the GitHub sign-in button + ?to= preserving where to come +// back to after auth) +// 5. Confirm the raw source is reachable at /content/sama/skill.md +// 6. Screenshot each step into test-results/ for visual evidence +// +// Authenticated submission isn't scripted here because admin write +// requires a real GitHub OAuth round-trip — see the bottom of this +// file for the storage-state-based test that runs when you've saved +// a logged-in session via `bunx playwright codegen` or similar. + +import { test, expect } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +const SCREENSHOT_DIR = "test-results/sama-skill-editable"; + +test.beforeAll(() => { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); +}); + +test.describe("/sama/skill is editable end-to-end", () => { + test("step 1: docs page renders with propose-an-edit link", async ({ page }) => { + const res = await page.goto("/sama/skill"); + expect(res?.status()).toBe(200); + + // Page identity — confirms we're on the SKILL.md viewer. The h1 + // is the SAMA mnemonic "SAMA — Sorted, Architecture, Modeled, + // Atomic" emitted from the markdown body. + await expect(page.getByRole("heading", { name: /SAMA.*Sorted.*Architecture/i }).first()).toBeVisible(); + + // Edit link present and points at our self-hosted editor route + // (NOT git.tdd.md). + const editLink = page.locator('a[href="/edit/sama/skill"]', { hasText: /propose an edit/i }); + await expect(editLink).toBeVisible(); + + // View-source goes to /content/sama/skill.md on the main domain. + const sourceLink = page.locator('a[href="/content/sama/skill.md"]', { hasText: /view source/i }); + await expect(sourceLink).toBeVisible(); + + // Hard guarantee: zero links on this page point at the git. + // subdomain anymore. + expect(await page.locator('a[href*="git.tdd.md"]').count()).toBe(0); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "1-sama-skill-page-with-edit-link.png"), + fullPage: true, + }); + }); + + test("step 2: /content/sama/skill.md serves raw markdown from tdd.md", async ({ request }) => { + const res = await request.get("/content/sama/skill.md"); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/text\/plain/); + const body = await res.text(); + // SKILL.md frontmatter — confirms we got the actual file, not a + // 200-with-html-error-page. + expect(body).toMatch(/^---/m); + expect(body).toMatch(/name:\s*sama/i); + expect(body.length).toBeGreaterThan(500); + }); + + test("step 3: clicking 'propose an edit' lands on the editor login wall", async ({ page }) => { + await page.goto("/sama/skill"); + const [navResponse] = await Promise.all([ + page.waitForResponse((r) => r.url().endsWith("/edit/sama/skill")), + page.locator('a[href="/edit/sama/skill"]').first().click(), + ]); + // 401 — login required. The page still renders content (login + // wall HTML), it's the status that signals the gate. + expect(navResponse.status()).toBe(401); + + await expect(page).toHaveURL(/\/edit\/sama\/skill$/); + await expect(page.getByRole("heading", { name: /edit · SKILL/i })).toBeVisible(); + + // Sign-in button preserves return path so the user lands back on + // the editor after GitHub OAuth. + const signIn = page.getByRole("link", { name: /sign in with github/i }); + await expect(signIn).toBeVisible(); + const href = await signIn.getAttribute("href"); + expect(href).toContain("/auth/github/start"); + expect(href).toContain("to=%2Fedit%2Fsama%2Fskill"); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "2-edit-sama-skill-login-wall.png"), + fullPage: true, + }); + }); + + test("step 4: /edit/sama/skill is the same form regardless of entry path", async ({ page }) => { + // Direct hit on /edit/sama/skill — should render the same login + // wall (proves the route resolves; nav-only pages are now + // editable via the SITE_NAV fallback in c32_edit_resolve). + const res = await page.goto("/edit/sama/skill"); + expect(res?.status(), "before this fix it 404'd because 'skill' isn't in ALL_SAMA").toBe(401); + await expect(page.getByRole("heading", { name: /edit · SKILL/i })).toBeVisible(); + }); +}); + +// Note: the admin-write happy-path test that used to live here was +// retired — it asserted on a git.tdd.md commit link that no longer +// exists (the applied-live page links to /GIT/... now), and the +// admin-edit-lands-in-bare-repo flow is fully covered by +// e2e/git-native-proof.spec.ts. Keeping a duplicate here would just +// double-edit the same path and fight optimistic concurrency. diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..00594635d7f241046aa3e3f28a206c478f4e7cf7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +// E2E tests run against a deployed instance — never against a local +// dev server (per the project's deploy workflow: ship to p620, verify +// on the live site). Default baseURL is the production site; override +// with PLAYWRIGHT_BASE_URL if you want to test a staging URL. +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "https://tdd.md"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [["list"]], + use: { + baseURL: BASE_URL, + trace: "retain-on-failure", + video: "retain-on-failure", + ignoreHTTPSErrors: true, + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], +});