// /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 { 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 === 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(); // Filtered: Next's route announcer is an alert too. await expect(page.getByRole("alert").filter({ hasText: "The publish lane is off" })).toBeVisible(); 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 }>("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 }, }); await openLane(page); // Hold the publish queue, 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 spec lets the holder go once the hold is // thrown (it used to be a 45 s timer sized for a cold dev server). const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); expect(hold.ok()).toBe(true); const holderId = ((await hold.json()) as { id: string }).id; 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 still queued behind the holder; let the holder go. expect((await metasOf("publish-update-index"))[0]?.status).not.toBe("done"); const released = await request.get(`${baseUrl}/api/test/stuck-job?release=${holderId}`); expect(((await released.json()) as { released: boolean }).released).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 }); });