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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,36 @@ jobs:
- run: bun run --cwd packages/core build:hyperframes-runtime
- run: bun run --filter '!@hyperframes/producer' test

producer-source-tests:
name: "Producer: ${{ matrix.lane }} tests"
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
lane: [unit, integration]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
lfs: true
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Install FFmpeg for integration tests
if: matrix.lane == 'integration'
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ffmpeg
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build
- run: bun run --filter @hyperframes/engine build
- run: bun run producer:test:${{ matrix.lane }}

# Tests under skills/**/*.test.mjs are bare `node --test` files with only
# `node:` built-in imports. They aren't part of any workspace package, and
# the main `Test` job's `code` path filter excludes `skills/**`, so without
Expand Down
27 changes: 14 additions & 13 deletions bun.lock

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

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@
"lint:fix": "oxlint --fix .",
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
"format": "oxfmt .",
"test": "bun run --filter '*' test",
"test": "bun run test:unit",
"test:unit": "bun run --filter '*' test",
"producer:test:classification": "bun run --cwd packages/producer test:classification",
"producer:test:unit": "bun run --cwd packages/producer test:unit",
"producer:test:unit:bun": "bun run --cwd packages/producer test:unit:bun",
"producer:test:unit:vitest": "bun run --cwd packages/producer test:unit:vitest",
"producer:test:integration": "bun run --cwd packages/producer test:integration",
"test:regression": "bun run --cwd packages/producer test:regression",
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
Expand Down
15 changes: 12 additions & 3 deletions packages/producer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,16 @@
"check:runtime-conformance": "tsx src/runtime-conformance.ts",
"benchmark": "tsx src/benchmark.ts",
"bench:hdr": "tsx src/benchmark.ts --tags hdr",
"test": "tsx src/regression-harness.ts --exclude-tags transparency",
"test:update": "tsx src/regression-harness.ts --update --exclude-tags transparency",
"test": "bun run test:unit",
"test:classification": "node --test scripts/test-classification.test.mjs && node scripts/check-test-classification.mjs",
"test:unit": "bun run test:classification && node scripts/run-test-lane.mjs unit",
"test:unit:bun": "node scripts/run-test-lane.mjs unit bun",
"test:unit:vitest": "node scripts/run-test-lane.mjs unit vitest",
"test:integration": "bun run test:classification && node scripts/run-test-lane.mjs integration",
"test:integration:bun": "node scripts/run-test-lane.mjs integration bun",
"test:integration:vitest": "node scripts/run-test-lane.mjs integration vitest",
"test:regression": "tsx src/regression-harness.ts --exclude-tags transparency",
"test:regression:update": "tsx src/regression-harness.ts --update --exclude-tags transparency",
"test:distributed": "tsx src/regression-harness.ts --exclude-tags transparency --mode=distributed-simulated",
"test:transparency": "tsx src/transparency-test.ts",
"docker:build:test": "docker build -f ../../Dockerfile.test -t hyperframes-producer:test ../..",
Expand Down Expand Up @@ -93,7 +101,8 @@
"@webgpu/types": "^0.1.69",
"esbuild": "^0.25.12",
"tsx": "^4.21.0",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=22"
Expand Down
9 changes: 9 additions & 0 deletions packages/producer/scripts/check-test-classification.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { discoverProducerTests, summarizeTests } from "./test-classification.mjs";

