// /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 });
});