Files
2026-05-27 23:47:53 +01:00

352 lines
12 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { MeterProvider } from "@opentelemetry/api";
import type { FetchLike } from "@suaveplan/types";
/** Minimal `file://` `FetchLike` for Node — Node's `globalThis.fetch` rejects file:// URLs. */
const nodeFileFetch: FetchLike = async (input, init) => {
const url =
typeof input === "string"
? new URL(input)
: input instanceof URL
? input
: new URL((input as Request).url);
if (url.protocol !== "file:") {
return globalThis.fetch(
input as Parameters<typeof globalThis.fetch>[0],
init
);
}
const path = fileURLToPath(url);
try {
const bytes = await readFile(path);
const meta = await stat(path);
return new Response(bytes, {
status: 200,
headers: {
"content-length": String(meta.size),
"content-type": "application/octet-stream",
},
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return new Response(null, { status: 404 });
}
throw err;
}
};
import { decodeWav } from "@suaveplan/audio-decode-wav";
import {
createIrRegistry,
createUriPluginCollector,
registerManifest,
} from "@suaveplan/audio-ir-core";
import {
createIrLoader,
getInstruments as getLoaderInstruments,
resetInstruments as resetLoaderInstruments,
setAudioIrLoaderMeterProvider,
} from "@suaveplan/audio-ir-loader";
import {
createStorageUriResolver,
STORAGE_URI_CODES,
StorageUriError,
type UriPlugin,
} from "@suaveplan/storage-uri";
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
import {
fallbackUriPlugin,
parseFallbackUri,
resetFallbackInstruments,
setFallbackMeterProvider,
} from "@suaveplan/storage-uri-plugin-fallback";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
BUNDLE_PACKAGE_SLUG,
ENTRY_ID,
manifest,
} from "../../src/manifest/manifest.js";
interface CounterObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
interface HistogramObs {
readonly name: string;
readonly value: number;
readonly attributes: Record<string, unknown>;
}
function installRecorder(): {
counters: CounterObs[];
histograms: HistogramObs[];
} {
const counters: CounterObs[] = [];
const histograms: HistogramObs[] = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (value: number, attrs?: Record<string, unknown>) => {
counters.push({ name, value, attributes: attrs ?? {} });
},
}),
createHistogram: (name: string) => ({
record: (value: number, attrs?: Record<string, unknown>) => {
histograms.push({ name, value, attributes: attrs ?? {} });
},
}),
createObservableGauge: () => ({
addCallback: () => {},
}),
}),
} as unknown as MeterProvider;
setAudioIrLoaderMeterProvider(provider);
setFallbackMeterProvider(provider);
getLoaderInstruments(true);
return { counters, histograms };
}
function makeHttpPlugin(): UriPlugin {
return {
scheme: "http",
adapter: {
async get(key: string) {
const url = `http://${key}`;
const res = await fetch(url);
if (res.status === 404) return undefined;
if (!res.ok) {
throw new StorageUriError(
STORAGE_URI_CODES.ADAPTER_ERROR,
`HTTP ${res.status} from ${url}`,
{ uri: url, scheme: "http" }
);
}
return new Uint8Array(await res.arrayBuffer());
},
async set() {
return;
},
async delete() {
return true;
},
async exists(key: string) {
const res = await fetch(`http://${key}`, { method: "HEAD" });
return res.ok;
},
async list() {
return { keys: [], total: 0, hasMore: false };
},
async clear() {
return;
},
},
parse(uri: string) {
const noScheme = uri.replace(/^http:\/\//, "");
return { key: noScheme };
},
};
}
/** Build a 16-bit mono silent WAV at the manifest's declared sample rate. */
function buildSilentWav(
sampleRate: number,
samplesPerChannel: number
): Uint8Array {
const dataBytes = samplesPerChannel * 2;
const buf = new Uint8Array(44 + dataBytes);
const dv = new DataView(buf.buffer);
const writeAscii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
writeAscii(0, "RIFF");
dv.setUint32(4, 36 + dataBytes, true);
writeAscii(8, "WAVE");
writeAscii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true);
dv.setUint16(22, 1, true);
dv.setUint32(24, sampleRate, true);
dv.setUint32(28, sampleRate * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
writeAscii(36, "data");
dv.setUint32(40, dataBytes, true);
return buf;
}
interface Harness {
server: Server;
port: number;
tempDir: string;
}
let harness: Harness | undefined;
beforeAll(async () => {
const tempDir = await mkdtemp(
join(tmpdir(), "audio-ir-room-plugin-pool-test-")
);
// Create the package-slug subdirectory but DO NOT place the WAV inside —
// so the bundle adapter's GET reports `BUNDLE_URI.NOT_FOUND`.
await mkdir(join(tempDir, BUNDLE_PACKAGE_SLUG), { recursive: true });
const wavBytes = buildSilentWav(
manifest.entries[0]?.sampleRate ?? 48_000,
manifest.entries[0]?.channels ?? 1
);
// Use a generous body so the decode validator has something to work
// with — 4096 samples = ~85ms @ 48kHz.
const richBytes = buildSilentWav(
manifest.entries[0]?.sampleRate ?? 48_000,
4096
);
const server = createServer((req, res) => {
if (req.url === "/roompool.wav") {
res.writeHead(200, { "Content-Type": "audio/wav" });
res.end(Buffer.from(richBytes));
return;
}
if (req.url === "/empty.wav") {
res.writeHead(200, { "Content-Type": "audio/wav" });
res.end(Buffer.from(wavBytes));
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const port = (server.address() as AddressInfo).port;
harness = { server, port, tempDir };
}, 30000);
afterAll(async () => {
if (harness !== undefined) {
await new Promise<void>((resolve, reject) =>
harness?.server.close((err) => (err ? reject(err) : resolve()))
);
await rm(harness.tempDir, { recursive: true, force: true });
}
setAudioIrLoaderMeterProvider(undefined);
setFallbackMeterProvider(undefined);
resetLoaderInstruments();
resetFallbackInstruments();
}, 30000);
describe("integration: fallback-walk", () => {
it("bundle miss falls through to HTTPS via foundation collector", {
timeout: 30000,
}, async () => {
if (harness === undefined) {
throw new Error("harness not initialised");
}
const rec = installRecorder();
// Build a bundle plugin rooted at the empty temp directory so
// any `bundle://audio-ir-room-plugin-pool/...` GET surfaces
// `BUNDLE_URI.NOT_FOUND`.
const bundlePlugin = bundleUriPlugin({
packageSlug: BUNDLE_PACKAGE_SLUG,
rootUrl: new URL(`file://${harness.tempDir}/`),
fetch: nodeFileFetch,
});
const httpPlugin = makeHttpPlugin();
// Build the plugin list via the foundation collector. This mirrors
// how production callers compose plugins across content packages
// before constructing the resolver.
const collector = createUriPluginCollector();
collector.use(bundlePlugin);
collector.use(httpPlugin);
// The manifest URI points at https://swch.b-cdn.net/... — we
// want the integration test to hit our local fixture instead.
// Compose a substitute fallback URI that mirrors the manifest's
// bundle/http(s) structure but targets the fixture.
const bundleCandidate = manifest.entries[0]?.uri ?? ""; // unused — kept for parity assertion
void bundleCandidate;
const parsedManifestUri = parseFallbackUri(
manifest.entries[0]?.uri ?? ""
);
expect(parsedManifestUri.candidates.length).toBe(2);
const httpFixtureUri = `http://127.0.0.1:${harness.port}/roompool.wav`;
const fixtureFallbackUri = `fallback://?u=${encodeURIComponent(
parsedManifestUri.candidates[0] ?? ""
)}&u=${encodeURIComponent(httpFixtureUri)}`;
const innerResolver = createStorageUriResolver([...collector.plugins]);
const fb = fallbackUriPlugin({
resolver: innerResolver,
policy: { errorClass: "any" },
});
const outerResolver = createStorageUriResolver([
...collector.plugins,
fb,
]);
// Register the manifest, but with a substituted URI on the
// sole entry so the loader walks bundle → http instead of
// bundle → https.
const registry = createIrRegistry();
const baseEntry = manifest.entries[0];
if (baseEntry === undefined) throw new Error("unreachable");
const fixtureManifest = {
...manifest,
entries: [
{
...baseEntry,
uri: fixtureFallbackUri,
},
],
};
registerManifest(registry, fixtureManifest);
const loader = createIrLoader({
registry,
resolver: outerResolver,
decoders: { wav: decodeWav },
});
const ir = await loader.load(ENTRY_ID);
expect(ir.id).toBe(ENTRY_ID);
expect(ir.family).toBe("room");
expect(ir.channels).toBe(baseEntry.channels);
expect(ir.sampleRate).toBe(baseEntry.sampleRate);
// The shipped fallback plugin emits its per-candidate counter
// under the name `storage_uri.fallback_total` (verified against
// packages/storage/storage-uri-plugin-fallback/src/observability/otel.ts).
// We expect candidate_index:0 to have at least one `transient`
// sample (the bundle miss).
const fbTransient = rec.counters.filter(
(c) =>
c.name === "storage_uri.fallback_total" &&
c.attributes["candidate_index"] === 0 &&
c.attributes["outcome"] === "transient"
);
expect(fbTransient.length).toBe(1);
// Loader bytes_read_total should tick once with family=room.
const bytesRead = rec.counters.filter(
(c) =>
c.name === "audio_ir_loader.bytes_read_total" &&
c.attributes["family"] === "room"
);
expect(bytesRead.length).toBe(1);
});
});