mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
Merge pull request #2148 from heygen-com/07-10-test_repo_enforce_workspace_contracts
test(repo): enforce workspace contracts
This commit is contained in:
@@ -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 = '<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="1" data-start="0"><div class="clip" data-start="0" data-duration="1">Test</div></div>';
|
||||
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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+4
-3
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<main>demo</main>")));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { adapter, initialHtml } = await createFileAdapter();
|
||||
|
||||
expect(initialHtml).toBe("<main>demo</main>");
|
||||
expect(await adapter.read("ignored.html")).toBe("<main>demo</main>");
|
||||
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", "<main>updated</main>")).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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
'<!doctype html><html><body><div data-composition-id="root" data-width="1920" ' +
|
||||
'data-height="1080" data-duration="1" data-start="0"><div class="clip" ' +
|
||||
'data-hf-id="title" data-start="0" data-duration="1">Test</div></div></body></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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user