// /sites → Publish → the Homepage row: the `homepage` package's build and // deploy (release 11 slice O4; a row of the Publish panel since release 18), // the twin of the Hub row. // // NOTHING HERE BUILDS THE HOMEPAGE, AND NOTHING HERE DEPLOYS IT. The homepage's // build publishes the source mirror (the operator's scrub and denylist files) // into the CHECKOUT's own homepage/out — in the primary checkout, the live // homepage's build. So: // - no spec clicks Deploy homepage, and none ticks Deploy after build and // then clicks Build homepage; // - the two specs that click Build homepage first hold the `publish` queue // with a fabricated job (/api/test/stuck-job, never released here), so the // stages the click enqueues — the index update (no index yet), then // build-homepage — only ever QUEUE, and are cancelled while still queued: // from the row's lane (a cancel of the run's first stage cancels the rest // of the run), or (the re-render spec) by the harness reset. No stage // starts: no log file, no child, nothing written to homepage/out. // If a spec fails before its cancel, the next resetData cancels jobs newest // first, so the queued stages are removed before the holder's slot is freed. import { readdir } from "node:fs/promises"; import { test, expect, type Page } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { pathExists, readJson, resetData, resolvePath, writeSettings, writeSite, } from "./helpers"; const group = (page: Page) => page.getByRole("group", { name: "Homepage build" }); // EXACT: a live lane's Cancel button is named "Cancel Build homepage", which // a substring match for "Build homepage" would also find. const buildButton = (page: Page) => group(page).getByRole("button", { name: "Build homepage", exact: true }); const deployButton = (page: Page) => group(page).getByRole("button", { name: "Deploy homepage", exact: true }); // Scoped: the Hub row has a "Deploy after build" checkbox too. const deployAfter = (page: Page) => group(page).getByLabel("Deploy after build"); const previewInput = (page: Page) => group(page).getByRole("textbox", { name: "preview branch", exact: true }); // A click or a keystroke that lands before React hydrates is lost without a // trace; the group stamps data-hydrated once its mount effect has run. async function openSites(page: Page) { await page.goto("/sites"); await expect(group(page)).toHaveAttribute("data-hydrated", "true", { timeout: 15_000, }); } type Meta = { id: string; kind: string; queueKey?: string; status: string; startedAt?: number; }; async function metasOfKind(kind: string): Promise { const files = ( await readdir(resolvePath("test-transcripts/.jobs")).catch(() => []) ).filter((f) => f.endsWith(".meta.json")); const metas: Meta[] = []; for (const f of files) { const meta = await readJson(`test-transcripts/.jobs/${f}`).catch( () => null, ); if (meta?.kind === kind) metas.push(meta); } return metas; } test.beforeEach(async () => { await resetData("empty"); }); test("the Homepage row follows Hub's in the Publish panel, with the build and deploy controls and what a deploy ships", async ({ page, }) => { await page.goto("/sites"); const panel = page.locator("section").filter({ has: page.getByRole("heading", { name: "Publish", exact: true }), }); const hub = panel.getByRole("heading", { name: "Hub", exact: true }); const home = panel.getByRole("heading", { name: "Homepage", exact: true }); await expect(home).toBeVisible(); expect((await hub.boundingBox())!.y).toBeLessThan((await home.boundingBox())!.y); await expect(buildButton(page)).toBeEnabled(); await expect(deployButton(page)).toBeEnabled(); // Unticked, as the hub's: a homepage deploy replaces a public site, so it is // never the default of a build. await expect(deployAfter(page)).not.toBeChecked(); // Empty is production. await expect(previewInput(page)).toHaveValue(""); // One line: what the homepage's last build is, the project, production, the // live URL. Its build is read from its stamp (_homepage/built.json), which a // fresh test corpus has none of. const ships = group(page).getByTestId("homepage-ships"); await expect(ships).toContainText( "homepage/out holds no build yet, so Deploy homepage has nothing to ship.", ); await expect(ships).toContainText("to archilyzer (production)"); await expect( ships.getByRole("link", { name: "https://archilyzer.pages.dev", exact: true }), ).toHaveAttribute("href", "https://archilyzer.pages.dev"); // "Run Build index first" is the row's chips now: the index it would be // built from, and its build waiting for it. await expect(group(page).locator('[data-chip="index"]')).toContainText("no index yet"); await expect(group(page).locator('[data-chip="built"]')).toContainText("update the index first"); await expect(group(page)).toContainText("Build homepage also publishes the source mirror"); }); test("a bad preview name greys out every deploy and says why; a good one shows its alias", async ({ page, }) => { await openSites(page); const ships = group(page).getByTestId("homepage-ships"); await previewInput(page).fill("main"); await expect(deployButton(page)).toBeDisabled(); await expect( group(page).getByRole("status", { name: "preview problem" }), ).toHaveText('"main" is the production branch; a preview needs another name.'); // A build alone deploys nothing, so it stays available… await expect(buildButton(page)).toBeEnabled(); // …until it would deploy. await deployAfter(page).check(); await expect(buildButton(page)).toBeDisabled(); await previewInput(page).fill("Main"); await expect( group(page).getByRole("status", { name: "preview problem" }), ).toContainText("not a valid preview branch name"); await expect(buildButton(page)).toBeDisabled(); // A good name re-arms both and names the alias before anything runs. await previewInput(page).fill("home-check"); await expect(buildButton(page)).toBeEnabled(); await expect(deployButton(page)).toBeEnabled(); await expect( group(page).getByRole("status", { name: "preview problem" }), ).toHaveCount(0); await expect(ships).toContainText("to archilyzer (preview home-check)"); await expect( ships.getByRole("link", { name: "https://home-check.archilyzer.pages.dev", exact: true, }), ).toBeVisible(); // Cleared, it is production again. await previewInput(page).fill(""); await expect(ships).toContainText("to archilyzer (production)"); // Nothing above was clicked: no publish stage of any kind exists. for (const kind of ["publish-update-index", "publish-build-homepage", "publish-deploy-homepage"]) { expect(await metasOfKind(kind), kind).toEqual([]); } }); test("Build homepage enqueues the index update, then build-homepage, on the publish queue (held there, then cancelled from its lane)", async ({ page, request, }) => { // See the header: with the publish queue held, whatever the click starts // can only queue. const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); expect(hold.ok()).toBe(true); await openSites(page); await expect(deployAfter(page)).not.toBeChecked(); await buildButton(page).click(); // The lane: queued behind the holder — not refused, not running. await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 }); await expect(group(page).getByRole("alert")).toHaveCount(0); // One run: the index update (there is no index yet) and one build-homepage, // both on the publish queue, queued — and no deploy. await expect.poll(async () => (await metasOfKind("publish-build-homepage")).length).toBe(1); const [job] = await metasOfKind("publish-build-homepage"); expect(job.queueKey).toBe("publish"); expect(job.status).toBe("queued"); const [index] = await metasOfKind("publish-update-index"); expect(index?.status).toBe("queued"); expect(await metasOfKind("publish-deploy-homepage")).toEqual([]); await group(page).getByRole("button", { name: "Cancel Build homepage" }).click(); await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible(); // It never started: the registry says cancelled, and start() — which opens // the job's log file before it runs anything — never ran. const res = await request.get( `${baseUrl}/api/jobs/${encodeURIComponent(job.id)}/log?from=0`, ); const log = (await res.json()) as { status: string; content: string }; expect(log.status).toBe("cancelled"); expect(log.content).toBe(""); expect(await pathExists(`test-transcripts/.jobs/${job.id}.log`)).toBe(false); // …and its sidecar says so too (release 13 slice W1). A job cancelled while // queued used to keep the "queued" its enqueue wrote: the cancel path never // rewrote the meta, so a restart's boot pass could re-queue a job the // operator had cancelled, and a cancelled clip fetch could read as queued. await expect .poll(async () => (await metasOfKind("publish-build-homepage"))[0]?.status) .toBe("cancelled"); // The run's Cancel was its first stage's: the rest of the run went with it. await expect .poll(async () => (await metasOfKind("publish-update-index"))[0]?.status) .toBe("cancelled"); }); test("a build lane that ends re-renders /sites, so the \"built \" line is current", async ({ page, request, }) => { // The line reads the homepage's stamp when /sites renders, and nothing here may // build (see the header) — so the proof is that the page RE-RENDERS when the // lane ends: a site written to disk after the page loaded appears without a // reload. Passive refresh is off (it would re-render on the job's status // change by itself), and the job is cancelled from OUTSIDE the page, through // the harness reset, because the lane's own Cancel is a server action that // revalidates, and its response re-renders the page on its own. await writeSettings({ autoRefreshIntervalSeconds: 0 }); const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); expect(hold.ok()).toBe(true); await openSites(page); await buildButton(page).click(); await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 }); await writeSite("refresh-probe"); const probe = page.locator('a[href="/sites/refresh-probe"]'); await expect(probe).toHaveCount(0); // Cancels every live job, newest first: the queued stages before the // holder's slot is freed, so nothing is promoted and nothing builds. const reset = await request.get(`${baseUrl}/api/test/invalidate-cache`); expect(reset.ok()).toBe(true); await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible({ timeout: 15_000, }); await expect(probe).toBeVisible({ timeout: 15_000 }); });