Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
u1: text_overrides axis in user_overrides_io u2: structure_overrides axis in user_overrides_io u3: vite allowlist for new endpoints u4: text_override_resolver u5: Step 12 text_overrides apply in phase_z2_pipeline u6: structure_override_resolver u7: text_path_stamper u8: SlideCanvas text-edit capture u9: SlideCanvas structure-edit overlay u10: userOverridesApi service extension u11: designAgent types extension u12: slidePlanUtils restore u13: user_overrides endpoint tests u14: user_overrides restore tests u15: pipeline fallback tests u16: edit-mode state + gating tests u17: slide_base print mode CSS u18: /api/connect endpoint (vite) u19: /api/export endpoint (vite) Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in 9439575; this commit lands u1-u19 that were authored but not committed before #90 was externally closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
256 lines
9.3 KiB
TypeScript
256 lines
9.3 KiB
TypeScript
// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export
|
|
// middleware and its supporting inlineAssetsAsDataUrls helper.
|
|
//
|
|
// Scope:
|
|
// 1) inlineAssetsAsDataUrls (pure helper):
|
|
// - no url(assets/...) refs → passthrough.
|
|
// - single PNG ref → inlined as base64 data: URL with image/png mime.
|
|
// - multiple refs → all inlined.
|
|
// - SVG ref → image/svg+xml mime.
|
|
// - missing asset file → left as-is (no throw, no rewrite).
|
|
// - data:/http:/ URLs (non-asset) → untouched.
|
|
// 2) handleExportStandalone (POST):
|
|
// - method != POST → false (chain continues; next middleware may handle).
|
|
// - invalid JSON / non-object body → 400.
|
|
// - missing run_id → 400.
|
|
// - invalid run_id (key gate / path traversal) → 400.
|
|
// - final.html missing → 404.
|
|
// - success → 200 with Content-Disposition: attachment; filename=...,
|
|
// Content-Type: text/html; charset=utf-8, body = inlined HTML.
|
|
//
|
|
// Tests exercise the pure handler with mock req/res — no real vite server.
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
handleExportStandalone,
|
|
inlineAssetsAsDataUrls,
|
|
} from "../../vite.config";
|
|
|
|
function makeMockRes() {
|
|
const state = {
|
|
statusCode: 0,
|
|
headers: {} as Record<string, string>,
|
|
body: "",
|
|
ended: false,
|
|
};
|
|
return {
|
|
state,
|
|
res: {
|
|
writeHead(status: number, headers?: Record<string, string>) {
|
|
state.statusCode = status;
|
|
if (headers) state.headers = headers;
|
|
},
|
|
end(body?: string) {
|
|
state.body = body ?? "";
|
|
state.ended = true;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeMockReq(opts: {
|
|
method?: string;
|
|
}): EventEmitter & { method?: string; send: (body: string) => void } {
|
|
const ee = new EventEmitter() as EventEmitter & {
|
|
method?: string;
|
|
send: (body: string) => void;
|
|
};
|
|
ee.method = opts.method;
|
|
ee.send = (body: string) => {
|
|
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
|
ee.emit("end");
|
|
};
|
|
return ee;
|
|
}
|
|
|
|
function seedRun(
|
|
daRoot: string,
|
|
runId: string,
|
|
htmlBody: string,
|
|
assets?: Record<string, Buffer | string>,
|
|
): string {
|
|
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
|
|
fs.mkdirSync(runDir, { recursive: true });
|
|
const html = path.join(runDir, "final.html");
|
|
fs.writeFileSync(html, htmlBody, "utf-8");
|
|
if (assets) {
|
|
for (const [rel, buf] of Object.entries(assets)) {
|
|
const dst = path.join(runDir, "assets", rel);
|
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
fs.writeFileSync(dst, buf);
|
|
}
|
|
}
|
|
return runDir;
|
|
}
|
|
|
|
describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => {
|
|
let tmp: string;
|
|
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns html unchanged when no url(assets/...) refs are present", () => {
|
|
const html = "<html><style>body{color:red;}</style><body>hi</body></html>";
|
|
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
|
});
|
|
|
|
it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => {
|
|
fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true });
|
|
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes);
|
|
const html = "background: url(assets/frame_x/a.png);";
|
|
const out = inlineAssetsAsDataUrls(html, tmp);
|
|
expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`);
|
|
expect(out).not.toContain("url(assets/frame_x/a.png)");
|
|
});
|
|
|
|
it("inlines multiple refs across the same HTML body", () => {
|
|
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
|
fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE"));
|
|
fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO"));
|
|
const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}";
|
|
const out = inlineAssetsAsDataUrls(html, tmp);
|
|
expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`);
|
|
expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`);
|
|
});
|
|
|
|
it("uses image/svg+xml mime for .svg refs", () => {
|
|
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
|
fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "<svg/>", "utf-8");
|
|
const html = "url(assets/f/icon.svg)";
|
|
const out = inlineAssetsAsDataUrls(html, tmp);
|
|
expect(out).toContain("data:image/svg+xml;base64,");
|
|
});
|
|
|
|
it("leaves the ref untouched when the asset file is missing", () => {
|
|
const html = "url(assets/missing/file.png)";
|
|
const out = inlineAssetsAsDataUrls(html, tmp);
|
|
expect(out).toBe(html);
|
|
});
|
|
|
|
it("does not touch data: or http(s): url() values (only matches assets/...)", () => {
|
|
const html =
|
|
"x{background:url(data:image/png;base64,AAA)} " +
|
|
"y{background:url(https://cdn.x/a.png)}";
|
|
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
|
});
|
|
|
|
it("handles quoted url(...) refs (single and double quotes)", () => {
|
|
fs.mkdirSync(path.join(tmp, "q"), { recursive: true });
|
|
fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K"));
|
|
const html =
|
|
"a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}";
|
|
const out = inlineAssetsAsDataUrls(html, tmp);
|
|
const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`;
|
|
expect(out.split(data).length - 1).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("handleExportStandalone (IMP-56 #90 u19)", () => {
|
|
let daRoot: string;
|
|
|
|
beforeEach(() => {
|
|
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(daRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns false (next chained) when method != POST", () => {
|
|
const req = makeMockReq({ method: "GET" });
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleExportStandalone(req, res, daRoot);
|
|
expect(handled).toBe(false);
|
|
expect(state.ended).toBe(false);
|
|
});
|
|
|
|
it("returns 400 on invalid JSON body", () => {
|
|
const req = makeMockReq({ method: "POST" });
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleExportStandalone(req, res, daRoot);
|
|
expect(handled).toBe(true);
|
|
req.send("{nope");
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body).error).toBe("invalid JSON");
|
|
});
|
|
|
|
it("returns 400 when body is not a JSON object (array root)", () => {
|
|
const req = makeMockReq({ method: "POST" });
|
|
const { res, state } = makeMockRes();
|
|
handleExportStandalone(req, res, daRoot);
|
|
req.send(JSON.stringify(["x"]));
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
|
|
});
|
|
|
|
it("returns 400 when run_id is missing", () => {
|
|
const req = makeMockReq({ method: "POST" });
|
|
const { res, state } = makeMockRes();
|
|
handleExportStandalone(req, res, daRoot);
|
|
req.send(JSON.stringify({}));
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body).error).toBe("missing run_id");
|
|
});
|
|
|
|
it("returns 400 when run_id contains path traversal", () => {
|
|
const req = makeMockReq({ method: "POST" });
|
|
const { res, state } = makeMockRes();
|
|
handleExportStandalone(req, res, daRoot);
|
|
req.send(JSON.stringify({ run_id: "../escape" }));
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body).error).toBe("invalid run_id");
|
|
});
|
|
|
|
it("returns 404 when final.html does not exist for run_id", () => {
|
|
const req = makeMockReq({ method: "POST" });
|
|
const { res, state } = makeMockRes();
|
|
handleExportStandalone(req, res, daRoot);
|
|
req.send(JSON.stringify({ run_id: "ghost_run" }));
|
|
expect(state.statusCode).toBe(404);
|
|
expect(JSON.parse(state.body).error).toBe("final.html not found");
|
|
});
|
|
|
|
it("returns 200 with text/html body + Content-Disposition on success", () => {
|
|
seedRun(daRoot, "mdx03_run", "<html><body>03</body></html>");
|
|
const req = makeMockReq({ method: "POST" });
|
|
const { res, state } = makeMockRes();
|
|
handleExportStandalone(req, res, daRoot);
|
|
req.send(JSON.stringify({ run_id: "mdx03_run" }));
|
|
expect(state.statusCode).toBe(200);
|
|
expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8");
|
|
expect(state.headers["Content-Disposition"]).toBe(
|
|
'attachment; filename="mdx03_run.html"',
|
|
);
|
|
expect(state.body).toBe("<html><body>03</body></html>");
|
|
});
|
|
|
|
it("inlines assets in final.html when run dir has assets/", () => {
|
|
const pngBytes = Buffer.from("PNGDATA");
|
|
seedRun(
|
|
daRoot,
|
|
"mdx05_run",
|
|
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
|
|
{ "f/x.png": pngBytes },
|
|
);
|
|
const req = makeMockReq({ method: "POST" });
|
|
const { res, state } = makeMockRes();
|
|
handleExportStandalone(req, res, daRoot);
|
|
req.send(JSON.stringify({ run_id: "mdx05_run" }));
|
|
expect(state.statusCode).toBe(200);
|
|
expect(state.body).toContain(
|
|
`data:image/png;base64,${pngBytes.toString("base64")}`,
|
|
);
|
|
expect(state.body).not.toContain("url(assets/f/x.png)");
|
|
});
|
|
});
|