Skip to content

Commit 46f7f01

Browse files
committed
test(repo): enforce workspace contracts
1 parent 53309af commit 46f7f01

13 files changed

Lines changed: 420 additions & 86 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -411,82 +411,7 @@ jobs:
411411
412412
# Load the studio in headless Chrome with API mocking to trigger
413413
# the full splash→main transition (catches hooks-after-early-return bugs)
414-
cd packages/producer
415-
node --input-type=module <<'SMOKE_EOF'
416-
import puppeteer from "puppeteer";
417-
const browser = await puppeteer.launch({
418-
headless: "new",
419-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
420-
});
421-
const page = await browser.newPage();
422-
const errors = [];
423-
page.on("pageerror", (err) => errors.push(err.message));
424-
page.on("console", (msg) => {
425-
if (msg.type() === "error") errors.push(msg.text());
426-
});
427-
428-
// Mock the project API so the studio transitions past the splash screen.
429-
// Without this, useServerConnection stays in "waiting" and the full React
430-
// tree (with all hooks) never renders — missing hooks-order violations.
431-
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>';
432-
await page.setRequestInterception(true);
433-
page.on("request", (req) => {
434-
const url = req.url();
435-
if (url.includes("/api/projects") && !url.includes("/files") && !url.includes("/preview") && !url.includes("/gsap")) {
436-
req.respond({
437-
status: 200,
438-
contentType: "application/json",
439-
body: JSON.stringify({ projects: [{ id: "smoke-test" }] }),
440-
});
441-
} else if (url.includes("/api/") && url.includes("/files")) {
442-
req.respond({
443-
status: 200,
444-
contentType: "application/json",
445-
body: JSON.stringify({ files: [{ path: "index.html", type: "file" }] }),
446-
});
447-
} else if (url.includes("/api/") && url.includes("/preview")) {
448-
req.respond({ status: 200, contentType: "text/html", body: COMP_HTML });
449-
} else if (url.includes("/api/")) {
450-
req.respond({ status: 200, contentType: "application/json", body: JSON.stringify({}) });
451-
} else {
452-
req.continue();
453-
}
454-
});
455-
456-
await page.goto("http://localhost:5199/#project=smoke-test", {
457-
waitUntil: "networkidle0",
458-
timeout: 30000,
459-
});
460-
// Wait for React to render past splash into the full studio UI
461-
await new Promise((r) => setTimeout(r, 3000));
462-
463-
// Check for React error boundary (catches hooks violations, render crashes)
464-
const errorBoundary = await page.evaluate(() => {
465-
const text = document.body.innerText;
466-
if (text.includes("Something went wrong")) return text;
467-
return null;
468-
});
469-
if (errorBoundary) {
470-
errors.push("React error boundary triggered: " + errorBoundary);
471-
}
472-
await browser.close();
473-
// Filter expected noise from mock endpoints
474-
const fatal = errors.filter(
475-
(e) =>
476-
!e.includes("favicon") &&
477-
!e.includes("ERR_CONNECTION_REFUSED") &&
478-
!e.includes("Failed to fetch") &&
479-
!e.includes("is not iterable") &&
480-
!e.includes("Cannot read properties of undefined") &&
481-
!e.includes("Cannot read properties of null"),
482-
);
483-
if (fatal.length > 0) {
484-
console.error("FAIL: studio had runtime errors:");
485-
for (const e of fatal) console.error(" •", e);
486-
process.exit(1);
487-
}
488-
console.log("PASS: studio loaded and transitioned without runtime errors");
489-
SMOKE_EOF
414+
node scripts/studio-runtime-smoke.mjs http://localhost:5199/#project=smoke-test
490415
491416
kill $SERVER_PID 2>/dev/null || true
492417

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"type": "module",
1212
"scripts": {
1313
"dev": "bun run studio",
14-
"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",
14+
"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",
1515
"build:producer": "bun run --filter @hyperframes/producer build",
1616
"studio": "bun run --filter @hyperframes/studio dev",
1717
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",
@@ -24,10 +24,11 @@
2424
"changelog:weekly": "tsx scripts/changelog-weekly.ts",
2525
"sync-schemas": "tsx scripts/sync-schemas.ts",
2626
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
27-
"lint": "bun run check:tracked-artifacts && oxlint . && tsx scripts/lint-skills.ts",
27+
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && oxlint . && tsx scripts/lint-skills.ts",
2828
"lint:skills": "tsx scripts/lint-skills.ts",
2929
"lint:fix": "oxlint --fix .",
3030
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
31+
"check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
3132
"format": "oxfmt .",
3233
"test": "bun run test:unit",
3334
"test:unit": "bun run --filter '*' test",
@@ -40,7 +41,7 @@
4041
"player:perf": "bun run --filter @hyperframes/player perf",
4142
"format:check": "oxfmt --check .",
4243
"knip": "knip",
43-
"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",
44+
"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",
4445
"test:skills": "node --test 'skills/**/*.test.mjs'",
4546
"generate:previews": "tsx scripts/generate-template-previews.ts",
4647
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",

packages/sdk-playground/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
8-
"build": "vite build"
8+
"build": "vite build",
9+
"typecheck": "tsc --noEmit",
10+
"test": "vitest run"
911
},
1012
"dependencies": {
1113
"@hyperframes/core": "workspace:*",
1214
"@hyperframes/sdk": "workspace:*",
1315
"gsap": "^3.15.0"
1416
},
1517
"devDependencies": {
16-
"vite": "^6.4.2"
18+
"@types/node": "^25.0.10",
19+
"typescript": "^5.0.0",
20+
"vite": "^6.4.2",
21+
"vitest": "^3.2.4"
1722
}
1823
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createFileAdapter } from "./fileAdapter";
3+
4+
afterEach(() => {
5+
vi.unstubAllGlobals();
6+
});
7+
8+
describe("createFileAdapter", () => {
9+
it("loads the initial composition through the public SDK adapter contract", async () => {
10+
const fetchMock = vi
11+
.fn()
12+
.mockImplementation(() => Promise.resolve(new Response("<main>demo</main>")));
13+
vi.stubGlobal("fetch", fetchMock);
14+
15+
const { adapter, initialHtml } = await createFileAdapter();
16+
17+
expect(initialHtml).toBe("<main>demo</main>");
18+
expect(await adapter.read("ignored.html")).toBe("<main>demo</main>");
19+
expect(fetchMock).toHaveBeenCalledWith("/api/composition");
20+
});
21+
22+
it("reports failed writes through persist:error without rejecting", async () => {
23+
const fetchMock = vi
24+
.fn()
25+
.mockResolvedValueOnce(new Response("", { status: 404 }))
26+
.mockResolvedValueOnce(new Response("failed", { status: 500 }));
27+
vi.stubGlobal("fetch", fetchMock);
28+
const { adapter } = await createFileAdapter();
29+
const errors: string[] = [];
30+
adapter.on("persist:error", (event) => errors.push(event.error.message));
31+
32+
await expect(adapter.write("ignored.html", "<main>updated</main>")).resolves.toBeUndefined();
33+
34+
expect(errors).toEqual(["Error: write failed: 500"]);
35+
});
36+
37+
it("maps persisted versions to the SDK version shape", async () => {
38+
const fetchMock = vi
39+
.fn()
40+
.mockResolvedValueOnce(new Response("", { status: 404 }))
41+
.mockResolvedValueOnce(Response.json([{ key: "version-2", timestamp: 42 }], { status: 200 }));
42+
vi.stubGlobal("fetch", fetchMock);
43+
const { adapter } = await createFileAdapter();
44+
45+
await expect(adapter.listVersions("ignored.html")).resolves.toEqual([
46+
{ key: "version-2", content: "", timestamp: 42 },
47+
]);
48+
});
49+
});

