// /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 });
});