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