summaryrefslogtreecommitdiff
path: root/workers/picker-test
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-09-25 17:11:19 +0200
committerGitHub <noreply@github.com>2026-09-25 16:11:19 +0100
commita783e56b774795c1f4bd8a6088dfcce96a307c5f (patch)
treedcd0c88ff02969351840c8222ea97c72d9258e18 /workers/picker-test
parent4c8d1d1a0f23e2106a97f4b672607121ff03a091 (diff)
downloadvyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.tar.gz
vyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.zip
ci: port the Cloudflare Workers docs pipeline to circinus (slug 1.5) (#2208)
* ci: port Cloudflare Workers docs pipeline files to circinus (verbatim) Copies the branch-agnostic half of the docs.vyos.io Cloudflare Workers pipeline from `rolling` at 8cb568bf, byte-identical: - .github/workflows/docs-build.yml - workers/ (entire tree) - scripts/docs_gates/ - docker/im-convert.sh - docs/_static/js/version-picker.js, js/pagefind-wrapper.js, css/version-picker.css (new files, no circinus counterpart) - docs/_templates/breadcrumbs.html, searchbox.html (new files) docs-build.yml already triggers on push to [rolling, circinus, sagitta] and resolves `circinus` -> worker vyos-docs-v15-en / slug 1.5 from workers/matrix.json; the files simply did not exist on this branch, so slug 1.5 still serves the bootstrap placeholder. workers/versions.json + workers/matrix.json are deliberately identical across all three branches and must be kept in sync. Advances: IS-572 * ci: wire circinus docs build into the Cloudflare Workers pipeline Hand-merges the CF-specific hunks onto circinus's own docker/Dockerfile and docs/conf.py rather than clobbering them with rolling's versions โ€” circinus keeps its own content-driven history in both files. docker/Dockerfile: - imagemagick + librsvg2-bin (sphinx.ext.imgconverter backend) and poppler-utils (pdfinfo, used by docs-build.yml's PDF page-count completeness check), installed --no-install-recommends - install docker/im-convert.sh as /usr/local/bin/im-convert docs/conf.py: - enable sphinx.ext.imgconverter + image_converter = 'im-convert' so the LaTeX/PDF builder stops silently dropping .webp/.svg images - register js/version-picker.js + css/version-picker.css unconditionally (degrades silently on ReadTheDocs) - _vyos_cf_build gate off the raw DOCS_VERSION_SLUG env var; only CF builds load js/pagefind-wrapper.js, and html_context['vyos_cf_build'] lets _templates/searchbox.html fall back to the stock Sphinx searchbox via the "!" bang-include on RTD The RTD path stays the default in both files: circinus continues building on ReadTheDocs until RTD sunset, and every CF feature activates only when DOCS_VERSION_SLUG is present. .readthedocs.yml is untouched. circinus keeps its own version/release/html_title/source_suffix and its hardcoded html_baseurl (its CF slug is also `1.5`, so rolling's DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is a no-op here and was deliberately not ported). Advances: IS-572 * ci: IS-572: re-sync ported Cloudflare Workers pipeline files with rolling The category-1 files in this port are byte-identical copies from `rolling`. `rolling` has since moved: [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) merged as `3a1c6c30`, thirteen rounds of hardening on exactly these files. Re-take all 14 category-1 paths from `origin/rolling` via `git checkout origin/rolling -- <paths>`, so byte-identity holds by construction rather than by hand-editing: .github/workflows/docs-build.yml scripts/docs_gates/{gates,parity,smoke,test_gates,test_parity,test_smoke}.py workers/.gitignore workers/apex/src/{index,special,uagate}.ts workers/apex/test/{router,uagate}.test.ts workers/apex/ua-policy.json Thirteen of the fourteen carry [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) exactly โ€” the pre-change tree was byte-identical to `3a1c6c30^` for those paths. `workers/.gitignore` additionally picks up the one-line `test-results/` entry from [vyos-documentation#2212](https://github.com/vyos/vyos-documentation/pull/2212); inert on circinus, since only the deliberately-unported `apex-deploy.yml` writes that directory. Deliberate exclusions are unchanged: `docs-canary-qa.yml` (cron runs on the default branch only, so it is not ported even though [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) touched it on `rolling`), `apex-deploy.yml`, and the `docs-preview-*` workflows. `docs/conf.py` stays hand-merged and circinus-specific, with its ReadTheDocs fallback intact. ๐Ÿค– Generated by [robots](https://vyos.io)
Diffstat (limited to 'workers/picker-test')
-rw-r--r--workers/picker-test/pagefind-wrapper.test.ts42
-rw-r--r--workers/picker-test/picker.test.ts156
2 files changed, 198 insertions, 0 deletions
diff --git a/workers/picker-test/pagefind-wrapper.test.ts b/workers/picker-test/pagefind-wrapper.test.ts
new file mode 100644
index 00000000..75e8be50
--- /dev/null
+++ b/workers/picker-test/pagefind-wrapper.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub โ€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed
+// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint
+// documented in picker.test.ts and apex/test/manifest.test.ts; import as Vite `?raw` asset
+// instead so content is inlined at bundle time โ€” no runtime filesystem access needed.
+// eslint-disable-next-line import/no-unresolved
+import src from "../../docs/_static/js/pagefind-wrapper.js?raw";
+
+const ns: Record<string, unknown> = {};
+new Function("window", src)(ns as never);
+const W = (ns as never as { VyOSSearch: Record<string, CallableFunction> }).VyOSSearch;
+
+describe("basePathFor (ยง9)", () => {
+ it("production/canary version path", () => {
+ expect(W.basePathFor("/en/rolling/search.html"))
+ .toEqual({ base: "/en/rolling/", prefix: "" });
+ });
+ it("PR preview path keeps /pr-<n>/ prefix and reports it", () => {
+ expect(W.basePathFor("/pr-42/en/1.5/search.html"))
+ .toEqual({ base: "/pr-42/en/1.5/", prefix: "/pr-42" });
+ });
+ it("prefixes result URLs in previews", () => {
+ expect(W.prefixResultUrl("/en/1.5/cli/index.html", "/pr-42")).toBe("/pr-42/en/1.5/cli/index.html");
+ expect(W.prefixResultUrl("/en/1.5/cli/index.html", "")).toBe("/en/1.5/cli/index.html");
+ });
+});
+
+describe("assetUrlsFor (css + js pair under the version base)", () => {
+ it("production/canary base", () => {
+ expect(W.assetUrlsFor("/en/rolling/")).toEqual({
+ css: "/en/rolling/pagefind/pagefind-ui.css",
+ js: "/en/rolling/pagefind/pagefind-ui.js",
+ });
+ });
+ it("PR preview base keeps the /pr-<n>/ prefix", () => {
+ expect(W.assetUrlsFor("/pr-42/en/1.5/")).toEqual({
+ css: "/pr-42/en/1.5/pagefind/pagefind-ui.css",
+ js: "/pr-42/en/1.5/pagefind/pagefind-ui.js",
+ });
+ });
+});
diff --git a/workers/picker-test/picker.test.ts b/workers/picker-test/picker.test.ts
new file mode 100644
index 00000000..560fd0d2
--- /dev/null
+++ b/workers/picker-test/picker.test.ts
@@ -0,0 +1,156 @@
+import { describe, it, expect } from "vitest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub โ€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed
+// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint
+// documented in apex/test/manifest.test.ts; import as Vite `?raw` / native JSON assets
+// instead so content is inlined at bundle time โ€” no runtime filesystem access needed.
+// eslint-disable-next-line import/no-unresolved
+import src from "../../docs/_static/js/version-picker.js?raw";
+// eslint-disable-next-line import/no-unresolved
+import manifest from "../versions.json";
+
+// Evaluate the plain script and grab its namespace (no DOM access at module scope allowed).
+const ns: Record<string, CallableFunction> = {};
+new Function("window", src)(ns as never);
+const P = (ns as never as { VyOSVersionPicker: Record<string, CallableFunction> }).VyOSVersionPicker;
+
+describe("parseLocation", () => {
+ it("extracts lang/slug/rest from a docs path", () => {
+ expect(P.parseLocation("/en/1.5/cli/index.html"))
+ .toEqual({ lang: "en", slug: "1.5", rest: "cli/index.html" });
+ });
+ it("returns null off the version tree (e.g. previews without prefix knowledge)", () => {
+ expect(P.parseLocation("/kb/x")).toBeNull();
+ });
+
+ // The slug segment is restricted to the sphinx slug charset so hostile text can never
+ // reach URL construction as a version identifier (CodeQL js/xss-through-dom).
+ it("accepts real version slugs", () => {
+ expect(P.parseLocation("/en/rolling/index.html")).toMatchObject({ slug: "rolling" });
+ expect(P.parseLocation("/en/1.5/cli/index.html")).toMatchObject({ slug: "1.5" });
+ });
+ it("rejects a slug carrying markup metacharacters", () => {
+ expect(P.parseLocation("/en/foo<img>/page.html")).toBeNull();
+ });
+ it("rejects a slug carrying a quote or a space", () => {
+ expect(P.parseLocation('/en/foo"bar/page.html')).toBeNull();
+ expect(P.parseLocation("/en/foo bar/page.html")).toBeNull();
+ });
+ // "." and ".." are the only normalizing dot-segments: as a slug they would walk out of
+ // the /<lang>/<slug>/ tree once the browser resolves the URL. Interior dots are fine.
+ it("rejects the dot-segments '.' and '..' as slugs, keeping dotted version slugs", () => {
+ expect(P.parseLocation("/en/../index.html")).toBeNull();
+ expect(P.parseLocation("/en/./index.html")).toBeNull();
+ expect(P.parseLocation("/en/1.5/index.html")).toMatchObject({ slug: "1.5" });
+ });
+});
+
+describe("bannerFor (ยง4)", () => {
+ it("dev โ†’ info banner", () => {
+ expect(P.bannerFor("rolling", manifest)).toMatchObject({ kind: "dev" });
+ });
+ it("newest lts โ†’ no banner; older lts โ†’ newer-lts notice naming 1.5", () => {
+ expect(P.bannerFor("1.5", manifest)).toBeNull();
+ expect(P.bannerFor("1.4", manifest)).toMatchObject({ kind: "newer-lts", newest: "1.5" });
+ });
+ it("eol โ†’ warning linking newest LTS", () => {
+ expect(P.bannerFor("1.3", manifest)).toMatchObject({ kind: "eol", newest: "1.5" });
+ });
+});
+
+describe("targetUrlFor", () => {
+ it("same path on target version", () => {
+ expect(P.targetUrlFor({ lang: "en", slug: "1.5", rest: "cli/index.html" }, "1.4"))
+ .toBe("/en/1.4/cli/index.html");
+ });
+});
+
+describe("navUrlFor (query + fragment preserved across version switch)", () => {
+ const loc = { lang: "en", slug: "1.4", rest: "quick-start.html" };
+ it("neither โ†’ bare target path", () => {
+ expect(P.navUrlFor(loc, "1.5", "", "")).toBe("/en/1.5/quick-start.html");
+ });
+ it("query-only", () => {
+ expect(P.navUrlFor(loc, "1.5", "?ref=x", ""))
+ .toBe("/en/1.5/quick-start.html?ref=x");
+ });
+ it("hash-only", () => {
+ expect(P.navUrlFor(loc, "1.5", "", "#section-3"))
+ .toBe("/en/1.5/quick-start.html#section-3");
+ });
+ it("both, in query-then-hash order", () => {
+ expect(P.navUrlFor(loc, "1.5", "?ref=x", "#section-3"))
+ .toBe("/en/1.5/quick-start.html?ref=x#section-3");
+ });
+});
+
+/* DOM-text sources (select.value, location.pathname) reach a location.href sink, so every
+ * path component is percent-encoded at construction time (CodeQL js/xss-through-dom). */
+describe("URL construction percent-encodes hostile path components", () => {
+ const loc = { lang: "en", slug: "1.5", rest: "cli/index.html" };
+ // Characters that could break out of a path segment or introduce a URL scheme.
+ const HOSTILE = ['"', "'", "<", ">", " ", ":"];
+
+ it("is a no-op on legitimate sphinx slugs โ€” URLs byte-identical to pre-hardening", () => {
+ expect(P.targetUrlFor(loc, "1.4")).toBe("/en/1.4/cli/index.html");
+ expect(P.targetUrlFor(loc, "rolling")).toBe("/en/rolling/cli/index.html");
+ expect(P.targetUrlFor({ lang: "en", slug: "1.4", rest: "" }, "1.5")).toBe("/en/1.5/");
+ expect(P.langUrlFor(loc, "de")).toBe("/de/1.5/cli/index.html");
+ });
+
+ it("encodePath keeps '/' separators while encoding each segment", () => {
+ expect(P.encodePath("cli/index.html")).toBe("cli/index.html");
+ expect(P.encodePath('a b/c"d/e.html')).toBe("a%20b/c%22d/e.html");
+ });
+
+ // location.pathname returns well-formed escapes verbatim, so encoding blindly would
+ // double-encode them (%2E -> %252E) and break the deep link on the HEAD probe.
+ it("encodePath normalizes pre-existing escapes instead of double-encoding", () => {
+ expect(P.encodePath("index%2Ehtml")).toBe("index.html");
+ expect(P.encodePath("a%20b/c.html")).toBe("a%20b/c.html");
+ expect(P.encodePath("a%2Fb/c")).toBe("a%2Fb/c"); // encoded slash stays in its segment
+ });
+
+ it("encodePath degrades safely on a malformed escape (no throw)", () => {
+ const url = P.encodePath("100%zz/x");
+ expect(url).toBe("100%25zz/x");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ // Normalization runs per %HH run, not per segment: a whole-segment decode throws on the
+ // malformed escape and then double-encodes the valid one beside it (a%2520b%25zz).
+ it("encodePath normalizes each escape run independently in a mixed-validity segment", () => {
+ const url = P.encodePath("a%20b%zz/x");
+ expect(url).toBe("a%20b%25zz/x");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("encodePath keeps an invalid-UTF-8 escape run verbatim (already pure %HH text)", () => {
+ const url = P.encodePath("x%E0%A4y.html");
+ expect(url).toBe("x%E0%A4y.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("targetUrlFor encodes a markup-injecting target slug", () => {
+ const url = P.targetUrlFor(loc, '"><img src=x>');
+ expect(url).toBe("/en/%22%3E%3Cimg%20src%3Dx%3E/cli/index.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("targetUrlFor kills the colon in a javascript:-shaped slug", () => {
+ expect(P.targetUrlFor(loc, "javascript:alert(1)"))
+ .toBe("/en/javascript%3Aalert(1)/cli/index.html");
+ });
+
+ it("targetUrlFor encodes a hostile lang segment", () => {
+ const url = P.targetUrlFor({ lang: 'en"><script>', slug: "1.5", rest: "a.html" }, "1.4");
+ expect(url).toBe("/en%22%3E%3Cscript%3E/1.4/a.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("langUrlFor encodes the new lang plus the retained slug and rest", () => {
+ const url = P.langUrlFor({ lang: "en", slug: 'x"y', rest: 'a b/c.html' }, "de<i>");
+ expect(url).toBe("/de%3Ci%3E/x%22y/a%20b/c.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+});