diff --git a/packages/dashboard/app/plugins/registerBundledPluginViews.ts b/packages/dashboard/app/plugins/registerBundledPluginViews.ts index d1b8f2af04..a78f27b225 100644 --- a/packages/dashboard/app/plugins/registerBundledPluginViews.ts +++ b/packages/dashboard/app/plugins/registerBundledPluginViews.ts @@ -37,6 +37,18 @@ async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> { return { default: component as PluginViewComponent }; } +async function loadCompoundEngineeringView(): Promise<{ default: PluginViewComponent }> { + const moduleId = "@fusion-plugin-examples/compound-engineering/dashboard-view"; + const exportName = "CompoundEngineeringDashboardView"; + const mod = await import("@fusion-plugin-examples/compound-engineering/dashboard-view") as unknown as Record>; + const component = mod[exportName]; + if (!component) { + console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`); + return { default: createMissingPluginView(moduleId, exportName) }; + } + return { default: component as PluginViewComponent }; +} + async function loadCliPrintingPressWizardView(): Promise<{ default: PluginViewComponent }> { const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view"; const exportName = "CliPrintingPressWizardView"; @@ -85,6 +97,12 @@ export function registerBundledPluginViews(): void { lazy(loadRoadmapView), ); + registerPluginView( + "fusion-plugin-compound-engineering", + "compound-engineering", + lazy(loadCompoundEngineeringView), + ); + registerPluginView( "fusion-plugin-cli-printing-press", "wizard", diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 436fd0fb7a..34a2610455 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -90,6 +90,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.4", + "@fusion-plugin-examples/compound-engineering": "workspace:*", "@fusion-plugin-examples/dependency-graph": "workspace:*", "@fusion-plugin-examples/roadmap": "workspace:*", "@fusion-plugin-examples/hermes-runtime": "workspace:*", diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts new file mode 100644 index 0000000000..716e29f9df --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as realFs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Mock node:fs so we can observe/inject behaviour around readFileSync without +// relying on vi.spyOn (ESM namespace exports are not configurable). The hooks +// below default to passthrough and individual tests override them. +let readFileHook: ((path: realFs.PathOrFileDescriptor, original: typeof realFs.readFileSync, args: unknown[]) => unknown) | undefined; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: (path: realFs.PathOrFileDescriptor, ...args: unknown[]) => { + if (readFileHook) return readFileHook(path, actual.readFileSync, args); + return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...args); + }, + }; +}); + +const { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } = realFs; +const { discoverArtifacts, readArtifactById } = await import("../discovery.js"); + +function makeRepo(): string { + return mkdtempSync(join(tmpdir(), "ce-discovery-")); +} + +describe("discoverArtifacts", () => { + let root: string; + + afterEach(() => { + if (root) rmSync(root, { recursive: true, force: true }); + readFileHook = undefined; + vi.restoreAllMocks(); + }); + + it("returns grouped artifacts from a fixture repo tree (happy path)", () => { + root = makeRepo(); + writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); + writeFileSync(join(root, "CONCEPTS.md"), "# Concepts"); + mkdirSync(join(root, "docs/ideation"), { recursive: true }); + writeFileSync(join(root, "docs/ideation/a.md"), "ideation a"); + writeFileSync(join(root, "docs/ideation/b.md"), "ideation b"); + mkdirSync(join(root, "docs/brainstorms"), { recursive: true }); + writeFileSync(join(root, "docs/brainstorms/x.md"), "brainstorm x"); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(root, "docs/plans/plan1.md"), "plan 1"); + mkdirSync(join(root, "docs/solutions"), { recursive: true }); + writeFileSync(join(root, "docs/solutions/sol.md"), "solution"); + + const result = discoverArtifacts(root); + const byStage = Object.fromEntries(result.groups.map((g) => [g.stage, g])); + + expect(result.totalArtifacts).toBe(7); + expect(result.totalErrors).toBe(0); + expect(byStage.strategy.entries).toHaveLength(1); + expect(byStage.concepts.entries).toHaveLength(1); + expect(byStage.ideation.entries).toHaveLength(2); + expect(byStage.brainstorm.entries).toHaveLength(1); + expect(byStage.plan.entries).toHaveLength(1); + expect(byStage.solution.entries).toHaveLength(1); + // Every group present is flagged present. + expect(byStage.ideation.present).toBe(true); + // All entries are artifacts in the happy path. + expect(result.groups.flatMap((g) => g.entries).every((e) => e.kind === "artifact")).toBe(true); + }); + + it("orders directory artifacts by updatedAt DESC", () => { + root = makeRepo(); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + const older = join(root, "docs/plans/old.md"); + const newer = join(root, "docs/plans/new.md"); + writeFileSync(older, "old"); + writeFileSync(newer, "new"); + // Force deterministic mtimes: old < new. + const now = Date.now(); + utimesSync(older, new Date(now - 10_000), new Date(now - 10_000)); + utimesSync(newer, new Date(now), new Date(now)); + + const result = discoverArtifacts(root); + const plan = result.groups.find((g) => g.stage === "plan")!; + expect(plan.entries.map((e) => e.name)).toEqual(["new.md", "old.md"]); + }); + + it("reports a partial-discovery state: some categories present, others empty", () => { + root = makeRepo(); + writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(root, "docs/plans/p.md"), "plan"); + // No ideation / brainstorms / solutions / CONCEPTS. + + const result = discoverArtifacts(root); + const populated = result.groups.filter((g) => g.entries.length > 0); + const empty = result.groups.filter((g) => g.entries.length === 0); + expect(populated.map((g) => g.stage).sort()).toEqual(["plan", "strategy"]); + expect(empty.length).toBeGreaterThan(0); + // Empty groups are still present in the result so the hub can render them. + expect(result.groups).toHaveLength(6); + }); + + it("returns an all-empty result when nothing is present (first-run)", () => { + root = makeRepo(); + const result = discoverArtifacts(root); + expect(result.totalArtifacts).toBe(0); + expect(result.totalErrors).toBe(0); + expect(result.groups.every((g) => g.entries.length === 0 && !g.present)).toBe(true); + }); + + it("represents an unreadable artifact as an error entry, not a crash or silent drop", () => { + root = makeRepo(); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + const readable = join(root, "docs/plans/good.md"); + writeFileSync(readable, "good"); + + // Simulate a malformed/unreadable artifact: the specific file throws on read. + readFileHook = (path, original, args) => { + if (typeof path === "string" && path.endsWith("good.md")) { + throw new Error("EIO: simulated read failure"); + } + return (original as (...a: unknown[]) => unknown)(path, ...args); + }; + + const result = discoverArtifacts(root); + const plan = result.groups.find((g) => g.stage === "plan")!; + expect(plan.entries).toHaveLength(1); + const entry = plan.entries[0]; + expect(entry.kind).toBe("error"); + expect(entry.kind === "error" && entry.error).toContain("simulated read failure"); + expect(result.totalErrors).toBe(1); + expect(result.totalArtifacts).toBe(0); + }); + + it("ignores unrelated files and does not read outside the conventional locations", () => { + root = makeRepo(); + // Conventional artifact that SHOULD be read. + writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); + mkdirSync(join(root, "docs/ideation"), { recursive: true }); + writeFileSync(join(root, "docs/ideation/keep.md"), "keep"); + // Unrelated files that must NOT be read. + writeFileSync(join(root, "README.md"), "readme"); // root-level non-conventional .md + writeFileSync(join(root, "package.json"), "{}"); + writeFileSync(join(root, "docs/ideation/notes.txt"), "non-md, ignore"); // non-.md in a scanned dir + mkdirSync(join(root, "secrets"), { recursive: true }); + writeFileSync(join(root, "secrets/secret.md"), "TOP SECRET"); // outside the allowlist + mkdirSync(join(root, "docs/random"), { recursive: true }); + writeFileSync(join(root, "docs/random/r.md"), "unrelated"); // docs subtree but not conventional + + const opened: string[] = []; + readFileHook = (path, original, args) => { + if (typeof path === "string") opened.push(path); + return (original as (...a: unknown[]) => unknown)(path, ...args); + }; + + const result = discoverArtifacts(root); + + // Only the two conventional artifacts were read. + expect(opened.some((p) => p.endsWith("STRATEGY.md"))).toBe(true); + expect(opened.some((p) => p.endsWith(join("ideation", "keep.md")))).toBe(true); + // Nothing outside the allowlist was opened. + expect(opened.some((p) => p.includes(`${join("secrets", "secret.md")}`))).toBe(false); + expect(opened.some((p) => p.endsWith("README.md"))).toBe(false); + expect(opened.some((p) => p.endsWith("package.json"))).toBe(false); + expect(opened.some((p) => p.endsWith("notes.txt"))).toBe(false); + expect(opened.some((p) => p.includes(join("random", "r.md")))).toBe(false); + + expect(result.totalArtifacts).toBe(2); + }); +}); + +describe("readArtifactById", () => { + let root: string; + + beforeEach(() => { + root = makeRepo(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("reads a conventional file artifact", () => { + writeFileSync(join(root, "STRATEGY.md"), "# Strategy body"); + const res = readArtifactById(root, "strategy:STRATEGY.md"); + expect(res).toBeDefined(); + expect(res && "content" in res && res.content).toContain("Strategy body"); + }); + + it("reads a directory artifact's immediate Markdown child", () => { + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(root, "docs/plans/p.md"), "plan body"); + const res = readArtifactById(root, "plan:docs/plans/p.md"); + expect(res && "content" in res && res.content).toBe("plan body"); + }); + + it("refuses a forged id that escapes the conventional location", () => { + writeFileSync(join(root, "secrets.md"), "secret"); + // Attempt to traverse out of docs/plans into the repo root. + expect(readArtifactById(root, "plan:../../secrets.md")).toBeUndefined(); + // Wrong stage/path pairing for a file location. + expect(readArtifactById(root, "strategy:CONCEPTS.md")).toBeUndefined(); + // Unknown stage. + expect(readArtifactById(root, "bogus:whatever.md")).toBeUndefined(); + }); + + it("refuses a nested path under a directory location (non-immediate child)", () => { + mkdirSync(join(root, "docs/plans/sub"), { recursive: true }); + writeFileSync(join(root, "docs/plans/sub/deep.md"), "deep"); + expect(readArtifactById(root, "plan:docs/plans/sub/deep.md")).toBeUndefined(); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts new file mode 100644 index 0000000000..d27a4a8b31 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts @@ -0,0 +1,364 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, sep } from "node:path"; + +/** + * CE artifact discovery (U3). + * + * Scans a fixed allowlist of conventional CE artifact locations relative to a + * project root and returns artifacts grouped by stage. The allowlist is the + * ONLY filesystem surface this module touches — it never recurses outside a + * conventional location and never reads a file that does not live under one of + * them. An artifact that cannot be read or is malformed is represented as an + * `error` entry rather than crashing the scan or being silently dropped. + * + * Locations (per the plan): STRATEGY.md, docs/ideation/, docs/brainstorms/, + * docs/plans/, docs/solutions/, CONCEPTS.md. + */ + +export type CeArtifactStage = + | "strategy" + | "ideation" + | "brainstorm" + | "plan" + | "solution" + | "concepts"; + +/** Whether a conventional location is a single file or a directory of files. */ +type LocationKind = "file" | "directory"; + +interface ConventionalLocation { + stage: CeArtifactStage; + /** Human label for the stage group. */ + label: string; + /** Project-root-relative path. */ + path: string; + kind: LocationKind; +} + +/** + * The conventional CE artifact locations. This is the discovery allowlist — the + * scanner reads ONLY these paths (and, for directories, their immediate `.md` + * children). Nothing outside this list is opened. + */ +export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = [ + { stage: "strategy", label: "Strategy", path: "STRATEGY.md", kind: "file" }, + { stage: "ideation", label: "Ideation", path: "docs/ideation", kind: "directory" }, + { stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" }, + { stage: "plan", label: "Plans", path: "docs/plans", kind: "directory" }, + { stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" }, + { stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" }, +]; + +/** A discovered, readable artifact. */ +export interface CeArtifact { + /** Stable id: `${stage}:${relativePath}`. Safe to use as a route param after encoding. */ + id: string; + stage: CeArtifactStage; + /** Project-root-relative path with forward slashes. */ + path: string; + /** Filename (basename). */ + name: string; + /** Size in bytes. */ + size: number; + /** Last-modified epoch ms — used for `(stage, updatedAt DESC)` ordering. */ + updatedAt: number; + /** Discriminator. */ + kind: "artifact"; +} + +/** An artifact location that exists but could not be read / was malformed. */ +export interface CeArtifactError { + id: string; + stage: CeArtifactStage; + path: string; + name: string; + /** Discriminator. */ + kind: "error"; + /** Human-readable reason the artifact could not be surfaced. */ + error: string; +} + +export type CeArtifactEntry = CeArtifact | CeArtifactError; + +/** Artifacts (and error entries) grouped by stage. */ +export interface CeArtifactGroup { + stage: CeArtifactStage; + label: string; + /** True when the conventional location for this stage exists on disk. */ + present: boolean; + /** Entries, ordered by `updatedAt DESC` (errors sort last, keyed by name). */ + entries: CeArtifactEntry[]; +} + +export interface DiscoveryResult { + groups: CeArtifactGroup[]; + /** Convenience flags for the hub's empty / partial states. */ + totalArtifacts: number; + totalErrors: number; +} + +const MAX_ARTIFACT_BYTES = 2_000_000; + +function toPosix(p: string): string { + return p.split(sep).join("/"); +} + +/** + * Guard: a resolved path must stay within the project root AND under the + * specific conventional location it was discovered through. This is the + * concrete enforcement of "do not read outside the conventional locations". + */ +function isWithin(root: string, locationAbs: string, candidate: string): boolean { + const relToLocation = relative(locationAbs, candidate); + if (relToLocation.startsWith("..") || isAbsolute(relToLocation)) return false; + const relToRoot = relative(root, candidate); + if (relToRoot.startsWith("..") || isAbsolute(relToRoot)) return false; + return true; +} + +function makeId(stage: CeArtifactStage, relPath: string): string { + return `${stage}:${relPath}`; +} + +function readArtifactEntry( + stage: CeArtifactStage, + root: string, + locationAbs: string, + abs: string, + relPath: string, +): CeArtifactEntry { + const name = relPath.split("/").pop() ?? relPath; + // Defense in depth: refuse anything that escaped the conventional location. + if (!isWithin(root, locationAbs, abs)) { + return { + id: makeId(stage, relPath), + stage, + path: relPath, + name, + kind: "error", + error: "Path is outside its conventional location", + }; + } + try { + const st = statSync(abs); + if (st.size > MAX_ARTIFACT_BYTES) { + return { + id: makeId(stage, relPath), + stage, + path: relPath, + name, + kind: "error", + error: `Artifact too large to read (${st.size} bytes)`, + }; + } + // Read eagerly so a malformed/unreadable file is surfaced now as an error + // entry rather than crashing later at render time. + readFileSync(abs, "utf8"); + return { + id: makeId(stage, relPath), + stage, + path: relPath, + name, + size: st.size, + updatedAt: st.mtimeMs, + kind: "artifact", + }; + } catch (err) { + return { + id: makeId(stage, relPath), + stage, + path: relPath, + name, + kind: "error", + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function sortEntries(entries: CeArtifactEntry[]): CeArtifactEntry[] { + // Composite ordering analogue: artifacts by updatedAt DESC; errors last, + // stable by name. (See docs/performance/dashboard-load.md — the persisted + // equivalent is a `(type, updatedAt DESC)` index.) + return [...entries].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "artifact" ? -1 : 1; + if (a.kind === "artifact" && b.kind === "artifact") return b.updatedAt - a.updatedAt; + return a.name.localeCompare(b.name); + }); +} + +function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGroup { + const locationAbs = join(root, loc.path); + const entries: CeArtifactEntry[] = []; + let present = false; + + let st: ReturnType | undefined; + try { + st = statSync(locationAbs); + present = true; + } catch { + // Location simply does not exist — an empty (but valid) category. + return { stage: loc.stage, label: loc.label, present: false, entries: [] }; + } + + if (loc.kind === "file") { + if (st.isFile()) { + entries.push(readArtifactEntry(loc.stage, root, locationAbs, locationAbs, toPosix(loc.path))); + } else { + // A conventional file path that is actually a directory is malformed. + entries.push({ + id: makeId(loc.stage, toPosix(loc.path)), + stage: loc.stage, + path: toPosix(loc.path), + name: loc.path.split("/").pop() ?? loc.path, + kind: "error", + error: "Expected a file at the conventional location but found a directory", + }); + } + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } + + // Directory location: read ONLY immediate children, only Markdown files. + // Non-recursive on purpose — we never descend into unrelated subtrees. + let names: string[] = []; + try { + if (!st.isDirectory()) { + entries.push({ + id: makeId(loc.stage, toPosix(loc.path)), + stage: loc.stage, + path: toPosix(loc.path), + name: loc.path.split("/").pop() ?? loc.path, + kind: "error", + error: "Expected a directory at the conventional location but found a file", + }); + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } + names = readdirSync(locationAbs); + } catch (err) { + entries.push({ + id: makeId(loc.stage, toPosix(loc.path)), + stage: loc.stage, + path: toPosix(loc.path), + name: loc.path.split("/").pop() ?? loc.path, + kind: "error", + error: err instanceof Error ? err.message : String(err), + }); + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } + + for (const childName of names) { + // Ignore unrelated files: only Markdown artifacts count. Dotfiles and any + // non-.md file are skipped outright (not read). + if (childName.startsWith(".")) continue; + if (!childName.toLowerCase().endsWith(".md")) continue; + const abs = join(locationAbs, childName); + const relPath = toPosix(join(loc.path, childName)); + // Skip nested directories named *.md — only regular files are artifacts. + let childStat: ReturnType; + try { + childStat = statSync(abs); + } catch (err) { + entries.push({ + id: makeId(loc.stage, relPath), + stage: loc.stage, + path: relPath, + name: childName, + kind: "error", + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (!childStat.isFile()) continue; + entries.push(readArtifactEntry(loc.stage, root, locationAbs, abs, relPath)); + } + + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; +} + +/** + * Discover CE artifacts under `projectRoot`, grouped by stage. Never throws for + * per-artifact problems — those become `error` entries. Always returns one + * group per conventional location (empty groups included so the hub can render + * a partial-discovery state). + */ +export function discoverArtifacts(projectRoot: string): DiscoveryResult { + const root = projectRoot; + const groups = CONVENTIONAL_LOCATIONS.map((loc) => discoverLocation(root, loc)); + let totalArtifacts = 0; + let totalErrors = 0; + for (const g of groups) { + for (const e of g.entries) { + if (e.kind === "artifact") totalArtifacts += 1; + else totalErrors += 1; + } + } + return { groups, totalArtifacts, totalErrors }; +} + +/** + * Resolve a single artifact by its `stage:relativePath` id and return its raw + * content. Re-validates the path against the conventional-location allowlist so + * a forged id can never read an arbitrary file. Returns `undefined` if the id + * does not map to a known conventional location or the file is missing. + */ +export function readArtifactById( + projectRoot: string, + id: string, +): { artifact: CeArtifact; content: string } | { error: string } | undefined { + const sepIdx = id.indexOf(":"); + if (sepIdx <= 0) return undefined; + const stage = id.slice(0, sepIdx) as CeArtifactStage; + const relPath = id.slice(sepIdx + 1); + const loc = CONVENTIONAL_LOCATIONS.find((l) => l.stage === stage); + if (!loc) return undefined; + + const locationAbs = join(projectRoot, loc.path); + const abs = join(projectRoot, relPath); + + // The requested path must live under the stage's conventional location. + // For file locations, the path must equal the location itself. + if (loc.kind === "file") { + if (toPosix(relPath) !== toPosix(loc.path)) return undefined; + } else if (!isWithin(projectRoot, locationAbs, abs)) { + return undefined; + } + // Directory artifacts must be immediate Markdown children. + if (loc.kind === "directory") { + const rel = relative(locationAbs, abs); + if (rel.includes(sep) || rel.startsWith("..") || !rel.toLowerCase().endsWith(".md")) { + return undefined; + } + } + + let content: string; + let mtimeMs: number; + let size: number; + try { + const st = statSync(abs); + if (!st.isFile()) return { error: "Artifact is not a readable file" }; + if (st.size > MAX_ARTIFACT_BYTES) return { error: `Artifact too large to read (${st.size} bytes)` }; + mtimeMs = st.mtimeMs; + size = st.size; + } catch (err) { + // A missing file is "not found" (404), not a malformed-artifact error (422). + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return undefined; + return { error: err instanceof Error ? err.message : String(err) }; + } + try { + content = readFileSync(abs, "utf8"); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + const name = relPath.split("/").pop() ?? relPath; + return { + artifact: { + id, + stage, + path: toPosix(relPath), + name, + size, + updatedAt: mtimeMs, + kind: "artifact", + }, + content, + }; +} diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx index 49af3fee7f..5793e56f1d 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx @@ -1,13 +1,12 @@ -import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; - /** - * Placeholder dashboard surface for the Compound Engineering plugin. + * Dashboard surface entry for the Compound Engineering plugin (U3). * - * U1 ships only a loadable scaffold. The real artifact hub and interactive - * ce-* session surface arrive in later units (U3+). + * Thin re-export of the real hub component (mirrors how reports splits + * `src/dashboard-view.tsx` from `src/dashboard/ReportsView.tsx`). The export + * name `CompoundEngineeringDashboardView` is the one `registerBundledPluginViews` + * imports — `componentPath` in the manifest is cosmetic; this binding is real. */ -export function CompoundEngineeringDashboardView(_props: { context?: PluginDashboardViewContext }) { - return
Compound Engineering
; -} - -export default CompoundEngineeringDashboardView; +export { + CompoundEngineeringView as CompoundEngineeringDashboardView, + default, +} from "./dashboard/CompoundEngineeringView.js"; diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css new file mode 100644 index 0000000000..90f763c3ad --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css @@ -0,0 +1,146 @@ +.ce-view { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 1.25rem; + height: 100%; + overflow: auto; +} + +.ce-view-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.ce-view-header h2 { + margin: 0; +} + +.ce-view-summary { + font-size: 0.8rem; + opacity: 0.7; +} + +.ce-loading, +.ce-view-error { + padding: 0.75rem 1rem; + font-size: 0.85rem; +} + +.ce-view-error { + color: var(--color-danger, #d23); +} + +.ce-empty { + max-width: 36rem; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: flex-start; +} + +.ce-empty h3 { + margin: 0; +} + +.ce-empty-hint { + opacity: 0.7; + font-size: 0.85rem; +} + +.ce-groups { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); + gap: 1rem; +} + +.ce-group { + border: 1px solid var(--color-border, rgba(128, 128, 128, 0.25)); + border-radius: 8px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ce-group[data-empty="true"] { + opacity: 0.65; +} + +.ce-group-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.ce-group-header h3 { + margin: 0; + font-size: 0.95rem; +} + +.ce-group-count { + font-size: 0.75rem; + opacity: 0.6; +} + +.ce-group-empty { + margin: 0; + font-size: 0.8rem; + opacity: 0.6; +} + +.ce-artifact-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.ce-artifact { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.85rem; +} + +.ce-artifact.is-selected { + font-weight: 600; +} + +.ce-artifact-btn { + background: none; + border: none; + text-align: left; + cursor: pointer; + display: flex; + flex-direction: column; + color: inherit; + padding: 0.25rem 0; + flex: 1; +} + +.ce-artifact-path { + font-size: 0.7rem; + opacity: 0.55; +} + +.ce-artifact-error .ce-artifact-error-msg { + color: var(--color-danger, #d23); + font-size: 0.78rem; +} + +.ce-artifact-error { + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; +} + +.ce-view[data-mobile="true"] .ce-groups { + grid-template-columns: 1fr; +} diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx new file mode 100644 index 0000000000..11e1aa721f --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx @@ -0,0 +1,190 @@ +import "./CompoundEngineeringView.css"; +import { useState } from "react"; +import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; +import { useArtifacts } from "./hooks/useArtifacts.js"; +import { useViewportMode } from "./hooks/useViewportMode.js"; +import { getArtifactPreviewUrl } from "./hooks/api.js"; +import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js"; + +interface CompoundEngineeringViewProps { + context?: PluginDashboardViewContext; + /** Test seam: override the active project id without a host context. */ + projectId?: string; + /** Test seam: force the viewport-gated fetch on/off. */ + enabledOverride?: boolean; +} + +function readProjectId(props: CompoundEngineeringViewProps): string | undefined { + if (props.projectId) return props.projectId; + const ctx = props.context as { projectId?: string } | undefined; + return ctx?.projectId; +} + +/** First-run / empty state: no artifacts AND no errors anywhere. */ +function EmptyState({ onStart }: { onStart: () => void }) { + return ( +
+

Start your compounding pipeline

+

+ No compound-engineering artifacts found yet. Compound Engineering tracks the documents your + pipeline produces — strategy, ideation, brainstorms, plans, solutions, and concepts — as you + move through each stage. +

+

+ Begin with a stage and its artifact will appear here, grouped and traceable. +

+ +
+ ); +} + +function ArtifactRow({ + entry, + projectId, + onSelect, + selected, +}: { + entry: CeArtifactEntry; + projectId?: string; + onSelect: (id: string) => void; + selected: boolean; +}) { + if (entry.kind === "error") { + return ( +
  • + {entry.name} + + Could not read: {entry.error} + + {entry.path} +
  • + ); + } + return ( +
  • + + + Open + +
  • + ); +} + +function StageGroup({ + group, + projectId, + onSelect, + selectedId, +}: { + group: CeArtifactGroup; + projectId?: string; + onSelect: (id: string) => void; + selectedId?: string; +}) { + const empty = group.entries.length === 0; + return ( +
    +
    +

    {group.label}

    + {group.entries.length} +
    + {empty ? ( +

    + No {group.label.toLowerCase()} artifacts yet. +

    + ) : ( +
      + {group.entries.map((entry) => ( + + ))} +
    + )} +
    + ); +} + +export function CompoundEngineeringView(props: CompoundEngineeringViewProps) { + const projectId = readProjectId(props); + const { mobile, active } = useViewportMode(); + const enabled = props.enabledOverride ?? active; + const { result, loading, error } = useArtifacts({ projectId, enabled }); + const [selectedId, setSelectedId] = useState(); + + const totalArtifacts = result?.totalArtifacts ?? 0; + const totalErrors = result?.totalErrors ?? 0; + const hasAnything = totalArtifacts > 0 || totalErrors > 0; + // Partial discovery: at least one category populated AND at least one empty. + const populatedGroups = result?.groups.filter((g) => g.entries.length > 0).length ?? 0; + const emptyGroups = result?.groups.filter((g) => g.entries.length === 0).length ?? 0; + const isPartial = populatedGroups > 0 && emptyGroups > 0; + + const onStart = () => { + // Wiring to launch a stage session is U6. A placeholder affordance is fine + // here; it makes the first-run orientation actionable without coupling U3 to + // the session launcher. + props.context?.addToast?.("Stage launcher arrives with the CE flow renderer (U6).", "info"); + }; + + return ( +
    +
    +

    Compound Engineering

    + {hasAnything ? ( + + {totalArtifacts} artifact{totalArtifacts === 1 ? "" : "s"} + {totalErrors > 0 ? ` · ${totalErrors} unreadable` : ""} + {isPartial ? " · partial" : ""} + + ) : null} +
    + + {error ? ( +
    + Failed to load artifacts: {error} +
    + ) : null} + + {loading && !result ? ( +
    + Discovering artifacts… +
    + ) : null} + + {result && !hasAnything ? ( + + ) : null} + + {result && hasAnything ? ( +
    + {result.groups.map((group) => ( + + ))} +
    + ) : null} +
    + ); +} + +export default CompoundEngineeringView; diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx new file mode 100644 index 0000000000..24bfc2ca6c --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { DiscoveryResult } from "../../artifacts/discovery.js"; + +// Mock the network layer so the view renders from seeded discovery results. +const listArtifacts = vi.fn(async (): Promise => { + throw new Error("listArtifacts mock not configured"); +}); +vi.mock("../hooks/api.js", () => ({ + listArtifacts: () => listArtifacts(), + getArtifactPreviewUrl: (id: string) => `/preview/${id}`, +})); + +import { CompoundEngineeringView } from "../CompoundEngineeringView.js"; +import { __test_clearArtifactsCache } from "../hooks/useArtifacts.js"; + +const ALL_STAGES: Array<{ stage: DiscoveryResult["groups"][number]["stage"]; label: string }> = [ + { stage: "strategy", label: "Strategy" }, + { stage: "ideation", label: "Ideation" }, + { stage: "brainstorm", label: "Brainstorms" }, + { stage: "plan", label: "Plans" }, + { stage: "solution", label: "Solutions" }, + { stage: "concepts", label: "Concepts" }, +]; + +function makeResult(overrides: Partial>): DiscoveryResult { + const groups = ALL_STAGES.map(({ stage, label }) => ({ + stage, + label, + present: Boolean(overrides[stage]?.length), + entries: overrides[stage] ?? [], + })); + let totalArtifacts = 0; + let totalErrors = 0; + for (const g of groups) { + for (const e of g.entries) { + if (e.kind === "artifact") totalArtifacts += 1; + else totalErrors += 1; + } + } + return { groups, totalArtifacts, totalErrors }; +} + +describe("CompoundEngineeringView", () => { + beforeEach(() => { + __test_clearArtifactsCache(); + listArtifacts.mockReset(); + }); + + afterEach(() => vi.clearAllMocks()); + + it("renders the empty / first-run state with an orientation + start action", async () => { + listArtifacts.mockResolvedValue(makeResult({})); + render(); + + await screen.findByTestId("ce-empty-state"); + expect(screen.getByText(/Start your compounding pipeline/i)).toBeInTheDocument(); + const start = screen.getByTestId("ce-start-action"); + expect(start).toBeInTheDocument(); + // Start affordance is wired to a placeholder (toast); clicking does not throw. + fireEvent.click(start); + }); + + it("renders the partial-discovery state (some categories present, others empty)", async () => { + listArtifacts.mockResolvedValue( + makeResult({ + strategy: [ + { kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 }, + ], + plan: [ + { kind: "artifact", id: "plan:docs/plans/p.md", stage: "plan", path: "docs/plans/p.md", name: "p.md", size: 5, updatedAt: 2 }, + ], + }), + ); + render(); + + await screen.findByTestId("ce-summary"); + // Partial flag surfaces in the summary and on the groups container. + expect(screen.getByTestId("ce-summary").textContent).toMatch(/partial/i); + const groups = screen.getByTestId("ce-summary").closest(".ce-view")!.querySelector(".ce-groups"); + expect(groups?.getAttribute("data-partial")).toBe("true"); + // Populated groups render artifacts; empty ones render an empty hint. + expect(screen.getAllByTestId("ce-artifact")).toHaveLength(2); + expect(screen.getAllByTestId("ce-group-empty").length).toBeGreaterThan(0); + }); + + it("renders an error entry for an unreadable artifact (not a crash or silent drop)", async () => { + listArtifacts.mockResolvedValue( + makeResult({ + plan: [ + { kind: "error", id: "plan:docs/plans/bad.md", stage: "plan", path: "docs/plans/bad.md", name: "bad.md", error: "EIO: simulated read failure" }, + ], + }), + ); + render(); + + const errorEntry = await screen.findByTestId("ce-artifact-error"); + expect(errorEntry).toBeInTheDocument(); + expect(errorEntry.textContent).toMatch(/simulated read failure/i); + // Surfaced as an unreadable count in the summary. + expect(screen.getByTestId("ce-summary").textContent).toMatch(/unreadable/i); + }); + + it("does not fetch when the viewport-gated flag is disabled", async () => { + listArtifacts.mockResolvedValue(makeResult({})); + render(); + // Give effects a tick. + await waitFor(() => expect(screen.getByTestId("compound-engineering-view")).toBeInTheDocument()); + expect(listArtifacts).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts new file mode 100644 index 0000000000..8f2fca8114 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts @@ -0,0 +1,45 @@ +import type { DiscoveryResult } from "../../artifacts/discovery.js"; + +const BASE = "/api/plugins/fusion-plugin-compound-engineering"; + +function qp(params: Record): string { + const entries = Object.entries(params).filter( + ([, v]) => typeof v === "string" && v.length > 0, + ) as Array<[string, string]>; + if (entries.length === 0) return ""; + return `?${entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`; +} + +async function request(path: string, init?: RequestInit, responseType: "json" | "text" = "json"): Promise { + const response = await fetch(`${BASE}${path}`, init); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + const data = (await response.json()) as { error?: string }; + if (data.error) message = data.error; + } catch { + // ignore + } + throw new Error(message); + } + if (responseType === "text") return (await response.text()) as T; + return (await response.json()) as T; +} + +export async function listArtifacts(projectId?: string): Promise { + return request(`/artifacts${qp({ projectId })}`); +} + +export async function getArtifact( + id: string, + projectId?: string, +): Promise<{ content: string; name: string }> { + const data = await request<{ artifact: { name: string }; content: string }>( + `/artifacts/${encodeURIComponent(id)}${qp({ projectId })}`, + ); + return { content: data.content, name: data.artifact.name }; +} + +export function getArtifactPreviewUrl(id: string, projectId?: string): string { + return `${BASE}/artifacts/${encodeURIComponent(id)}/preview.html${qp({ projectId })}`; +} diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useArtifacts.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useArtifacts.ts new file mode 100644 index 0000000000..b223bb2a6d --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useArtifacts.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from "react"; +import { listArtifacts } from "./api.js"; +import type { DiscoveryResult } from "../../artifacts/discovery.js"; + +/** + * Short-TTL discovery cache keyed by `projectId` (the discovery scan has no + * per-row id, so the project is the cache unit). Mirrors the dashboard + * performance kit (docs/performance/dashboard-load.md): a 30s TTL balances + * freshness against repeated viewport-driven refetches. + */ +const CACHE_TTL_MS = 30_000; +const discoveryCache = new Map(); + +function cacheKey(projectId?: string): string { + return `discovery:${projectId ?? "__default__"}`; +} + +/** Exposed for tests. */ +export function __test_clearArtifactsCache(): void { + discoveryCache.clear(); +} + +export interface UseArtifactsResult { + result?: DiscoveryResult; + loading: boolean; + error?: string; +} + +/** + * Discover CE artifacts for the active project. The fetch is viewport-gated via + * the `enabled` flag — when the CE view is offscreen/disabled it returns stable + * empty state and triggers no network request (performance kit). Results are + * served from a short-TTL cache to collapse repeated mounts. + */ +export function useArtifacts({ + projectId, + enabled = true, +}: { + projectId?: string; + enabled?: boolean; +}): UseArtifactsResult { + const [result, setResult] = useState(() => { + const cached = discoveryCache.get(cacheKey(projectId)); + return cached && cached.expiresAt > Date.now() ? cached.value : undefined; + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + if (!enabled) return; + + const key = cacheKey(projectId); + const cached = discoveryCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + setResult(cached.value); + setError(undefined); + return; + } + + const controller = new AbortController(); + setLoading(true); + listArtifacts(projectId) + .then((value) => { + if (controller.signal.aborted) return; + discoveryCache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); + setResult(value); + setError(undefined); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : "Failed to load artifacts"); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [projectId, enabled]); + + return useMemo(() => ({ result, loading, error }), [result, loading, error]); +} diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useViewportMode.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useViewportMode.ts new file mode 100644 index 0000000000..0bc523ff28 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useViewportMode.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +/** + * Tracks viewport state for the CE hub. `mobile` mirrors the dashboard's mobile + * breakpoint (includes landscape phones). `active` reports whether the document + * is currently visible — used to viewport-gate the discovery fetch so an + * offscreen/backgrounded hub triggers no network work (performance kit). + */ +export function useViewportMode() { + const [mobile, setMobile] = useState(false); + const [active, setActive] = useState(true); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 768px), (max-height: 480px)"); + const onChange = () => setMobile(mq.matches); + onChange(); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + + useEffect(() => { + const onVisibility = () => setActive(document.visibilityState !== "hidden"); + onVisibility(); + document.addEventListener("visibilitychange", onVisibility); + return () => document.removeEventListener("visibilitychange", onVisibility); + }, []); + + return { mobile, active }; +} diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/test-setup.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/test-setup.ts new file mode 100644 index 0000000000..a900510005 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/test-setup.ts @@ -0,0 +1,26 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, vi } from "vitest"; + +// @testing-library/react only auto-registers cleanup when vitest globals are +// enabled. We don't enable globals here, so we wire it manually — otherwise +// React leaves the test tree mounted, its scheduler fires a deferred update +// via setImmediate after the jsdom environment is torn down, and the suite +// fails with "ReferenceError: window is not defined". +afterEach(() => cleanup()); + +if (typeof window !== "undefined") { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} diff --git a/plugins/fusion-plugin-compound-engineering/src/index.ts b/plugins/fusion-plugin-compound-engineering/src/index.ts index 11b7178310..dc01d5bc91 100644 --- a/plugins/fusion-plugin-compound-engineering/src/index.ts +++ b/plugins/fusion-plugin-compound-engineering/src/index.ts @@ -3,6 +3,7 @@ import { COMPOUND_ENGINEERING_SKILLS } from "./skills.js"; import { installBundledCeSkills } from "./skill-installation.js"; import { ensureCeSchema } from "./schema.js"; import { createSessionRoutes } from "./routes/session-routes.js"; +import { createArtifactRoutes } from "./routes/artifact-routes.js"; export { CompoundEngineeringDashboardView } from "./dashboard-view.js"; export { COMPOUND_ENGINEERING_SKILLS } from "./skills.js"; @@ -60,7 +61,7 @@ const plugin = definePlugin({ } }, }, - routes: [...createSessionRoutes()], + routes: [...createSessionRoutes(), ...createArtifactRoutes()], dashboardViews: [ { viewId: "compound-engineering", diff --git a/plugins/fusion-plugin-compound-engineering/src/routes/__tests__/artifact-routes.test.ts b/plugins/fusion-plugin-compound-engineering/src/routes/__tests__/artifact-routes.test.ts new file mode 100644 index 0000000000..9f8001830d --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/routes/__tests__/artifact-routes.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { PluginRouteResponse } from "@fusion/core"; +import { createArtifactRoutes } from "../artifact-routes.js"; +import { makeHarness, type TestHarness } from "../../__tests__/_harness.js"; + +function route(path: string, method = "GET") { + const def = createArtifactRoutes().find((r) => r.path === path && r.method === method); + if (!def) throw new Error(`route not found: ${method} ${path}`); + return def; +} + +describe("artifact routes", () => { + let h: TestHarness; + + afterEach(() => h?.close()); + + it("GET /artifacts lists discovered artifacts grouped by stage", async () => { + h = makeHarness(); + writeFileSync(join(h.projectRoot, "STRATEGY.md"), "# Strategy"); + mkdirSync(join(h.projectRoot, "docs/plans"), { recursive: true }); + writeFileSync(join(h.projectRoot, "docs/plans/p.md"), "plan body"); + + const res = (await route("/artifacts").handler({ query: {} }, h.ctx)) as PluginRouteResponse; + expect(res.status).toBe(200); + const body = res.body as { totalArtifacts: number; groups: Array<{ stage: string; entries: unknown[] }> }; + expect(body.totalArtifacts).toBe(2); + const plan = body.groups.find((g) => g.stage === "plan")!; + expect(plan.entries).toHaveLength(1); + }); + + it("GET /artifacts/:id returns raw content", async () => { + h = makeHarness(); + writeFileSync(join(h.projectRoot, "STRATEGY.md"), "# Strategy body"); + + const res = (await route("/artifacts/:id").handler( + { params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} }, + h.ctx, + )) as PluginRouteResponse; + expect(res.status).toBe(200); + expect((res.body as { content: string }).content).toContain("Strategy body"); + }); + + it("GET /artifacts/:id 404s an unknown id", async () => { + h = makeHarness(); + const res = (await route("/artifacts/:id").handler( + { params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} }, + h.ctx, + )) as PluginRouteResponse; + expect(res.status).toBe(404); + }); + + it("GET /artifacts/:id/preview.html returns a self-contained, escaped HTML document", async () => { + h = makeHarness(); + // Content with an injection attempt — must be escaped, not executed. + writeFileSync(join(h.projectRoot, "STRATEGY.md"), "\n# Plan"); + + const res = (await route("/artifacts/:id/preview.html").handler( + { params: { id: encodeURIComponent("strategy:STRATEGY.md") }, query: {} }, + h.ctx, + )) as PluginRouteResponse; + + expect(res.status).toBe(200); + expect(res.contentType).toContain("text/html"); + const html = res.body as string; + expect(html.startsWith("")).toBe(true); + // Self-contained: inlined + + +
    +

    ${escapeHtml(name)}

    +
    ${escaped}
    +
    + +`; +} + +export function createArtifactRoutes(): PluginRouteDefinition[] { + return [ + { + method: "GET", + path: "/artifacts", + description: "List discovered CE artifacts grouped by stage.", + handler: async (req: unknown, ctx: PluginContext): Promise => { + const query = (req as RouteRequest).query ?? {}; + const projectId = asString(query.projectId); + const root = await resolveProjectRoot(ctx, projectId); + const result = discoverArtifacts(root); + return { status: 200, body: result }; + }, + }, + { + method: "GET", + path: "/artifacts/:id", + description: "Read a single CE artifact's raw content (JSON).", + handler: async (req: unknown, ctx: PluginContext): Promise => { + const request = req as RouteRequest; + const id = decodeURIComponent(request.params.id); + const projectId = asString(request.query?.projectId); + const root = await resolveProjectRoot(ctx, projectId); + const result = readArtifactById(root, id); + if (!result) return { status: 404, body: { error: `Artifact ${id} not found` } }; + if ("error" in result) return { status: 422, body: { error: result.error } }; + return { status: 200, body: { artifact: result.artifact, content: result.content } }; + }, + }, + { + method: "GET", + path: "/artifacts/:id/preview.html", + description: "Read a single CE artifact rendered as a self-contained HTML document.", + handler: async (req: unknown, ctx: PluginContext): Promise => { + const request = req as RouteRequest; + const id = decodeURIComponent(request.params.id); + const projectId = asString(request.query?.projectId); + const root = await resolveProjectRoot(ctx, projectId); + const result = readArtifactById(root, id); + if (!result) return { status: 404, body: { error: `Artifact ${id} not found` } }; + if ("error" in result) return { status: 422, body: { error: result.error } }; + return { + status: 200, + contentType: "text/html; charset=utf-8", + body: renderArtifactDocument(result.artifact.name, result.content), + }; + }, + }, + ]; +} diff --git a/plugins/fusion-plugin-compound-engineering/vitest.config.ts b/plugins/fusion-plugin-compound-engineering/vitest.config.ts index e680986c0e..c9594b1487 100644 --- a/plugins/fusion-plugin-compound-engineering/vitest.config.ts +++ b/plugins/fusion-plugin-compound-engineering/vitest.config.ts @@ -4,6 +4,11 @@ import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest const maxWorkers = computeMaxWorkers(); +const coreSetup = fileURLToPath( + new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url), +); +const dashboardSetup = fileURLToPath(new URL("./src/dashboard/test-setup.ts", import.meta.url)); + export default defineConfig({ resolve: { alias: [ @@ -28,12 +33,32 @@ export default defineConfig({ ], }, test: { - include: ["src/**/*.test.{ts,tsx}"], - environment: "jsdom", - setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], + // coreSetup runs for all projects via extends: true inheritance. + setupFiles: [coreSetup], globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], pool: "threads", maxWorkers, poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, + projects: [ + { + extends: true, + test: { + name: "compound-engineering-dashboard", + environment: "jsdom", + include: ["src/dashboard/**/__tests__/**/*.test.{ts,tsx}", "src/dashboard/**/*.test.{ts,tsx}"], + // jsdom-specific setup; coreSetup is inherited via extends: true. + setupFiles: [dashboardSetup], + }, + }, + { + extends: true, + test: { + name: "compound-engineering-node", + environment: "node", + include: ["src/**/__tests__/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"], + exclude: ["src/dashboard/**/__tests__/**/*.test.{ts,tsx}", "src/dashboard/**/*.test.{ts,tsx}"], + }, + }, + ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd268a90fc..3ebfc96757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: '@fusion-plugin-examples/cli-printing-press': specifier: workspace:* version: link:../../plugins/fusion-plugin-cli-printing-press + '@fusion-plugin-examples/compound-engineering': + specifier: workspace:* + version: link:../../plugins/fusion-plugin-compound-engineering '@fusion-plugin-examples/cursor-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-cursor-runtime