/**
* Build Output Tests - Infrastructure tests for widget build artifacts.
*
* These tests verify that `pnpm run build` produces the expected output
* structure required for MCP Apps integration.
*
* Key requirements verified:
* 1. Build produces JS, CSS, and HTML files for each widget
* 2. Asset hashing works correctly (all files use same hash)
* 3. HTML files reference assets with correct URLs
* 4. Live and hashed HTML files are in sync
*
* NOT verified (trivial/testing build script):
* - HTML doctype/structure (generated by build script)
* - CSS/JS content validity (if broken, app just won't work)
*
* NOTE: These tests require a build to have been run first.
* Run `pnpm run build` before running these tests.
*/
import { describe, it, expect, beforeAll } from "vitest";
import fs from "fs";
import path from "path";
const assetsDir = path.resolve("assets");
// Auto-discover widget targets by scanning src/*/index.tsx (mirrors build-all.mts)
function getWidgetTargets(): string[] {
const srcDir = path.resolve("src");
return fs
.readdirSync(srcDir)
.filter((name) => {
const entryTsx = path.join(srcDir, name, "index.tsx");
const entryJsx = path.join(srcDir, name, "index.jsx");
return (
fs.statSync(path.join(srcDir, name)).isDirectory() &&
(fs.existsSync(entryTsx) || fs.existsSync(entryJsx))
);
})
.sort();
}
const widgets = getWidgetTargets();
// Check if build has been run
const buildExists = fs.existsSync(assetsDir) && fs.readdirSync(assetsDir).length > 0;
describe("Build Output", () => {
beforeAll(() => {
if (!buildExists) {
console.warn(
"\n Assets directory is empty or missing. Run 'pnpm run build' first.\n"
);
}
});
describe("assets directory", () => {
it("assets directory exists", () => {
expect(fs.existsSync(assetsDir)).toBe(true);
});
it.skipIf(!buildExists)("assets directory is not empty", () => {
const files = fs.readdirSync(assetsDir);
expect(files.length).toBeGreaterThan(0);
});
});
describe.skipIf(!buildExists)("widget output files", () => {
it.each(widgets)("widget '%s' has HTML file", (widget) => {
const htmlPath = path.join(assetsDir, `${widget}.html`);
expect(fs.existsSync(htmlPath)).toBe(true);
});
it.each(widgets)("widget '%s' has hashed HTML file", (widget) => {
const files = fs.readdirSync(assetsDir);
const hashedHtml = files.find(
(f) => f.startsWith(`${widget}-`) && f.endsWith(".html")
);
expect(hashedHtml).toBeDefined();
});
it.each(widgets)("widget '%s' has hashed JS file", (widget) => {
const files = fs.readdirSync(assetsDir);
const hashedJs = files.find(
(f) => f.startsWith(`${widget}-`) && f.endsWith(".js")
);
expect(hashedJs).toBeDefined();
});
it.each(widgets)("widget '%s' has hashed CSS file", (widget) => {
const files = fs.readdirSync(assetsDir);
const hashedCss = files.find(
(f) => f.startsWith(`${widget}-`) && f.endsWith(".css")
);
expect(hashedCss).toBeDefined();
});
});
describe.skipIf(!buildExists)("file naming conventions", () => {
it("all hashed files use same hash", () => {
const files = fs.readdirSync(assetsDir);
const hashes = new Set<string>();
for (const file of files) {
// Match pattern: name-hash.ext
const match = file.match(/^[a-z-]+-([a-f0-9]+)\.(js|css|html)$/);
if (match) {
hashes.add(match[1]);
}
}
// All hashed files should use the same hash
expect(hashes.size).toBe(1);
});
it("hash is 4 characters", () => {
const files = fs.readdirSync(assetsDir);
const hashedFile = files.find((f) => /^[a-z-]+-[a-f0-9]+\.js$/.test(f));
if (hashedFile) {
const match = hashedFile.match(/-([a-f0-9]+)\.js$/);
expect(match?.[1].length).toBe(4);
}
});
});
});
describe.skipIf(!buildExists)("HTML Asset References", () => {
describe("required elements", () => {
it.each(widgets)("widget '%s' HTML has root div with correct ID", (widget) => {
const htmlPath = path.join(assetsDir, `${widget}.html`);
const content = fs.readFileSync(htmlPath, "utf-8");
// Should have div with id="${widget}-root"
const expectedId = `${widget}-root`;
expect(content).toContain(`id="${expectedId}"`);
});
it.each(widgets)(
"widget '%s' HTML script points to hashed JS",
(widget) => {
const htmlPath = path.join(assetsDir, `${widget}.html`);
const content = fs.readFileSync(htmlPath, "utf-8");
// Script src should include widget name and hash
const pattern = new RegExp(`${widget}-[a-f0-9]+\\.js`);
expect(content).toMatch(pattern);
}
);
it.each(widgets)(
"widget '%s' HTML stylesheet points to hashed CSS",
(widget) => {
const htmlPath = path.join(assetsDir, `${widget}.html`);
const content = fs.readFileSync(htmlPath, "utf-8");
// Stylesheet href should include widget name and hash
const pattern = new RegExp(`${widget}-[a-f0-9]+\\.css`);
expect(content).toMatch(pattern);
}
);
});
describe("asset URLs", () => {
it.each(widgets)("widget '%s' HTML has valid asset URLs", (widget) => {
const htmlPath = path.join(assetsDir, `${widget}.html`);
const content = fs.readFileSync(htmlPath, "utf-8");
// Should have either relative paths (./) or absolute URLs (https?://)
// Default build uses relative paths; server converts to absolute for MCP responses
expect(content).toMatch(/src="(\.\/|https?:\/\/)/);
expect(content).toMatch(/href="(\.\/|https?:\/\/)/);
});
});
});
describe.skipIf(!buildExists)("Build Consistency", () => {
it("live HTML and hashed HTML have same content", () => {
for (const widget of widgets) {
const liveHtmlPath = path.join(assetsDir, `${widget}.html`);
const files = fs.readdirSync(assetsDir);
const hashedHtmlFile = files.find(
(f) => f.startsWith(`${widget}-`) && f.endsWith(".html")
);
if (hashedHtmlFile) {
const liveContent = fs.readFileSync(liveHtmlPath, "utf-8");
const hashedContent = fs.readFileSync(
path.join(assetsDir, hashedHtmlFile),
"utf-8"
);
expect(liveContent).toBe(hashedContent);
}
}
});
it("all widgets have same number of output files (4 each)", () => {
const files = fs.readdirSync(assetsDir);
for (const widget of widgets) {
const widgetFiles = files.filter(
(f) => f.startsWith(widget) && (f.startsWith(`${widget}-`) || f === `${widget}.html`)
);
// Each widget should have: .html, -hash.html, -hash.js, -hash.css
expect(widgetFiles.length).toBe(4);
}
});
});
describe.skipIf(!buildExists)("Bundle Size Budgets", () => {
const DEFAULT_LIMITS = {
js: 500 * 1024, // 500KB max JS
css: 200 * 1024, // 200KB max CSS
};
// Widgets with legitimate large dependencies (e.g., Three.js) can have higher limits
const WIDGET_JS_LIMITS: Record<string, number> = {
"solar-system": 1500 * 1024, // Three.js for 3D rendering
};
it.each(widgets)("widget '%s' JS bundle within size budget", (widget) => {
const files = fs.readdirSync(assetsDir);
const jsFile = files.find(f => f.startsWith(`${widget}-`) && f.endsWith('.js'));
if (jsFile) {
const stats = fs.statSync(path.join(assetsDir, jsFile));
const limit = WIDGET_JS_LIMITS[widget] ?? DEFAULT_LIMITS.js;
expect(stats.size).toBeLessThan(limit);
}
});
it.each(widgets)("widget '%s' CSS bundle under 200KB", (widget) => {
const files = fs.readdirSync(assetsDir);
const cssFile = files.find(f => f.startsWith(`${widget}-`) && f.endsWith('.css'));
if (cssFile) {
const stats = fs.statSync(path.join(assetsDir, cssFile));
expect(stats.size).toBeLessThan(DEFAULT_LIMITS.css);
}
});
});