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 = '
Test
'; - 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 = + '
Test
'; + +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); + }); +});