Initial Commit
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import {
|
||||
createIrRegistry,
|
||||
createUriPluginCollector,
|
||||
} from "@suaveplan/audio-ir-core";
|
||||
import { createIrLoader } from "@suaveplan/audio-ir-loader";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
type UriPlugin,
|
||||
} from "@suaveplan/storage-uri";
|
||||
import { fallbackUriPlugin } from "@suaveplan/storage-uri-plugin-fallback";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Minimal `file://` `FetchLike` for Node — Node's `globalThis.fetch` rejects
|
||||
* `file://` URLs, so we substitute one that round-trips via `node:fs`. Lives
|
||||
* in the test file by design (no new package introduced).
|
||||
*/
|
||||
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) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ENTRY_ID, manifest } from "../../src/manifest/manifest.js";
|
||||
import { register } from "../../src/register/register.js";
|
||||
|
||||
/**
|
||||
* Minimal in-test https `UriPlugin` wrapping `globalThis.fetch`. Lives in
|
||||
* the test file by design — there is no shipped storage-uri plugin for
|
||||
* https today, and the integration test must not introduce a new package.
|
||||
*/
|
||||
function makeHttpsPlugin(spy: { calls: number }): UriPlugin {
|
||||
return {
|
||||
scheme: "https",
|
||||
adapter: {
|
||||
async get(key: string) {
|
||||
spy.calls += 1;
|
||||
const url = `https://${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: "https" }
|
||||
);
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
},
|
||||
async set() {
|
||||
return;
|
||||
},
|
||||
async delete() {
|
||||
return true;
|
||||
},
|
||||
async exists() {
|
||||
spy.calls += 1;
|
||||
return false;
|
||||
},
|
||||
async list() {
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
},
|
||||
async clear() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
parse(uri: string) {
|
||||
const noScheme = uri.replace(/^https:\/\//, "");
|
||||
return { key: noScheme };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("integration: bundle-only", () => {
|
||||
it("bundle candidate wins without fetching HTTPS via foundation collector", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const collector = createUriPluginCollector();
|
||||
const registry = createIrRegistry();
|
||||
register(registry, collector, { fetch: nodeFileFetch });
|
||||
|
||||
const spy = { calls: 0 };
|
||||
const httpsPlugin = makeHttpsPlugin(spy);
|
||||
collector.use(httpsPlugin);
|
||||
|
||||
const plugins: readonly UriPlugin[] = collector.plugins;
|
||||
const innerResolver = createStorageUriResolver([...plugins]);
|
||||
const fb = fallbackUriPlugin({
|
||||
resolver: innerResolver,
|
||||
policy: { errorClass: "any" },
|
||||
});
|
||||
const outerResolver = createStorageUriResolver([...plugins, fb]);
|
||||
|
||||
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(manifest.entries[0]?.channels);
|
||||
expect(ir.sampleRate).toBe(manifest.entries[0]?.sampleRate);
|
||||
expect(Object.isFrozen(ir)).toBe(true);
|
||||
|
||||
// Critical assertion: HTTPS adapter was never invoked.
|
||||
expect(spy.calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { decodeWav } from "@suaveplan/audio-decode-wav";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CHANNELS, SAMPLE_RATE } from "../../src/manifest/manifest.js";
|
||||
|
||||
const WAV_URL = new URL(
|
||||
"../../files/outdoor-swimming-pool.wav",
|
||||
import.meta.url
|
||||
);
|
||||
|
||||
describe("integration: file-presence", () => {
|
||||
it("outdoor-swimming-pool.wav exists on disk and decodes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const path = fileURLToPath(WAV_URL);
|
||||
const meta = await stat(path);
|
||||
expect(meta.isFile()).toBe(true);
|
||||
expect(meta.size).toBeGreaterThan(0);
|
||||
|
||||
const bytes = await readFile(path);
|
||||
const decoded = await decodeWav(new Uint8Array(bytes));
|
||||
expect(decoded.channels).toBe(CHANNELS);
|
||||
expect(decoded.sampleRate).toBe(SAMPLE_RATE);
|
||||
expect(decoded.channelData.length).toBe(CHANNELS);
|
||||
expect(decoded.bytesDecoded).toBe(meta.size);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user