packages/sdk-playground/src/fileAdapter.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { PersistAdapter, PersistVersionEntry } from "@hyperframes/sdk/adapters/types";
2-
import type { PersistErrorEvent } from "@hyperframes/sdk";
1+
import type { PersistAdapter, PersistErrorEvent, PersistVersionEntry } from "@hyperframes/sdk";
32

43
const API = "/api/composition";
54

packages/sdk-playground/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ const TRACK_COLORS = ["#3b82f6", "#8b5cf6", "#f59e0b", "#10b981", "#f87171", "#0
256256

257257
function selectorToHfId(selector: string): string | null {
258258
const m = /\[data-hf-id=['"]([^'"]+)['"]\]/.exec(selector);
259-
if (m) return m[1];
259+
if (m) return m[1] ?? null;
260260
if (/^#/.test(selector.trim())) return selector.trim().slice(1);
261261
return null;
262262
}
@@ -347,7 +347,7 @@ function buildTrackRow(
347347
index: number,
348348
dur: number,
349349
): HTMLDivElement {
350-
const color = TRACK_COLORS[index % TRACK_COLORS.length];
350+
const color = TRACK_COLORS[index % TRACK_COLORS.length] ?? "#3b82f6";
351351
const row = document.createElement("div");
352352
row.className = "tl-row";
353353
const labelEl = document.createElement("div");
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
7+
"types": ["node", "vite/client"],
8+
"strict": true,
9+
"noUncheckedIndexedAccess": true,
10+
"esModuleInterop": true,
11+
"skipLibCheck": true,
12+
"noEmit": true,
13+
"isolatedModules": true
14+
},
15+
"include": ["src", "vite.config.ts"]
16+
}

packages/sdk-playground/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Plugin } from "vite";
44
import type { Connect } from "vite";
55
import type { ServerResponse } from "node:http";
66
import { createFsAdapter } from "@hyperframes/sdk/adapters/fs";
7-
import type { PersistAdapter } from "@hyperframes/sdk/adapters/types";
7+
import type { PersistAdapter } from "@hyperframes/sdk";
88