try {
const tests = discoverProducerTests();
console.log(JSON.stringify({ event: "producer_tests_classified", ...summarizeTests(tests) }));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
34 changes: 34 additions & 0 deletions packages/producer/scripts/run-test-lane.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { spawnSync } from "node:child_process";
import { discoverProducerTests, PRODUCER_ROOT } from "./test-classification.mjs";

const lane = process.argv[2];
const requestedRunner = process.argv[3];
if (lane !== "unit" && lane !== "integration") {
throw new Error("Usage: node scripts/run-test-lane.mjs <unit|integration> [bun|vitest]");
}
if (requestedRunner && requestedRunner !== "bun" && requestedRunner !== "vitest") {
throw new Error(`Unknown test runner: ${requestedRunner}`);
}

const tests = discoverProducerTests().filter(
(test) => test.lane === lane && (!requestedRunner || test.runner === requestedRunner),
);

function run(args) {
const result = spawnSync("bun", args, {
cwd: PRODUCER_ROOT,
env: { ...process.env, HYPERFRAMES_TEST_LANE: lane },
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}

const vitestFiles = tests.filter((test) => test.runner === "vitest").map((test) => test.file);
if (vitestFiles.length > 0) run(["x", "vitest", "run", ...vitestFiles]);

// Bun's mock.module registry is process-global. Run each file in a fresh
// process so mocks from one source test cannot mutate another test's imports.
for (const test of tests.filter((entry) => entry.runner === "bun")) {
run(["test", test.file]);
}
74 changes: 74 additions & 0 deletions packages/producer/scripts/test-classification.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// fallow-ignore-file complexity
import { readdirSync, readFileSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

export const PRODUCER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");

// These tests need host capabilities such as Chrome, ffmpeg, worker threads,
// or local sockets. Keep the list explicit so a filename-only rename does not
// make Git/fallow re-audit thousands of unchanged test lines as new code.
const INTEGRATION_TEST_FILES = new Set([
"src/services/deterministicFonts-systemCapture.test.ts",
"src/services/distributed/assemble.test.ts",
"src/services/distributed/chunkBoundary.test.ts",
"src/services/distributed/crossWorkerIdempotency.test.ts",
"src/services/distributed/plan.test.ts",
"src/services/distributed/planSizeCap.test.ts",
"src/services/distributed/renderChunk.test.ts",
"src/services/fileServer.test.ts",
"src/services/healthWorker.test.ts",
"src/utils/audioRegression.test.ts",
"src/utils/streamDurationParity.test.ts",
]);

function collectTestFiles(directory, files = []) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = resolve(directory, entry.name);
if (entry.isDirectory()) collectTestFiles(entryPath, files);
else if (entry.isFile() && entry.name.endsWith(".test.ts")) files.push(entryPath);
}
return files;
}

export function classifyTestSource(filePath, source, integrationFiles = INTEGRATION_TEST_FILES) {
const importsBun = /\bfrom\s+["']bun:test["']/.test(source);
const importsVitest = /\bfrom\s+["']vitest["']/.test(source);
if (importsBun === importsVitest) {
const detail = importsBun ? "imports both bun:test and vitest" : "imports neither runner";
throw new Error(`${filePath}: ${detail}`);
}
return {
file: filePath,
runner: importsBun ? "bun" : "vitest",
lane: integrationFiles.has(filePath) ? "integration" : "unit",
};
}

export function discoverProducerTests(producerRoot = PRODUCER_ROOT) {
const srcDir = resolve(producerRoot, "src");
const files = collectTestFiles(srcDir).map((absolutePath) => ({
absolutePath,
filePath: relative(producerRoot, absolutePath).replaceAll("\\", "/"),
}));
const discoveredFiles = new Set(files.map((test) => test.filePath));
const staleEntries = [...INTEGRATION_TEST_FILES].filter((file) => !discoveredFiles.has(file));
if (staleEntries.length > 0) {
throw new Error(`Integration test manifest contains missing files: ${staleEntries.join(", ")}`);
}
return files
.map(({ absolutePath, filePath }) => {
return classifyTestSource(filePath, readFileSync(absolutePath, "utf8"));
})
.sort((left, right) => left.file.localeCompare(right.file));
}

export function summarizeTests(tests) {
const summary = {
total: tests.length,
unit: { bun: 0, vitest: 0 },
integration: { bun: 0, vitest: 0 },
};
for (const test of tests) summary[test.lane][test.runner] += 1;
return summary;
}
48 changes: 48 additions & 0 deletions packages/producer/scripts/test-classification.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
classifyTestSource,
discoverProducerTests,
summarizeTests,
} from "./test-classification.mjs";

describe("producer test classification", () => {
it("classifies each supported runner into one lane", () => {
assert.deepEqual(classifyTestSource("src/a.test.ts", 'import { it } from "bun:test";'), {
file: "src/a.test.ts",
runner: "bun",
lane: "unit",
});
assert.deepEqual(
classifyTestSource(
"src/a.test.ts",
'import { it } from "vitest";',
new Set(["src/a.test.ts"]),
),
{ file: "src/a.test.ts", runner: "vitest", lane: "integration" },
);
});

it("rejects missing and ambiguous runner imports", () => {
assert.throws(() => classifyTestSource("src/a.test.ts", "export {};"), /neither runner/);
assert.throws(
() =>
classifyTestSource(
"src/a.test.ts",
'import { it } from "bun:test"; import { expect } from "vitest";',
),
/both bun:test and vitest/,
);
});

it("classifies every current source test exactly once", () => {
const tests = discoverProducerTests();
assert.equal(new Set(tests.map((test) => test.file)).size, tests.length);
const summary = summarizeTests(tests);
assert.ok(summary.total > 0);
assert.equal(
summary.unit.bun + summary.unit.vitest + summary.integration.bun + summary.integration.vitest,
summary.total,
);
});
});
Loading
Loading