commit 6c175b0d312cb6aaf6accbc065064f7952707797
parent 1275ccbf877d55b6eb216c3cb971640b5eb2d88d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 12:43:42 -0400
e2e: publish.spec and publish-lane.spec; ops-api, jobs and duplicate-shorts on the publish stages
- publish.spec: the index chip before and after the Pool's Build index (the
stamp, with settingsSig); a row's Build (the site's bundle, built.json,
export/out a link to it), Deploy preview (the fake wrangler's --branch and
--project-name, previews[b]), Deploy production (--branch main), Cloudflare
refusing the token (the mode sidecar: the sentence, deployed.json
untouched); Publish now with every policy off = the index update alone, one
run on /jobs; a site's Build & deploy = one run of build + deploy, "Last
deployed" updates; the site form refusing a preview policy with no project.
- publish-lane.spec: the settings form, Start refused while off, a hold
thrown while the lane's index update waits behind a holder — the stage
finishes, nothing is dispatched after it — then resume and Drain.
- ops-api.spec: the publish queue is the one held; deploy-only refusals are
the stage's "no build of X in <dir> — archilyzer publish …" (the hub's and
the homepage's are fixture-controlled now, so no sub-case is skipped);
build-site starts publish-build-site; the publish verbs, their refusals and
GET publish.
- jobs.spec reads "Update the index"; duplicate-shorts builds its stats with
the index (Build stats dataset is gone); the buildIndex helper waits for
the stage's last line; writeSite passes `audience` through.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 596 insertions(+), 108 deletions(-)
diff --git a/editor/e2e/duplicate-shorts.spec.ts b/editor/e2e/duplicate-shorts.spec.ts
@@ -180,14 +180,10 @@ async function seed(seeds: Seed[] = SEEDS): Promise<void> {
}
}
+// The index AND the stats: one stage since release 18 ("Build stats dataset"
+// is gone — the index update builds the stats datasets too).
async function buildData(page: import("@playwright/test").Page): Promise<void> {
await buildIndex(page);
- // Same page, disclosure still open.
- await page.getByRole("button", { name: "Build stats dataset" }).click();
- await expect(page.getByLabel("Build stats dataset output")).toContainText(
- "Stats built",
- { timeout: 30_000 },
- );
}
function clusterWith(report: DuplicateReport, slug: string): DuplicateCluster | undefined {
diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts
@@ -154,6 +154,7 @@ export async function writeSite(
...(site.siteUrl ? { siteUrl: site.siteUrl } : {}),
...(site.listed === false ? { listed: false } : {}),
...(site.search === false ? { search: false } : {}),
+ ...(site.audience !== undefined ? { audience: site.audience } : {}),
// The legacy report-only key, written as given (siteSchema migrates it).
...(site.publish !== undefined ? { publish: site.publish } : {}),
...(site.relatedSites ? { relatedSites: site.relatedSites } : {}),
@@ -451,17 +452,23 @@ export async function writeChannelConfig(
);
}
-// Run the corpus-wide Build index job from the family page, the way a user
-// does: open the Pool disclosure, press the button, wait for "Done". The
-// controls sit under <details> so /sites does not open on eight job consoles,
+// Run the corpus-wide Build index from the family page, the way a user does:
+// open the Pool disclosure, press the button, wait for the stage to end. The
+// controls sit under <details> so /sites does not open on six job consoles,
// and getByRole ignores what a closed disclosure hides — hence the click first.
-// Replaces the six-line copy three specs carried inline. goto() resets the
-// disclosure, so calling this twice in one test is fine.
-export async function buildIndex(page: Page, timeout = 30_000) {
+// goto() resets the disclosure, so calling this twice in one test is fine.
+//
+// Build index is the `update-index` publish stage since release 18: the LMDB
+// index, the stats datasets and the chart templates in one child process, so
+// the wait is for the STAGE's last line (`[stage] update-index _index: Done`),
+// not for the index build's own "Done in …" halfway through it — a spec reads
+// the stats right after.
+export async function buildIndex(page: Page, timeout = 60_000) {
await page.goto("/sites");
await page.getByText("Pool jobs", { exact: true }).click();
await page.getByRole("button", { name: "Build index" }).click();
- await expect(page.getByLabel("Build index output")).toContainText("Done", {
- timeout,
- });
+ await expect(page.getByLabel("Build index output")).toContainText(
+ /\[stage\] update-index _index: Done/,
+ { timeout },
+ );
}
diff --git a/editor/e2e/jobs.spec.ts b/editor/e2e/jobs.spec.ts
@@ -14,7 +14,9 @@ test("empty state when no jobs have run", async ({ page }) => {
await expect(page.getByText("No jobs have run yet.")).toBeVisible();
});
-test("kicks off a build-index job, lists it, and tails its log", async ({
+// Build index is the update-index publish stage (release 18): /jobs labels it
+// "Update the index" and reads it as one run, `run <id> · _index`.
+test("kicks off the index update, lists it, and tails its log", async ({
page,
}) => {
await resetData("one-youtube-channel-with-data");
@@ -23,7 +25,7 @@ test("kicks off a build-index job, lists it, and tails its log", async ({
await page.goto("/jobs");
const buildRow = page
.getByRole("row")
- .filter({ hasText: "build-index" })
+ .filter({ hasText: "Update the index" })
.first();
await expect(buildRow).toBeVisible();
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -52,6 +52,7 @@ type OpsResponse = {
skipped?: { slug?: string; siteId?: string; reason: string }[];
jobs?: { siteId: string; jobId: string; previewUrl?: string }[];
previewUrl?: string;
+ runId?: string;
};
async function ops(
@@ -1130,22 +1131,16 @@ async function listJobIds(): Promise<string[]> {
.sort();
}
-// Hold the `build` AND `deploy` queues with fabricated running jobs
-// (/api/test/stuck-job; never released by the caller), so any build or deploy
-// job that a refusal under test failed to stop can only QUEUE, never run. BOTH
-// queues, because a regression can turn a refused call into a job on either
-// one: a dropped "preview needs deploy" guard makes a build, a dropped preview
-// check makes a deploy. The fabricated holders write no .meta.json, so they are
-// invisible to listJobIds; take `before` after this. The next resetData
-// cancels jobs newest first, so a queued job is removed before a holder's slot
-// is freed and nothing is ever promoted.
-async function holdBuildAndDeployQueues(request: APIRequestContext) {
- for (const queue of ["build", "deploy"]) {
- const hold = await request.get(
- `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`,
- );
- expect(hold.ok(), queue).toBe(true);
- }
+// Hold the `publish` queue with a fabricated running job (/api/test/stuck-job;
+// never released by the caller), so any build or deploy stage that a refusal
+// under test failed to stop can only QUEUE, never run — every build and deploy
+// is a publish stage on that one queue since release 18. The fabricated holder
+// writes no .meta.json, so it is invisible to listJobIds; take `before` after
+// this. The next resetData cancels jobs newest first, so a queued stage is
+// removed before the holder's slot is freed and nothing is ever promoted.
+async function holdPublishQueue(request: APIRequestContext) {
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
+ expect(hold.ok()).toBe(true);
}
test("build-site and build-deploy each take siteId or siteIds, and refuse both or neither", async ({
@@ -1187,12 +1182,11 @@ test("build-site and build-deploy each take siteId or siteIds, and refuse both o
// --- preview deploys ----------------------------------------------------------
//
-// NOTHING HERE STARTS A DEPLOY JOB, and that is deliberate. Every external
-// binary this suite touches is a fake in e2e/fixtures/bin (yt-dlp, whisper,
-// ffmpeg, …); wrangler has no fake, because until previews existed no spec had
-// any reason to reach a deploy. So these assert the half that is decided
-// BEFORE a job exists — which is the whole of what the preview rules are — and
-// prove it by watching the job sidecars not appear.
+// NOTHING HERE STARTS A DEPLOY JOB. Wrangler has a fake since release 18
+// (fake-wrangler.mjs; publish.spec deploys through it), but the preview rules
+// are decided BEFORE a job exists — that is the whole of what they are — so
+// these assert that half, and prove it by watching the job sidecars not
+// appear.
test("deploy-site and build-deploy refuse a preview name that is not one, before any job", async ({
request,
}) => {
@@ -1264,41 +1258,34 @@ test("a valid preview is accepted and reaches the action, on both deploy routes"
}
});
-test("deploy-site refuses to ship a build of a DIFFERENT site", async ({
+test("deploy-site refuses a site that was never built, in the deploy stage's words, before any job", async ({
request,
}) => {
await resetData("title-filter-channel");
await settings();
await writeSite("previewsite", { cloudflareProject: "proj" });
- // export/out is ONE shared directory whichever site composed into it — the
- // basic build ignores the site id — so a deploy-only action used to ship
- // whatever was built last to whichever project was asked for. The bundle
- // names itself in its own site.json, and the action reads it before starting.
- //
- // Both refusals are asserted because both are correct depending on what is
- // on disk: a checkout that has never built has no export/out at all, and one
- // that has built holds some OTHER site (it is never "previewsite", which
- // exists only inside this fixture). Neither starts a job.
+ // A deploy ships the site's OWN bundle (export/.export-builds/<id>/out,
+ // stamped built.json) — never "whatever export/out holds", which is how a
+ // deploy-only used to ship one site's build to another's project. A site
+ // with no build is refused before any job, with the stage's sentence.
const before = await listJobIds();
const { status, body } = await ops(request, "deploy-site", {
siteId: "previewsite",
});
expect(status).toBe(400);
expect(body.error).toMatch(
- /export\/out holds (no built site|a build of ".*", not "previewsite")/,
+ /^no build of previewsite in .*\.export-builds\/previewsite — archilyzer publish build previewsite$/,
);
- expect(body.error).toContain("build previewsite first");
expect(await listJobIds()).toEqual(before);
- // A preview is refused for the same reason and just as early — it must not
- // cost a deploy to learn the bundle is somebody else's.
+ // A preview is refused for the same reason and just as early.
const preview = await ops(request, "deploy-site", {
siteId: "previewsite",
preview: "tags-exclude",
});
expect(preview.status).toBe(400);
- expect(preview.body.error).toContain("build previewsite first");
+ expect(preview.body.error).toContain("no build of previewsite");
expect(await listJobIds()).toEqual(before);
});
@@ -1345,7 +1332,7 @@ test("build-deploy refuses a preview alongside all rather than building everythi
expect(await listJobIds()).toEqual(before);
});
-test("build-site with a bare siteId starts one build-export job", async ({
+test("build-site with a bare siteId starts its build stage (the index first: there is none)", async ({
request,
}) => {
test.setTimeout(120_000);
@@ -1358,10 +1345,12 @@ test("build-site with a bare siteId starts one build-export job", async ({
});
expect(status).toBe(200);
expect(body.ok).toBe(true);
- // The response is the LIST shape whichever key was used — one job, named.
+ // The response is the LIST shape whichever key was used — one job per site,
+ // named: the site's build stage (the run's index update is not the site's).
expect(body.jobs?.length).toBe(1);
expect(body.jobs?.[0].siteId).toBe("buildsite");
expect(body.skipped).toEqual([]);
+ expect(typeof body.runId).toBe("string");
const jobId = body.jobs![0].jobId;
await expect
@@ -1371,7 +1360,7 @@ test("build-site with a bare siteId starts one build-export job", async ({
).catch(() => null);
return meta?.kind ?? null;
})
- .toBe("build-export");
+ .toBe("publish-build-site");
});
// The hub's deploy path (release 7). Every refusal here is answered BEFORE a
@@ -1384,7 +1373,7 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i
}) => {
await resetData("title-filter-channel");
await settings();
- await holdBuildAndDeployQueues(request);
+ await holdPublishQueue(request);
const before = await listJobIds();
// The fixture has no homepage.json at all: no project.
@@ -1404,29 +1393,13 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i
expect(homepages.status).toBe(400);
expect(homepages.body.error).toContain('"archilyzer", which is the Archilyzer homepage\'s');
- // A real project, but export/out holds no hub: never built here, or a
- // site's bundle (shared directory). `export/out` is the CHECKOUT's build dir
- // (resolveOutDir is not fixture-controlled), so in a checkout that holds a
- // hub build — e.g. after `e2e:2origin` built one — the refusal cannot fire,
- // and the call would start a REAL deploy of that hub. The sub-case is then
- // recorded as not exercisable here and never run.
+ // A real project, but no hub build: the hub's bundle is the FIXTURE's
+ // (test-transcripts/.export-builds/_hub, stamped built.json) since release
+ // 18, so this refusal fires in any checkout — export/out is not read.
await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer-hub" }));
- const hubBuiltHere =
- (await pathExists("../export/out/hub-sites.json")) &&
- !(await pathExists("../export/out/site.json"));
- if (hubBuiltHere) {
- test.info().annotations.push({
- type: "subcase-not-exercisable",
- description:
- "export/out holds a hub build in this checkout, so the unbuilt refusal cannot fire (and the call would deploy it)",
- });
- } else {
- const unbuilt = await ops(request, "deploy-hub", { preview: "hub-check" });
- expect(unbuilt.status).toBe(400);
- expect(unbuilt.body.error).toMatch(
- /export\/out holds (no hub build|a build of ".*", not the hub) — build the hub first/,
- );
- }
+ const unbuilt = await ops(request, "deploy-hub", { preview: "hub-check" });
+ expect(unbuilt.status).toBe(400);
+ expect(unbuilt.body.error).toMatch(/^no build of _hub in .*\.export-builds\/_hub — archilyzer publish hub$/);
// build-hub: a preview without a deploy is a mistake, not a build.
const previewOnly = await ops(request, "build-hub", { preview: "hub-check" });
@@ -1439,25 +1412,24 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i
// 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 BOTH the `build` and the `deploy` queue held
-// by fabricated jobs (holdBuildAndDeployQueues). A build or deploy job
-// that a refusal failed to stop — `build-homepage {preview}` is a build
-// if the "preview needs deploy" guard regresses — would only QUEUE; the
-// listJobIds assertion then fails, and the next resetData cancels jobs
-// newest first, so the queued job is removed before a holder's slot is
-// freed.
+// NOTHING HERE CAN REACH WRANGLER (the fake's, even), and three things make
+// sure of it:
+// 1. "built" is the homepage's stamp, `_homepage/built.json` in the fixture's
+// .export-builds (release 18) — the checkout's homepage/out is not asked
+// until a stamp says it was built, and the fixture has none;
+// 2. the missing-build call asks for a PREVIEW, never production;
+// 3. the whole test runs with the `publish` queue held by a fabricated job
+// (holdPublishQueue). A stage that a refusal failed to stop —
+// `build-homepage {preview}` is a build if the "preview needs deploy"
+// guard regresses — would only QUEUE; the listJobIds assertion then fails,
+// and the next resetData cancels jobs newest first, so the queued stage 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();
- await holdBuildAndDeployQueues(request);
+ await holdPublishQueue(request);
const before = await listJobIds();
// A bad preview name is judged before homepage/out is even looked at, so
@@ -1483,20 +1455,132 @@ test("deploy-homepage refuses a bad preview name and a missing build; build-home
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",
- );
- }
+ // No build: the homepage's stamp is the fixture's.
+ const unbuilt = await ops(request, "deploy-homepage", { preview: "home-check" });
+ expect(unbuilt.status).toBe(400);
+ expect(unbuilt.body.error).toMatch(
+ /^no build of _homepage in .*\.export-builds\/_homepage — archilyzer publish homepage$/,
+ );
expect(await listJobIds()).toEqual(before);
});
+
+// --- publish (release 18) ---------------------------------------------------
+//
+// ONE route for the stages, a verb in the body, and its GET the status. The
+// publish queue is held throughout (holdPublishQueue): every stage the calls
+// enqueue only QUEUES, so what is asserted is the run each verb enqueues — its
+// kinds, its one run id, a duplicate answered with the existing job — and the
+// refusals before any job. publish.spec runs the stages.
+type PublishJobs = {
+ ok?: boolean;
+ error?: string;
+ runId?: string;
+ jobId?: string;
+ jobs?: { target: string; kind: string; jobId: string; existing?: boolean; previewUrl?: string }[];
+ skipped?: { kind: string; target: string; reason: string }[];
+ refused?: { target: string; error: string }[];
+};
+
+test("publish: each verb enqueues its run of stages on the publish queue; a duplicate stage is the existing job", async ({
+ request,
+}) => {
+ await resetData("title-filter-channel");
+ await settings();
+ await writeSite("pubsite", { cloudflareProject: "pubproj" });
+ await holdPublishQueue(request);
+
+ const index = (await ops(request, "publish", { verb: "index" })).body as PublishJobs;
+ expect(index.ok).toBe(true);
+ expect(index.jobs?.map((j) => [j.kind, j.target])).toEqual([["update-index", "_index"]]);
+ expect(index.jobId).toBe(index.jobs?.[0].jobId);
+ const meta = await readJson<{ kind: string; queueKey: string; status: string }>(
+ `test-transcripts/.jobs/${index.jobId}.meta.json`,
+ );
+ expect([meta.kind, meta.queueKey, meta.status]).toEqual(["publish-update-index", "publish", "queued"]);
+
+ // Publish now, every policy off: the index update alone — already queued,
+ // so it is the same job, not a second one.
+ const now = (await ops(request, "publish", { verb: "now" })).body as PublishJobs;
+ expect(now.ok).toBe(true);
+ expect(now.jobs?.map((j) => [j.kind, j.target, j.jobId, j.existing === true])).toEqual([
+ ["update-index", "_index", index.jobId, true],
+ ]);
+ expect(now.skipped?.some((x) => x.target === "pubsite")).toBe(true);
+
+ // Build: the site's build after the (queued) index update, one run.
+ const build = (await ops(request, "publish", { verb: "build", siteIds: ["pubsite"] })).body as PublishJobs;
+ expect(build.ok).toBe(true);
+ expect(build.jobs?.map((j) => [j.kind, j.target])).toEqual([
+ ["update-index", "_index"],
+ ["build-site", "pubsite"],
+ ]);
+ expect(build.jobs?.[0].existing).toBe(true);
+
+ // Build all stale: the same build, queued already.
+ const stale = (await ops(request, "publish", { verb: "stale" })).body as PublishJobs;
+ expect(stale.ok).toBe(true);
+ expect(stale.jobs?.find((j) => j.kind === "build-site")?.existing).toBe(true);
+});
+
+test("publish: refusals before any job — a never-built deploy, a preview that is not one, a key the verb does not take", async ({
+ request,
+}) => {
+ await resetData("title-filter-channel");
+ await settings();
+ await writeSite("pubsite", { cloudflareProject: "pubproj" });
+ await holdPublishQueue(request);
+ const before = await listJobIds();
+
+ const unbuilt = await ops(request, "publish", { verb: "deploy", siteId: "pubsite", preview: "r18" });
+ expect(unbuilt.status).toBe(400);
+ expect(unbuilt.body.error).toMatch(/^no build of pubsite in .* — archilyzer publish build pubsite$/);
+
+ const main = await ops(request, "publish", { verb: "deploy", siteId: "pubsite", preview: "main" });
+ expect(main.status).toBe(400);
+ expect(main.body.error).toContain("production branch");
+
+ const verb = await ops(request, "publish", { verb: "launch" });
+ expect(verb.status).toBe(400);
+ expect(verb.body.error).toContain('"verb" must be one of index, build, deploy, hub, homepage, now, stale');
+
+ const extra = await ops(request, "publish", { verb: "index", siteId: "pubsite" });
+ expect(extra.status).toBe(400);
+ expect(extra.body.error).toContain('verb "index" takes no other keys');
+
+ const hubPreview = await ops(request, "publish", { verb: "hub", preview: "r18" });
+ expect(hubPreview.status).toBe(400);
+ expect(hubPreview.body.error).toContain('need "deploy": true');
+
+ expect(await listJobIds()).toEqual(before);
+});
+
+test("GET publish is the publish status: the index, the lane, a row per target", async ({ request }) => {
+ await resetData("title-filter-channel");
+ await settings();
+ await writeSite("pubsite", { cloudflareProject: "pubproj" });
+
+ const res = await request.get(`${baseUrl}/api/ops/publish`, {
+ headers: { authorization: "Bearer test-worker-token" },
+ });
+ expect(res.status()).toBe(200);
+ const status = (await res.json()) as {
+ ok: boolean;
+ index: { chip: { text: string } };
+ lane: { held: boolean; enabled: boolean };
+ sites: { target: string; chips: { built: { text: string } } }[];
+ hub: { target: string };
+ homepage: { target: string };
+ plan: { steps: { kind: string }[] };
+ };
+ expect(status.ok).toBe(true);
+ expect(status.index.chip.text).toBe("no index yet");
+ expect(status.lane).toMatchObject({ held: false, enabled: false });
+ expect(status.sites.map((x) => x.target)).toEqual(["pubsite"]);
+ expect(status.sites[0].chips.built.text).toBe("update the index first");
+ expect([status.hub.target, status.homepage.target]).toEqual(["_hub", "_homepage"]);
+ expect(status.plan.steps.map((x) => x.kind)).toEqual(["update-index"]);
+
+ const anon = await request.get(`${baseUrl}/api/ops/publish`);
+ expect(anon.status()).toBe(401);
+});
diff --git a/editor/e2e/publish-lane.spec.ts b/editor/e2e/publish-lane.spec.ts
@@ -0,0 +1,132 @@
+// /operations/publish — the publish lane (release 18 S4): its settings form,
+// the runner's Start / Drain / Stop, and the hold, which stops the lane
+// DISPATCHING between stages and never kills the stage in flight.
+//
+// The lane's stages really run (publish.spec's header says how: a real index
+// update, compose + the fake `next`, the fake wrangler). Settings are written
+// through writeSettings (merged over the fixture, never wholesale — the disk
+// floor trap).
+
+import { readdir } from "node:fs/promises";
+import { test, expect, type Page } from "@playwright/test";
+import { baseUrl } from "./baseUrl";
+import { readJson, resetData, resolvePath, writeSettings, writeSite } from "./helpers";
+
+type Meta = { id: string; kind: string; status: string; spec?: { params?: { runId?: string } } };
+
+async function metasOf(kind: string): Promise<Meta[]> {
+ const files = (await readdir(resolvePath("test-transcripts/.jobs")).catch(() => [])).filter((f) =>
+ f.endsWith(".meta.json"),
+ );
+ const out: Meta[] = [];
+ for (const f of files) {
+ const m = await readJson<Meta>(`test-transcripts/.jobs/${f}`).catch(() => null);
+ if (m?.kind === kind) out.push(m);
+ }
+ return out;
+}
+
+async function openLane(page: Page) {
+ await page.goto("/operations/publish");
+ await expect(page.locator("[data-hydrated=true]").first()).toBeVisible({ timeout: 15_000 });
+}
+
+async function publishStatus(request: import("@playwright/test").APIRequestContext) {
+ const res = await request.get(`${baseUrl}/api/ops/publish`, {
+ headers: { authorization: "Bearer test-worker-token" },
+ });
+ expect(res.status()).toBe(200);
+ return (await res.json()) as { lane: { held: boolean; enabled: boolean; live: { running: boolean } } };
+}
+
+test.beforeEach(async () => {
+ await resetData("one-youtube-channel-with-data");
+ await writeSite("lanesite", {
+ channels: [{ slug: "test-youtube", groupId: "default" }],
+ publish: { auto: "build" },
+ });
+});
+
+test("the settings form saves settings.publish, and Start says why when the lane is off", async ({ page }) => {
+ await writeSettings({ minFreeDiskGB: 0, buildArchives: false });
+ await openLane(page);
+ await expect(page.getByRole("heading", { name: "Publish lane" })).toBeVisible();
+ await expect(page.getByText("Runner stopped")).toBeVisible();
+
+ // Off: Start is refused, in the runner's words.
+ await page.getByRole("button", { name: "Start publish lane" }).click();
+ await expect(page.getByRole("alert")).toContainText("The publish lane is off");
+
+ const form = page.locator('form[data-settings-block="publish"]');
+ await form.getByLabel("Check every (minutes)").fill("5");
+ await form.getByLabel("Preview branch").fill("lane-prev");
+ await form.locator('select[name="publishHub"]').selectOption("build");
+ await form.getByRole("button", { name: "Save publish settings" }).click();
+ await expect(form.getByRole("status")).toHaveText("Saved.");
+ await expect(async () => {
+ const s = await readJson<{ publish?: Record<string, unknown> }>("test-settings.json");
+ expect(s.publish).toMatchObject({ checkEveryMinutes: 5, previewBranch: "lane-prev", hub: "build", enabled: false });
+ }).toPass({ timeout: 10_000 });
+
+ // A preview name the deploy buttons refuse is refused here too.
+ await form.getByLabel("Preview branch").fill("main");
+ await form.getByRole("button", { name: "Save publish settings" }).click();
+ await expect(form.getByRole("alert")).toContainText("production branch");
+});
+
+test("enabled and started, the lane updates the index; a hold mid-stage stops the next dispatch, never the stage; Drain stops it", async ({
+ page,
+ request,
+}) => {
+ test.setTimeout(240_000);
+ await writeSettings({
+ minFreeDiskGB: 0,
+ buildArchives: false,
+ publish: { enabled: true, checkEveryMinutes: 1, refreshEveryMinutes: 0 },
+ });
+ // Hold the publish queue for a while, so the lane's first stage (the index
+ // update: there is no stamp) queues behind the holder and is IN FLIGHT from
+ // the lane's view while the hold is thrown. The holder lets go by itself.
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish&releaseAfterMs=20000`);
+ expect(hold.ok()).toBe(true);
+
+ await openLane(page);
+ await page.getByRole("button", { name: "Start publish lane" }).click();
+ await expect(page.getByText("Runner running")).toBeVisible({ timeout: 15_000 });
+
+ // The pass dispatches the index update, which waits behind the holder.
+ await expect.poll(async () => (await metasOf("publish-update-index")).length, { timeout: 30_000 }).toBe(1);
+ const [index] = await metasOf("publish-update-index");
+ expect(index.spec?.params?.runId).toMatch(/^lane-/);
+
+ // Hold the lane while that stage is in flight.
+ await page.getByRole("button", { name: "pause publishing" }).click();
+ await expect(page.getByRole("button", { name: "resume publishing" })).toBeVisible();
+ expect((await publishStatus(request)).lane.held).toBe(true);
+ const settings = await readJson<{ publish?: { held?: boolean } }>("test-settings.json");
+ expect(settings.publish?.held).toBe(true);
+
+ // The stage is not killed: it runs once the holder lets go, and ends done.
+ await expect
+ .poll(async () => (await metasOf("publish-update-index"))[0]?.status, { timeout: 120_000 })
+ .toBe("done");
+ // …and nothing is dispatched after it: the site's policy is "build" and it
+ // was never built, but the lane is held.
+ await expect
+ .poll(async () => {
+ await page.reload();
+ return (await page.getByTestId("publish-lane-facts").textContent()) ?? "";
+ }, { timeout: 30_000 })
+ .toContain("no next stage dispatched");
+ expect(await metasOf("publish-build-site")).toEqual([]);
+
+ // Resume, then Drain: the runner finishes what it is doing and stops.
+ await page.getByRole("button", { name: "resume publishing" }).click();
+ await expect(page.getByRole("button", { name: "pause publishing" })).toBeVisible();
+ expect((await publishStatus(request)).lane.held).toBe(false);
+ await page.getByRole("button", { name: "Drain publish lane" }).click();
+ await expect(async () => {
+ await page.reload();
+ await expect(page.getByText("Runner stopped")).toBeVisible({ timeout: 2_000 });
+ }).toPass({ timeout: 120_000 });
+});
diff --git a/editor/e2e/publish.spec.ts b/editor/e2e/publish.spec.ts
@@ -0,0 +1,267 @@
+// Publishing as stages, end to end through the editor (release 18 S4): the
+// /sites Publish panel's rows, the Pool's Build index, a site's Publish tab,
+// and the site form's publish policy.
+//
+// EVERY STAGE HERE REALLY RUNS, as the child process the editor spawns
+// (`archilyzer stage …` on the publish queue), against the fixture corpus:
+// - the index update is the real one (LMDB index, stats, chart templates);
+// - a site's build runs compose for real, then EXPORT_NEXT_BIN — the fake
+// `next` (e2e/fixtures/bin/fake-next.mjs), which copies the composed public
+// dir to export/out — and installs the bundle under
+// test-transcripts/.export-builds/<id>/out;
+// - a deploy runs the fake wrangler (WRANGLER_BIN), which deploys nothing and
+// records its argv in `.fake-wrangler.json` beside the bundle; the live
+// check is skipped (E2E_LIVE_CHECK=skip). ARCHILYZER_BRANCH=main is what
+// lets production ship a build made in a worktree.
+// Nothing reaches Cloudflare, and nothing is built into the checkout but the
+// worktree's export/out — a link, re-pointed by every build.
+
+import { lstat, readdir, readlink, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { test, expect, type Page } from "@playwright/test";
+import {
+ buildIndex,
+ pathExists,
+ readJson,
+ resetData,
+ resolvePath,
+ writeSettings,
+ writeSite,
+} from "./helpers";
+
+const BUILDS = "test-transcripts/.export-builds";
+const SITE = "pubsite";
+const PROJECT = "pubproj";
+
+const row = (page: Page, id = SITE) => page.getByRole("group", { name: `Publish ${id}` });
+const chip = (page: Page, name: string, id = SITE) => row(page, id).locator(`[data-chip="${name}"]`);
+
+type Meta = { id: string; kind: string; queueKey?: string; status: string; spec?: { params?: { runId?: string } } };
+
+async function publishMetas(): Promise<Meta[]> {
+ const files = (await readdir(resolvePath("test-transcripts/.jobs")).catch(() => [])).filter((f) =>
+ f.endsWith(".meta.json"),
+ );
+ const out: Meta[] = [];
+ for (const f of files) {
+ const m = await readJson<Meta>(`test-transcripts/.jobs/${f}`).catch(() => null);
+ if (m?.kind.startsWith("publish-")) out.push(m);
+ }
+ return out.sort((a, b) => a.id.localeCompare(b.id));
+}
+
+async function logOf(kind: string): Promise<string> {
+ const metas = (await publishMetas()).filter((m) => m.kind === kind);
+ const last = metas[metas.length - 1];
+ if (!last) return "";
+ const { readFile } = await import("node:fs/promises");
+ return readFile(resolvePath(`test-transcripts/.jobs/${last.id}.log`), "utf8").catch(() => "");
+}
+
+type Invocation = { argv: string[]; project: string; branch: string; outDir: string; files: number };
+
+async function wranglerCalls(id = SITE): Promise<Invocation[]> {
+ return (
+ (await readJson<{ invocations: Invocation[] }>(`${BUILDS}/${id}/.fake-wrangler.json`).catch(() => null))
+ ?.invocations ?? []
+ );
+}
+
+async function openSites(page: Page) {
+ await page.goto("/sites");
+ await expect(row(page)).toHaveAttribute("data-hydrated", "true", { timeout: 15_000 });
+}
+
+// A lane on a row ends: its chip says so.
+async function laneEnds(page: Page, word: "Done" | "Failed", timeout = 90_000) {
+ await expect(row(page).getByText(word, { exact: true })).toBeVisible({ timeout });
+}
+
+async function setup() {
+ await resetData("one-youtube-channel-with-data");
+ // No archive zips: nothing here is about them, and they are the slow part of
+ // a compose.
+ await writeSettings({ minFreeDiskGB: 0, buildArchives: false });
+ await writeSite(SITE, {
+ siteTitle: "Pub Site",
+ channels: [{ slug: "test-youtube", groupId: "default" }],
+ cloudflareProject: PROJECT,
+ });
+}
+
+test("the index: no stamp, then the Pool's Build index writes it and every row's index chip turns fresh", async ({
+ page,
+}) => {
+ await setup();
+ await openSites(page);
+ await expect(chip(page, "index")).toContainText("no index yet");
+ await expect(chip(page, "built")).toContainText("update the index first");
+ expect(await pathExists("test-transcripts/.export-index/stamp.json")).toBe(false);
+
+ await buildIndex(page);
+ const stamp = await readJson<{ v: number; stampId: string; sites: Record<string, unknown>; settingsSig?: string }>(
+ "test-transcripts/.export-index/stamp.json",
+ );
+ expect(stamp.v).toBe(1);
+ expect(Object.keys(stamp.sites)).toEqual([SITE]);
+ expect(typeof stamp.settingsSig).toBe("string");
+
+ await openSites(page);
+ await expect(chip(page, "index")).toContainText("fresh");
+ await expect(chip(page, "built")).toContainText("never built");
+});
+
+test("a row's Build, Deploy preview and Deploy production: the site's own bundle, the fake wrangler's --branch, deployed.json", async ({
+ page,
+}) => {
+ test.setTimeout(300_000);
+ await setup();
+ await buildIndex(page);
+ await openSites(page);
+
+ await test.step("Build: the bundle, its stamp, and export/out a link to it", async () => {
+ await row(page).getByRole("button", { name: "Build", exact: true }).click();
+ await laneEnds(page, "Done");
+ const built = await readJson<{ target: string; kind: string; branch: string | null; files: number }>(
+ `${BUILDS}/${SITE}/built.json`,
+ );
+ expect([built.target, built.kind, built.branch]).toEqual([SITE, "site", "main"]);
+ expect(built.files).toBeGreaterThan(0);
+ expect(await pathExists(`${BUILDS}/${SITE}/out/index.html`)).toBe(true);
+ expect(await readJson<{ siteId: string }>(`${BUILDS}/${SITE}/out/site.json`)).toMatchObject({ siteId: SITE });
+ const exportOut = resolvePath("../export/out");
+ expect((await lstat(exportOut)).isSymbolicLink()).toBe(true);
+ expect(path.resolve(path.dirname(exportOut), await readlink(exportOut))).toBe(
+ resolvePath(`${BUILDS}/${SITE}/out`),
+ );
+ await openSites(page);
+ await expect(chip(page, "built")).toContainText("built just now");
+ });
+
+ await test.step("Deploy preview: --branch <b> to the site's project, recorded under previews[b]", async () => {
+ await row(page).getByRole("textbox", { name: "preview branch", exact: true }).fill("r18e2e");
+ await row(page).getByRole("button", { name: "Deploy preview", exact: true }).click();
+ await laneEnds(page, "Done");
+ const calls = await wranglerCalls();
+ expect(calls).toHaveLength(1);
+ expect(calls[0].argv).toEqual(expect.arrayContaining(["--branch", "r18e2e", "--project-name", PROJECT]));
+ expect(calls[0].outDir).toBe(resolvePath(`${BUILDS}/${SITE}/out`));
+ const deployed = await readJson<{ previews: Record<string, { kind: string; url: string | null }> }>(
+ `${BUILDS}/${SITE}/deployed.json`,
+ );
+ expect(deployed.previews.r18e2e.kind).toBe("preview");
+ expect(await logOf("publish-deploy-site")).toContain("https://r18e2e.pubproj.pages.dev");
+ await openSites(page);
+ await expect(chip(page, "deployed")).toContainText("preview r18e2e");
+ });
+
+ await test.step("Deploy production: --branch main, recorded as production", async () => {
+ await row(page).getByRole("button", { name: "Deploy production", exact: true }).click();
+ await laneEnds(page, "Done");
+ const calls = await wranglerCalls();
+ expect(calls).toHaveLength(2);
+ expect(calls[1].argv).toEqual(expect.arrayContaining(["--branch", "main", "--project-name", PROJECT]));
+ const deployed = await readJson<{ production?: { kind: string } }>(`${BUILDS}/${SITE}/deployed.json`);
+ expect(deployed.production?.kind).toBe("production");
+ await openSites(page);
+ await expect(chip(page, "deployed")).toContainText("production");
+ });
+
+ await test.step("Cloudflare refusing the token: the deploy fails with the sentence, deployed.json untouched", async () => {
+ const before = await readJson(`${BUILDS}/${SITE}/deployed.json`);
+ // The fake's mode sidecar: the editor's actions pass the stage no env of a
+ // spec's, so the mode is a file the fake reads.
+ await writeFile(resolvePath(`${BUILDS}/.fake-wrangler-mode.json`), JSON.stringify({ authFail: true }));
+ try {
+ await row(page).getByRole("button", { name: "Deploy production", exact: true }).click();
+ await laneEnds(page, "Failed");
+ expect(await logOf("publish-deploy-site")).toContain(
+ "[deploy] REFUSED by Cloudflare — the API token was not accepted",
+ );
+ expect(await readJson(`${BUILDS}/${SITE}/deployed.json`)).toEqual(before);
+ const last = (await publishMetas()).filter((m) => m.kind === "publish-deploy-site").pop();
+ expect(last?.status).toBe("failed");
+ } finally {
+ await rm(resolvePath(`${BUILDS}/.fake-wrangler-mode.json`), { force: true });
+ }
+ });
+});
+
+test("Publish now with every policy off is the index update alone, one run on /jobs", async ({ page }) => {
+ test.setTimeout(120_000);
+ await setup();
+ await openSites(page);
+ await expect(page.getByTestId("publish-plan")).toContainText("update-index _index");
+ await page.getByRole("button", { name: "Publish now", exact: true }).click();
+ await expect(
+ page.getByRole("group", { name: "Publish all" }).getByText("Done", { exact: true }),
+ ).toBeVisible({ timeout: 90_000 });
+
+ const metas = await publishMetas();
+ expect(metas.map((m) => m.kind)).toEqual(["publish-update-index"]);
+ expect(metas[0].queueKey).toBe("publish");
+ const runId = metas[0].spec?.params?.runId ?? "";
+ expect(runId).toMatch(/^run-/);
+
+ await page.goto("/jobs");
+ await expect(
+ page.getByRole("row").filter({ hasText: "Update the index" }).filter({ hasText: `run ${runId.slice(-6)}` }),
+ ).toBeVisible();
+
+ // With the index fresh and every policy off, Publish now has nothing left.
+ await openSites(page);
+ await expect(page.getByTestId("publish-plan")).toContainText("Publish now has nothing to run");
+});
+
+test("a site's Build & deploy is one run: its build and its production deploy, one run id; Last deployed updates", async ({
+ page,
+}) => {
+ test.setTimeout(240_000);
+ await setup();
+ await buildIndex(page);
+ await page.goto(`/sites/${SITE}/publish`);
+ const last = page.getByTestId("last-deployed");
+ await expect(last).toContainText("Last deployed:never");
+
+ await page.getByRole("button", { name: "Build & deploy", exact: true }).click();
+ await expect(page.getByLabel("Build and deploy output")).toContainText(
+ /\[stage\] deploy-site pubsite: Done/,
+ { timeout: 180_000 },
+ );
+
+ const metas = (await publishMetas()).filter((m) => m.kind !== "publish-update-index");
+ expect(metas.map((m) => m.kind)).toEqual(["publish-build-site", "publish-deploy-site"]);
+ const runs = new Set(metas.map((m) => m.spec?.params?.runId));
+ expect(runs.size).toBe(1);
+ expect((await wranglerCalls()).map((c) => c.branch)).toEqual(["main"]);
+
+ await page.reload();
+ await expect(last).not.toContainText("Last deployed:never");
+ await expect(last).toContainText("live check skipped");
+});
+
+test("the site form's publish policy: preview without a Pages project is refused, with the reason", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await writeSite("noproj", { siteTitle: "No Project" });
+ await page.goto("/sites/noproj");
+ const policy = page.getByLabel("Publish policy");
+ await expect(policy).toHaveValue("off");
+ await policy.selectOption("preview");
+ await page.getByRole("button", { name: /save site/i }).click();
+ await expect(page.getByRole("alert")).toContainText(
+ 'publish.auto "preview" deploys the site, and it has no cloudflareProject',
+ );
+ const site = await readJson<Record<string, unknown>>("test-transcripts/sites/noproj/site.json");
+ expect("publish" in site).toBe(false);
+
+ // Build is allowed without a project, and is written.
+ await policy.selectOption("build");
+ await page.getByRole("button", { name: /save site/i }).click();
+ await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
+ await expect(async () => {
+ const saved = await readJson<{ publish?: { auto: string } }>("test-transcripts/sites/noproj/site.json");
+ expect(saved.publish?.auto).toBe("build");
+ }).toPass({ timeout: 10_000 });
+});