From 716adbfc4186ea0570791a1242bd7f471d1cf88a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 18:44:40 -0700 Subject: [PATCH 1/3] Render readable documentation diagrams with the native runtime --- docs/as-is-handoff.md | 7 +- docs/product-lifecycle.md | 2 +- mkdocs.yml | 6 +- scripts/docs-browser/image-readiness.mjs | 34 ++-- scripts/docs-browser/image-readiness.spec.mjs | 32 ++++ scripts/docs-browser/mermaid-runtime.mjs | 147 ++++++++++++++++ scripts/docs-browser/mermaid.spec.mjs | 159 ++++++++++++++++++ 7 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 scripts/docs-browser/mermaid-runtime.mjs create mode 100644 scripts/docs-browser/mermaid.spec.mjs diff --git a/docs/as-is-handoff.md b/docs/as-is-handoff.md index b6bc5a67..3a83772f 100644 --- a/docs/as-is-handoff.md +++ b/docs/as-is-handoff.md @@ -10,7 +10,12 @@ Inspect the existing repositories before proposing changes. firefly forge reads Use the logical project's component graph to select the repositories and paths to inspect. The project's `product_repository_id` owns the product definition and assistant conversation. Component repositories remain separate scopes. ```mermaid -flowchart LR +--- +config: + flowchart: + wrappingWidth: 120 +--- +flowchart TD Source[Committed component source] --> Inspect[Source inspection] Inspect --> Review[Owner reviews exact evidence digest] Review --> Context[Frozen assistant context] diff --git a/docs/product-lifecycle.md b/docs/product-lifecycle.md index 3f45dd9c..e23ea766 100644 --- a/docs/product-lifecycle.md +++ b/docs/product-lifecycle.md @@ -9,7 +9,7 @@ A product can contain several customer journeys, features and integrations. Each Incremental changes preserve the existing product definition. A request names its base revision and digest, upserts or removes specific contract IDs, and optionally appends journeys, acceptance criteria or user stories. Existing acceptance numbers and untouched features remain unchanged. ```mermaid -flowchart LR +flowchart TD A[Current product revision] --> B[Preview feature change] B --> C[Save immutable definition revision] C --> D[Review function, design and architecture] diff --git a/mkdocs.yml b/mkdocs.yml index f39e8ac8..4ffa49c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,7 +50,11 @@ markdown_extensions: - toc: permalink: true - pymdownx.highlight - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.details - pymdownx.tabbed: alternate_style: true diff --git a/scripts/docs-browser/image-readiness.mjs b/scripts/docs-browser/image-readiness.mjs index 1cfd4243..85f330b7 100644 --- a/scripts/docs-browser/image-readiness.mjs +++ b/scripts/docs-browser/image-readiness.mjs @@ -6,30 +6,24 @@ const { expect } = createRequire( )("@playwright/test"); export async function readyImages(images) { - await images.evaluateAll((nodes) => { - for (const image of nodes) - if (image.loading === "lazy" && !image.currentSrc) - image.loading = "eager"; - }); - await expect - .poll(() => - images.evaluateAll((nodes) => - nodes.every((image) => { - const declared = image.getAttribute("src"); + for (const image of await images.all()) { + await image.scrollIntoViewIfNeeded(); + await expect + .poll(() => + image.evaluate((element) => { + const declared = element.getAttribute("src"); return ( declared && - image.currentSrc === new URL(declared, document.baseURI).href && - image.complete && - image.naturalWidth > 0 && - image.naturalHeight > 0 + element.currentSrc === new URL(declared, document.baseURI).href && + element.complete && + element.naturalWidth > 0 && + element.naturalHeight > 0 ); }), - ), - ) - .toBe(true); - await images.evaluateAll((nodes) => - Promise.all(nodes.map((image) => image.decode())), - ); + ) + .toBe(true); + await image.evaluate((element) => element.decode()); + } } export async function readyPictureImage(image) { diff --git a/scripts/docs-browser/image-readiness.spec.mjs b/scripts/docs-browser/image-readiness.spec.mjs index 5a4377a6..093963b6 100644 --- a/scripts/docs-browser/image-readiness.spec.mjs +++ b/scripts/docs-browser/image-readiness.spec.mjs @@ -6,6 +6,38 @@ const { test, expect } = createRequire( new URL("../../studio/package.json", import.meta.url), )("@playwright/test"); +test("bulk readiness visits offscreen lazy images without changing loading", async ({ + page, +}) => { + await page.goto("/console-tour/"); + const source = await page + .locator(".forge-shot__link img") + .first() + .evaluate( + (image) => new URL(image.getAttribute("src"), document.baseURI).href, + ); + await page.setContent( + ` +
+ `, + ); + await page.evaluate(() => { + const decode = HTMLImageElement.prototype.decode; + HTMLImageElement.prototype.decode = function () { + this.dataset.decodes = String(Number(this.dataset.decodes || 0) + 1); + return decode.call(this); + }; + }); + const images = page.locator("img"); + expect((await images.last().boundingBox()).y).toBeGreaterThan(10000); + await readyImages(images); + for (const image of await images.all()) { + await expect(image).toHaveAttribute("loading", "lazy"); + await expect(image).toHaveAttribute("data-decodes", "1"); + } + await expect(images.last()).toBeInViewport(); +}); + for (const kind of ["screenshot", "picture"]) test(`ready ${kind} does not restart an already loaded image`, async ({ page, diff --git a/scripts/docs-browser/mermaid-runtime.mjs b/scripts/docs-browser/mermaid-runtime.mjs new file mode 100644 index 00000000..465b7866 --- /dev/null +++ b/scripts/docs-browser/mermaid-runtime.mjs @@ -0,0 +1,147 @@ +/* Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 */ +import { + readFileSync, + readdirSync, + mkdtempSync, + writeFileSync, + mkdirSync, + statSync, +} from "node:fs"; +import { join, resolve, sep, extname } from "node:path"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; + +export function mermaidPages(directory, prefix = "") { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const name = join(prefix, entry.name); + if (entry.isDirectory()) + return mermaidPages(join(directory, entry.name), name); + if (!entry.name.endsWith(".md")) return []; + const count = [ + ...readFileSync(join(directory, entry.name), "utf8").matchAll( + /^[ \t]*(?:`{3,}|~{3,})[ \t]*mermaid[ \t]*$/gm, + ), + ].length; + return count + ? [ + { + path: + name.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, "/") || + "/", + count, + }, + ] + : []; + }); +} + +export async function buildRuntimeFixture(root) { + const directory = mkdtempSync(join(tmpdir(), "docs-mermaid-runtime-")); + const output = join(directory, "site"); + const server = createServer((request, response) => { + try { + let target = resolve( + output, + "." + + decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname), + ); + if (!target.startsWith(output + sep) && target !== output) + throw new Error("Outside fixture"); + if (statSync(target).isDirectory()) target = join(target, "index.html"); + const types = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".xml": "application/xml", + }; + response.setHeader( + "Content-Type", + types[extname(target)] ?? "application/octet-stream", + ); + response.end(readFileSync(target)); + } catch { + response.writeHead(404); + response.end(); + } + }); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const baseURL = `http://127.0.0.1:${server.address().port}/`; + const close = () => { + server.closeAllConnections(); + server.close(); + }; + const docs = join(directory, "docs"); + mkdirSync(docs); + writeFileSync( + join(docs, "index.md"), + "# Diagram runtime fixture\n\n[Next diagram](next.md)\n\n```mermaid\nflowchart TD\n Source[Owned source] --> Receipt[Verified receipt]\n```\n", + ); + writeFileSync( + join(docs, "next.md"), + "# Second diagram\n\n[First diagram](index.md)\n\n```mermaid\nsequenceDiagram\n Caller->>Worker: Execute approved work\n Worker-->>Caller: Retain receipt\n```\n", + ); + const config = join(directory, "mkdocs.yml"); + writeFileSync( + config, + `INHERIT: ${JSON.stringify(join(root, "mkdocs.yml"))}\ndocs_dir: ${JSON.stringify(docs)}\nsite_dir: ${JSON.stringify(output)}\nsite_url: ${JSON.stringify(baseURL)}\nhooks: []\nextra_css: []\nextra_javascript: []\nnav:\n - First: index.md\n - Second: next.md\ntheme:\n name: material\n font: false\n logo: null\n favicon: assets/images/favicon.png\n features:\n - navigation.instant\n - navigation.instant.prefetch\n`, + ); + const built = spawnSync( + process.env.BRIDGE_PYTHON ?? "python3", + ["-m", "mkdocs", "build", "--strict", "-f", config], + { cwd: root, encoding: "utf8" }, + ); + if (built.status !== 0) { + close(); + throw new Error(built.stderr || built.stdout); + } + return { baseURL, close }; +} + +export async function renderedDiagrams(page) { + const session = await page.context().newCDPSession(page); + try { + const { root } = await session.send("DOM.getDocument", { + depth: -1, + pierce: true, + }); + const diagrams = []; + async function visit(node, diagram = false) { + const attributes = node.attributes ?? []; + const classes = attributes[attributes.indexOf("class") + 1] ?? ""; + const inside = diagram || classes.split(" ").includes("mermaid"); + if (inside && node.localName === "svg") { + const { object } = await session.send("DOM.resolveNode", { + nodeId: node.nodeId, + }); + const { result } = await session.send("Runtime.callFunctionOn", { + objectId: object.objectId, + functionDeclaration: + "function() { const box=this.getBoundingClientRect(); const shape=this.querySelector('.node rect, .actor'); return {html:this.outerHTML, width:box.width, height:box.height, fill:shape ? getComputedStyle(shape).fill : null, error:Boolean(this.querySelector('.error-icon, .error-text')), labelPixels:[...this.querySelectorAll('.nodeLabel')].map(label => { const transform=(label.closest('foreignObject') ?? label).getScreenCTM(); return parseFloat(getComputedStyle(label).fontSize) * Math.hypot(transform.a, transform.b); })}; }", + returnByValue: true, + }); + diagrams.push(result.value); + } + for (const child of [ + ...(node.children ?? []), + ...(node.shadowRoots ?? []), + ]) + await visit(child, inside); + } + await visit(root); + return diagrams; + } catch (error) { + // Instant navigation can detach a node while its CDP snapshot is read. + if ( + error.message.includes("DOM.resolveNode") && + error.message.includes("No node with given id found") + ) + return []; + throw error; + } finally { + await session.detach(); + } +} diff --git a/scripts/docs-browser/mermaid.spec.mjs b/scripts/docs-browser/mermaid.spec.mjs new file mode 100644 index 00000000..f4dabf4a --- /dev/null +++ b/scripts/docs-browser/mermaid.spec.mjs @@ -0,0 +1,159 @@ +/* Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 */ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { + buildRuntimeFixture, + mermaidPages, + renderedDiagrams, +} from "./mermaid-runtime.mjs"; +const { test, expect } = createRequire( + new URL("../../studio/package.json", import.meta.url), +)("@playwright/test"); +const root = fileURLToPath(new URL("../..", import.meta.url)); + +async function optionalRepositoryBadges(page) { + for (const endpoint of [ + "https://api.github.com/repos/fireflyframework/firefly-forge", + "https://api.github.com/repos/fireflyframework/firefly-forge/releases/latest", + ]) { + await page.route(endpoint, (route) => + route.fulfill({ + contentType: "application/json", + json: { stargazers_count: 0, forks_count: 0, tag_name: "docs-fixture" }, + }), + ); + } +} + +async function assertDiagrams(page, count) { + await expect + .poll(async () => (await renderedDiagrams(page)).length) + .toBe(count); + const diagrams = await renderedDiagrams(page); + for (const diagram of diagrams) { + expect(diagram.width).toBeGreaterThan(100); + expect(diagram.height).toBeGreaterThan(30); + expect(diagram.error).toBe(false); + } + expect( + await page + .locator("pre") + .evaluateAll( + (nodes) => + nodes.filter((node) => + /^\s*(flowchart|sequenceDiagram)\b/.test(node.textContent), + ).length, + ), + ).toBe(0); + return diagrams; +} + +for (const entry of mermaidPages(join(root, "docs"))) { + for (const theme of ["light", "dark"]) { + for (const width of [1440, 390]) { + test(`all Mermaid fences on ${entry.path} render legible SVG at ${width}px in ${theme}`, async ({ + page, + }, testInfo) => { + await optionalRepositoryBadges(page); + await page.setViewportSize({ width, height: 2000 }); + await page.emulateMedia({ colorScheme: theme }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (["warning", "error"].includes(message.type())) + errors.push(message.text() + " " + message.location().url); + }); + page.on("response", (response) => { + if (response.status() >= 400) + errors.push(`${response.status()} ${response.url()}`); + }); + await page.goto(entry.path); + await expect(page.locator("body")).toHaveAttribute( + "data-md-color-scheme", + theme === "dark" ? "slate" : "default", + ); + const diagrams = await assertDiagrams(page, entry.count); + await page + .locator(".mermaid") + .first() + .screenshot({ path: testInfo.outputPath("diagram.png") }); + await testInfo.attach("render-metrics", { + body: JSON.stringify( + diagrams.map(({ width, height, labelPixels, error }) => ({ + width, + height, + labelPixels, + error, + })), + ), + contentType: "application/json", + }); + for (const diagram of diagrams) { + expect(diagram.labelPixels.length).toBeGreaterThan(0); + expect(Math.min(...diagram.labelPixels)).toBeGreaterThanOrEqual(12); + } + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + expect(errors).toEqual([]); + }); + } + } +} + +test("Mermaid survives theme changes, instant navigation and browser history", async ({ + page, +}, testInfo) => { + await optionalRepositoryBadges(page); + const fixture = await buildRuntimeFixture(root); + try { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (["warning", "error"].includes(message.type())) + errors.push(message.text() + " " + message.location().url); + }); + await page.emulateMedia({ colorScheme: "light" }); + await page.goto(fixture.baseURL); + const originalDocument = await page.evaluate(() => { + window.diagramDocument = "retained"; + return window.diagramDocument; + }); + const original = await assertDiagrams(page, 1); + await page.getByRole("link", { name: "Next diagram", exact: true }).click(); + await expect(page).toHaveURL(/\/next\/$/); + expect(await page.evaluate(() => window.diagramDocument)).toBe( + originalDocument, + ); + const next = await assertDiagrams(page, 1); + await page.locator('label[title="Switch to dark appearance"]').click(); + await expect(page.locator("body")).toHaveAttribute( + "data-md-color-scheme", + "slate", + ); + const dark = await assertDiagrams(page, 1); + expect(dark[0].fill).not.toEqual(next[0].fill); + await page + .locator(".mermaid") + .screenshot({ path: testInfo.outputPath("instant-dark.png") }); + await page.goBack(); + await assertDiagrams(page, 1); + await page.setViewportSize({ width: 390, height: 844 }); + await page + .locator(".mermaid") + .screenshot({ path: testInfo.outputPath("mobile-dark.png") }); + expect(original[0].html).toContain("Owned source"); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + expect(errors).toEqual([]); + } finally { + fixture.close(); + } +}); From 8f7c1324617d3fecf6db62ecf2326ef6c3dcf05a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 18:49:53 -0700 Subject: [PATCH 2/3] Keep diagram test routes portable across platforms --- scripts/docs-browser/mermaid-runtime.mjs | 4 ++-- scripts/docs-browser/mermaid.spec.mjs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/docs-browser/mermaid-runtime.mjs b/scripts/docs-browser/mermaid-runtime.mjs index 465b7866..92f727ca 100644 --- a/scripts/docs-browser/mermaid-runtime.mjs +++ b/scripts/docs-browser/mermaid-runtime.mjs @@ -8,14 +8,14 @@ import { mkdirSync, statSync, } from "node:fs"; -import { join, resolve, sep, extname } from "node:path"; +import { join, resolve, sep, extname, posix } from "node:path"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; export function mermaidPages(directory, prefix = "") { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const name = join(prefix, entry.name); + const name = posix.join(prefix, entry.name); if (entry.isDirectory()) return mermaidPages(join(directory, entry.name), name); if (!entry.name.endsWith(".md")) return []; diff --git a/scripts/docs-browser/mermaid.spec.mjs b/scripts/docs-browser/mermaid.spec.mjs index f4dabf4a..e15eb420 100644 --- a/scripts/docs-browser/mermaid.spec.mjs +++ b/scripts/docs-browser/mermaid.spec.mjs @@ -3,6 +3,8 @@ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { buildRuntimeFixture, mermaidPages, @@ -13,6 +15,22 @@ const { test, expect } = createRequire( )("@playwright/test"); const root = fileURLToPath(new URL("../..", import.meta.url)); +test("Mermaid enumeration maps nested index pages to portable directory URLs", () => { + const docs = mkdtempSync(join(tmpdir(), "docs-mermaid-paths-")); + mkdirSync(join(docs, "guides", "nested"), { recursive: true }); + const fence = " ```mermaid\nflowchart TD\n A --> B\n ```\n"; + for (const name of ["index.md", "guides/index.md", "guides/nested/graph.md"]) + writeFileSync(join(docs, name), fence); + writeFileSync(join(docs, "ordinary.md"), "# Ordinary prose\n"); + expect( + mermaidPages(docs).sort((a, b) => a.path.localeCompare(b.path)), + ).toEqual([ + { path: "/", count: 1 }, + { path: "guides/", count: 1 }, + { path: "guides/nested/graph/", count: 1 }, + ]); +}); + async function optionalRepositoryBadges(page) { for (const endpoint of [ "https://api.github.com/repos/fireflyframework/firefly-forge", From 7a254e93592366ded7d7d3dac369d156d0cd1ecd Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 19:17:52 -0700 Subject: [PATCH 3/3] Run documentation browser fixtures in the managed Python environment --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 21b9f381..57449ac2 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ docs-check: docs node studio/node_modules/prettier/bin/prettier.cjs --check scripts/docs-scene scripts/docs-browser scripts/*docs*.mjs scripts/test_operator_motion.mjs scripts/test_factory_sequence.mjs node --test scripts/test_docs_scene.mjs scripts/test_operator_motion.mjs scripts/test_factory_sequence.mjs uv run --group docs python -m unittest scripts.test_docs_scene_hook scripts.test_architecture_diagrams scripts.test_docs_discovery - node studio/node_modules/@playwright/test/cli.js test --config scripts/docs-playwright.config.mjs + uv run --group docs node studio/node_modules/@playwright/test/cli.js test --config scripts/docs-playwright.config.mjs docs-pages-check: uv run --group docs mkdocs build --strict --site-dir build/docs-pages/firefly-forge