Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 1 addition & 76 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions packages/sdk-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/sdk": "workspace:*",
"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"
}
}
49 changes: 49 additions & 0 deletions packages/sdk-playground/src/fileAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
});
3 changes: 1 addition & 2 deletions packages/sdk-playground/src/fileAdapter.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
4 changes: 2 additions & 2 deletions packages/sdk-playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk-playground/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 1 addition & 1 deletion packages/sdk-playground/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
62 changes: 62 additions & 0 deletions scripts/check-workspace-contracts.mjs
Original file line number Diff line number Diff line change
@@ -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();
40 changes: 40 additions & 0 deletions scripts/check-workspace-contracts.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading