diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8f7acb03b..3865705c3c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -419,82 +419,7 @@ jobs:
# Load the studio in headless Chrome with API mocking to trigger
# the full splash→main transition (catches hooks-after-early-return bugs)
- cd packages/producer
- node --input-type=module <<'SMOKE_EOF'
- import puppeteer from "puppeteer";
- const browser = await puppeteer.launch({
- headless: "new",
- args: ["--no-sandbox", "--disable-setuid-sandbox"],
- });
- const page = await browser.newPage();
- const errors = [];
- page.on("pageerror", (err) => errors.push(err.message));
- page.on("console", (msg) => {
- if (msg.type() === "error") errors.push(msg.text());
- });
-
- // Mock the project API so the studio transitions past the splash screen.
- // Without this, useServerConnection stays in "waiting" and the full React
- // tree (with all hooks) never renders — missing hooks-order violations.
- const COMP_HTML = '
';
- await page.setRequestInterception(true);
- page.on("request", (req) => {
- const url = req.url();
- if (url.includes("/api/projects") && !url.includes("/files") && !url.includes("/preview") && !url.includes("/gsap")) {
- req.respond({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({ projects: [{ id: "smoke-test" }] }),
- });
- } else if (url.includes("/api/") && url.includes("/files")) {
- req.respond({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({ files: [{ path: "index.html", type: "file" }] }),
- });
- } else if (url.includes("/api/") && url.includes("/preview")) {
- req.respond({ status: 200, contentType: "text/html", body: COMP_HTML });
- } else if (url.includes("/api/")) {
- req.respond({ status: 200, contentType: "application/json", body: JSON.stringify({}) });
- } else {
- req.continue();
- }
- });
-
- await page.goto("http://localhost:5199/#project=smoke-test", {
- waitUntil: "networkidle0",
- timeout: 30000,
- });
- // Wait for React to render past splash into the full studio UI
- await new Promise((r) => setTimeout(r, 3000));
-
- // Check for React error boundary (catches hooks violations, render crashes)
- const errorBoundary = await page.evaluate(() => {
- const text = document.body.innerText;
- if (text.includes("Something went wrong")) return text;
- return null;
- });
- if (errorBoundary) {
- errors.push("React error boundary triggered: " + errorBoundary);
- }
- await browser.close();
- // Filter expected noise from mock endpoints
- const fatal = errors.filter(
- (e) =>
- !e.includes("favicon") &&
- !e.includes("ERR_CONNECTION_REFUSED") &&
- !e.includes("Failed to fetch") &&
- !e.includes("is not iterable") &&
- !e.includes("Cannot read properties of undefined") &&
- !e.includes("Cannot read properties of null"),
- );
- if (fatal.length > 0) {
- console.error("FAIL: studio had runtime errors:");
- for (const e of fatal) console.error(" •", e);
- process.exit(1);
- }
- console.log("PASS: studio loaded and transitioned without runtime errors");
- SMOKE_EOF
+ node scripts/studio-runtime-smoke.mjs http://localhost:5199/#project=smoke-test
kill $SERVER_PID 2>/dev/null || true
diff --git a/bun.lock b/bun.lock
index 4339fb118a..77c87f2c0b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -285,7 +285,10 @@
"gsap": "^3.15.0",
},
"devDependencies": {
+ "@types/node": "^25.0.10",
+ "typescript": "^5.0.0",
"vite": "^6.4.2",
+ "vitest": "^3.2.4",
},
},
"packages/shader-transitions": {
diff --git a/package.json b/package.json
index 12d17ffce3..83eeed4c20 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"type": "module",
"scripts": {
"dev": "bun run studio",
- "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build",
+ "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build && bun run --filter @hyperframes/sdk-playground build",
"build:producer": "bun run --filter @hyperframes/producer build",
"studio": "bun run --filter @hyperframes/studio dev",
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",
@@ -24,10 +24,11 @@
"changelog:weekly": "tsx scripts/changelog-weekly.ts",
"sync-schemas": "tsx scripts/sync-schemas.ts",
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
- "lint": "bun run check:tracked-artifacts && oxlint . && tsx scripts/lint-skills.ts",
+ "lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && oxlint . && tsx scripts/lint-skills.ts",
"lint:skills": "tsx scripts/lint-skills.ts",
"lint:fix": "oxlint --fix .",
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
+ "check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
"format": "oxfmt .",
"test": "bun run test:unit",
"test:unit": "bun run --filter '*' test",
@@ -40,7 +41,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
- "test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/verify-packed-manifests.test.mjs",
+ "test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
diff --git a/packages/sdk-playground/package.json b/packages/sdk-playground/package.json
index 7e89b43486..ed208000a4 100644
--- a/packages/sdk-playground/package.json
+++ b/packages/sdk-playground/package.json
@@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "vite build"
+ "build": "vite build",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
},
"dependencies": {
"@hyperframes/core": "workspace:*",
@@ -13,6 +15,9 @@
"gsap": "^3.15.0"
},
"devDependencies": {
- "vite": "^6.4.2"
+ "@types/node": "^25.0.10",
+ "typescript": "^5.0.0",
+ "vite": "^6.4.2",
+ "vitest": "^3.2.4"
}
}
diff --git a/packages/sdk-playground/src/fileAdapter.test.ts b/packages/sdk-playground/src/fileAdapter.test.ts
new file mode 100644
index 0000000000..cc06b06b04
--- /dev/null
+++ b/packages/sdk-playground/src/fileAdapter.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createFileAdapter } from "./fileAdapter";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("createFileAdapter", () => {
+ it("loads the initial composition through the public SDK adapter contract", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockImplementation(() => Promise.resolve(new Response("demo")));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const { adapter, initialHtml } = await createFileAdapter();
+
+ expect(initialHtml).toBe("demo");
+ expect(await adapter.read("ignored.html")).toBe("demo");
+ expect(fetchMock).toHaveBeenCalledWith("/api/composition");
+ });
+
+ it("reports failed writes through persist:error without rejecting", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response("", { status: 404 }))
+ .mockResolvedValueOnce(new Response("failed", { status: 500 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const { adapter } = await createFileAdapter();
+ const errors: string[] = [];
+ adapter.on("persist:error", (event) => errors.push(event.error.message));
+
+ await expect(adapter.write("ignored.html", "updated")).resolves.toBeUndefined();
+
+ expect(errors).toEqual(["Error: write failed: 500"]);
+ });
+
+ it("maps persisted versions to the SDK version shape", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response("", { status: 404 }))
+ .mockResolvedValueOnce(Response.json([{ key: "version-2", timestamp: 42 }], { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const { adapter } = await createFileAdapter();
+
+ await expect(adapter.listVersions("ignored.html")).resolves.toEqual([
+ { key: "version-2", content: "", timestamp: 42 },
+ ]);
+ });
+});
diff --git a/packages/sdk-playground/src/fileAdapter.ts b/packages/sdk-playground/src/fileAdapter.ts
index 2e23b1c427..9e59236755 100644
--- a/packages/sdk-playground/src/fileAdapter.ts
+++ b/packages/sdk-playground/src/fileAdapter.ts
@@ -1,5 +1,4 @@
-import type { PersistAdapter, PersistVersionEntry } from "@hyperframes/sdk/adapters/types";
-import type { PersistErrorEvent } from "@hyperframes/sdk";
+import type { PersistAdapter, PersistErrorEvent, PersistVersionEntry } from "@hyperframes/sdk";
const API = "/api/composition";
diff --git a/packages/sdk-playground/src/main.ts b/packages/sdk-playground/src/main.ts
index e00ee7a5b4..685a2f3011 100644
--- a/packages/sdk-playground/src/main.ts
+++ b/packages/sdk-playground/src/main.ts
@@ -256,7 +256,7 @@ const TRACK_COLORS = ["#3b82f6", "#8b5cf6", "#f59e0b", "#10b981", "#f87171", "#0
function selectorToHfId(selector: string): string | null {
const m = /\[data-hf-id=['"]([^'"]+)['"]\]/.exec(selector);
- if (m) return m[1];
+ if (m) return m[1] ?? null;
if (/^#/.test(selector.trim())) return selector.trim().slice(1);
return null;
}
@@ -347,7 +347,7 @@ function buildTrackRow(
index: number,
dur: number,
): HTMLDivElement {
- const color = TRACK_COLORS[index % TRACK_COLORS.length];
+ const color = TRACK_COLORS[index % TRACK_COLORS.length] ?? "#3b82f6";
const row = document.createElement("div");
row.className = "tl-row";
const labelEl = document.createElement("div");
diff --git a/packages/sdk-playground/tsconfig.json b/packages/sdk-playground/tsconfig.json
new file mode 100644
index 0000000000..5c69492a4a
--- /dev/null
+++ b/packages/sdk-playground/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node", "vite/client"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/packages/sdk-playground/vite.config.ts b/packages/sdk-playground/vite.config.ts
index 300c6fc61f..45e8031e17 100644
--- a/packages/sdk-playground/vite.config.ts
+++ b/packages/sdk-playground/vite.config.ts
@@ -4,7 +4,7 @@ import type { Plugin } from "vite";
import type { Connect } from "vite";
import type { ServerResponse } from "node:http";
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
-import type { PersistAdapter } from "@hyperframes/sdk/adapters/types";
+import type { PersistAdapter } from "@hyperframes/sdk";
const COMP_ROOT = path.resolve(import.meta.dirname);
const COMP_PATH = "composition.html";
diff --git a/scripts/check-workspace-contracts.mjs b/scripts/check-workspace-contracts.mjs
new file mode 100644
index 0000000000..91ae1e2167
--- /dev/null
+++ b/scripts/check-workspace-contracts.mjs
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = join(import.meta.dirname, "..");
+export const REQUIRED_WORKSPACE_SCRIPTS = ["build", "typecheck", "test"];
+
+function hasExecutableScript(pkg, script) {
+ return typeof pkg.scripts?.[script] === "string" && pkg.scripts[script].trim().length > 0;
+}
+
+function hasSpecificReason(reason) {
+ return typeof reason === "string" ? reason.trim().length >= 20 : false;
+}
+
+function hasDocumentedOptOut(pkg, script) {
+ const optOut = pkg.hyperframesWorkspaceContract?.[script];
+ return optOut?.optOut === true && hasSpecificReason(optOut.reason);
+}
+
+export function listWorkspaceContractIssues(workspace, pkg) {
+ return REQUIRED_WORKSPACE_SCRIPTS.flatMap((script) => {
+ if (hasExecutableScript(pkg, script) || hasDocumentedOptOut(pkg, script)) return [];
+ return [
+ `${workspace}: missing executable \`${script}\` script or ` +
+ `hyperframesWorkspaceContract.${script} opt-out with a specific reason`,
+ ];
+ });
+}
+
+export function listWorkspacePackages(root = ROOT) {
+ const packagesDir = join(root, "packages");
+ return readdirSync(packagesDir)
+ .sort()
+ .filter((name) => existsSync(join(packagesDir, name, "package.json")))
+ .map((name) => {
+ const workspace = `packages/${name}`;
+ const pkg = JSON.parse(readFileSync(join(root, workspace, "package.json"), "utf8"));
+ return { workspace, pkg };
+ });
+}
+
+export function checkWorkspaceContracts(root = ROOT) {
+ return listWorkspacePackages(root).flatMap(({ workspace, pkg }) =>
+ listWorkspaceContractIssues(workspace, pkg),
+ );
+}
+
+function main() {
+ const issues = checkWorkspaceContracts();
+ if (issues.length > 0) {
+ console.error("Workspace contract violations:");
+ issues.forEach((issue) => console.error(`- ${issue}`));
+ process.exitCode = 1;
+ return;
+ }
+ console.log("Workspace contracts verified: build, typecheck, and test are explicit.");
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) main();
diff --git a/scripts/check-workspace-contracts.test.mjs b/scripts/check-workspace-contracts.test.mjs
new file mode 100644
index 0000000000..a61c158375
--- /dev/null
+++ b/scripts/check-workspace-contracts.test.mjs
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ listWorkspaceContractIssues,
+ REQUIRED_WORKSPACE_SCRIPTS,
+} from "./check-workspace-contracts.mjs";
+
+describe("workspace contract checker", () => {
+ it("accepts workspaces with all executable lifecycle scripts", () => {
+ const scripts = Object.fromEntries(REQUIRED_WORKSPACE_SCRIPTS.map((name) => [name, "echo ok"]));
+ assert.deepEqual(listWorkspaceContractIssues("packages/example", { scripts }), []);
+ });
+
+ it("reports every missing or empty lifecycle script", () => {
+ assert.deepEqual(
+ listWorkspaceContractIssues("packages/example", { scripts: { build: " " } }),
+ REQUIRED_WORKSPACE_SCRIPTS.map(
+ (script) =>
+ `packages/example: missing executable \`${script}\` script or ` +
+ `hyperframesWorkspaceContract.${script} opt-out with a specific reason`,
+ ),
+ );
+ });
+
+ it("accepts an explicit opt-out only when it has a specific reason", () => {
+ const pkg = {
+ scripts: { build: "build", typecheck: "typecheck" },
+ hyperframesWorkspaceContract: {
+ test: {
+ optOut: true,
+ reason: "Generated fixture validated by the owning package integration suite.",
+ },
+ },
+ };
+ assert.deepEqual(listWorkspaceContractIssues("packages/example", pkg), []);
+
+ pkg.hyperframesWorkspaceContract.test.reason = "no tests";
+ assert.equal(listWorkspaceContractIssues("packages/example", pkg).length, 1);
+ });
+});
diff --git a/scripts/studio-runtime-smoke.mjs b/scripts/studio-runtime-smoke.mjs
new file mode 100644
index 0000000000..bfe0640e00
--- /dev/null
+++ b/scripts/studio-runtime-smoke.mjs
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+
+import { createRequire } from "node:module";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = join(import.meta.dirname, "..");
+const PROJECT_ID = "smoke-test";
+export const SMOKE_COMPOSITION_HTML =
+ '';
+
+function json(body, status = 200) {
+ return { status, contentType: "application/json", body: JSON.stringify(body) };
+}
+
+function text(body, contentType = "text/plain") {
+ return { status: 200, contentType, body };
+}
+
+const PROJECT_PATH = `/api/projects/${PROJECT_ID}`;
+const GET_RESPONSES = new Map([
+ [
+ "/api/projects",
+ json({ projects: [{ id: PROJECT_ID, dir: "/tmp/smoke-test", title: "Smoke test" }] }),
+ ],
+ [
+ PROJECT_PATH,
+ json({
+ id: PROJECT_ID,
+ dir: "/tmp/smoke-test",
+ title: "Smoke test",
+ files: ["index.html"],
+ compositions: ["index.html"],
+ }),
+ ],
+ [`${PROJECT_PATH}/preview`, text(SMOKE_COMPOSITION_HTML, "text/html")],
+ [`${PROJECT_PATH}/renders`, json({ renders: [] })],
+ [`${PROJECT_PATH}/lint`, json({ findings: [] })],
+ [
+ `${PROJECT_PATH}/storyboard`,
+ json({
+ exists: false,
+ path: "STORYBOARD.md",
+ globals: { extra: {} },
+ frames: [],
+ warnings: [],
+ script: { exists: false, path: "SCRIPT.md", content: "" },
+ }),
+ ],
+ [`${PROJECT_PATH}/selection`, json({ selection: null, updatedAt: null })],
+ ["/api/registry/blocks", json([])],
+ ["/api/fonts", json({ fonts: [] })],
+ ["/api/fonts/google", json({ fonts: [] })],
+ ["/api/assets/global", json({ assets: [] })],
+]);
+const MUTATION_RESPONSES = new Map([
+ [`${PROJECT_PATH}/selection`, json({ ok: true, selection: null, updatedAt: null })],
+]);
+
+function projectFileResponse(pathname) {
+ if (!pathname.startsWith(`${PROJECT_PATH}/files/`)) return undefined;
+ const filename = decodeURIComponent(pathname.split("/files/")[1] ?? "");
+ return json({
+ filename,
+ content: filename === "index.html" ? SMOKE_COMPOSITION_HTML : "",
+ });
+}
+
+function projectPreviewResponse(pathname) {
+ if (!pathname.startsWith(`${PROJECT_PATH}/preview/`)) return undefined;
+ return pathname.endsWith("/.media/manifest.jsonl")
+ ? text("", "application/x-ndjson")
+ : text(SMOKE_COMPOSITION_HTML, "text/html");
+}
+
+function gsapAnimationsResponse(pathname) {
+ if (!pathname.startsWith(`${PROJECT_PATH}/gsap-animations/`)) return undefined;
+ return json({ animations: [], timelineVar: "tl", preamble: "", postamble: "" });
+}
+
+function getStudioSmokeResponse(pathname) {
+ return (
+ GET_RESPONSES.get(pathname) ??
+ projectFileResponse(pathname) ??
+ projectPreviewResponse(pathname) ??
+ gsapAnimationsResponse(pathname)
+ );
+}
+
+function studioSmokeApiPathResponse(method, pathname) {
+ return method === "GET"
+ ? (getStudioSmokeResponse(pathname) ?? null)
+ : (MUTATION_RESPONSES.get(pathname) ?? null);
+}
+
+export function studioSmokeApiResponse(method, requestUrl) {
+ const { pathname } = new URL(requestUrl);
+ return pathname.startsWith("/api/") ? studioSmokeApiPathResponse(method, pathname) : undefined;
+}
+
+export function isExpectedStudioSmokeError(message) {
+ return message.includes("favicon.ico");
+}
+
+async function loadPuppeteer() {
+ const requireFromProducer = createRequire(join(ROOT, "packages", "producer", "package.json"));
+ return requireFromProducer("puppeteer");
+}
+
+export async function runStudioRuntimeSmoke(targetUrl) {
+ const puppeteer = await loadPuppeteer();
+ const browser = await puppeteer.launch({
+ headless: "new",
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
+ });
+ const page = await browser.newPage();
+ const errors = [];
+ const unmockedApiRequests = [];
+
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error") errors.push(message.text());
+ });
+ await page.setRequestInterception(true);
+ page.on("request", (request) => {
+ const response = studioSmokeApiResponse(request.method(), request.url());
+ if (response === undefined) {
+ void request.continue();
+ return;
+ }
+ if (response === null) {
+ unmockedApiRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
+ void request.respond(json({ error: "unmocked smoke endpoint" }, 501));
+ return;
+ }
+ void request.respond(response);
+ });
+
+ try {
+ await page.goto(targetUrl, { waitUntil: "networkidle0", timeout: 30_000 });
+ await new Promise((resolve) => setTimeout(resolve, 3_000));
+ const errorBoundary = await page.evaluate(() => {
+ const textContent = document.body.innerText;
+ return textContent.includes("Something went wrong") ? textContent : null;
+ });
+ if (errorBoundary) errors.push(`React error boundary triggered: ${errorBoundary}`);
+ } finally {
+ await browser.close();
+ }
+
+ const fatal = errors.filter((error) => !isExpectedStudioSmokeError(error));
+ const failures = [
+ ...new Set(unmockedApiRequests.map((request) => `unmocked API request: ${request}`)),
+ ...fatal,
+ ];
+ if (failures.length > 0) {
+ throw new Error(
+ `Studio runtime smoke failed:\n${failures.map((error) => `- ${error}`).join("\n")}`,
+ );
+ }
+}
+
+async function main() {
+ const targetUrl = process.argv[2] ?? "http://localhost:5199/#project=smoke-test";
+ await runStudioRuntimeSmoke(targetUrl);
+ console.log("PASS: studio loaded with schema-valid API fixtures and no runtime errors");
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/studio-runtime-smoke.test.mjs b/scripts/studio-runtime-smoke.test.mjs
new file mode 100644
index 0000000000..a686b48e93
--- /dev/null
+++ b/scripts/studio-runtime-smoke.test.mjs
@@ -0,0 +1,58 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ isExpectedStudioSmokeError,
+ SMOKE_COMPOSITION_HTML,
+ studioSmokeApiResponse,
+} from "./studio-runtime-smoke.mjs";
+
+function bodyOf(method, path) {
+ const response = studioSmokeApiResponse(method, `http://localhost:5199${path}`);
+ assert.ok(response && response !== null);
+ return JSON.parse(response.body);
+}
+
+describe("Studio runtime smoke fixtures", () => {
+ it("matches the collection and project-detail API schemas", () => {
+ assert.deepEqual(bodyOf("GET", "/api/projects"), {
+ projects: [{ id: "smoke-test", dir: "/tmp/smoke-test", title: "Smoke test" }],
+ });
+ assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test"), {
+ id: "smoke-test",
+ dir: "/tmp/smoke-test",
+ title: "Smoke test",
+ files: ["index.html"],
+ compositions: ["index.html"],
+ });
+ });
+
+ it("returns file content rather than the unrelated file-tree shape", () => {
+ assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/files/index.html"), {
+ filename: "index.html",
+ content: SMOKE_COMPOSITION_HTML,
+ });
+ });
+
+ it("returns iterable collections for eager Studio queries", () => {
+ assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/renders"), { renders: [] });
+ assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/gsap-animations/index.html"), {
+ animations: [],
+ timelineVar: "tl",
+ preamble: "",
+ postamble: "",
+ });
+ assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/lint"), { findings: [] });
+ });
+
+ it("marks unknown API requests as fixture failures", () => {
+ assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/api/new-endpoint"), null);
+ assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/src/main.tsx"), undefined);
+ });
+
+ it("does not suppress generic JavaScript or network failures", () => {
+ assert.equal(isExpectedStudioSmokeError("Cannot read properties of undefined"), false);
+ assert.equal(isExpectedStudioSmokeError("value is not iterable"), false);
+ assert.equal(isExpectedStudioSmokeError("Failed to fetch"), false);
+ assert.equal(isExpectedStudioSmokeError("GET /favicon.ico 404"), true);
+ });
+});