// 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//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 { baseUrl } from "./baseUrl"; 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 { 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(`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 { 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 { 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; 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 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 }>( `${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("a console's Cancel cancels its own run — every stage of it — and never another run's", async ({ page, request, }) => { await setup(); // Every stage below only queues: the publish queue is held, never released. const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); expect(hold.ok()).toBe(true); const status = async (kind: string) => (await publishMetas()).filter((m) => m.kind === kind).map((m) => m.status); // Run 1: the Pool's Build index — the index update, queued. await page.goto("/sites"); await page.getByText("Pool jobs", { exact: true }).click(); await page.getByRole("button", { name: "Build index" }).click(); await expect.poll(() => status("publish-update-index")).toEqual(["queued"]); // Run 2: the row's Build — the index update is run 1's (already queued), the // build is run 2's own. Its console's Cancel takes the build, not the index. await openSites(page); await row(page).getByRole("button", { name: "Build", exact: true }).click(); await expect.poll(() => status("publish-build-site")).toEqual(["queued"]); await row(page).getByRole("button", { name: `Cancel Build ${SITE}` }).click(); await expect.poll(() => status("publish-build-site")).toEqual(["cancelled"]); expect(await status("publish-update-index")).toEqual(["queued"]); // Run 3: the site tab's Build & deploy — its build AND its deploy, both // queued; one Cancel takes the run, whichever of its jobs the console holds. await page.goto(`/sites/${SITE}/publish`); await page.getByRole("button", { name: "Build & deploy", exact: true }).click(); await expect.poll(() => status("publish-deploy-site")).toEqual(["queued"]); await page.getByRole("button", { name: "Cancel Build and deploy" }).click(); await expect.poll(() => status("publish-deploy-site")).toEqual(["cancelled"]); expect(await status("publish-build-site")).toEqual(["cancelled", "cancelled"]); expect(await status("publish-update-index")).toEqual(["queued"]); }); 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(); // Filtered: Next's route announcer is an alert too. await expect( page.getByRole("alert").filter({ hasText: 'publish.auto "preview" deploys the site, and it has no cloudflareProject' }), ).toBeVisible(); const site = await readJson>("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 }); });