From 1136f1dafe00bae2bcbcbbfe78dd4d73f5dc926e Mon Sep 17 00:00:00 2001 From: Lenoux01 <60329596+Lenoux01@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:38:21 +0200 Subject: [PATCH 1/3] feat(build): resolve relative item links against meta.repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `links[].url` like `docs/design/blueprint-module.md` was emitted verbatim, so on a consumer site it resolved against that site's origin and 404ed (new-campus#139 worked around it by hand). Both projections now resolve relative links to `https://github.com//blob/HEAD/` before they leave — HEAD so the output is identical with and without a token and `build --check` never drifts on a branch rename. Absolute URLs, `mailto:` and `#fragment` pass through; a link that cannot resolve (climbs out of the repo, query-only) is dropped and `validate` warns as V15, naming the item and the link. Documented in README, roadmap-yaml.md and validation.md. Refs #3 Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 4 ++ README.md | 9 ++++ index.ts | 1 + links.test.ts | 69 +++++++++++++++++++++++++ links.ts | 74 +++++++++++++++++++++++++++ project.test.ts | 45 ++++++++++++++++ project.ts | 8 ++- site/src/content/docs/roadmap-yaml.md | 29 ++++++++++- site/src/content/docs/validation.md | 1 + validate.test.ts | 24 +++++++++ validate.ts | 16 +++++- 11 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 links.test.ts create mode 100644 links.ts diff --git a/CLAUDE.md b/CLAUDE.md index 350bb0b..9580fce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,10 @@ turns it into JSX. Add a field to the view model, then to both renderers. - **Status is derived once linked.** `rollup.ts` is a pure function; every branch has a fixture test. Manual `status:` on a linked item is a validation error, not a preference. +- **Item links leave resolved.** `links.ts` turns a relative `url` into a + `github.com//blob/HEAD/…` URL in BOTH projections and drops what it + cannot resolve (V15 warns). Renderers never see a relative href; do not add + a second resolver in a renderer. - **dispatch is dry-run by default** and creates only issues + the `roadmap` label. It never edits an existing issue, never removes a label, never writes yaml, never sets `auto-merge-ok` without `--auto-merge`, and puts `ready` diff --git a/README.md b/README.md index 01c76ec..1d5c0e9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ items: phase: P1 visibility: public depends_on: [p0-simcore] + links: + - { title: Design note, url: docs/design/garage-loop.md } tasks: - id: job-board title: Job board with three job archetypes @@ -53,6 +55,13 @@ milestones: `source: { kind, by, at }`. Undated milestones render as drafts. A computed date is not a date. +Item `links` are written from the repo's point of view and leave resolved: a +relative `url` such as `docs/design/garage-loop.md` becomes +`https://github.com//blob/HEAD/docs/design/garage-loop.md` in +every projection (`HEAD` is the default branch, whatever it is called). +Absolute URLs, `mailto:` and `#fragment` links pass through untouched; a link +that cannot be resolved (`../outside`) is dropped and `validate` warns (V15). + ## Commands Run it with `npx @hansenexus/nextup ` (node ≥ 20) or diff --git a/index.ts b/index.ts index 92353b5..5d85bbf 100644 --- a/index.ts +++ b/index.ts @@ -27,6 +27,7 @@ export type { ClientOptions, CreateIssueInput, GitHubClient } from "./github"; export { createGitHubClient, fetchRoadmapIssues, GitHubError } from "./github"; export { reachable, topoSort } from "./graph"; export { localesOf, missingLocale, resolveText } from "./i18n"; +export { GITHUB_LINK_REF, resolveLink, resolveLinks } from "./links"; export type { LoadAllResult, LoadedRoadmap, Located, ParseResult } from "./load"; export { expandGlobs, diff --git a/links.test.ts b/links.test.ts new file mode 100644 index 0000000..a753134 --- /dev/null +++ b/links.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { GITHUB_LINK_REF, resolveLink, resolveLinks } from "./links"; + +const REPO = "hansenexus/new-campus"; +const BASE = `https://github.com/${REPO}/blob/${GITHUB_LINK_REF}/`; + +describe("resolveLink", () => { + it("leaves absolute URLs untouched", () => { + for (const url of [ + "https://example.com/x", + "http://example.com/x?y=1#z", + "HTTPS://EXAMPLE.COM", + "//cdn.example.com/asset.png", + "ftp://host/file", + ]) + expect(resolveLink(url, REPO)).toBe(url); + }); + it("leaves #fragment-only and mailto:/tel: links untouched", () => { + expect(resolveLink("#section", REPO)).toBe("#section"); + expect(resolveLink("mailto:hello@example.com", REPO)).toBe("mailto:hello@example.com"); + expect(resolveLink("tel:+491234", REPO)).toBe("tel:+491234"); + // …even when no repo is known: nothing to resolve against is needed. + expect(resolveLink("#section", null)).toBe("#section"); + expect(resolveLink("mailto:a@b", null)).toBe("mailto:a@b"); + }); + it("resolves a relative path against the repo at HEAD", () => { + expect(resolveLink("docs/design/blueprint-module.md", REPO)).toBe( + `${BASE}docs/design/blueprint-module.md` + ); + expect(GITHUB_LINK_REF).toBe("HEAD"); + }); + it("keeps the fragment and query of a relative path", () => { + expect(resolveLink("docs/PLAN.md#phasing", REPO)).toBe(`${BASE}docs/PLAN.md#phasing`); + expect(resolveLink("docs/PLAN.md?plain=1#phasing", REPO)).toBe( + `${BASE}docs/PLAN.md?plain=1#phasing` + ); + }); + it("normalizes ./, /, // and a trailing slash to a repo-root path", () => { + expect(resolveLink("./docs/x.md", REPO)).toBe(`${BASE}docs/x.md`); + expect(resolveLink("/docs/x.md", REPO)).toBe(`${BASE}docs/x.md`); + expect(resolveLink("docs//x.md", REPO)).toBe(`${BASE}docs/x.md`); + expect(resolveLink("docs/a/../x.md", REPO)).toBe(`${BASE}docs/x.md`); + expect(resolveLink("docs/", REPO)).toBe(`${BASE}docs`); + }); + it("drops what it cannot resolve: no repo, a path above the root, query-only, empty", () => { + expect(resolveLink("docs/x.md", null)).toBeNull(); + expect(resolveLink("../other-repo/x.md", REPO)).toBeNull(); + expect(resolveLink("docs/../../x.md", REPO)).toBeNull(); + expect(resolveLink("?tab=readme", REPO)).toBeNull(); + expect(resolveLink(".", REPO)).toBeNull(); + expect(resolveLink("", REPO)).toBeNull(); + }); +}); + +describe("resolveLinks", () => { + it("maps every link and drops the unresolvable ones, keeping order and titles", () => { + const links = [ + { title: "site", url: "https://example.com" }, + { title: "escapes", url: "../x" }, + { title: "doc", url: "docs/x.md" }, + ]; + expect(resolveLinks(links, REPO)).toEqual([ + { title: "site", url: "https://example.com" }, + { title: "doc", url: `${BASE}docs/x.md` }, + ]); + expect(resolveLinks(links, null)).toEqual([{ title: "site", url: "https://example.com" }]); + expect(resolveLinks([], REPO)).toEqual([]); + }); +}); diff --git a/links.ts b/links.ts new file mode 100644 index 0000000..7a07a68 --- /dev/null +++ b/links.ts @@ -0,0 +1,74 @@ +/** + * Item links, resolved against the roadmap's repository. + * + * A `links[].url` in roadmap.yaml is written from the repo's point of view — + * `docs/design/blueprint-module.md` — but rendered on a consumer's site, where + * a relative href resolves against THAT site's origin and 404s. So both + * projections resolve every relative link before it leaves: + * + * docs/PLAN.md#phasing → https://github.com///blob/HEAD/docs/PLAN.md#phasing + * + * `HEAD` on purpose, not the default branch: GitHub resolves it server-side, + * so the output is identical with and without a token, and `build --check` + * never reports drift because someone renamed `master` to `main`. + * + * Untouched: anything with a scheme (`https:`, `mailto:`, `tel:` …), + * protocol-relative `//host/…`, and a bare `#fragment`. Dropped — and flagged + * by `validate` as V15 — is what cannot resolve: a path that climbs out of + * the repo (`../x`), a query-only `?x`, or any relative link when no repo is + * known. A link that cannot resolve is worse than no link. + * + * Pure and node-free so the renderers' dependency graph stays browser-safe. + */ + +import type { Link } from "./types"; + +/** The ref relative links resolve to. GitHub maps it to the default branch. */ +export const GITHUB_LINK_REF = "HEAD"; + +const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; + +/** `a/./b/../c/` → `a/c`; null when the path climbs above the root or is empty. */ +function normalizeRepoPath(p: string): string | null { + const out: string[] = []; + for (const seg of p.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") { + if (out.length === 0) return null; + out.pop(); + continue; + } + out.push(seg); + } + return out.length === 0 ? null : out.join("/"); +} + +/** + * The href a renderer may emit for `url`, or null when there is none to emit. + * `repo` is `owner/name`; null means "unknown", which drops every relative link. + */ +export function resolveLink(url: string, repo: string | null): string | null { + if (url === "") return null; + if (SCHEME_RE.test(url) || url.startsWith("//") || url.startsWith("#")) return url; + if (url.startsWith("?")) return null; + if (!repo) return null; + const hash = url.indexOf("#"); + const fragment = hash >= 0 ? url.slice(hash) : ""; + const beforeHash = hash >= 0 ? url.slice(0, hash) : url; + const q = beforeHash.indexOf("?"); + const query = q >= 0 ? beforeHash.slice(q) : ""; + const rawPath = q >= 0 ? beforeHash.slice(0, q) : beforeHash; + const repoPath = normalizeRepoPath(rawPath); + if (repoPath === null) return null; + return `https://github.com/${repo}/blob/${GITHUB_LINK_REF}/${repoPath}${query}${fragment}`; +} + +/** The links a projection ships: resolved, with the unresolvable ones dropped. */ +export function resolveLinks(links: readonly Link[], repo: string | null): Link[] { + const out: Link[] = []; + for (const l of links) { + const url = resolveLink(l.url, repo); + if (url !== null) out.push({ title: l.title, url }); + } + return out; +} diff --git a/project.test.ts b/project.test.ts index e34ef36..be17e61 100644 --- a/project.test.ts +++ b/project.test.ts @@ -78,6 +78,43 @@ items: const r2 = rollup({ roadmap: p2.value, file: "roadmap.yaml", issues: [] }); expect(toPublic(p2.value, r2).items[0]?.depends_on).toEqual([]); }); + it("resolves relative item links against meta.repo at HEAD; absolute ones pass through", () => { + // examples/dev-empire links `docs/PLAN.md#phasing` — the exact case new-campus hit. + const simcore = pub.items.find((i) => i.id === "p0-simcore"); + expect(simcore?.links).toEqual([ + { + title: "PLAN.md §Phasing", + url: "https://github.com/example/dev-empire/blob/HEAD/docs/PLAN.md#phasing", + }, + ]); + const p2 = parseRoadmap(`schema: 1 +meta: { project: x, repo: o/r, title: X, version: 1, updated: 2026-09-01, public: true, current_phase: P0 } +phases: [{ id: P0, title: A }] +items: + - id: a + title: A + summary: S + phase: P0 + visibility: public + links: + - { title: site, url: "https://example.com/x" } + - { title: mail, url: "mailto:a@b" } + - { title: anchor, url: "#top" } + - { title: doc, url: ./docs/x.md } + - { title: escapes, url: ../other/x.md } +`); + if (!p2.ok) throw new Error("parse"); + const r2 = rollup({ roadmap: p2.value, file: "roadmap.yaml", issues: [] }); + const links = toPublic(p2.value, r2).items[0]?.links.map((l) => l.url); + expect(links).toEqual([ + "https://example.com/x", + "mailto:a@b", + "#top", + "https://github.com/o/r/blob/HEAD/docs/x.md", + ]); + // Every link a projection ships is one a browser can follow from any origin. + expect(findForbiddenKeys(toPublic(p2.value, r2))).toEqual([]); + }); it("an internal phase hides its public items", () => { const p2 = parseRoadmap(`schema: 1 meta: { project: x, repo: o/r, title: X, version: 1, updated: 2026-09-01, public: true, current_phase: P0 } @@ -106,4 +143,12 @@ describe("toInternal", () => { it("would be caught by the forbidden-key walk (sanity check of the guard itself)", () => { expect(findForbiddenKeys(internal).length).toBeGreaterThan(0); }); + it("resolves item links the same way the public projection does", () => { + // The internal page is served from a tailnet forward, not from the repo: + // a relative href is just as broken there. + const simcore = internal.items.find((i) => i.id === "p0-simcore"); + expect(simcore?.links.map((l) => l.url)).toEqual([ + "https://github.com/example/dev-empire/blob/HEAD/docs/PLAN.md#phasing", + ]); + }); }); diff --git a/project.ts b/project.ts index 2edcfcd..a7f4fd8 100644 --- a/project.ts +++ b/project.ts @@ -8,8 +8,13 @@ * apart silently. * * `toInternal` is everything, plus what the rollup learned. + * + * Both resolve item `links` against `meta.repo` (see `links.ts`): a + * projection is consumed on somebody else's origin, so a relative href must + * not leave here. */ +import { resolveLinks } from "./links"; import type { InternalRoadmap, Item, @@ -75,7 +80,7 @@ export function toPublic(roadmap: Roadmap, roll: Rollup): PublicRoadmap { kind: it.kind, ...(it.area ? { area: it.area } : {}), depends_on: it.depends_on.filter((d) => visibleIds.has(d)), - links: it.links, + links: resolveLinks(it.links, roadmap.meta.repo), ...(it.since ? { since: it.since } : {}), }; }); @@ -149,6 +154,7 @@ export function toInternal(roadmap: Roadmap, roll: Rollup, file: string): Intern const r = roll.items[it.id]; return { ...it, + links: resolveLinks(it.links, roadmap.meta.repo), horizon: r?.horizon ?? "later", derived_status: r?.status ?? it.status ?? "proposed", because: r?.because ?? "", diff --git a/site/src/content/docs/roadmap-yaml.md b/site/src/content/docs/roadmap-yaml.md index 5d8bfdc..8de581b 100644 --- a/site/src/content/docs/roadmap-yaml.md +++ b/site/src/content/docs/roadmap-yaml.md @@ -83,7 +83,8 @@ Ordered. Each phase: | `since` | — | required with a manual status | | `reason` | — | required with `dropped` | | `mechanics_wave` | `null` | `done` additionally needs this wave all green | -| `owners`, `notes`, `links` | | internal | +| `owners`, `notes` | | internal | +| `links` | `[]` | `{ title, url }` pairs; ship in both projections, resolved against `meta.repo` (below) | | `clients` | `[]` | reserved for a client tier; must stay empty in schema 1 | ### tasks @@ -104,6 +105,32 @@ tasks: `acceptance` and `verify` each need at least one entry: an issue without a way to check it is not something an agent should pick up. +### links + +```yaml +links: + - { title: Design note, url: docs/design/blueprint-module.md } + - { title: Tracking board, url: "https://github.com/orgs/acme/projects/4" } +``` + +A `url` is written from the repository's point of view, but the projection is +rendered on somebody else's origin — a consumer site, a Pages build — where a +relative href would resolve against the wrong host. So `build` (and every +other path that writes a projection) resolves links before they leave: + +| `url` in the file | in the projection | +|---|---| +| `docs/x.md`, `./docs/x.md`, `/docs/x.md`, `docs/x.md#section` | `https://github.com//blob/HEAD/docs/x.md` (fragment kept) | +| `https://…`, `//cdn.…`, `mailto:…`, `tel:…` | untouched | +| `#section` | untouched | +| `../outside-the-repo`, `?query-only` | dropped; `validate` warns (V15) | + +`HEAD` rather than a branch name on purpose: GitHub resolves it to the +default branch, so the output is the same with or without a token and does +not drift when `master` becomes `main`. The React component and the web +component render whatever the projection carries, so a relative href never +reaches a page. + ## milestones | field | default | | diff --git a/site/src/content/docs/validation.md b/site/src/content/docs/validation.md index a647a6e..bb9ddf4 100644 --- a/site/src/content/docs/validation.md +++ b/site/src/content/docs/validation.md @@ -28,6 +28,7 @@ roadmap.yaml: warning V5 items[5].depends_on[0]: depends on "p2-staff" which is | V12 | `meta.updated` and `reached_at` are not in the future | error | | V13 | a phase with no items | warning | | V14 | `mechanics_wave` references need `meta.mechanics_waves` and the wave file must exist | error | +| V15 | an item link that cannot be resolved against `meta.repo` (climbs out of the repo, query-only) — it is dropped from the projections | warning | `--live` adds three network checks: every linked issue number exists, markers on `roadmap`-labelled issues resolve to an item, and a manual status diff --git a/validate.test.ts b/validate.test.ts index b73429f..4f997dc 100644 --- a/validate.test.ts +++ b/validate.test.ts @@ -101,6 +101,30 @@ milestones: expect(v6.length).toBe(2); }); + it("warns (V15), naming item and link, when a link cannot be resolved against the repo", async () => { + const parsed = parseRoadmap(`schema: 1 +meta: { project: x, repo: o/r, title: X, version: 1, updated: 2026-09-01, current_phase: P0 } +phases: [{ id: P0, title: A }] +items: + - id: a + title: A + phase: P0 + links: + - { title: fine, url: docs/x.md } + - { title: also fine, url: "https://example.com" } + - { title: escapes, url: ../elsewhere/x.md } +`); + if (!parsed.ok) throw new Error("parse"); + const r = await validateRoadmap(parsed.value, { today: TODAY }); + expect(r.errors).toEqual([]); + const v15 = r.warnings.filter((w) => w.rule === "V15"); + expect(v15.length).toBe(1); + expect(v15[0]?.path).toBe("items[0].links[2].url"); + expect(v15[0]?.message).toContain('"a"'); + expect(v15[0]?.message).toContain("../elsewhere/x.md"); + expect(v15[0]?.message).toContain("o/r"); + }); + it("errors (V14) when the wave file is missing under root", async () => { const parsed = parseRoadmap(`schema: 1 meta: { project: x, repo: o/r, title: X, version: 1, updated: 2026-09-01, current_phase: P0, mechanics_waves: waves } diff --git a/validate.ts b/validate.ts index 5555eb3..41a044b 100644 --- a/validate.ts +++ b/validate.ts @@ -1,5 +1,5 @@ /** - * Offline validation, rules V2–V14. V1 (shape) is the Zod parse in `load.ts`. + * Offline validation, rules V2–V15. V1 (shape) is the Zod parse in `load.ts`. * * Errors are things a build must not ship; warnings are things a reviewer * should see in the PR. The split is the point: a public item without a @@ -12,6 +12,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { topoSort } from "./graph"; import { missingLocale, resolveText } from "./i18n"; +import { resolveLink } from "./links"; import { taskKey } from "./markers"; import type { Finding, Roadmap, ValidationResult } from "./types"; @@ -277,6 +278,19 @@ export async function validateRoadmap( } } + // V15 — a link the projections would have to drop: the reader sees a title + // with nothing behind it, and the author should hear why before the build. + roadmap.items.forEach((item, i) => { + item.links.forEach((l, j) => { + if (resolveLink(l.url, roadmap.meta.repo) !== null) return; + warn( + "V15", + `items[${i}].links[${j}].url`, + `"${item.id}" link "${l.title}" (${l.url}) cannot be resolved against ${roadmap.meta.repo} and is dropped from the projections — use a repo-relative path, an absolute URL, mailto: or #fragment` + ); + }); + }); + // Title sanity for the fallback locale — a roadmap that cannot render its own name is broken. if (resolveText(roadmap.meta.title, fallback).trim() === "") err("V1", "meta.title", "empty title"); From bea7cde1fab8a2ec51171667c75ef9c13f7071a8 Mon Sep 17 00:00:00 2001 From: Lenoux01 <60329596+Lenoux01@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:38:21 +0200 Subject: [PATCH 2/3] fix(react,wc): name the phase progress bar and count it in items Both renderers emitted `role="progressbar"` as a percentage with no accessible name, so a screen reader announced a bare bar. The bar now carries `aria-label` from the existing localized progress string ("3 of 8 done" / "3 von 8 erledigt"), `aria-valuemin="0"`, `aria-valuemax` = items in the phase and `aria-valuenow` = items done. Markup and class names are otherwise unchanged. Refs #3 Co-Authored-By: Claude Fable 5.1 --- render-html.ts | 3 ++- site/src/content/docs/sites.md | 10 ++++++++++ ui-react/index.test.tsx | 18 ++++++++++++++++++ ui-react/index.tsx | 7 +++++-- view-model.test.ts | 13 +++++++++++++ wc/index.test.ts | 15 +++++++++++++++ 6 files changed, 63 insertions(+), 3 deletions(-) diff --git a/render-html.ts b/render-html.ts index e214c28..5920004 100644 --- a/render-html.ts +++ b/render-html.ts @@ -97,8 +97,9 @@ function renderPhase(p: PhaseView, view: RoadmapView, mode: ViewMode = "phases") if (p.getsYou) parts.push(`

