commit 1275ccbf877d55b6eb216c3cb971640b5eb2d88d
parent a335cdb3f1f210e376af5c03224a77ed55e494e1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 12:38:41 -0400
e2e: the publish stages' seams — EXPORT_NEXT_BIN (a fake next build), the fake wrangler's mode sidecar, ARCHILYZER_BRANCH=main; specs for the moved labels
- common: `nextBuildStep` runs `<EXPORT_NEXT_BIN> build` in place of `pnpm
exec next build` for a site's and the hub's build (ENVIRONMENT.md
regenerated). The editor's test server points it at
e2e/fixtures/bin/fake-next.mjs, which copies the composed public dir to
export/out: compose runs for real, the export app is never rebuilt beside
its dev server.
- playwright.config.ts: ARCHILYZER_BRANCH=main (a worktree's branch is never
main, and production refuses any other build), a dummy CLOUDFLARE_API_TOKEN
for the credential preflight (the fake never sends it), EXPORT_NEXT_BIN.
- fake-wrangler.mjs reads `<exportBuildsDir>/.fake-wrangler-mode.json`
{"authFail": true}, so one spec can make a deploy fail as Cloudflare
refusing the token; E2E_FAKE_WRANGLER_AUTH_FAIL stays the fallback.
- publishRunStream: a job of a run that ends cancelled cancels the jobs after
it (the console's Cancel is the run's).
- build.spec (no "Skip data rebuild"), deploy-page.spec (the Publish panel's
rows replace "Build all sites" and the batch panel), sites-homepage.spec
(the Homepage row; publish-* kinds on the publish queue; the chips say
"no index yet").
- source.test: the "without a valid manifest" refusal keeps a test (its only
one was build.test's deployHomepage's); two stale comments in
builtExport.test.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
13 files changed, 267 insertions(+), 131 deletions(-)
diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md
@@ -73,6 +73,7 @@ Tokens, credentials and knobs a running process reads. Most configuration is not
| `ARCHIVE_CHANNEL_CONCURRENCY` | `4` | How many channels' archive zips `build archives` builds at once. | common/bin/build-archives.ts |
| `HOST` | every interface | The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine. | export/scripts/serve-out.mjs |
| `MAX_ARCHIVE_BYTES` | the Cloudflare-safe cap | The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins. | common/bin/compose-site.ts |
+| `EXPORT_NEXT_BIN` | unset: `pnpm exec next build` in export/ | The `next` a site's or the hub's build runs as `<bin> build` in export/, in place of `pnpm exec next build`. The editor's e2e suite points it at its fake, which copies the composed public dir to export/out. | common/publish/build.ts (nextBuildStep) |
| `WRANGLER_BIN` | `common/node_modules/.bin/wrangler` (the pinned devDependency) | The wrangler a deploy spawns. The editor's e2e suite points it at its fake. | common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts |
| `CHOUGH_BIN` | `chough` on PATH | The chough transcription engine, when a worker names no binary. | common/lib/transcriptionApps.ts |
| `CHOUGH_MODEL` | chough's own | Passed to chough from a worker's model field; chough auto-downloads one when unset. | chough (set by common/lib/transcriptionApps.ts) |
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -228,9 +228,8 @@ test("builtHubProblem: the shell's reports/ and m/ pages pass; report data, mome
});
// The homepage package builds into its own homepage/out, which nothing else
-// writes, so "built" is one question: is there an index.html? The deploy-only
-// action refuses before any job on exactly the file deployHomepage checks
-// inside it.
+// writes, so "built" is one question: is there an index.html? The homepage's
+// deploy stage (deployStage.ts) refuses on exactly that file.
test("builtHomepageProblem refuses an out dir with no index.html, and only that", () => {
const built = tempOut();
const empty = tempOut();
@@ -317,9 +316,8 @@ test("builtBundleProblem refuses a bundle missing either identity file, naming t
}
});
-// deploySite and the editor's deploy action answer with builtSiteProblem before
-// any job; the deploy itself refuses with builtBundleProblem just before
-// wrangler. The two must never disagree about a bundle: a legitimately built
+// builtSiteProblem is the looser question; the deploy stage refuses with
+// builtBundleProblem just before wrangler. The two must never disagree about a bundle: a legitimately built
// site refused only at the last step, or a bad one let through to it.
test("builtSiteProblem refuses exactly what builtBundleProblem refuses", () => {
const cases: { name: string; site?: unknown; corpus?: unknown }[] = [
diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts
@@ -109,6 +109,7 @@ const DECLARED: EnvVarDecl[] = [
{ name: "ARCHIVE_CHANNEL_CONCURRENCY", audience: "runtime", default: "`4`", readBy: "common/bin/build-archives.ts", doc: "How many channels' archive zips `build archives` builds at once." },
{ name: "HOST", audience: "runtime", default: "every interface", readBy: "export/scripts/serve-out.mjs", doc: "The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine." },
{ name: "MAX_ARCHIVE_BYTES", audience: "runtime", default: "the Cloudflare-safe cap", readBy: "common/bin/compose-site.ts", doc: "The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins." },
+ { name: "EXPORT_NEXT_BIN", audience: "runtime", default: "unset: `pnpm exec next build` in export/", readBy: "common/publish/build.ts (nextBuildStep)", doc: "The `next` a site's or the hub's build runs as `<bin> build` in export/, in place of `pnpm exec next build`. The editor's e2e suite points it at its fake, which copies the composed public dir to export/out." },
{ name: "WRANGLER_BIN", audience: "runtime", default: "`common/node_modules/.bin/wrangler` (the pinned devDependency)", readBy: "common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts", doc: "The wrangler a deploy spawns. The editor's e2e suite points it at its fake." },
{ name: "CHOUGH_BIN", audience: "runtime", default: "`chough` on PATH", readBy: "common/lib/transcriptionApps.ts", doc: "The chough transcription engine, when a worker names no binary." },
{ name: "CHOUGH_MODEL", audience: "runtime", default: "chough's own", readBy: "chough (set by common/lib/transcriptionApps.ts)", doc: "Passed to chough from a worker's model field; chough auto-downloads one when unset." },
diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts
@@ -116,6 +116,20 @@ test("buildSiteSteps: allowMissingMedia lets compose through a report citation w
);
});
+test("EXPORT_NEXT_BIN replaces `pnpm exec next build` in a site's and the hub's build (the e2e fake)", () => {
+ const p = { exportDir: "/x/export", transcriptsDir: "/x/t", exportPublicDir: "/x/t/.p" } as Paths;
+ const site = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } });
+ assert.deepEqual(site.map((s) => [s.command, ...s.args]), [
+ ["pnpm", "run", "compose:site"],
+ ["/bin/fake-next", "build"],
+ ]);
+ const hub = buildHubSteps({ paths: p, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } });
+ assert.deepEqual(hub[1].command, "/bin/fake-next");
+ assert.equal(hub[1].env?.INSTANCE_MODE, "hub");
+ const plain = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: {} });
+ assert.deepEqual(plain[1].args, ["exec", "next", "build"]);
+});
+
test("buildHubSteps: compose:hub, then next build with INSTANCE_MODE=hub, in export/", () => {
const steps = buildHubSteps({ paths, baseEnv: { PATH: "/bin" } });
const env = {
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -99,10 +99,23 @@ export function buildSiteSteps(opts: {
return [
...(opts.skipData ? [] : [step(["run", "build:data"])]),
step(["run", "compose:site"]),
- step(["exec", "next", "build"]),
+ nextBuildStep(paths, env),
];
}
+/**
+ * The export app's `next build`: `pnpm exec next build` in export/, or
+ * `<EXPORT_NEXT_BIN> build` when that is set — the editor's e2e suite points
+ * it at a fake that copies the composed public dir to out/ (a real build there
+ * would rebuild the app beside its running dev server).
+ */
+export function nextBuildStep(paths: Paths, env: NodeJS.ProcessEnv): BuildStep {
+ const bin = env.EXPORT_NEXT_BIN?.trim();
+ return bin
+ ? { command: bin, args: ["build"], cwd: paths.exportDir, env }
+ : { command: "pnpm", args: ["exec", "next", "build"], cwd: paths.exportDir, env };
+}
+
// Run a list of child steps in order, streaming into `onLog`, stopping at the
// first non-zero exit (or a cancel). Returns the exit code.
async function runSteps(
@@ -582,12 +595,7 @@ export function buildHubSteps(opts: {
};
return [
{ command: "pnpm", args: ["run", "compose:hub"], cwd: paths.exportDir, env },
- {
- command: "pnpm",
- args: ["exec", "next", "build"],
- cwd: paths.exportDir,
- env: { ...env, INSTANCE_MODE: "hub" },
- },
+ nextBuildStep(paths, { ...env, INSTANCE_MODE: "hub" }),
];
}
diff --git a/common/publish/source.test.ts b/common/publish/source.test.ts
@@ -976,3 +976,18 @@ test("history: a refusal with a render cache it cannot remove still exits 1 and
chmodSync(cacheDir, 0o755);
}
});
+
+test("a homepage/out holding a source publish with no manifest is refused (the deploy check; its old home was build.test.ts deployHomepage)", async () => {
+ const root = mkdtempSync(path.join(os.tmpdir(), "source-no-manifest-"));
+ try {
+ const out = path.join(root, "homepage", "out");
+ mkdirSync(path.join(out, "source", MIRROR_DIR), { recursive: true });
+ writeFileSync(path.join(out, "source", "index.html"), "<p>the /source page</p>");
+ assert.match(
+ (await publishedSourceProblem({ monorepoRoot: root } as Paths, out))!,
+ /homepage\/out holds a source publish without a valid manifest/,
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/editor/app/sites/lib/publishRunStream.ts b/editor/app/sites/lib/publishRunStream.ts
@@ -5,6 +5,11 @@
// line, and settles when the last one does: the run's verdict is its first
// job that did not end `done`, else `done`.
//
+// A CANCEL IS THE RUN'S. The console's Cancel cancels the run's first job (the
+// id it holds); a job of the run that ends `cancelled` cancels every job
+// after it, queued or running — else the console would wait on a build still
+// queued behind something else, for a run its operator called off.
+//
// Nothing here chains one job to another — the order is on disk (a build
// carries `indexAfter`, a deploy `builtAfter`; publishStages.ts). A deploy
// whose build failed ends `failed` itself, with the sentence in its log.
@@ -12,6 +17,7 @@
// Not a "use server" module: a server action file may only export async
// functions, and these are plain helpers the actions call.
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import type { JobDoneResult, StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
export type RunPart = {
@@ -64,6 +70,9 @@ export function followRun(
}
const term = part.done ? await part.done : null;
if (term && term.status !== "done" && verdict === null) verdict = term;
+ if (term?.status === "cancelled") {
+ for (const later of parts.slice(i + 1)) getRegistry().cancel(later.jobId);
+ }
}
resolveDone(verdict ?? { status: "done", jobId: first.jobId });
if (!closed) {
diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts
@@ -90,9 +90,11 @@ test("build static export lives on the site's Publish tab, not the family page",
});
await expect(exportButton).toBeEnabled();
- // The build-only control offers a "skip data rebuild" option (compose from
- // existing staging without regenerating index/stats/charts). Default off.
- const skipData = page.getByLabel("Skip data rebuild (index, stats, charts)");
- await expect(skipData).toBeVisible();
- await expect(skipData).not.toBeChecked();
+ // No "skip data rebuild" any more (release 18): the data is the index
+ // stage, shared by every site, and a build updates it first when it is
+ // stale. The archive opt-out stays, default off.
+ await expect(page.getByLabel("Skip data rebuild (index, stats, charts)")).toHaveCount(0);
+ const skipArchives = page.getByLabel("Skip archive zips (transcript & live-chat downloads)");
+ await expect(skipArchives).toBeVisible();
+ await expect(skipArchives).not.toBeChecked();
});
diff --git a/editor/e2e/deploy-page.spec.ts b/editor/e2e/deploy-page.spec.ts
@@ -2,30 +2,35 @@ import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { resetData, writeSite } from "./helpers";
-// The "pick specific sites" panel (BuildSitesPanel). /sites renders two
-// controls named "Deploy after build" — one here, one in BuildAllSitesButton —
-// so anything targeting this panel's copy must be scoped to it by name.
-const batchPanel = (page: Page) =>
- page.getByRole("group", { name: "Build specific sites" });
+// Where publishing lives (release 18): the family page has the Publish panel —
+// a row per site, the hub and the homepage, Publish now and Build all stale —
+// and a site's Publish tab has its own Build & deploy. The panel's stages run
+// in publish.spec; this pins the layout and the wiring decided before a job.
+
+const row = (page: Page, siteId: string) =>
+ page.getByRole("group", { name: `Publish ${siteId}` });
test.beforeEach(async () => {
await resetData("empty");
});
-test("publishing splits: the family page releases and batches, the site page builds & deploys", async ({
+test("publishing splits: the family page releases and publishes, the site page builds & deploys", async ({
page,
}) => {
await writeSite("testsite", { cloudflareProject: "proj" });
await page.goto("/sites");
const release = page.getByRole("heading", { name: "Release notes" });
- const batch = page.getByRole("heading", { name: "Build all sites" });
+ const publish = page.getByRole("heading", { name: "Publish", exact: true });
await expect(release).toBeVisible();
- await expect(batch).toBeVisible();
- // Order: the release sits above the batch panel.
+ await expect(publish).toBeVisible();
+ // Order: the release sits above the Publish panel.
expect((await release.boundingBox())!.y).toBeLessThan(
- (await batch.boundingBox())!.y,
+ (await publish.boundingBox())!.y,
);
+ // "Build all sites" and the batch panel are gone.
+ await expect(page.getByRole("heading", { name: "Build all sites" })).toHaveCount(0);
+ await expect(page.getByRole("group", { name: "Build specific sites" })).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Build & deploy", exact: true }),
).toHaveCount(0);
@@ -35,7 +40,7 @@ test("publishing splits: the family page releases and batches, the site page bui
page.getByRole("heading", { name: "Build & deploy", exact: true }),
).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Build all sites" }),
+ page.getByRole("heading", { name: "Publish", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Release notes" }),
@@ -51,16 +56,12 @@ test("Build & deploy is enabled only when the active site has a Cloudflare proje
// With a Cloudflare project → enabled.
await page.goto("/sites/with-proj/publish");
await expect(
- // exact: true — kept from the /deploy days, where "Build & deploy all
- // sites" sat below; harmless here.
page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeEnabled();
// Without one → disabled, with an explanatory notice.
await page.goto("/sites/no-proj/publish");
await expect(
- // exact: true — kept from the /deploy days, where "Build & deploy all
- // sites" sat below; harmless here.
page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeDisabled();
await expect(
@@ -68,46 +69,50 @@ test("Build & deploy is enabled only when the active site has a Cloudflare proje
).toBeVisible();
});
-// The Basic | Docker toggle was a label nothing read, and it is gone (release
-// 11, follow-up O6c): Build all uses containers whenever an engine answers.
-test("/sites has no build-mode toggle, and Build all says when it uses containers", async ({
+test("/sites has no build-mode toggle: the Publish panel has Publish now and Build all stale", async ({
page,
}) => {
await writeSite("testsite", { cloudflareProject: "proj" });
await page.goto("/sites");
- await expect(page.getByRole("heading", { name: "Build all sites" })).toBeVisible();
await expect(page.getByRole("group", { name: "Build mode" })).toHaveCount(0);
- await expect(
- page.getByText(/When a container engine answers: the shared data phase runs once/i),
- ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Publish now", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Build all stale", exact: true })).toBeVisible();
+ // The plan Publish now would run is shown before it is pressed: with no
+ // index stamp yet, the index update comes first.
+ await expect(page.getByTestId("publish-plan")).toContainText("update-index _index");
});
-test("batch panel: selecting sites enables the launch button and reflects deploy intent", async ({
+test("a row per site: its chips, and the deploy buttons only where it can deploy", async ({
page,
}) => {
- // Deliberately does NOT click the launch button — that would spawn a real
- // `pnpm run build`, which the e2e suite never does (cf. build.spec.ts only
- // runs the lightweight Build index job). We verify the panel's UI wiring.
await writeSite("alpha", { siteTitle: "Alpha", cloudflareProject: "a" });
- await writeSite("beta", { siteTitle: "Beta", cloudflareProject: "b" });
+ await writeSite("beta", { siteTitle: "Beta" });
+ await writeSite("mine", { siteTitle: "Mine", audience: "private" });
await page.goto("/sites");
- const launch = page.getByRole("button", { name: "Build selected" });
- await expect(launch).toBeDisabled();
+ const alpha = row(page, "alpha");
+ await expect(alpha.getByRole("button", { name: "Build", exact: true })).toBeVisible();
+ await expect(alpha.getByRole("button", { name: "Deploy preview", exact: true })).toBeVisible();
+ await expect(alpha.getByRole("button", { name: "Deploy production", exact: true })).toBeVisible();
+ // No index yet: every row says so on its index chip, and its build waits for it.
+ await expect(alpha.locator('[data-chip="index"]')).toContainText("no index yet");
+ await expect(alpha.locator('[data-chip="built"]')).toContainText("update the index first");
+ await expect(alpha.locator('[data-chip="deployed"]')).toBeVisible();
+ await expect(alpha.locator('[data-chip="live"]')).toBeVisible();
- await page.getByLabel("Select Alpha").check();
- await page.getByLabel("Select Beta").check();
- await expect(launch).toBeEnabled();
- await expect(page.getByText("2 selected")).toBeVisible();
+ // No project: built, never deployed — and it says why.
+ const beta = row(page, "beta");
+ await expect(beta.getByRole("button", { name: "Build", exact: true })).toBeVisible();
+ await expect(beta.getByRole("button", { name: "Deploy production", exact: true })).toHaveCount(0);
+ await expect(beta.getByText(/No Cloudflare Pages project/)).toBeVisible();
- // Opting into deploy renames the launch button. Scoped to the batch panel:
- // BuildAllSitesButton has an identically-labelled checkbox in the same
- // section, so an unscoped getByLabel resolves to two elements.
- await batchPanel(page).getByLabel("Deploy after build").check();
- await expect(
- page.getByRole("button", { name: "Build & deploy selected" }),
- ).toBeVisible();
+ // Private: never deployed.
+ const mine = row(page, "mine");
+ await expect(mine.getByRole("button", { name: "Deploy production", exact: true })).toHaveCount(0);
+ await expect(mine.getByText(/is private \(audience: private\)/)).toBeVisible();
- // The panel notes that the batch runs serially, whatever the mode.
- await expect(page.getByText(/These run one at a time/i)).toBeVisible();
+ // A preview name is judged as you type, with the server's sentence.
+ await alpha.getByRole("textbox", { name: "preview branch", exact: true }).fill("main");
+ await expect(alpha.getByRole("status", { name: "preview problem" })).toContainText(/production branch/);
+ await expect(alpha.getByRole("button", { name: "Deploy preview", exact: true })).toBeDisabled();
});
diff --git a/editor/e2e/fixtures/bin/fake-next.mjs b/editor/e2e/fixtures/bin/fake-next.mjs
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+// E2E fake `next` for the export app's build (release 18 S4). A site's and the
+// hub's build stage run compose for real, then `<EXPORT_NEXT_BIN> build` in
+// export/ in place of `pnpm exec next build` (common/publish/build.ts) — which
+// in the editor's e2e would rebuild the export app beside its running dev
+// server, for a minute, to prove nothing about publishing.
+//
+// What a real `next build` of a static export leaves that the publish stages
+// read is `export/out`: the composed `public/` copied in (site.json and
+// corpus.json name the site — builtBundleProblem; the hub's tombstones —
+// builtHubProblem) and the rendered pages. This writes exactly that: the
+// composed public dir (EXPORT_PUBLIC_DIR, which the build step sets) copied to
+// ./out, and an index.html. Nothing is rendered.
+import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { installFixtureWatchdog } from "./_watchdog.mjs";
+
+installFixtureWatchdog();
+
+const argv = process.argv.slice(2);
+if (argv[0] === "--version" || argv[0] === "-v") {
+ process.stdout.write("Next.js v16.0.0 (fake)\n");
+ process.exit(0);
+}
+if (argv[0] !== "build") {
+ process.stderr.write(`[fake-next] unsupported invocation: ${argv.join(" ")}\n`);
+ process.exit(2);
+}
+
+const publicDir = process.env.EXPORT_PUBLIC_DIR?.trim() || path.resolve("public");
+if (!existsSync(publicDir)) {
+ process.stderr.write(`[fake-next] no public dir at ${publicDir}\n`);
+ process.exit(1);
+}
+const out = path.resolve("out");
+rmSync(out, { recursive: true, force: true });
+mkdirSync(out, { recursive: true });
+cpSync(publicDir, out, { recursive: true, dereference: true });
+const mode = process.env.INSTANCE_MODE === "hub" ? "hub" : "site";
+writeFileSync(
+ path.join(out, "index.html"),
+ `<!doctype html><html><head><title>fake ${mode} build</title></head><body>${mode}</body></html>\n`,
+);
+process.stdout.write(" ▲ Next.js 16 (fake)\n");
+process.stdout.write(` ✓ Exported ${mode} from ${publicDir} to ${out}\n`);
diff --git a/editor/e2e/fixtures/bin/fake-wrangler.mjs b/editor/e2e/fixtures/bin/fake-wrangler.mjs
@@ -13,11 +13,15 @@
// what was "deployed", from where, to which branch;
// - prints wrangler 4's success lines, ending on "Take a peek over at
// https://<branch>.<project>.pages.dev" — the line deploymentUrlIn reads;
-// - with E2E_FAKE_WRANGLER_AUTH_FAIL=1, prints wrangler's own refusal for a
-// token Cloudflare does not accept ("Authentication error [code: 10000]")
-// and exits 1, without writing the sidecar — the deploy stage must turn
-// that into "[deploy] REFUSED by Cloudflare — the API token was not
-// accepted" and leave deployed.json untouched;
+// - in AUTH-FAIL mode, prints wrangler's own refusal for a token Cloudflare
+// does not accept ("Authentication error [code: 10000]") and exits 1,
+// without writing the sidecar — the deploy stage must turn that into
+// "[deploy] REFUSED by Cloudflare — the API token was not accepted" and
+// leave deployed.json untouched. The mode is a MODE SIDECAR a spec writes
+// and removes, `<exportBuildsDir>/.fake-wrangler-mode.json` `{"authFail":
+// true}` (the editor's actions pass the stage no env of a spec's, so an
+// env var cannot reach one test; fake-ytdlp.mjs's sidecar pattern), or
+// E2E_FAKE_WRANGLER_AUTH_FAIL=1 as the fallback;
// - answers `--version` like the pinned binary.
//
// A missing <outDir> fails as wrangler does. Anything else is a usage error.
@@ -51,7 +55,27 @@ const branch = opt("--branch") ?? "main";
process.stdout.write(`\n ⛅️ wrangler ${VERSION} (fake)\n───────────────────\n`);
-if (process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") {
+// The mode sidecar: in EXPORT_BUILDS_DIR when set, else beside the editor's
+// export-public dir (paths.ts exportBuildsDir), else two levels above the
+// bundle (<builds>/<target>/out).
+function modeSidecar() {
+ const dirs = [
+ process.env.EXPORT_BUILDS_DIR,
+ process.env.EXPORT_PUBLIC_DIR && path.join(path.dirname(process.env.EXPORT_PUBLIC_DIR), ".export-builds"),
+ path.dirname(path.dirname(outDir)),
+ ].filter(Boolean);
+ for (const d of dirs) {
+ try {
+ return JSON.parse(readFileSync(path.join(d, ".fake-wrangler-mode.json"), "utf8"));
+ } catch {
+ // absent or unreadable: the next place
+ }
+ }
+ return {};
+}
+const mode = modeSidecar();
+
+if (mode.authFail === true || process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") {
process.stderr.write(
`\n✘ [ERROR] A request to the Cloudflare API (/accounts/0000/pages/projects/${project ?? "?"}) failed.\n\n` +
" Authentication error [code: 10000]\n\n" +
diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts
@@ -1,22 +1,22 @@
-// /sites → Homepage: the `homepage` package's build and deploy (release 11,
-// slice O4), the twin of the Hub section's buttons.
+// /sites → Publish → the Homepage row: the `homepage` package's build and
+// deploy (release 11 slice O4; a row of the Publish panel since release 18),
+// the twin of the Hub row.
//
-// NOTHING HERE DEPLOYS, AND NOTHING HERE BUILDS. Wrangler has no fake in
-// e2e/fixtures/bin, and `homepage/out` is the CHECKOUT's own directory — in the
-// primary checkout it holds the live homepage's build — so a deploy clicked
-// from a spec would be a real one. So:
+// NOTHING HERE BUILDS THE HOMEPAGE, AND NOTHING HERE DEPLOYS IT. The homepage's
+// build publishes the source mirror (the operator's scrub and denylist files)
+// into the CHECKOUT's own homepage/out — in the primary checkout, the live
+// homepage's build. So:
// - no spec clicks Deploy homepage, and none ticks Deploy after build and
// then clicks Build homepage;
-// - the two specs that click Build homepage first hold BOTH the `build` and
-// the `deploy` queue with fabricated jobs (/api/test/stuck-job, never
-// released here), so the job each starts only ever QUEUES — the
-// build-homepage it expects on `build`, and equally a build-deploy-homepage
-// on `deploy` if a regression turned the click into a build-and-deploy —
-// and it is cancelled while still queued: from its own lane, or (the
-// re-render spec) by the harness reset. Its start function never runs: no
-// log file, no child, nothing written to homepage/public or homepage/out.
+// - the two specs that click Build homepage first hold the `publish` queue
+// with a fabricated job (/api/test/stuck-job, never released here), so the
+// stages the click enqueues — the index update (no index yet), then
+// build-homepage — only ever QUEUE, and are cancelled while still queued:
+// from the row's lane (a cancel of the run's first stage cancels the rest
+// of the run), or (the re-render spec) by the harness reset. No stage
+// starts: no log file, no child, nothing written to homepage/out.
// If a spec fails before its cancel, the next resetData cancels jobs newest
-// first, so the queued job is removed before a holder's slot is freed.
+// first, so the queued stages are removed before the holder's slot is freed.
import { readdir } from "node:fs/promises";
import { test, expect, type Page } from "@playwright/test";
@@ -39,8 +39,7 @@ const buildButton = (page: Page) =>
group(page).getByRole("button", { name: "Build homepage", exact: true });
const deployButton = (page: Page) =>
group(page).getByRole("button", { name: "Deploy homepage", exact: true });
-// Scoped: /sites has three other "Deploy after build" checkboxes (the batch,
-// the specific-sites panel, the hub).
+// Scoped: the Hub row has a "Deploy after build" checkbox too.
const deployAfter = (page: Page) => group(page).getByLabel("Deploy after build");
const previewInput = (page: Page) =>
group(page).getByRole("textbox", { name: "preview branch", exact: true });
@@ -80,13 +79,16 @@ test.beforeEach(async () => {
await resetData("empty");
});
-test("the Homepage section follows Hub, with the build and deploy controls and what a deploy ships", async ({
+test("the Homepage row follows Hub's in the Publish panel, with the build and deploy controls and what a deploy ships", async ({
page,
}) => {
await page.goto("/sites");
- const hub = page.getByRole("heading", { name: "Hub", exact: true });
- const home = page.getByRole("heading", { name: "Homepage", exact: true });
+ const panel = page.locator("section").filter({
+ has: page.getByRole("heading", { name: "Publish", exact: true }),
+ });
+ const hub = panel.getByRole("heading", { name: "Hub", exact: true });
+ const home = panel.getByRole("heading", { name: "Homepage", exact: true });
await expect(home).toBeVisible();
expect((await hub.boundingBox())!.y).toBeLessThan((await home.boundingBox())!.y);
@@ -98,29 +100,22 @@ test("the Homepage section follows Hub, with the build and deploy controls and w
// Empty is production.
await expect(previewInput(page)).toHaveValue("");
- // One line: what homepage/out holds, the project, production, the live URL.
+ // One line: what the homepage's last build is, the project, production, the
+ // live URL. Its build is read from its stamp (_homepage/built.json), which a
+ // fresh test corpus has none of.
const ships = group(page).getByTestId("homepage-ships");
- if (await pathExists("../homepage/out/index.html")) {
- await expect(ships).toContainText(/Deploy homepage ships homepage\/out, built /);
- } else {
- await expect(ships).toContainText(
- "homepage/out holds no build yet, so Deploy homepage has nothing to ship.",
- );
- }
+ await expect(ships).toContainText(
+ "homepage/out holds no build yet, so Deploy homepage has nothing to ship.",
+ );
await expect(ships).toContainText("to archilyzer (production)");
await expect(
ships.getByRole("link", { name: "https://archilyzer.pages.dev", exact: true }),
).toHaveAttribute("href", "https://archilyzer.pages.dev");
- await expect(group(page)).toContainText(
- "The homepage reads the search index as it stands: run Build index",
- );
-
- // The homepage's prose moved out of the Hub section and into its own.
- const hubSection = page.locator("section").filter({ has: hub });
- const homeSection = page.locator("section").filter({ has: home });
- await expect(hubSection).not.toContainText("archilyzer deploy homepage");
- await expect(homeSection).toContainText("archilyzer deploy homepage");
- await expect(homeSection).toContainText("homepage/out");
+ // "Run Build index first" is the row's chips now: the index it would be
+ // built from, and its build waiting for it.
+ await expect(group(page).locator('[data-chip="index"]')).toContainText("no index yet");
+ await expect(group(page).locator('[data-chip="built"]')).toContainText("update the index first");
+ await expect(group(page)).toContainText("Build homepage also publishes the source mirror");
});
test("a bad preview name greys out every deploy and says why; a good one shows its alias", async ({
@@ -165,24 +160,20 @@ test("a bad preview name greys out every deploy and says why; a good one shows i
await previewInput(page).fill("");
await expect(ships).toContainText("to archilyzer (production)");
- // Nothing above was clicked: no homepage job of any kind exists.
- for (const kind of ["build-homepage", "deploy-homepage", "build-deploy-homepage"]) {
+ // Nothing above was clicked: no publish stage of any kind exists.
+ for (const kind of ["publish-update-index", "publish-build-homepage", "publish-deploy-homepage"]) {
expect(await metasOfKind(kind), kind).toEqual([]);
}
});
-test("Build homepage starts a build-homepage job on the build queue (held there, then cancelled from its lane)", async ({
+test("Build homepage enqueues the index update, then build-homepage, on the publish queue (held there, then cancelled from its lane)", async ({
page,
request,
}) => {
- // See the header: with both queues held, whatever the click starts can only
- // queue.
- 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);
- }
+ // See the header: with the publish queue held, whatever the click starts
+ // can only queue.
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
+ expect(hold.ok()).toBe(true);
await openSites(page);
await expect(deployAfter(page)).not.toBeChecked();
@@ -192,13 +183,15 @@ test("Build homepage starts a build-homepage job on the build queue (held there,
await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 });
await expect(group(page).getByRole("alert")).toHaveCount(0);
- // One build-homepage job, on the build queue, queued — and nothing else.
- await expect.poll(async () => (await metasOfKind("build-homepage")).length).toBe(1);
- const [job] = await metasOfKind("build-homepage");
- expect(job.queueKey).toBe("build");
+ // One run: the index update (there is no index yet) and one build-homepage,
+ // both on the publish queue, queued — and no deploy.
+ await expect.poll(async () => (await metasOfKind("publish-build-homepage")).length).toBe(1);
+ const [job] = await metasOfKind("publish-build-homepage");
+ expect(job.queueKey).toBe("publish");
expect(job.status).toBe("queued");
- expect(await metasOfKind("build-deploy-homepage")).toEqual([]);
- expect(await metasOfKind("deploy-homepage")).toEqual([]);
+ const [index] = await metasOfKind("publish-update-index");
+ expect(index?.status).toBe("queued");
+ expect(await metasOfKind("publish-deploy-homepage")).toEqual([]);
await group(page).getByRole("button", { name: "Cancel Build homepage" }).click();
await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible();
@@ -217,7 +210,11 @@ test("Build homepage starts a build-homepage job on the build queue (held there,
// rewrote the meta, so a restart's boot pass could re-queue a job the
// operator had cancelled, and a cancelled clip fetch could read as queued.
await expect
- .poll(async () => (await metasOfKind("build-homepage"))[0]?.status)
+ .poll(async () => (await metasOfKind("publish-build-homepage"))[0]?.status)
+ .toBe("cancelled");
+ // The run's Cancel was its first stage's: the rest of the run went with it.
+ await expect
+ .poll(async () => (await metasOfKind("publish-update-index"))[0]?.status)
.toBe("cancelled");
});
@@ -225,7 +222,7 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is
page,
request,
}) => {
- // The line reads homepage/out when /sites renders, and nothing here may
+ // The line reads the homepage's stamp when /sites renders, and nothing here may
// build (see the header) — so the proof is that the page RE-RENDERS when the
// lane ends: a site written to disk after the page loaded appears without a
// reload. Passive refresh is off (it would re-render on the job's status
@@ -233,12 +230,8 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is
// the harness reset, because the lane's own Cancel is a server action that
// revalidates, and its response re-renders the page on its own.
await writeSettings({ autoRefreshIntervalSeconds: 0 });
- 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);
- }
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
+ expect(hold.ok()).toBe(true);
await openSites(page);
await buildButton(page).click();
@@ -248,7 +241,7 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is
const probe = page.locator('a[href="/sites/refresh-probe"]');
await expect(probe).toHaveCount(0);
- // Cancels every live job, newest first: the queued build before either
+ // Cancels every live job, newest first: the queued stages before the
// holder's slot is freed, so nothing is promoted and nothing builds.
const reset = await request.get(`${baseUrl}/api/test/invalidate-cache`);
expect(reset.ok()).toBe(true);
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -28,7 +28,10 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
// E2E_FAKE_WRANGLER_AUTH_FAIL=1 fake-wrangler.mjs fails as Cloudflare
// refusing the API token ("Authentication error
// [code: 10000]"), for the deploy stage's
-// "[deploy] REFUSED by Cloudflare" sentence
+// "[deploy] REFUSED by Cloudflare" sentence — the
+// fallback of its mode sidecar, which a spec
+// writes (`<exportBuildsDir>/.fake-wrangler-
+// mode.json` `{"authFail": true}`)
// E2E_LIVE_CHECK=skip the deploy stage's live check reads nothing and
// records "skipped" (common/publish/liveCheck.ts):
// the fake wrangler deploys nothing to read. Set
@@ -42,6 +45,21 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
// (common/lib/pagesDeploy.ts wranglerBin), pointed below at
// e2e/fixtures/bin/fake-wrangler.mjs so no spec can reach Cloudflare — the fake
// writes its argv to `.fake-wrangler.json` beside the bundle it was handed.
+// Three more product variables the publish stages read are set for the test
+// server (release 18 S4):
+// ARCHILYZER_BRANCH=main the branch a build stamps (stamps.ts: the env
+// wins over git). A worktree's branch is never
+// main, and production refuses a build of any
+// other — without it every production deploy in
+// the suite would be refused
+// CLOUDFLARE_API_TOKEN a dummy: the deploy stage's credential
+// preflight asks for a token or a wrangler login
+// on disk; the fake wrangler never sends it, and
+// the suite must not depend on the host's login
+// EXPORT_NEXT_BIN e2e/fixtures/bin/fake-next.mjs: a site's and
+// the hub's build run compose for real, then this
+// in place of `next build` (it copies the composed
+// public dir to export/out)
//
// The ports are NOT prefixed: they are common/lib/ports.mjs's, injected per
// worktree by scripts/worktree.mjs and named in queue-lock's --ports list. Nor
@@ -56,6 +74,9 @@ const E2E_SERVER_ENV = {
E2E_AUDIO_CHECK_RECOVER_AFTER: "2",
E2E_LIVE_CHECK: "skip",
WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"),
+ ARCHILYZER_BRANCH: "main",
+ CLOUDFLARE_API_TOKEN: "e2e-fake-token-never-sent",
+ EXPORT_NEXT_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-next.mjs"),
};
const PORT = portFor("PORT");