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[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; } interface HistogramObs { readonly name: string; readonly value: number; readonly attributes: Record; } function installRecorder(): { counters: CounterObs[]; histograms: HistogramObs[]; } { const counters: CounterObs[] = []; const histograms: HistogramObs[] = []; const provider = { getMeter: () => ({ createCounter: (name: string) => ({ add: (value: number, attrs?: Record) => { counters.push({ name, value, attributes: attrs ?? {} }); }, }), createHistogram: (name: string) => ({ record: (value: number, attrs?: Record) => { 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((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((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); }); });