${esc(p.getsYou)}

`); if (p.goal) parts.push(`

${esc(p.goal)}

`); parts.push(`
${esc(p.progress.label)}
`); + // Counted in items, not percent, and named — see the React component. parts.push( - `
` + `
` ); if (p.items.length === 0) parts.push(`

—

`); else { diff --git a/site/src/content/docs/sites.md b/site/src/content/docs/sites.md index 598c0bc..2cc179c 100644 --- a/site/src/content/docs/sites.md +++ b/site/src/content/docs/sites.md @@ -14,6 +14,16 @@ Every projection carries `version`, `updated`, `generated_at` and `stale`; the components render "v3 · Stand 2026-09-07" and a *Stand veraltet* badge when the data is stale. +Item links arrive resolved: a relative `url` in the yaml is already a +`https://github.com//blob/HEAD/…` URL by the time a component sees it +(see [roadmap.yaml → links](/roadmap-yaml/#links)), so a site never emits an +href that resolves against its own origin by accident. + +Each phase's progress bar is a `role="progressbar"` counted in items +(`aria-valuenow` done, `aria-valuemax` total) and named in the requested +locale — "3 of 8 done" / "3 von 8 erledigt" — so a screen reader announces +the phase's progress rather than a bare bar. + ## React ```tsx diff --git a/ui-react/index.test.tsx b/ui-react/index.test.tsx index 86e2950..6606fbe 100644 --- a/ui-react/index.test.tsx +++ b/ui-react/index.test.tsx @@ -47,4 +47,22 @@ describe("", () => { const html = renderToStaticMarkup(); expect(html).toContain('lang="en"'); }); + it("names the phase progress bar and counts it in items, in the requested locale", () => { + // P0 in dev-empire: 2 of 2 public items done. + const en = renderToStaticMarkup(); + expect(en).toContain( + '
' + ); + const de = renderToStaticMarkup(); + expect(de).toContain('aria-label="2 von 2 erledigt" aria-valuemin="0" aria-valuemax="2"'); + // Never percent: a bar over 100 would read as "2 of 100". + expect(en).not.toContain('aria-valuemax="100"'); + }); + it("emits item links already resolved against the repo", () => { + const html = renderToStaticMarkup(); + expect(html).toContain( + 'href="https://github.com/example/dev-empire/blob/HEAD/docs/PLAN.md#phasing"' + ); + expect(html).not.toContain('href="docs/'); + }); }); diff --git a/ui-react/index.tsx b/ui-react/index.tsx index 0a5155b..598a04a 100644 --- a/ui-react/index.tsx +++ b/ui-react/index.tsx @@ -143,12 +143,15 @@ function Phase({
{p.progress.label}
+ {/* Counted in items, not percent, and named: a screen reader says + "2 of 8 done" rather than announcing a bare bar. */}
diff --git a/view-model.test.ts b/view-model.test.ts index 6aba668..e570b85 100644 --- a/view-model.test.ts +++ b/view-model.test.ts @@ -79,6 +79,19 @@ describe("renderRoadmapHTML", () => { expect(html2).not.toContain("