feat(FN-3785): add standalone HTML rendering and export routes for reports

Adds a complete HTML rendering and export pipeline to the fusion-plugin-reports plugin, including a standalone HTML renderer with template and stylesheet support, new export routes (`/api/reports/:id/export/html`) that persist rendered HTML to the store, and plugin-type support for non-JSON route re

Fusion-Task-Id: FN-3785
This commit is contained in:
Fusion
2026-05-10 10:05:07 -07:00
committed by gsxdsm
parent 85b96a7546
commit e9c4c6e4f1
23 changed files with 911 additions and 3 deletions

View File

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginContext } from "@fusion/core";
import type { Report } from "../../store/report-types.js";
import { createReportExportRoutes } from "../report-export-routes.js";
function report(overrides: Partial<Report> = {}): Report {
return {
id: "rep_1",
cadence: "daily",
periodStart: "2026-05-01",
periodEnd: "2026-05-02",
title: "Demo",
status: "review_complete",
generationStartedAt: "2026-05-02T00:00:00.000Z",
generationCompletedAt: "2026-05-02T00:01:00.000Z",
reviewStartedAt: null,
reviewCompletedAt: null,
approvedAt: null,
approvedBy: null,
publishedAt: null,
archivedAt: null,
failureReason: null,
draftMarkdown: null,
renderedHtmlPath: null,
renderedHtml: null,
renderedHtmlGeneratedAt: null,
metadata: {},
combinedReview: null,
createdAt: "2026-05-02T00:00:00.000Z",
updatedAt: "2026-05-02T00:01:00.000Z",
...overrides,
};
}
function ctxWithStore(store: { getReport: (id: string) => Report | null; setRenderedHtml: (id: string, html: string) => void }): PluginContext {
return {
pluginId: "fusion-plugin-reports",
taskStore: { getDatabase: () => ({}), getReportStore: () => store } as any,
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: undefined,
resolveProjectTaskStore: undefined,
...({} as any),
} as PluginContext;
}
describe("report export routes", () => {
it("returns export html with attachment header", async () => {
const routes = createReportExportRoutes();
const route = routes.find((r) => r.path.endsWith("export.html"))!;
const record = report();
const getReport = vi.fn().mockReturnValue(record);
const setRenderedHtml = vi.fn();
const ctx = ctxWithStore({ getReport, setRenderedHtml });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(200);
expect(res.contentType).toContain("text/html");
expect(res.headers["Content-Disposition"]).toContain("attachment;");
});
it("returns 404 for missing id", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(null), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "missing" } }, ctx as any) as any;
expect(res.status).toBe(404);
});
it("returns 409 for generating report", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report({ status: "generating" })), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(409);
});
it("returns body-only preview html", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("preview.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report()), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(200);
expect(res.contentType).toContain("text/html");
expect(res.body).toContain("<article");
expect(res.body).not.toContain("<!doctype html>");
});
it("caches rendered html after first export", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const mutable = report();
const getReport = vi.fn().mockImplementation(() => mutable);
const setRenderedHtml = vi.fn().mockImplementation((_id: string, html: string) => {
mutable.renderedHtml = html;
});
const ctx = ctxWithStore({ getReport, setRenderedHtml });
const first = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
const second = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(setRenderedHtml).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,77 @@
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
import { ReportStore } from "../store/report-store.js";
import { renderReportHtml } from "../render/html-template.js";
import { renderStandaloneReportHtml, slugifyReportFilename } from "../render/standalone-html.js";
interface RouteRequest {
params: Record<string, string>;
}
const reportStoreCache = new WeakMap<object, ReportStore>();
function getStore(ctx: PluginContext): ReportStore {
const taskStoreWithReports = ctx.taskStore as PluginContext["taskStore"] & { getReportStore?: () => ReportStore };
if (typeof taskStoreWithReports.getReportStore === "function") {
return taskStoreWithReports.getReportStore();
}
const key = ctx.taskStore as object;
const cached = reportStoreCache.get(key);
if (cached) return cached;
const store = new ReportStore(ctx.taskStore.getDatabase());
reportStoreCache.set(key, store);
return store;
}
function notFound(message: string): PluginRouteResponse {
return { status: 404, body: { error: message } };
}
function conflict(message: string): PluginRouteResponse {
return { status: 409, body: { error: message } };
}
export function createReportExportRoutes(): PluginRouteDefinition[] {
return [
{
method: "GET",
path: "/reports/:id/export.html",
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
const request = req as RouteRequest;
const id = request.params.id;
const store = getStore(ctx);
const record = store.getReport(id);
if (!record) return notFound(`Report ${id} not found`);
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
const html = record.renderedHtml ?? renderStandaloneReportHtml(record);
if (!record.renderedHtml) {
store.setRenderedHtml(id, html);
}
return {
status: 200,
body: html,
contentType: "text/html; charset=utf-8",
headers: {
"Content-Disposition": `attachment; filename="${slugifyReportFilename(record)}"`,
},
};
},
},
{
method: "GET",
path: "/reports/:id/preview.html",
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
const request = req as RouteRequest;
const id = request.params.id;
const store = getStore(ctx);
const record = store.getReport(id);
if (!record) return notFound(`Report ${id} not found`);
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
return {
status: 200,
body: renderReportHtml(record, { includeChrome: false }),
contentType: "text/html; charset=utf-8",
};
},
},
];
}