syntaxai/tdd.md · main · e2e / git-native-forgejo-down.spec.ts

git-native-forgejo-down.spec.ts 131 lines · 5222 bytes raw
// 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";

// When run inside a Flatpak sandbox (e.g. the VS Code .flatpak), `ssh` lives
// on the host so we have to hop through flatpak-spawn. When run on the host
// directly, flatpak-spawn isn't on PATH — call ssh straight. Detect by env.
const SSH_HOP = process.env.FLATPAK_ID ? "flatpak-spawn --host ssh" : "ssh";

const sshExec = (cmd: string): string =>
  execSync(`${SSH_HOP} 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", () => {
  // Opt-in via env: this test stops the live forgejo container briefly to
  // prove the CMS is git-native. Don't fire it from a casual `bun x
  // playwright test` — only when the operator is OK with a few seconds of
  // forgejo downtime.
  test.skip(
    process.env.RUN_DESTRUCTIVE_E2E !== "1",
    "set RUN_DESTRUCTIVE_E2E=1 to run — this stops the live forgejo container",
  );
  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 = `<!-- forgejo-down proof @ ${new Date().toISOString()} -->`;
      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/<sha> 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");
    }
  });
});