Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions e2e/cms-publish-flow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/ and assert the post is
Expand Down Expand Up @@ -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/<slug>/ (and the manufactured
// /tags/<tag>/ archive) into the sitemap; deleting the rendered dirs
// above leaves those <loc>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() {
Expand Down
6 changes: 6 additions & 0 deletions e2e/select-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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\//,
Expand Down
30 changes: 30 additions & 0 deletions e2e/sitemap-prune.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Pure helper: remove <url> 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/<slug>/` (and the manufactured tag archive) but the
// built `_site/sitemap.xml` still advertises those <loc>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
// <url> 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 <url>…</url> block whose body contains any of `locNeedles`
// (matched as plain substrings against the block text, which includes the
// full <loc> URL). Returns the cleaned XML; unmatched input is returned
// unchanged. Robust to attribute/whitespace variation because it slices on
// the <url> 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(/<url>[\s\S]*?<\/url>\s*/g, (block) =>
locNeedles.some((needle) => needle && block.includes(needle)) ? "" : block,
);
}

module.exports = { pruneSitemapUrls };
66 changes: 66 additions & 0 deletions e2e/sitemap-prune.test.js
Original file line number Diff line number Diff line change
@@ -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 <urlset> of <url><loc>…</loc>…</url> 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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://adamdaniel.ai/blog/introducing-gha-bench/</loc>
<lastmod>2026-05-12T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://adamdaniel.ai/blog/e2e-publish-flow-smoke/</loc>
<lastmod>2026-05-24T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://adamdaniel.ai/tags/e2e-smoke-flow-tag/</loc>
</url>
<url>
<loc>https://adamdaniel.ai/</loc>
</url>
</urlset>
`;

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("<loc>https://adamdaniel.ai/</loc>");
// …and exactly two <url> blocks were removed (4 → 2).
expect((out.match(/<url>/g) || []).length).toBe(2);
// Still well-formed: every opening <url> has a matching close.
expect((out.match(/<url>/g) || []).length).toBe(
(out.match(/<\/url>/g) || []).length,
);
expect(out).toContain("</urlset>");
});

test("substring match doesn't over-prune a longer path that contains the needle", () => {
const xml = `<urlset>
<url><loc>https://adamdaniel.ai/blog/smoke/</loc></url>
<url><loc>https://adamdaniel.ai/blog/smoke-test-results/</loc></url>
</urlset>`;
// 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(/<url>/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("");
});
});
Loading