Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Meditor/e2e/duplicate-shorts.spec.ts | 8++------
Meditor/e2e/helpers.ts | 25++++++++++++++++---------
Meditor/e2e/jobs.spec.ts | 6++++--
Meditor/e2e/ops-api.spec.ts | 266++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Aeditor/e2e/publish-lane.spec.ts | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/publish.spec.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 }); +});