From 55d4662dd239ced4da676d33d753a9b13f4c4457 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 21:13:02 +0000 Subject: [PATCH] fix(e2e): prune smoke-post URLs from sitemap on cms-publish-flow cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cms-publish-flow.spec.js creates a smoke post via the local Decap backend and runs `jekyll build` into the SHARED _site/ the Playwright webServer serves, which bakes /blog/e2e-publish-flow-smoke/ (and the manufactured /tags/e2e-smoke-flow-tag/ archive) into _site/sitemap.xml. Its cleanup deleted the rendered dirs but NOT those sitemap entries, leaving them advertised-but-404ing. image-alt-text.spec.js runs in the same e2e-admin job, shares that _site/, walks the sitemap, and failed: "expected 200 from /blog/e2e-publish-flow-smoke/, got 404" — an intermittent, order-dependent failure on unrelated PRs (e.g. #1654). Fix: extend removeSmokePost() to prune the orphaned blocks from _site/sitemap.xml so it stays consistent with what's on disk. Extracted the prune into a pure helper (e2e/sitemap-prune.js) with a unit test (e2e/sitemap-prune.test.js); verified against the real built sitemap. Not related to #1665 (e0d0256), which only touches cms/* preview-alias DNS slugs in deploy-preview.yml. https://claude.ai/code/session_01Bj5DjryAqCqW4czqyZ47f8 --- e2e/cms-publish-flow.spec.js | 17 ++++++++++ e2e/select-specs.js | 6 ++++ e2e/sitemap-prune.js | 30 ++++++++++++++++ e2e/sitemap-prune.test.js | 66 ++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 e2e/sitemap-prune.js create mode 100644 e2e/sitemap-prune.test.js diff --git a/e2e/cms-publish-flow.spec.js b/e2e/cms-publish-flow.spec.js index 81ea1975d..3fa3310c6 100644 --- a/e2e/cms-publish-flow.spec.js +++ b/e2e/cms-publish-flow.spec.js @@ -4,6 +4,7 @@ const path = require("node:path"); const { execFileSync } = require("node:child_process"); const { test, expect } = require("./base"); const { captureStep } = require("./manual-capture"); +const { pruneSitemapUrls } = require("./sitemap-prune"); // True end-to-end content loop: drive the live Decap admin to create a new // post, rebuild the site, then GET /blog// and assert the post is @@ -78,6 +79,22 @@ function removeSmokePost() { ]) { if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); } + // ...and prune those URLs from the prebuilt `_site/sitemap.xml`. The + // in-test jekyllBuild() baked /blog// (and the manufactured + // /tags// archive) into the sitemap; deleting the rendered dirs + // above leaves those s advertised but 404-ing. image-alt-text.spec.js + // runs in the SAME e2e-admin job, shares this `_site/`, walks the + // sitemap, and fails on the orphaned 404 — so keep the sitemap + // consistent with what's actually on disk. + const sitemap = path.join(REPO_ROOT, "_site", "sitemap.xml"); + if (fs.existsSync(sitemap)) { + const xml = fs.readFileSync(sitemap, "utf8"); + const cleaned = pruneSitemapUrls(xml, [ + `/blog/${SMOKE_SLUG}/`, + `/tags/${SMOKE_TAG_SLUG}/`, + ]); + if (cleaned !== xml) fs.writeFileSync(sitemap, cleaned); + } } function jekyllBuild() { diff --git a/e2e/select-specs.js b/e2e/select-specs.js index 43b111591..0fc800fb5 100644 --- a/e2e/select-specs.js +++ b/e2e/select-specs.js @@ -297,6 +297,12 @@ const SPEC_RULES = { /^_posts\//, /^_layouts\/(post|default)\.html$/, /^_includes\//, + // Cleanup helper that prunes the smoke post's orphaned sitemap URLs. + /^e2e\/sitemap-prune\.js$/, + ], + // Pure-node unit test for the sitemap-prune cleanup helper. + "e2e/sitemap-prune.test.js": [ + /^e2e\/sitemap-prune\.js$/, ], "e2e/cms-preview-url.spec.js": [ /^admin\//, diff --git a/e2e/sitemap-prune.js b/e2e/sitemap-prune.js new file mode 100644 index 000000000..a7dfe2741 --- /dev/null +++ b/e2e/sitemap-prune.js @@ -0,0 +1,30 @@ +// Pure helper: remove blocks from a jekyll-sitemap XML string. +// +// Why this exists: cms-publish-flow.spec.js creates a smoke post via the +// local Decap backend, runs `jekyll build` into the SHARED `_site/` the +// Playwright webServer serves, then cleans up. Its cleanup deletes the +// rendered `_site/blog//` (and the manufactured tag archive) but the +// built `_site/sitemap.xml` still advertises those s — so they 404. +// image-alt-text.spec.js runs in the SAME e2e-admin job, shares that +// `_site/`, walks the sitemap, and fails on the orphaned 404 ("expected +// 200 from /blog/e2e-publish-flow-smoke/, got 404"). Pruning the orphaned +// blocks on cleanup keeps the sitemap consistent with what's on disk. +// +// Kept as a pure, exported function so it's unit-testable without booting +// Jekyll, Decap, or a browser (see sitemap-prune.test.js). + +// Remove every block whose body contains any of `locNeedles` +// (matched as plain substrings against the block text, which includes the +// full URL). Returns the cleaned XML; unmatched input is returned +// unchanged. Robust to attribute/whitespace variation because it slices on +// the element boundaries rather than parsing the whole document. +function pruneSitemapUrls(xml, locNeedles) { + if (!xml || !Array.isArray(locNeedles) || locNeedles.length === 0) { + return xml; + } + return xml.replace(/[\s\S]*?<\/url>\s*/g, (block) => + locNeedles.some((needle) => needle && block.includes(needle)) ? "" : block, + ); +} + +module.exports = { pruneSitemapUrls }; diff --git a/e2e/sitemap-prune.test.js b/e2e/sitemap-prune.test.js new file mode 100644 index 000000000..d1f6990cf --- /dev/null +++ b/e2e/sitemap-prune.test.js @@ -0,0 +1,66 @@ +// @lane: local — pure-node unit test for e2e/sitemap-prune.js (no browser) +const { test, expect } = require("./base"); +const { pruneSitemapUrls } = require("./sitemap-prune"); + +// jekyll-sitemap emits a flat of blocks. +// This mirrors the shape cms-publish-flow.spec.js's jekyllBuild() produces, +// including the orphaned smoke-post + manufactured-tag entries its cleanup +// must prune so image-alt-text.spec.js (shared _site) doesn't 404 on them. +const SITEMAP = ` + + +https://adamdaniel.ai/blog/introducing-gha-bench/ +2026-05-12T00:00:00+00:00 + + +https://adamdaniel.ai/blog/e2e-publish-flow-smoke/ +2026-05-24T00:00:00+00:00 + + +https://adamdaniel.ai/tags/e2e-smoke-flow-tag/ + + +https://adamdaniel.ai/ + + +`; + +test.describe("sitemap-prune", () => { + test("removes the orphaned smoke-post and tag URLs, keeps the rest", () => { + const out = pruneSitemapUrls(SITEMAP, [ + "/blog/e2e-publish-flow-smoke/", + "/tags/e2e-smoke-flow-tag/", + ]); + expect(out).not.toContain("/blog/e2e-publish-flow-smoke/"); + expect(out).not.toContain("/tags/e2e-smoke-flow-tag/"); + // Untouched URLs survive… + expect(out).toContain("/blog/introducing-gha-bench/"); + expect(out).toContain("https://adamdaniel.ai/"); + // …and exactly two blocks were removed (4 → 2). + expect((out.match(//g) || []).length).toBe(2); + // Still well-formed: every opening has a matching close. + expect((out.match(//g) || []).length).toBe( + (out.match(/<\/url>/g) || []).length, + ); + expect(out).toContain(""); + }); + + test("substring match doesn't over-prune a longer path that contains the needle", () => { + const xml = ` +https://adamdaniel.ai/blog/smoke/ +https://adamdaniel.ai/blog/smoke-test-results/ +`; + // Needle "/blog/smoke/" has a trailing slash, so it must NOT match + // "/blog/smoke-test-results/". + const out = pruneSitemapUrls(xml, ["/blog/smoke/"]); + expect(out).not.toContain("/blog/smoke/"); + expect(out).toContain("/blog/smoke-test-results/"); + expect((out.match(//g) || []).length).toBe(1); + }); + + test("no-ops when there are no needles or no match", () => { + expect(pruneSitemapUrls(SITEMAP, [])).toBe(SITEMAP); + expect(pruneSitemapUrls(SITEMAP, ["/blog/does-not-exist/"])).toBe(SITEMAP); + expect(pruneSitemapUrls("", ["/x/"])).toBe(""); + }); +});