Initial Commit
This commit is contained in:
160
packages/ui/react/react-audio/tests/exports.test.ts
Normal file
160
packages/ui/react/react-audio/tests/exports.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = resolve(__dirname, "..");
|
||||
const PKG_JSON = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
|
||||
const REQUIRED_HOOKS = [
|
||||
"use-engine-state",
|
||||
"use-audio-engine",
|
||||
"use-timeline",
|
||||
"use-transport",
|
||||
"use-transport-time",
|
||||
"use-track",
|
||||
"use-plugin-chain",
|
||||
"use-chain-plugin",
|
||||
"use-audio-fx-plugin",
|
||||
"use-parameter",
|
||||
"use-preset",
|
||||
"use-chain-snapshot",
|
||||
"use-bus",
|
||||
"use-midi-controller",
|
||||
"use-analyser",
|
||||
"use-playlist",
|
||||
];
|
||||
|
||||
const REQUIRED_COMPONENTS = [
|
||||
"plugin-slot",
|
||||
"plugin-chain-view",
|
||||
"plugin-parameter-knob",
|
||||
"plugin-parameter-slider",
|
||||
"preset-manager",
|
||||
"midi-controller-setup",
|
||||
"transport-controls",
|
||||
"track-list",
|
||||
"track-row",
|
||||
"master-meter",
|
||||
"mixer",
|
||||
"audio-visualizer",
|
||||
"audio-player",
|
||||
"basic-player",
|
||||
"premium-player",
|
||||
"microphone-controls",
|
||||
"glados-controls",
|
||||
"plugin-loader",
|
||||
];
|
||||
|
||||
const REQUIRED_SURFACES = [
|
||||
".",
|
||||
"./provider",
|
||||
"./hooks",
|
||||
...REQUIRED_HOOKS.map((h) => `./hooks/${h}`),
|
||||
"./components",
|
||||
...REQUIRED_COMPONENTS.map((c) => `./components/${c}`),
|
||||
"./stores",
|
||||
"./a11y",
|
||||
"./error-codes",
|
||||
];
|
||||
|
||||
describe("subpath exports", () => {
|
||||
it("every Phase-1 surface has a subpath export entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const surface of REQUIRED_SURFACES) {
|
||||
expect(PKG_JSON.exports[surface]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("every component has a subpath export entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const c of REQUIRED_COMPONENTS) {
|
||||
expect(PKG_JSON.exports[`./components/${c}`]).toBeDefined();
|
||||
}
|
||||
expect(PKG_JSON.exports["./components"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes exactly 16 hook subpaths (13 foundation + 1 Phase 3 + 2 player-tier)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hookKeys = Object.keys(PKG_JSON.exports).filter((k) =>
|
||||
k.startsWith("./hooks/")
|
||||
);
|
||||
expect(hookKeys.length).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
const STORY_NAME_REGEX = /^(Default|Interactive|Edge.*)$/;
|
||||
|
||||
function readStoryNames(filePath: string): ReadonlyArray<string> {
|
||||
if (!existsSync(filePath)) return [];
|
||||
const text = readFileSync(filePath, "utf8");
|
||||
const names: string[] = [];
|
||||
const re = /export\s+const\s+(\w+)\s*:\s*Story\s*=/g;
|
||||
let m: RegExpExecArray | null = re.exec(text);
|
||||
while (m !== null) {
|
||||
names.push(m[1] as string);
|
||||
m = re.exec(text);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
interface ExportedComponentFile {
|
||||
readonly path: string;
|
||||
readonly storyPath: string;
|
||||
}
|
||||
|
||||
function listExportedComponentFiles(): ExportedComponentFile[] {
|
||||
const files: ExportedComponentFile[] = [];
|
||||
const componentsDir = join(PKG_ROOT, "src", "components");
|
||||
for (const name of readdirSync(componentsDir)) {
|
||||
const dir = join(componentsDir, name);
|
||||
if (!statSync(dir).isDirectory()) continue;
|
||||
if (name === "internal") continue;
|
||||
files.push({
|
||||
path: join(dir, `${name}.tsx`),
|
||||
storyPath: join(dir, `${name}.stories.tsx`),
|
||||
});
|
||||
}
|
||||
files.push({
|
||||
path: join(PKG_ROOT, "src", "provider", "audio-provider.tsx"),
|
||||
storyPath: join(
|
||||
PKG_ROOT,
|
||||
"src",
|
||||
"provider",
|
||||
"audio-provider.stories.tsx"
|
||||
),
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("Storybook coverage gate", () => {
|
||||
it("every component has default, interactive, and edge-case Storybook stories", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const componentFiles = listExportedComponentFiles();
|
||||
const missing: string[] = [];
|
||||
for (const file of componentFiles) {
|
||||
if (!existsSync(file.storyPath)) {
|
||||
missing.push(`${file.path}: no sibling ${file.storyPath}`);
|
||||
continue;
|
||||
}
|
||||
const names = readStoryNames(file.storyPath);
|
||||
const matched = names.filter((n) => STORY_NAME_REGEX.test(n));
|
||||
const hasDefault = matched.includes("Default");
|
||||
const hasInteractive = matched.includes("Interactive");
|
||||
const hasEdge = matched.some((n) => n.startsWith("Edge"));
|
||||
if (!hasDefault || !hasInteractive || !hasEdge) {
|
||||
missing.push(
|
||||
`${file.path}: stories=[${names.join(
|
||||
", "
|
||||
)}]; require Default + Interactive + Edge*`
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
131
packages/ui/react/react-audio/tests/fake-midi.ts
Normal file
131
packages/ui/react/react-audio/tests/fake-midi.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Real `MidiAccessLike` + `MidiAccessAdapter` fixtures — mirrors the
|
||||
* `tests/fake-midi.ts` shipped by `@suaveplan/audio-controllers`. Lives
|
||||
* outside `src/` so it does not appear in coverage reports yet remains
|
||||
* shared by every Phase-3 unit / story / integration test.
|
||||
*
|
||||
* Re-implements the audio-controllers fixture locally because that
|
||||
* fixture is not published through the package exports. The shape is
|
||||
* intentionally identical so the controller plumbing is unchanged.
|
||||
*/
|
||||
|
||||
import type {
|
||||
MidiAccessAdapter,
|
||||
MidiAccessLike,
|
||||
MidiInputLike,
|
||||
MidiOutputLike,
|
||||
} from "@suaveplan/audio-controllers";
|
||||
|
||||
export interface FakeMidiInput extends MidiInputLike {
|
||||
emit(data: ReadonlyArray<number>, timeStamp?: number): void;
|
||||
}
|
||||
|
||||
export interface FakeMidiOutput extends MidiOutputLike {
|
||||
readonly sent: number[][];
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
export interface FakeMidiAccess extends MidiAccessLike {
|
||||
readonly mutableInputs: Map<string, FakeMidiInput>;
|
||||
readonly mutableOutputs: Map<string, FakeMidiOutput>;
|
||||
disconnectPort(port: FakeMidiInput | FakeMidiOutput): void;
|
||||
reconnectPort(port: FakeMidiInput | FakeMidiOutput): void;
|
||||
}
|
||||
|
||||
function createInput(id: string, name: string): FakeMidiInput {
|
||||
const port: FakeMidiInput = {
|
||||
id,
|
||||
name,
|
||||
state: "connected",
|
||||
type: "input",
|
||||
onmidimessage: null,
|
||||
emit(data: ReadonlyArray<number>, timeStamp = 0) {
|
||||
port.onmidimessage?.({
|
||||
data: new Uint8Array(data),
|
||||
timeStamp,
|
||||
});
|
||||
},
|
||||
} as FakeMidiInput;
|
||||
return port;
|
||||
}
|
||||
|
||||
function createOutput(id: string, name: string): FakeMidiOutput {
|
||||
const sent: number[][] = [];
|
||||
let connectedState: "connected" | "disconnected" = "connected";
|
||||
const port = {
|
||||
id,
|
||||
name,
|
||||
get state(): "connected" | "disconnected" {
|
||||
return connectedState;
|
||||
},
|
||||
type: "output" as const,
|
||||
sent,
|
||||
send(data: ReadonlyArray<number> | Uint8Array) {
|
||||
sent.push(Array.isArray(data) ? data.slice() : Array.from(data));
|
||||
},
|
||||
disconnect() {
|
||||
connectedState = "disconnected";
|
||||
},
|
||||
} satisfies FakeMidiOutput;
|
||||
return port;
|
||||
}
|
||||
|
||||
export function createFakeAccess(
|
||||
options: { readonly inputName?: string; readonly outputName?: string } = {}
|
||||
): FakeMidiAccess {
|
||||
const inputName = options.inputName ?? "Demo Input";
|
||||
const outputName = options.outputName ?? "Demo Output";
|
||||
const input = createInput("in-1", inputName);
|
||||
const output = createOutput("out-1", outputName);
|
||||
const mutableInputs = new Map<string, FakeMidiInput>([[input.id, input]]);
|
||||
const mutableOutputs = new Map<string, FakeMidiOutput>([
|
||||
[output.id, output],
|
||||
]);
|
||||
const access: FakeMidiAccess = {
|
||||
get inputs(): ReadonlyMap<string, MidiInputLike> {
|
||||
return mutableInputs;
|
||||
},
|
||||
get outputs(): ReadonlyMap<string, MidiOutputLike> {
|
||||
return mutableOutputs;
|
||||
},
|
||||
mutableInputs,
|
||||
mutableOutputs,
|
||||
onstatechange: null,
|
||||
disconnectPort(port) {
|
||||
if (port.type === "input") mutableInputs.delete(port.id);
|
||||
else {
|
||||
port.disconnect();
|
||||
mutableOutputs.delete(port.id);
|
||||
}
|
||||
access.onstatechange?.({
|
||||
port: { ...port, state: "disconnected" },
|
||||
});
|
||||
},
|
||||
reconnectPort(port) {
|
||||
if (port.type === "input") mutableInputs.set(port.id, port);
|
||||
else mutableOutputs.set(port.id, port);
|
||||
access.onstatechange?.({
|
||||
port: { ...port, state: "connected" },
|
||||
});
|
||||
},
|
||||
};
|
||||
return access;
|
||||
}
|
||||
|
||||
export function createFakeAdapter(
|
||||
access: FakeMidiAccess,
|
||||
options: { readonly reject?: Error } = {}
|
||||
): MidiAccessAdapter {
|
||||
return {
|
||||
async requestAccess() {
|
||||
if (options.reject) throw options.reject;
|
||||
return access;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getFirstInput(access: FakeMidiAccess): FakeMidiInput {
|
||||
const first = access.mutableInputs.values().next().value;
|
||||
if (!first) throw new Error("no input");
|
||||
return first;
|
||||
}
|
||||
120
packages/ui/react/react-audio/tests/fake-transport.ts
Normal file
120
packages/ui/react/react-audio/tests/fake-transport.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Real `TransportLike` implementation backed by an in-memory event log.
|
||||
* Mirrors the fixture shipped by `@suaveplan/audio-controllers/tests/
|
||||
* fake-transport.ts`. Lives outside `src/` so it is excluded from
|
||||
* coverage but available to every test in this package.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BarsBeatsSixteenths,
|
||||
SignalLike,
|
||||
TransportCallback,
|
||||
TransportLike,
|
||||
TransportTime,
|
||||
} from "@suaveplan/audio-transport";
|
||||
|
||||
interface ScheduledRepeat {
|
||||
readonly id: number;
|
||||
readonly callback: TransportCallback;
|
||||
readonly interval: TransportTime;
|
||||
}
|
||||
|
||||
interface ScheduledOnce {
|
||||
readonly id: number;
|
||||
readonly callback: TransportCallback;
|
||||
readonly time: number;
|
||||
}
|
||||
|
||||
const NOOP_BBS: BarsBeatsSixteenths = "0:0:0";
|
||||
|
||||
function makeFakeSignal(): SignalLike {
|
||||
let v = 120;
|
||||
const points: { time: number; value: number; type: "set" }[] = [];
|
||||
const sig: SignalLike = {
|
||||
get points() {
|
||||
return points.slice();
|
||||
},
|
||||
get initialValue() {
|
||||
return 120;
|
||||
},
|
||||
get value() {
|
||||
return v;
|
||||
},
|
||||
setValueAtTime(value: number, time: number) {
|
||||
v = value;
|
||||
points.push({ time, value, type: "set" });
|
||||
},
|
||||
linearRampToValueAtTime(value: number) {
|
||||
v = value;
|
||||
},
|
||||
exponentialRampToValueAtTime(value: number) {
|
||||
v = value;
|
||||
},
|
||||
getValueAtTime() {
|
||||
return v;
|
||||
},
|
||||
cancelScheduledValues() {
|
||||
points.length = 0;
|
||||
},
|
||||
};
|
||||
return sig;
|
||||
}
|
||||
|
||||
export class FakeTransport implements TransportLike {
|
||||
public bpm: SignalLike = makeFakeSignal();
|
||||
public readonly ppq = 192;
|
||||
public readonly timeSignature = 4;
|
||||
public swing = 0;
|
||||
public swingSubdivision: TransportTime = "8n";
|
||||
public loop = false;
|
||||
public loopStart: TransportTime = 0;
|
||||
public loopEnd: TransportTime = 0;
|
||||
public state: "stopped" | "playing" | "paused" = "stopped";
|
||||
public position: BarsBeatsSixteenths = NOOP_BBS;
|
||||
public ticks = 0;
|
||||
public seconds = 0;
|
||||
|
||||
public readonly repeats = new Map<number, ScheduledRepeat>();
|
||||
public readonly onces = new Map<number, ScheduledOnce>();
|
||||
private nextId = 1;
|
||||
|
||||
start(): void {
|
||||
this.state = "playing";
|
||||
}
|
||||
stop(): void {
|
||||
this.state = "stopped";
|
||||
}
|
||||
pause(): void {
|
||||
this.state = "paused";
|
||||
}
|
||||
schedule(callback: TransportCallback, time: TransportTime): number {
|
||||
return this.scheduleOnce(callback, time);
|
||||
}
|
||||
scheduleOnce(callback: TransportCallback, time: TransportTime): number {
|
||||
const id = this.nextId++;
|
||||
const num = typeof time === "number" ? time : 0;
|
||||
this.onces.set(id, { id, callback, time: num });
|
||||
return id;
|
||||
}
|
||||
scheduleRepeat(
|
||||
callback: TransportCallback,
|
||||
interval: TransportTime
|
||||
): number {
|
||||
const id = this.nextId++;
|
||||
this.repeats.set(id, { id, callback, interval });
|
||||
return id;
|
||||
}
|
||||
cancel(eventId: number): void {
|
||||
this.repeats.delete(eventId);
|
||||
this.onces.delete(eventId);
|
||||
}
|
||||
cancelAfter(_time: TransportTime): void {
|
||||
// unused
|
||||
}
|
||||
toSeconds(time: TransportTime): number {
|
||||
return typeof time === "number" ? time / this.ppq : 0;
|
||||
}
|
||||
toTicks(time: TransportTime): number {
|
||||
return typeof time === "number" ? time * this.ppq : 0;
|
||||
}
|
||||
}
|
||||
104
packages/ui/react/react-audio/tests/parity-fixture.ts
Normal file
104
packages/ui/react/react-audio/tests/parity-fixture.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Main-branch component families captured at authoring time of the
|
||||
* enterprise-react-audio-components-extras change. Read live via:
|
||||
*
|
||||
* ls /mnt/s/web/suaveplan/packages/react/react-audio/src/components/
|
||||
*
|
||||
* Updated 2026-05-19. Each family maps to one or more genesis surfaces.
|
||||
* The parity test asserts that every entry's `genesisSubpaths` resolves
|
||||
* to a real exports entry in `@suaveplan/react-audio`.
|
||||
*/
|
||||
|
||||
export interface ParityEntry {
|
||||
/** Main-branch family directory name. */
|
||||
readonly mainFamily: string;
|
||||
/** Genesis subpath(s) covering the family. */
|
||||
readonly genesisSubpaths: ReadonlyArray<string>;
|
||||
/**
|
||||
* Human-readable rationale when the genesis equivalent is not a
|
||||
* direct rename (e.g. plugin-controls is folded into PluginSlot).
|
||||
*/
|
||||
readonly notes?: string;
|
||||
}
|
||||
|
||||
export const MAIN_BRANCH_COMPONENT_FAMILIES: ReadonlyArray<ParityEntry> = [
|
||||
{
|
||||
mainFamily: "audio-dashboard",
|
||||
genesisSubpaths: [
|
||||
"./components/mixer",
|
||||
"./components/transport-controls",
|
||||
],
|
||||
notes: "audio-dashboard upstream is a composition over mixer + transport + visualiser; genesis composes the same surface from <Mixer>, <TransportControls>, <AudioVisualizer>.",
|
||||
},
|
||||
{
|
||||
mainFamily: "audio-engine-controls",
|
||||
genesisSubpaths: ["./components/transport-controls"],
|
||||
notes: "audio-engine-controls upstream is the transport-style play/stop/seek/tempo bar.",
|
||||
},
|
||||
{
|
||||
mainFamily: "audio-player",
|
||||
genesisSubpaths: ["./components/audio-player"],
|
||||
},
|
||||
{
|
||||
mainFamily: "audio-provider",
|
||||
genesisSubpaths: ["./provider"],
|
||||
notes: "audio-provider upstream is the React context provider; genesis exports <AudioProvider> from the ./provider subpath.",
|
||||
},
|
||||
{
|
||||
mainFamily: "audio-visualizer",
|
||||
genesisSubpaths: ["./components/audio-visualizer"],
|
||||
},
|
||||
{
|
||||
mainFamily: "basic-player",
|
||||
genesisSubpaths: ["./components/basic-player"],
|
||||
},
|
||||
{
|
||||
mainFamily: "glados-controls",
|
||||
genesisSubpaths: ["./components/glados-controls"],
|
||||
},
|
||||
{
|
||||
mainFamily: "microphone-controls",
|
||||
genesisSubpaths: ["./components/microphone-controls"],
|
||||
},
|
||||
{
|
||||
mainFamily: "mixer-controls",
|
||||
genesisSubpaths: ["./components/mixer"],
|
||||
notes: "Renamed mixer-controls → mixer in genesis.",
|
||||
},
|
||||
{
|
||||
mainFamily: "players",
|
||||
genesisSubpaths: [
|
||||
"./components/audio-player",
|
||||
"./components/basic-player",
|
||||
"./components/premium-player",
|
||||
],
|
||||
notes: "Upstream `players/` directory holds premium-player; genesis splits into three sibling player components.",
|
||||
},
|
||||
{
|
||||
mainFamily: "plugin-chain",
|
||||
genesisSubpaths: ["./components/plugin-chain-view"],
|
||||
notes: "Renamed plugin-chain → plugin-chain-view in genesis.",
|
||||
},
|
||||
{
|
||||
mainFamily: "plugin-controls",
|
||||
genesisSubpaths: [
|
||||
"./components/plugin-slot",
|
||||
"./components/plugin-parameter-knob",
|
||||
"./components/plugin-parameter-slider",
|
||||
],
|
||||
notes: "Upstream plugin-controls contains the EQ/Compressor/Delay/Reverb specialised slot renderers; genesis exposes the same surface via <PluginSlot> + the `slotRenderers` provider option, plus the primitive knob/slider components.",
|
||||
},
|
||||
{
|
||||
mainFamily: "plugin-loader",
|
||||
genesisSubpaths: ["./components/plugin-loader"],
|
||||
},
|
||||
{
|
||||
mainFamily: "plugins",
|
||||
genesisSubpaths: ["./components/audio-player"],
|
||||
notes: "Upstream `plugins/` is the audio-player's internal subcomponents (library-panel, queue-panel, progress-bar, track-info, volume-control). Genesis folds them into <AudioPlayer>'s control surface.",
|
||||
},
|
||||
{
|
||||
mainFamily: "preset-manager",
|
||||
genesisSubpaths: ["./components/preset-manager"],
|
||||
},
|
||||
];
|
||||
53
packages/ui/react/react-audio/tests/parity.test.ts
Normal file
53
packages/ui/react/react-audio/tests/parity.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MAIN_BRANCH_COMPONENT_FAMILIES } from "./parity-fixture.js";
|
||||
|
||||
const PKG_ROOT = resolve(__dirname, "..");
|
||||
const PKG_JSON = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
|
||||
describe("main-branch parity", () => {
|
||||
it("every main-branch component family has a genesis equivalent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const missing: string[] = [];
|
||||
for (const entry of MAIN_BRANCH_COMPONENT_FAMILIES) {
|
||||
for (const subpath of entry.genesisSubpaths) {
|
||||
if (PKG_JSON.exports[subpath] === undefined) {
|
||||
missing.push(
|
||||
`${entry.mainFamily} -> ${subpath} (not exported)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("fixture lists every required parity family", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"audio-dashboard",
|
||||
"audio-engine-controls",
|
||||
"audio-player",
|
||||
"audio-provider",
|
||||
"audio-visualizer",
|
||||
"basic-player",
|
||||
"glados-controls",
|
||||
"microphone-controls",
|
||||
"mixer-controls",
|
||||
"players",
|
||||
"plugin-chain",
|
||||
"plugin-controls",
|
||||
"plugin-loader",
|
||||
"plugins",
|
||||
"preset-manager",
|
||||
];
|
||||
const got = MAIN_BRANCH_COMPONENT_FAMILIES.map((e) => e.mainFamily);
|
||||
for (const name of expected) {
|
||||
expect(got.includes(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
77
packages/ui/react/react-audio/tests/purity.test.ts
Normal file
77
packages/ui/react/react-audio/tests/purity.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = resolve(__dirname, "..");
|
||||
const DIST = join(PKG_ROOT, "dist");
|
||||
|
||||
const ALLOWED_RUNTIME_DEPS = new Set([
|
||||
"@suaveplan/audio-controllers",
|
||||
"@suaveplan/audio-engine-browser",
|
||||
"@suaveplan/audio-fx-plugin-audio-player",
|
||||
"@suaveplan/audio-fx-plugin-base",
|
||||
"@suaveplan/audio-fx-plugin-glados",
|
||||
"@suaveplan/audio-fx-plugin-microphone",
|
||||
"@suaveplan/audio-fx-plugin-mixer",
|
||||
"@suaveplan/audio-timeline",
|
||||
"@suaveplan/audio-transport",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"zustand",
|
||||
]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (/\.(js|mjs|cjs)$/.test(name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("react-audio purity gates", () => {
|
||||
it("runtime dependency set matches the allowlist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const deps = Object.keys(pkg.dependencies ?? {});
|
||||
for (const dep of deps) {
|
||||
expect(ALLOWED_RUNTIME_DEPS.has(dep)).toBe(true);
|
||||
}
|
||||
for (const allowed of ALLOWED_RUNTIME_DEPS) {
|
||||
expect(deps.includes(allowed)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("dist references no audio context or Node globals", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
if (!existsSync(DIST)) {
|
||||
// Skip pre-build — `bunx turbo build` will rerun.
|
||||
return;
|
||||
}
|
||||
const files = walk(DIST);
|
||||
const forbidden = [
|
||||
/\bnew\s+AudioContext\b/,
|
||||
/\bnew\s+OfflineAudioContext\b/,
|
||||
/\binstanceof\s+AudioNode\b/,
|
||||
/['"]node:fs['"]/,
|
||||
/['"]node:path['"]/,
|
||||
/\bprocess\.env\b/,
|
||||
/\bBuffer\.from\b/,
|
||||
];
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
for (const rx of forbidden) {
|
||||
expect(rx.test(text)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user