commit 750c5cd9512b4ac65209de21ecdb4274770b4eee
parent c8d18beff044d7e1a1a5e228696b00285ffa7371
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:40:14 -0400
e2e: deploy-homepage's refusals over /api/ops, and /sites → Homepage (the section, the preview box, and Build homepage starting a build-homepage job) — none of it can build or deploy
ops-api.spec +1: a bad preview name (deploy-homepage and build-homepage with
deploy), a preview without deploy, and — only when this checkout has no
homepage/out/index.html, and as a preview — the missing build; the deploy
queue is held by a fabricated job for the whole test, so a deploy a refusal
failed to stop could only queue. sites-homepage.spec (3): the section after
Hub, unticked Deploy after build, the ships line and the index note; a bad
preview greys out every deploy with previewBranchProblem's sentence; Build
homepage with the build queue held → one build-homepage job, queued on
"build", cancelled from its lane before it ever started (no log file).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 258 insertions(+), 0 deletions(-)
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -1148,3 +1148,69 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i
expect(await listJobIds()).toEqual(before);
});
+
+// The homepage's deploy path (release 11, slice O4). Every refusal is answered
+// BEFORE a job exists, like deploy-hub's.
+//
+// NOTHING HERE CAN REACH WRANGLER, and three separate things make sure of it:
+// 1. `homepage/out` is the CHECKOUT's (homepageOutDir = <repo>/homepage/out,
+// not fixture-controlled — the primary checkout holds the live homepage's
+// build), so the missing-build call is made only when this checkout has no
+// `homepage/out/index.html`, as deploy-hub's is (211d4666);
+// 2. that call asks for a PREVIEW, never production;
+// 3. the whole test runs with the `deploy` queue held by a fabricated job
+// (/api/test/stuck-job, never released here). A deploy job that a refusal
+// failed to stop would only QUEUE — the listJobIds assertion then fails —
+// and the next resetData cancels jobs newest first, so the queued deploy
+// is removed before the holder's slot is freed.
+test("deploy-homepage refuses a bad preview name and a missing build; build-homepage refuses a preview without a deploy", async ({
+ request,
+}) => {
+ await resetData("empty");
+ await settings();
+ const hold = await request.get(
+ `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("deploy")}`,
+ );
+ expect(hold.ok()).toBe(true);
+ const before = await listJobIds();
+
+ // A bad preview name is judged before homepage/out is even looked at, so
+ // these hold in any checkout.
+ const production = await ops(request, "deploy-homepage", { preview: "main" });
+ expect(production.status).toBe(400);
+ expect(production.body.error).toBe(
+ '"main" is the production branch; a preview needs another name.',
+ );
+ const shape = await ops(request, "deploy-homepage", { preview: "Not_Valid" });
+ expect(shape.status).toBe(400);
+ expect(shape.body.error).toMatch(/not a valid preview branch name/);
+ // And on the build-then-deploy, before anything is built.
+ const buildDeploy = await ops(request, "build-homepage", {
+ deploy: true,
+ preview: "main",
+ });
+ expect(buildDeploy.status).toBe(400);
+ expect(buildDeploy.body.error).toContain("is the production branch");
+
+ // build-homepage: a preview without a deploy is a mistake, not a build.
+ const previewOnly = await ops(request, "build-homepage", { preview: "home-check" });
+ expect(previewOnly.status).toBe(400);
+ expect(previewOnly.body.error).toContain('"preview" needs "deploy": true');
+
+ // No build in homepage/out.
+ if (await pathExists("../homepage/out/index.html")) {
+ test.info().annotations.push({
+ type: "subcase-not-exercisable",
+ description:
+ "homepage/out holds a build in this checkout, so the unbuilt refusal cannot fire (and the call would deploy it)",
+ });
+ } else {
+ const unbuilt = await ops(request, "deploy-homepage", { preview: "home-check" });
+ expect(unbuilt.status).toBe(400);
+ expect(unbuilt.body.error).toBe(
+ "homepage/out holds no build — build the homepage first",
+ );
+ }
+
+ expect(await listJobIds()).toEqual(before);
+});
diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts
@@ -0,0 +1,192 @@
+// /sites → Homepage: the `homepage` package's build and deploy (release 11,
+// slice O4), the twin of the Hub section's buttons.
+//
+// NOTHING HERE DEPLOYS, AND NOTHING HERE BUILDS. Wrangler has no fake in
+// e2e/fixtures/bin, and `homepage/out` is the CHECKOUT's own directory — in the
+// primary checkout it holds the live homepage's build — so a deploy clicked
+// from a spec would be a real one. So:
+// - no spec clicks Deploy homepage, and none ticks Deploy after build and
+// then clicks Build homepage;
+// - the one spec that clicks Build homepage first holds the `build` queue
+// with a fabricated job (/api/test/stuck-job, never released here), so the
+// build-homepage job it starts only ever QUEUES, and it is cancelled from
+// its own lane while still queued. Its start function never runs: no log
+// file, no child, nothing written to homepage/public or homepage/out. If
+// the spec fails before its Cancel, the next resetData cancels jobs newest
+// first, so the queued build is 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 } 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: /sites has three other "Deploy after build" checkboxes (the batch,
+// the specific-sites panel, the hub).
+const deployAfter = (page: Page) => group(page).getByLabel("Deploy after build");
+const previewInput = (page: Page) =>
+ group(page).getByRole("textbox", { name: "preview branch", exact: true });
+
+type Meta = {
+ id: string;
+ kind: string;
+ queueKey?: string;
+ status: string;
+ startedAt?: number;
+};
+
+async function metasOfKind(kind: string): Promise<Meta[]> {
+ 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<Meta>(`test-transcripts/.jobs/${f}`).catch(
+ () => null,
+ );
+ if (meta?.kind === kind) metas.push(meta);
+ }
+ return metas;
+}
+
+test.beforeEach(async () => {
+ await resetData("empty");
+});
+
+test("the Homepage section follows Hub, with the build and deploy controls and what a deploy ships", async ({
+ page,
+}) => {
+ await page.goto("/sites");
+
+ const hub = page.getByRole("heading", { name: "Hub", exact: true });
+ const home = page.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 homepage/out holds, the project, production, the live URL.
+ const ships = group(page).getByTestId("homepage-ships");
+ if (await pathExists("../homepage/out/index.html")) {
+ await expect(ships).toContainText(/Deploy homepage ships homepage\/out, built /);
+ } else {
+ 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");
+ await expect(group(page)).toContainText(
+ "The homepage reads the search index as it stands: run Build index",
+ );
+
+ // The homepage's prose moved out of the Hub section and into its own.
+ const hubSection = page.locator("section").filter({ has: hub });
+ const homeSection = page.locator("section").filter({ has: home });
+ await expect(hubSection).not.toContainText("archilyzer deploy homepage");
+ await expect(homeSection).toContainText("archilyzer deploy homepage");
+ await expect(homeSection).toContainText("homepage/out");
+});
+
+test("a bad preview name greys out every deploy and says why; a good one shows its alias", async ({
+ page,
+}) => {
+ await page.goto("/sites");
+ 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 homepage job of any kind exists.
+ for (const kind of ["build-homepage", "deploy-homepage", "build-deploy-homepage"]) {
+ expect(await metasOfKind(kind), kind).toEqual([]);
+ }
+});
+
+test("Build homepage starts a build-homepage job on the build queue (held there, then cancelled from its lane)", async ({
+ page,
+ request,
+}) => {
+ // See the header: with the build queue held, the job can only queue.
+ const hold = await request.get(
+ `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("build")}`,
+ );
+ expect(hold.ok()).toBe(true);
+
+ await page.goto("/sites");
+ await expect(deployAfter(page)).not.toBeChecked();
+ await buildButton(page).click();
+
+ // The lane: queued behind the holder.
+ await expect(group(page).getByText(/^Queued/)).toBeVisible();
+
+ // One build-homepage job, on the build queue, queued — and nothing else.
+ await expect.poll(async () => (await metasOfKind("build-homepage")).length).toBe(1);
+ const [job] = await metasOfKind("build-homepage");
+ expect(job.queueKey).toBe("build");
+ expect(job.status).toBe("queued");
+ expect(await metasOfKind("build-deploy-homepage")).toEqual([]);
+ expect(await metasOfKind("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);
+});