99
const COMP_ROOT = path.resolve(import.meta.dirname);
1010
const COMP_PATH = "composition.html";
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env node
2+
3+
import { existsSync, readFileSync, readdirSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
7+
const ROOT = join(import.meta.dirname, "..");
8+
export const REQUIRED_WORKSPACE_SCRIPTS = ["build", "typecheck", "test"];
9+
10+
function hasExecutableScript(pkg, script) {
11+
return typeof pkg.scripts?.[script] === "string" && pkg.scripts[script].trim().length > 0;
12+
}
13+
14+
function hasSpecificReason(reason) {
15+
return typeof reason === "string" ? reason.trim().length >= 20 : false;
16+
}
17+
18+
function hasDocumentedOptOut(pkg, script) {
19+
const optOut = pkg.hyperframesWorkspaceContract?.[script];
20+
return optOut?.optOut === true && hasSpecificReason(optOut.reason);
21+
}
22+
23+
export function listWorkspaceContractIssues(workspace, pkg) {
24+
return REQUIRED_WORKSPACE_SCRIPTS.flatMap((script) => {
25+
if (hasExecutableScript(pkg, script) || hasDocumentedOptOut(pkg, script)) return [];
26+
return [
27+
`${workspace}: missing executable \`${script}\` script or ` +
28+
`hyperframesWorkspaceContract.${script} opt-out with a specific reason`,
29+
];
30+
});
31+
}
32+
33+
export function listWorkspacePackages(root = ROOT) {
34+
const packagesDir = join(root, "packages");
35+
return readdirSync(packagesDir)
36+
.sort()
37+
.filter((name) => existsSync(join(packagesDir, name, "package.json")))
38+
.map((name) => {
39+
const workspace = `packages/${name}`;
40+
const pkg = JSON.parse(readFileSync(join(root, workspace, "package.json"), "utf8"));
41+
return { workspace, pkg };
42+
});
43+
}
44+
45+
export function checkWorkspaceContracts(root = ROOT) {
46+
return listWorkspacePackages(root).flatMap(({ workspace, pkg }) =>
47+
listWorkspaceContractIssues(workspace, pkg),
48+
);
49+
}
50+
51+
function main() {
52+
const issues = checkWorkspaceContracts();
53+
if (issues.length > 0) {
54+
console.error("Workspace contract violations:");
55+
issues.forEach((issue) => console.error(`- ${issue}`));
56+
process.exitCode = 1;
57+
return;
58+
}
59+
console.log("Workspace contracts verified: build, typecheck, and test are explicit.");
60+
}
61+
62+
if (process.argv[1] === fileURLToPath(import.meta.url)) main();

0 commit comments

Comments
 (0)