diff --git a/AGENTS.md b/AGENTS.md index cb29b16..87ba416 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ agentops-showcase/ │ └── skills/ # Project-level Cursor agent skills ├── AGENTS.md # This file — AI agent context ├── README.md # Project overview, quick start -├── assets/ # Diagrams (overall-architecture.png for README; strategy-image.png) +├── assets/ # Diagrams (overall-architecture.png/.gif for README; strategy-image.png) ├── config/ │ ├── openclaw.json.tpl # OpenClaw harness config template │ └── openshell/ # OpenShell sandbox policies (Landlock, egress) @@ -216,6 +216,7 @@ agentops-showcase/ │ ├── validate-demo-external-css.sh # Lint docs/demo HTML/JS for external CSS only │ ├── validate-demo-ui.sh # CSS lint + demo unit tests (no cluster) │ ├── export-readme-architecture.sh # Playwright PNG for README (v5/live.html step 0) +│ ├── export-readme-architecture-gif.sh # Playwright frame capture + ffmpeg GIF (hops 0–6 default) │ ├── demo-observability-proxy.py │ ├── demo-enable-guardrails.sh # Live demo Change 2: NeMo inference path │ ├── demo-disable-guardrails.sh # Reset to direct MaaS diff --git a/Makefile b/Makefile index bc89024..ea311aa 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,16 @@ DEPLOY_DIR := deploy .DEFAULT_GOAL := help -.PHONY: help export-architecture +.PHONY: help export-architecture export-architecture-gif help: @$(MAKE) -C $(DEPLOY_DIR) help export-architecture: @./scripts/export-readme-architecture.sh +export-architecture-gif: + @./scripts/export-readme-architecture-gif.sh + # Forward any other target to deploy/Makefile (demo, deploy-all, validate-full, …) %: @$(MAKE) -C $(DEPLOY_DIR) $@ diff --git a/README.md b/README.md index d650515..0d3ba9c 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,43 @@ This project demonstrates the **BYOA (Bring Your Own Agent)** approach from the ## Platform Stack +```mermaid +flowchart TB + subgraph USER["USER LAYER"] + UI["End user → Control UI
(openclaw-ui-proxy · nginx mTLS bridge)"] + end + + subgraph AGENT["AGENT LAYER · BYOA"] + OC["OpenClaw harness
Agent Sandbox · Landlock policies"] + end + + subgraph PLATFORM["PLATFORM LAYER · Red Hat"] + GW["OpenShell Gateway — egress choke · key injection"] + ML["MLflow — tracing + prompt registry"] + NG["NeMo Guardrails · TrustyAI"] + end + + subgraph INFERENCE["INFERENCE LAYER"] + IR["inference.local → NeMo → MaaS → LLM"] + end + + subgraph INFRA["INFRASTRUCTURE"] + OCP["OpenShift + RHOAI 3.x · Agent Sandbox Operator (OLM)"] + end + + USER --> AGENT --> PLATFORM --> INFERENCE --> INFRA ``` -┌─────────────────────────────────────────────────────────────┐ -│ USER LAYER │ -│ End user → Control UI (openclaw-ui-proxy, nginx mTLS bridge)│ -├─────────────────────────────────────────────────────────────┤ -│ AGENT LAYER (BYOA — demo harness: OpenClaw) │ -│ OpenClaw in Agent Sandbox (OpenShell policies: Landlock) │ -├─────────────────────────────────────────────────────────────┤ -│ PLATFORM LAYER (Red Hat) │ -│ OpenShell Gateway (egress choke point, key injection) │ -│ MLflow — tracing + prompt registry (background spans) │ -│ NeMo Guardrails via TrustyAI (enabled live in Change 2) │ -├─────────────────────────────────────────────────────────────┤ -│ INFERENCE LAYER │ -│ inference.local → [NeMo] → MaaS → LLM (external MaaS) │ -├─────────────────────────────────────────────────────────────┤ -│ INFRASTRUCTURE │ -│ OpenShift + RHOAI 3.x · Agent Sandbox Operator (OLM) │ -└─────────────────────────────────────────────────────────────┘ -``` -The agent harness is interchangeable (BYOA); the platform stack works regardless of framework. Interactive architecture map (live): [v5/live.html](docs/demo/v5/live.html) step **Overall Demo**. Deep dive: [Agent Sandbox and OpenShell — How It Works](docs/AGENT-SANDBOX-AND-OPENSHELL.md). +| Layer | Tag | Components | +|---|---|---| +| **User** | ![Control UI](https://img.shields.io/badge/Control-UI-0066cc?style=flat-square) | Control UI via `openclaw-ui-proxy` (nginx mTLS bridge + password auth) | +| **Agent (BYOA)** | ![BYOA](https://img.shields.io/badge/Agent-BYOA-e00?style=flat-square) | OpenClaw in Agent Sandbox — interchangeable harness; Landlock + egress policies | +| **Platform** | ![Red Hat](https://img.shields.io/badge/Platform-Red_Hat-ee0000?style=flat-square) ![MLflow](https://img.shields.io/badge/Traces-MLflow-ffc107?style=flat-square&labelColor=333) | OpenShell · MLflow · NeMo Guardrails (TrustyAI) | +| **Inference** | ![MaaS](https://img.shields.io/badge/Inference-MaaS-0066cc?style=flat-square) | `inference.local` → MaaS — router injects API key; credentials never enter the sandbox | +| **Infrastructure** | ![RHOAI](https://img.shields.io/badge/OpenShift-RHOAI-ee0000?style=flat-square) | OCP + RHOAI 3.x — pinned operators; Agent Sandbox via OLM | + +> **BYOA:** the agent harness is interchangeable; the platform stack works regardless of framework. +> **Live map:** [v5/live.html](docs/demo/v5/live.html) step **Overall Demo** · **Deep dive:** [Agent Sandbox and OpenShell — How It Works](docs/AGENT-SANDBOX-AND-OPENSHELL.md) · **Full matrix:** [AGENTS.md § Tech Stack](AGENTS.md#tech-stack) ## Understanding the Platform diff --git a/assets/overall-architecture.gif b/assets/overall-architecture.gif new file mode 100644 index 0000000..60b4e10 Binary files /dev/null and b/assets/overall-architecture.gif differ diff --git a/assets/preview/README.md b/assets/preview/README.md new file mode 100644 index 0000000..02b9c81 --- /dev/null +++ b/assets/preview/README.md @@ -0,0 +1,94 @@ +# Architecture GIF previews (local, temporary) + +**Do not commit this folder.** It exists only to compare three animated variants of the v5 **Overall Demo** FlowStory diagram before picking one for the README (or dropping the GIF entirely). + +Source: `docs/demo/v5/live.html#step-0` (same canvas as `assets/overall-architecture.png`). + +## Variants in this folder + +| File | Hops (`jumpTo` index) | Narrative | Frames | FPS | ~Duration | +|------|------------------------|-----------|--------|-----|-----------| +| `overall-architecture-request-path.gif` | 0–6 | End user → OpenClaw → inference → NeMo → MaaS → LLM | 7 | 2 | ~3.5 s | +| `overall-architecture-request-response.gif` | 0–13 | Above + LLM response path + Landlock | 14 | 2 | ~7 s | +| `overall-architecture-full.gif` | 0–21 | Full baseline (+ egress + MLflow trace) | 22 | 3 | ~7.3 s | + +Open each GIF side by side in the IDE or a browser tab to compare pacing and scope. + +## Regenerate previews + +From repo root (requires `ffmpeg`, Node, Playwright Chromium): + +```bash +./scripts/export-architecture-gif-preview.sh +``` + +Single variant with custom output: + +```bash +cd tests +ARCH_GIF_LAST_INDEX=13 \ +ARCH_GIF_FPS=2 \ +ARCH_GIF_OUTPUT=../assets/preview/my-variant.gif \ +PLAYWRIGHT_BROWSERS_PATH=0 node export-architecture-gif.mjs +``` + +Useful env vars (see `tests/export-architecture-gif.mjs` header): + +| Variable | Default | Purpose | +|----------|---------|---------| +| `ARCH_GIF_FIRST_INDEX` | `0` | First hop index | +| `ARCH_GIF_LAST_INDEX` | `6` | Last hop index | +| `ARCH_GIF_FPS` | `2` | GIF playback rate | +| `ARCH_GIF_FRAME_DELAY_MS` | `700` | Wait after each `jumpTo` before screenshot | +| `ARCH_GIF_WIDTH` | `1200` | Output width (px) | +| `ARCH_GIF_INCLUDE_RESET` | `false` | Extra idle frame before hop 1 | +| `ARCH_GIF_OUTPUT` | `assets/overall-architecture.gif` | Output path | + +Official export (after a variant is chosen): + +```bash +make export-architecture-gif +# example: ARCH_GIF_LAST_INDEX=13 make export-architecture-gif +``` + +## When you pick a winner — cleanup checklist + +### A) Keep one GIF in the repo + +1. Copy the chosen file to the canonical path: + ```bash + cp assets/preview/overall-architecture-.gif assets/overall-architecture.gif + ``` +2. Set defaults in `tests/export-architecture-gif.mjs` (`ARCH_GIF_LAST_INDEX`, `ARCH_GIF_FPS`) to match the winner so `make export-architecture-gif` reproduces it. +3. **Delete temporary preview boilerplate:** + - `assets/preview/` (this entire directory, including this README and the three `*.gif` files) + - `scripts/export-architecture-gif-preview.sh` +4. Optionally wire `assets/overall-architecture.gif` into root `README.md` next to the PNG. +5. Commit only `assets/overall-architecture.gif` + any default/env doc tweaks — not `assets/preview/`. + +### B) Do not ship a GIF + +1. **Delete preview artifacts:** + - `assets/preview/` (entire directory) + - `scripts/export-architecture-gif-preview.sh` +2. **Delete GIF export tooling** (if no longer needed): + - `tests/export-architecture-gif.mjs` + - `scripts/export-readme-architecture-gif.sh` + - `assets/overall-architecture.gif` (if present) + - `make export-architecture-gif` target in root `Makefile` + - `export-architecture-gif` script in `tests/package.json` + - GIF section in `docs/demo/README.md` + - `export-readme-architecture-gif.sh` line in `AGENTS.md` +3. Keep `make export-architecture` (PNG) — that remains the documented static diagram. + +## Related (keep unless option B) + +| Path | Role | +|------|------| +| `tests/export-architecture-gif.mjs` | Playwright frame capture + ffmpeg assembly | +| `scripts/export-readme-architecture-gif.sh` | Wrapper for `make export-architecture-gif` | +| `tests/export-architecture-diagram.mjs` | Static PNG export (unchanged) | + +--- + +*Temporary local note — remove with `assets/preview/` once a decision is made.* diff --git a/assets/preview/overall-architecture-full.gif b/assets/preview/overall-architecture-full.gif new file mode 100644 index 0000000..357500a Binary files /dev/null and b/assets/preview/overall-architecture-full.gif differ diff --git a/assets/preview/overall-architecture-request-path.gif b/assets/preview/overall-architecture-request-path.gif new file mode 100644 index 0000000..60b4e10 Binary files /dev/null and b/assets/preview/overall-architecture-request-path.gif differ diff --git a/assets/preview/overall-architecture-request-response.gif b/assets/preview/overall-architecture-request-response.gif new file mode 100644 index 0000000..ed196d8 Binary files /dev/null and b/assets/preview/overall-architecture-request-response.gif differ diff --git a/docs/demo/README.md b/docs/demo/README.md index a04025a..e79db4d 100644 --- a/docs/demo/README.md +++ b/docs/demo/README.md @@ -180,6 +180,15 @@ make export-architecture Output: [`assets/overall-architecture.png`](../../assets/overall-architecture.png) (baseline flow with all hops revealed). Commit the PNG after regenerating — CI does not auto-export or validate it. +Animated GIF (request path hops 0–6 by default; requires `ffmpeg`): + +```bash +make export-architecture-gif +# full baseline: ARCH_GIF_LAST_INDEX=21 make export-architecture-gif +``` + +Output: [`assets/overall-architecture.gif`](../../assets/overall-architecture.gif). Same manual-only policy as the PNG. + ## Deprecated (dev / bookmarks only) Do not use these on stage — v5 embeds the same maps in-card. diff --git a/docs/preview/platform-stack-preview.html b/docs/preview/platform-stack-preview.html new file mode 100644 index 0000000..b53502f --- /dev/null +++ b/docs/preview/platform-stack-preview.html @@ -0,0 +1,127 @@ + + + + + + Platform Stack preview — README + + + + + + + +
+

