281 lines
9.3 KiB
TypeScript
281 lines
9.3 KiB
TypeScript
import { mkdir, mkdtemp, rm } 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 type { MeterProvider } from "@opentelemetry/api";
|
|
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
|
import {
|
|
createIrRegistry,
|
|
type IrManifestEntry,
|
|
} from "@suaveplan/audio-ir-core";
|
|
import {
|
|
createStorageUriResolver,
|
|
STORAGE_URI_CODES,
|
|
StorageUriError,
|
|
type UriPlugin,
|
|
} from "@suaveplan/storage-uri";
|
|
import { bundleUriPlugin } from "@suaveplan/storage-uri-plugin-bundle";
|
|
import {
|
|
buildFallbackUri,
|
|
fallbackUriPlugin,
|
|
resetFallbackInstruments,
|
|
setFallbackMeterProvider,
|
|
} from "@suaveplan/storage-uri-plugin-fallback";
|
|
import {
|
|
afterAll,
|
|
beforeAll,
|
|
describe,
|
|
expect,
|
|
it,
|
|
} from "@suaveplan/testing/runner";
|
|
import { createIrLoader } from "../../src/index.js";
|
|
import {
|
|
getInstruments,
|
|
resetInstruments,
|
|
setAudioIrLoaderMeterProvider,
|
|
} from "../../src/otel/otel.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>;
|
|
}
|
|
|
|
/**
|
|
* In-test minimal `http`/`https` `UriPlugin` adapter wrapping
|
|
* `globalThis.fetch`. Lives in the test file by design — there is no
|
|
* shipped storage-uri plugin for HTTPS yet, and the integration test
|
|
* must not introduce a new published package.
|
|
*/
|
|
function makeHttpPlugin(scheme: "http" | "https"): UriPlugin {
|
|
return {
|
|
scheme,
|
|
adapter: {
|
|
async get(key: string) {
|
|
const url = `${scheme}://${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 }
|
|
);
|
|
}
|
|
return new Uint8Array(await res.arrayBuffer());
|
|
},
|
|
async set() {
|
|
return;
|
|
},
|
|
async delete() {
|
|
return true;
|
|
},
|
|
async exists(key: string) {
|
|
const url = `${scheme}://${key}`;
|
|
const res = await fetch(url, { method: "HEAD" });
|
|
return res.ok;
|
|
},
|
|
async list() {
|
|
return { keys: [], total: 0, hasMore: false };
|
|
},
|
|
async clear() {
|
|
return;
|
|
},
|
|
},
|
|
parse(uri: string) {
|
|
const noScheme = uri.replace(new RegExp(`^${scheme}://`), "");
|
|
return { key: noScheme };
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Minimal RIFF/WAVE 16-bit mono PCM builder for the integration fixture. */
|
|
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); // PCM
|
|
dv.setUint16(22, 1, true); // mono
|
|
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);
|
|
// body left as silence (zeros) — sufficient for the decode happy path.
|
|
return buf;
|
|
}
|
|
|
|
function installCombinedRecorder(): {
|
|
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);
|
|
getInstruments(true);
|
|
return { counters, histograms };
|
|
}
|
|
|
|
interface Harness {
|
|
server: Server;
|
|
port: number;
|
|
tempDir: string;
|
|
}
|
|
|
|
let harness: Harness | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "audio-ir-loader-test-"));
|
|
// Create a club/ subdirectory but DON'T put missing.wav in it — so the
|
|
// bundle adapter's GET for `bundle://club/missing.wav` will surface a
|
|
// local-file 404 from the file:// URL.
|
|
await mkdir(join(tempDir, "club"), { recursive: true });
|
|
const wavBytes = buildSilentWav(44100, 32);
|
|
// Stand up a node:http server that serves /club1.wav with the WAV bytes
|
|
// and 404 for everything else.
|
|
const server = createServer((req, res) => {
|
|
if (req.url === "/club1.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);
|
|
resetInstruments();
|
|
resetFallbackInstruments();
|
|
}, 30000);
|
|
|
|
describe("integration: bundle-then-http fallback walk", () => {
|
|
it("bundle misses fall back to HTTPS and load succeeds end-to-end", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
if (harness === undefined) throw new Error("harness not initialised");
|
|
const rec = installCombinedRecorder();
|
|
|
|
// Build a UriPlugin set: bundle:// rooted at the tempDir (no
|
|
// missing.wav exists), http:// via the in-test plugin.
|
|
// The fallback plugin builds a resolver-of-resolvers chain; the
|
|
// inner resolver must include bundle + http.
|
|
const innerBundle = bundleUriPlugin({
|
|
packageSlug: "club",
|
|
rootUrl: new URL(`file://${harness.tempDir}/`),
|
|
});
|
|
const innerHttp = makeHttpPlugin("http");
|
|
const innerResolver = createStorageUriResolver([
|
|
innerBundle,
|
|
innerHttp,
|
|
]);
|
|
const fbPlugin = fallbackUriPlugin({
|
|
resolver: innerResolver,
|
|
policy: { errorClass: "any" },
|
|
});
|
|
const outerResolver = createStorageUriResolver([
|
|
innerBundle,
|
|
innerHttp,
|
|
fbPlugin,
|
|
]);
|
|
|
|
const bundleUri = "bundle://club/missing.wav";
|
|
const httpUri = `http://127.0.0.1:${harness.port}/club1.wav`;
|
|
const fallbackUri = buildFallbackUri([bundleUri, httpUri]);
|
|
|
|
const entry: IrManifestEntry = {
|
|
id: "club-pool",
|
|
family: "room",
|
|
uri: fallbackUri,
|
|
channels: 1,
|
|
sampleRate: 44100,
|
|
gainDb: 0,
|
|
license: "MIT",
|
|
source: "fixture",
|
|
} as IrManifestEntry;
|
|
const registry = createIrRegistry();
|
|
registry.register(entry, {
|
|
manifestSlug: "club",
|
|
manifestFamily: "room",
|
|
});
|
|
const loader = createIrLoader({
|
|
registry,
|
|
resolver: outerResolver,
|
|
decoders: { wav: decodeWav },
|
|
});
|
|
const ir = await loader.load("club-pool");
|
|
expect(ir.id).toBe("club-pool");
|
|
expect(ir.channels).toBe(1);
|
|
expect(ir.sampleRate).toBe(44100);
|
|
expect(ir.channelData.length).toBe(1);
|
|
|
|
// Loader bytes_read_total should have ticked exactly 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);
|
|
|
|
// The fallback plugin's per-candidate counter should have a
|
|
// candidate_index=0 transient sample (the missing bundle URL).
|
|
const fbTransient = rec.counters.filter(
|
|
(c) =>
|
|
c.name === "storage_uri.fallback_total" &&
|
|
c.attributes["candidate_index"] === 0 &&
|
|
c.attributes["outcome"] === "transient"
|
|
);
|
|
expect(fbTransient.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|