Platform Stack

+ +
+flowchart TB
+  subgraph USER["USER LAYER"]
+    UI["End user → Control UI
(openclaw-ui-proxy · nginx mTLS bridge)"] + end + + subgraph AGENT["AGENT LAYER · BYOA"] + OC["OpenClaw harness
Agent Sandbox · Landlock policies"] + end + + subgraph PLATFORM["PLATFORM LAYER · Red Hat"] + GW["OpenShell Gateway — egress choke · key injection"] + ML["MLflow — tracing + prompt registry"] + NG["NeMo Guardrails · TrustyAI"] + end + + subgraph INFERENCE["INFERENCE LAYER"] + IR["inference.local → NeMo → MaaS → LLM"] + end + + subgraph INFRA["INFRASTRUCTURE"] + OCP["OpenShift + RHOAI 3.x · Agent Sandbox Operator (OLM)"] + end + + USER --> AGENT --> PLATFORM --> INFERENCE --> INFRA +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LayerTagComponents
UserControl UIControl UI via openclaw-ui-proxy (nginx mTLS bridge + password auth)
Agent (BYOA)BYOAOpenClaw in Agent Sandbox — interchangeable harness; Landlock + egress policies
Platform + Red Hat + MLflow + OpenShell · MLflow · NeMo Guardrails (TrustyAI)
InferenceMaaSinference.local → MaaS — router injects API key; credentials never enter the sandbox
InfrastructureRHOAIOCP + RHOAI 3.x — pinned operators; Agent Sandbox via OLM
+ +
+

BYOA: the agent harness is interchangeable; the platform stack works regardless of framework.
+ Live map: v5/live.html step Overall Demo · + Deep dive: Agent Sandbox and OpenShell — How It Works · + Full matrix: AGENTS.md § Tech Stack

+
+
+ + diff --git a/scripts/export-architecture-gif-preview.sh b/scripts/export-architecture-gif-preview.sh new file mode 100755 index 0000000..4cfc700 --- /dev/null +++ b/scripts/export-architecture-gif-preview.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Generate three temporary GIF variants for comparison (not for commit). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +TESTS_DIR="${ROOT_DIR}/tests" +PREVIEW_DIR="${ROOT_DIR}/assets/preview" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: required command not found: $1" >&2 + exit 1 + fi +} + +require_cmd python3 +require_cmd node +require_cmd npm +require_cmd ffmpeg + +if [[ ! -d "${TESTS_DIR}/node_modules/@playwright/test" ]]; then + echo "==> Installing test dependencies" + (cd "${TESTS_DIR}" && npm ci) +fi + +export PLAYWRIGHT_BROWSERS_PATH=0 +(cd "${TESTS_DIR}" && npx playwright install chromium) + +mkdir -p "${PREVIEW_DIR}" + +run_variant() { + local name="$1" + local last_index="$2" + local fps="$3" + local out="${PREVIEW_DIR}/overall-architecture-${name}.gif" + + echo "" + echo "==> Variant: ${name} (hops 0..${last_index}, ${fps} fps) -> ${out}" + ( + cd "${TESTS_DIR}" + ARCH_GIF_LAST_INDEX="${last_index}" \ + ARCH_GIF_FPS="${fps}" \ + ARCH_GIF_OUTPUT="${out}" \ + node export-architecture-gif.mjs + ) +} + +# 1) Request path only — End user through LLM (7 hops) +run_variant "request-path" 6 2 + +# 2) Request + response — through Landlock round-trip (14 hops) +run_variant "request-response" 13 2 + +# 3) Full baseline — all hops including egress + MLflow trace (22 hops) +run_variant "full" 21 3 + +echo "" +echo "==> Preview GIFs written to ${PREVIEW_DIR}/" +ls -lh "${PREVIEW_DIR}"/overall-architecture-*.gif diff --git a/scripts/export-readme-architecture-gif.sh b/scripts/export-readme-architecture-gif.sh new file mode 100755 index 0000000..b0efcae --- /dev/null +++ b/scripts/export-readme-architecture-gif.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Export docs/demo/v5/live.html step 0 (Overall Demo) to assets/overall-architecture.gif. +# Frame-by-frame Playwright capture + ffmpeg palettegen. Manual only — not run in CI. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +TESTS_DIR="${ROOT_DIR}/tests" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: required command not found: $1" >&2 + exit 1 + fi +} + +require_cmd python3 +require_cmd node +require_cmd npm +require_cmd ffmpeg + +if [[ ! -d "${TESTS_DIR}/node_modules/@playwright/test" ]]; then + echo "==> Installing test dependencies" + (cd "${TESTS_DIR}" && npm ci) +fi + +echo "==> Ensuring Playwright Chromium is installed (project-local)" +export PLAYWRIGHT_BROWSERS_PATH=0 +(cd "${TESTS_DIR}" && npx playwright install chromium) + +echo "==> Exporting README architecture GIF (baseline hops ${ARCH_GIF_FIRST_INDEX:-0}..${ARCH_GIF_LAST_INDEX:-6})" +(cd "${TESTS_DIR}" && PLAYWRIGHT_BROWSERS_PATH=0 node export-architecture-gif.mjs) diff --git a/tests/export-architecture-diagram.mjs b/tests/export-architecture-diagram.mjs index f8e36c6..cf249c3 100644 --- a/tests/export-architecture-diagram.mjs +++ b/tests/export-architecture-diagram.mjs @@ -98,7 +98,7 @@ async function exportDiagram() { }, ); - await page.goto(PAGE_URL, { waitUntil: "networkidle" }); + await page.goto(PAGE_URL, { waitUntil: "load" }); await page.waitForFunction(() => document.body.classList.contains("nr-v5-step-0")); await page.waitForSelector(".nr-v5-overall-mounted", { state: "attached" }); diff --git a/tests/export-architecture-gif.mjs b/tests/export-architecture-gif.mjs new file mode 100644 index 0000000..7c75775 --- /dev/null +++ b/tests/export-architecture-gif.mjs @@ -0,0 +1,279 @@ +/** + * Export v5 live companion step 0 (Overall Demo) FlowStory embed to an animated GIF. + * Captures one frame per hop (jumpTo index) and assembles with ffmpeg palettegen. + * No cluster required. + * + * Env: + * ARCH_GIF_FIRST_INDEX — first jumpTo index (default 0) + * ARCH_GIF_LAST_INDEX — last jumpTo index (default 6 = request path through LLM) + * ARCH_GIF_FRAME_DELAY_MS — wait after jumpTo before screenshot (default 700) + * ARCH_GIF_FPS — output GIF frame rate (default 2) + * ARCH_GIF_WIDTH — output width in px (default 1200) + * ARCH_GIF_INCLUDE_RESET — include idle frame before hop 1 (default false) + * ARCH_GIF_OUTPUT — output path (default assets/overall-architecture.gif) + */ +import { chromium } from "@playwright/test"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.resolve(__dirname, ".."); +const DOCS_DIR = path.join(ROOT_DIR, "docs"); +const OUTPUT_PATH = process.env.ARCH_GIF_OUTPUT + ? path.resolve(ROOT_DIR, process.env.ARCH_GIF_OUTPUT) + : path.join(ROOT_DIR, "assets", "overall-architecture.gif"); + +const HTTP_PORT = Number(process.env.ARCH_DIAGRAM_HTTP_PORT || 18765); +const PAGE_URL = `http://127.0.0.1:${HTTP_PORT}/demo/v5/live.html#step-0`; +const VIEWPORT = { width: 1600, height: 1100 }; +const DIAGRAM_SELECTOR = ".nr-v5-overall-mounted .fs-overall-canvas-wrap"; + +const FIRST_INDEX = Number(process.env.ARCH_GIF_FIRST_INDEX ?? 0); +const LAST_INDEX = Number(process.env.ARCH_GIF_LAST_INDEX ?? 6); +const FRAME_DELAY_MS = Number(process.env.ARCH_GIF_FRAME_DELAY_MS ?? 700); +const OUTPUT_FPS = Number(process.env.ARCH_GIF_FPS ?? 2); +const OUTPUT_WIDTH = Number(process.env.ARCH_GIF_WIDTH ?? 1200); +const INCLUDE_RESET = + (process.env.ARCH_GIF_INCLUDE_RESET ?? "false").toLowerCase() === "true"; + +const LAYERS_DOCK_MODE_KEY = "agentops-layers-dock-mode"; +const LAYERS_DOCK_VISIBLE_KEY = "agentops-layers-dock-visible"; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function requireFfmpeg() { + const probe = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }); + if (probe.status !== 0) { + throw new Error("ffmpeg is required for GIF export (install ffmpeg and retry)"); + } +} + +async function waitForServer(url, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + if (res.ok) return; + } catch { + // retry + } + await sleep(200); + } + throw new Error(`Timed out waiting for static server at ${url}`); +} + +function startStaticServer() { + const child = spawn("python3", ["-m", "http.server", String(HTTP_PORT)], { + cwd: DOCS_DIR, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.on("error", (err) => { + console.error("Failed to start python3 http.server:", err.message); + process.exit(1); + }); + + return child; +} + +function assembleGif(framesDir, frameCount, outputPath) { + const inputPattern = path.join(framesDir, "frame-%04d.png"); + const palettePath = path.join(framesDir, "palette.png"); + const scale = `scale=${OUTPUT_WIDTH}:-1:flags=lanczos`; + + const paletteGen = spawnSync( + "ffmpeg", + [ + "-y", + "-framerate", + String(OUTPUT_FPS), + "-i", + inputPattern, + "-frames:v", + String(frameCount), + "-vf", + `${scale},palettegen=stats_mode=diff`, + palettePath, + ], + { stdio: "inherit" }, + ); + if (paletteGen.status !== 0) { + throw new Error("ffmpeg palettegen failed"); + } + + const paletteUse = spawnSync( + "ffmpeg", + [ + "-y", + "-framerate", + String(OUTPUT_FPS), + "-i", + inputPattern, + "-i", + palettePath, + "-frames:v", + String(frameCount), + "-lavfi", + `${scale}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`, + "-loop", + "0", + outputPath, + ], + { stdio: "inherit" }, + ); + if (paletteUse.status !== 0) { + throw new Error("ffmpeg paletteuse failed"); + } +} + +async function exportGif() { + if (FIRST_INDEX < 0 || LAST_INDEX < FIRST_INDEX) { + throw new Error(`Invalid hop range: ${FIRST_INDEX}..${LAST_INDEX}`); + } + + requireFfmpeg(); + await mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + + const framesDir = await mkdtemp(path.join(os.tmpdir(), "agentops-arch-gif-")); + const server = startStaticServer(); + let browser; + + try { + await waitForServer(PAGE_URL.split("#")[0]); + + browser = await chromium.launch({ + headless: true, + args: ["--disable-gpu", "--disable-font-subpixel-positioning"], + }); + const page = await browser.newPage({ + viewport: VIEWPORT, + deviceScaleFactor: 1, + }); + + await page.addInitScript( + ({ modeKey, visibleKey }) => { + localStorage.clear(); + localStorage.setItem(modeKey, "off"); + localStorage.setItem(visibleKey, "false"); + + const style = document.createElement("style"); + style.textContent = ` + :root { + --nr-mono: "DejaVu Sans Mono", "Liberation Mono", monospace !important; + --nr-display: "DejaVu Sans", "Liberation Sans", sans-serif !important; + --nr-body: "DejaVu Sans", "Liberation Sans", sans-serif !important; + } + * { + -webkit-font-smoothing: none !important; + font-smooth: never !important; + } + `; + document.documentElement.appendChild(style); + }, + { + modeKey: LAYERS_DOCK_MODE_KEY, + visibleKey: LAYERS_DOCK_VISIBLE_KEY, + }, + ); + + await page.goto(PAGE_URL, { waitUntil: "load" }); + + await page.waitForFunction(() => document.body.classList.contains("nr-v5-step-0")); + await page.waitForSelector(".nr-v5-overall-mounted", { state: "attached" }); + await page.waitForFunction(() => Boolean(window.__flowstory?._canvas)); + await page.evaluate(() => document.body.classList.add("light")); + + const stepCount = await page.evaluate(() => { + const viz = window.__flowstory; + if (!viz?._diagram?.flows) { + throw new Error("FlowStory diagram not loaded"); + } + viz.closeOverlay?.(); + const flowId = viz.state.activeFlow || viz._diagram.defaultFlow || "baseline"; + const steps = viz._diagram.flows[flowId]?.steps; + if (!steps?.length) { + throw new Error(`No steps for flow: ${flowId}`); + } + return steps.length; + }); + + if (LAST_INDEX >= stepCount) { + throw new Error( + `ARCH_GIF_LAST_INDEX=${LAST_INDEX} exceeds baseline hop count (${stepCount})`, + ); + } + + const diagram = page.locator(DIAGRAM_SELECTOR); + await diagram.waitFor({ state: "visible" }); + await page.evaluate(() => document.fonts?.ready); + + let frameNumber = 1; + + async function captureFrame() { + const framePath = path.join(framesDir, `frame-${String(frameNumber).padStart(4, "0")}.png`); + await diagram.screenshot({ + path: framePath, + animations: "disabled", + scale: "css", + }); + frameNumber += 1; + } + + if (INCLUDE_RESET) { + await page.evaluate(() => { + window.__flowstory?.reset?.(); + }); + await page.waitForTimeout(FRAME_DELAY_MS); + await captureFrame(); + } + + for (let index = FIRST_INDEX; index <= LAST_INDEX; index += 1) { + await page.evaluate((hopIndex) => { + const viz = window.__flowstory; + viz.closeOverlay?.(); + viz.jumpTo(hopIndex); + const flowId = viz.state.activeFlow || viz._diagram.defaultFlow || "baseline"; + const steps = viz._diagram.flows[flowId]?.steps ?? []; + const expectedDone = hopIndex + 1; + if (viz.state.currentStepDone !== expectedDone && hopIndex < steps.length - 1) { + throw new Error( + `jumpTo(${hopIndex}) expected currentStepDone=${expectedDone}, got ${viz.state.currentStepDone}`, + ); + } + }, index); + await page.waitForTimeout(FRAME_DELAY_MS); + await captureFrame(); + } + + const frameCount = frameNumber - 1; + if (frameCount === 0) { + throw new Error("No frames captured"); + } + + console.log( + `Assembling ${frameCount} frame(s) (hops ${FIRST_INDEX}..${LAST_INDEX}${INCLUDE_RESET ? ", with reset" : ""}) at ${OUTPUT_FPS} fps`, + ); + assembleGif(framesDir, frameCount, OUTPUT_PATH); + console.log(`Wrote ${OUTPUT_PATH}`); + } finally { + if (browser) { + await browser.close(); + } + server.kill("SIGTERM"); + await sleep(300); + if (!server.killed) { + server.kill("SIGKILL"); + } + await rm(framesDir, { recursive: true, force: true }); + } +} + +exportGif().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/package.json b/tests/package.json index ed6b577..de2d793 100644 --- a/tests/package.json +++ b/tests/package.json @@ -13,6 +13,7 @@ "test:log-rules": "npx playwright test observability-log-rules.spec.ts", "test:demo-unit": "npx playwright test observability-log-rules.spec.ts demo-scenario-consistency.spec.ts overall-in-doc-legend-layout.spec.ts ui-helpers.unit.spec.ts", "export-architecture": "PLAYWRIGHT_BROWSERS_PATH=0 node export-architecture-diagram.mjs", + "export-architecture-gif": "PLAYWRIGHT_BROWSERS_PATH=0 node export-architecture-gif.mjs", "test:scenario-a": "bash ../scripts/run-playwright-tests.sh scenario-a-regression.spec.ts", "test:mlflow": "bash ../scripts/run-playwright-tests.sh mlflow-ui.spec.ts" },