openUrl(url, opts) returns a RemoteFile with the methods of H5File (kind, list, info, attrs, attrErrors, read, readHyperslab), each a promise, and stats(). It runs every call through the restartable LazyStorage: a pass that misses reports the byte ranges, js/remote.js fetches them with fetch() and Range headers (six at a time), and the pass is re-run. This keeps the main thread free without a Worker or synchronous XHR (h5wasm's lazy files need both), as the design doc recommends; the cost is re-running a pass per wave of misses. Every answer is checked: a 206 with exactly the bytes asked for, and the same ETag/Last-Modified and length as at open, else an error (never data). A server that ignores Range (200) is downloaded whole, up to maxDownload (512 MiB), unless fallback: "error". Options: blockSize, cacheSize, headers, credentials, parallel, fetch. test/serve.py is a range-capable static server with request counting (and /norange/ for a server without range support). test.mjs repeats every fixture check on files opened by URL (1 MiB and 512 B blocks), checks the request budget on a 200 MB h5py file (list, three small reads and a window of the big dataset: 5 requests, 6 MiB), the download fallback, and HTTP errors, changed files and wrong answers; with CLAWHDF5_WASM_CORPUS every corpus file is compared with open(bytes). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
418 lines
18 KiB
JavaScript
418 lines
18 KiB
JavaScript
// Node test of the built wasm package (the exact pkg/ the viewer page loads)
|
|
// and the viewer's DOM-free helpers. Run by test/run.sh:
|
|
// node test.mjs PKG_DIR FIXTURE_DIR [SERVER_URL]
|
|
// FIXTURE_DIR holds fixture.h5, fixture.nc and expected.json from
|
|
// make_fixture.py (values as libhdf5 reads them back), and big.h5/big.json
|
|
// when it was run with WASM_BIG_MB. SERVER_URL is test/serve.py serving
|
|
// FIXTURE_DIR under /fix (and $CLAWHDF5_WASM_CORPUS under /corpus): with
|
|
// it, every check is repeated on files opened with openUrl (HTTP range
|
|
// requests), and the request budget, the full-download fallback and the
|
|
// error paths of openUrl are tested.
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { join, relative } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const [pkgDir, fixDir, base] = process.argv.slice(2);
|
|
const pkg = await import(pathToFileURL(join(pkgDir, "clawhdf5_wasm.js")));
|
|
pkg.initSync({ module: readFileSync(join(pkgDir, "clawhdf5_wasm_bg.wasm")) });
|
|
const lib = await import(pathToFileURL(join(import.meta.dirname, "..", "viewer-lib.js")));
|
|
|
|
let checks = 0;
|
|
const eq = (a, b, msg) => { assert.deepEqual(a, b, msg); checks++; };
|
|
// A call that must fail: a thrown Error (open) or a rejected promise
|
|
// (openUrl), whose message matches `re`.
|
|
const fails = async (fn, re, msg) => {
|
|
await assert.rejects(async () => fn(), (e) => e instanceof Error && re.test(e.message), msg);
|
|
checks++;
|
|
};
|
|
|
|
const ARRAY_TYPES = {
|
|
f32: Float32Array, f64: Float64Array, i8: Int8Array, i16: Int16Array, i32: Int32Array,
|
|
i64: BigInt64Array, u8: Uint8Array, u16: Uint16Array, u32: Uint32Array, u64: BigUint64Array,
|
|
strings: Array,
|
|
};
|
|
|
|
function values(kind, data) {
|
|
const arr = Array.from(data);
|
|
if (kind === "f32" || kind === "f64" || kind === "strings") return arr;
|
|
return arr.map(String);
|
|
}
|
|
|
|
function checkAttr(ctx, a, want) {
|
|
const v = a.value;
|
|
if ("raw" in want) {
|
|
eq(v, null, ctx);
|
|
assert.ok(a.dtype.includes(want.raw), `${ctx}: ${a.dtype}`);
|
|
return;
|
|
}
|
|
eq(a.dtype, null, `${ctx} dtype`);
|
|
if ("string" in want) return eq(v, want.string, ctx);
|
|
if ("strings" in want) return eq(v, want.strings, ctx);
|
|
if ("int" in want) {
|
|
if (want.scalar) {
|
|
assert.ok(typeof v === "number" || typeof v === "bigint", ctx);
|
|
return eq([String(v)], want.int, ctx);
|
|
}
|
|
assert.ok(v instanceof BigInt64Array || v instanceof BigUint64Array, ctx);
|
|
return eq(Array.from(v, String), want.int, ctx);
|
|
}
|
|
if ("float" in want) {
|
|
if (want.scalar) return eq([v], want.float, ctx);
|
|
assert.ok(v instanceof Float64Array, ctx);
|
|
return eq(Array.from(v), want.float, ctx);
|
|
}
|
|
assert.fail(`${ctx}: unknown expectation ${JSON.stringify(want)}`);
|
|
}
|
|
|
|
// Every listing, dataset, error and attribute of `file` against what
|
|
// libhdf5 reads (expected.json). `file` is an H5File (synchronous methods)
|
|
// or a RemoteFile (promises): every call is awaited.
|
|
async function checkFile(name, exp, file) {
|
|
for (const [path, want] of Object.entries(exp.lists)) {
|
|
eq(await file.kind(path), "group", `${name}:${path} kind`);
|
|
const list = await file.list(path);
|
|
for (const [kind, key] of [["group", "groups"], ["dataset", "datasets"]]) {
|
|
eq(list.filter((c) => c.kind === kind).map((c) => c.name).sort(), want[key], `${name}:${path} ${key}`);
|
|
}
|
|
}
|
|
|
|
for (const [path, want] of Object.entries(exp.datasets)) {
|
|
const ctx = `${name}:${path}`;
|
|
eq(await file.kind(path), "dataset", `${ctx} kind`);
|
|
if (want.unavailable) {
|
|
// The wasm build has no zstd (it links C): a clear error, no data.
|
|
await fails(() => file.read(path), new RegExp(want.unavailable), ctx);
|
|
continue;
|
|
}
|
|
const info = await file.info(path);
|
|
eq([...info.shape, ...info.elementShape], want.shape, `${ctx} info shape`);
|
|
const r = await file.read(path);
|
|
eq(r.shape, want.shape, `${ctx} shape`);
|
|
eq(r.dtype, info.dtype, `${ctx} dtype`);
|
|
assert.ok(r.data instanceof ARRAY_TYPES[want.kind], `${ctx}: ${r.data.constructor.name} for ${want.kind}`);
|
|
eq(values(want.kind, r.data), want.values, ctx);
|
|
if (want.slab) {
|
|
const s = want.slab;
|
|
const part = await file.readHyperslab(path, s.start, s.count, s.stride);
|
|
eq(part.shape, s.shape, `${ctx} slab shape`);
|
|
eq(values(want.kind, part.data), s.values, `${ctx} slab`);
|
|
}
|
|
}
|
|
|
|
for (const [path, what] of Object.entries(exp.errors)) {
|
|
await fails(() => file.read(path), new RegExp(what), `${name}:${path}`);
|
|
}
|
|
|
|
for (const [path, want] of Object.entries(exp.attrs)) {
|
|
const attrs = await file.attrs(path);
|
|
eq(await file.attrErrors(path), [], `${name}:${path} attr errors`);
|
|
const seen = attrs.filter((a) => !a.name.startsWith("_") && !exp.skip_attrs.includes(a.name));
|
|
eq(seen.map((a) => a.name).sort(), Object.keys(want).sort(), `${name}:${path} attr names`);
|
|
for (const a of seen) checkAttr(`${name}:${path}@${a.name}`, a, want[a.name]);
|
|
}
|
|
}
|
|
|
|
// The error paths of the reader, for an H5File or a RemoteFile of
|
|
// fixture.h5.
|
|
async function checkErrors(h5) {
|
|
await fails(() => h5.read("/nope"), /./);
|
|
await fails(() => h5.list("/grid"), /not a group/);
|
|
await fails(() => h5.readHyperslab("/grid", [0], [1]), /dimensions/);
|
|
await fails(() => h5.readHyperslab("/grid", [5, 0], [2, 1]), /exceeds/);
|
|
await fails(() => h5.readHyperslab("/grid", [-1, 0], [1, 1]), /non-negative integers/);
|
|
await fails(() => h5.readHyperslab("/grid", [0.5, 0], [1, 1]), /non-negative integers/);
|
|
// Big integers stay exact.
|
|
eq((await h5.read("/u64")).data[0], 18446744073709551615n, "u64 max");
|
|
}
|
|
|
|
const expected = JSON.parse(readFileSync(join(fixDir, "expected.json"), "utf8"));
|
|
for (const [name, exp] of Object.entries(expected)) {
|
|
const file = pkg.open(new Uint8Array(readFileSync(join(fixDir, name))));
|
|
await checkFile(name, exp, file);
|
|
file.free();
|
|
}
|
|
|
|
// Errors reach JavaScript as thrown Errors, never as data.
|
|
const h5 = pkg.open(new Uint8Array(readFileSync(join(fixDir, "fixture.h5"))));
|
|
await fails(() => pkg.open(new Uint8Array(64)), /./);
|
|
await checkErrors(h5);
|
|
// Info for a dataset with an unlimited dimension (netCDF "time").
|
|
const nc = pkg.open(new Uint8Array(readFileSync(join(fixDir, "fixture.nc"))));
|
|
eq(nc.info("/time").maxshape, [null], "unlimited dimension is null");
|
|
eq(typeof pkg.version(), "string", "version");
|
|
|
|
// Viewer helpers.
|
|
eq(lib.joinPath("/", "a"), "/a", "joinPath root");
|
|
eq(lib.joinPath("/a", "b"), "/a/b", "joinPath nested");
|
|
eq(lib.viewWindow([], {}), null, "scalar window");
|
|
eq(lib.viewWindow([7], { row: 5, rows: 50 }), { start: [5], count: [2], rows: 2, cols: 1, row: 5, col: 0 }, "1-D window");
|
|
const w = lib.viewWindow([2, 5, 6], { row: 1, col: 4, rows: 3, cols: 5, fixed: [1] });
|
|
eq(w, { start: [1, 1, 4], count: [1, 3, 2], rows: 3, cols: 2, row: 1, col: 4 }, "3-D window");
|
|
// The window the page would request reads the same values as a direct slab.
|
|
const cube = h5.readHyperslab("/cube", w.start, w.count);
|
|
eq(lib.toRows(cube.data, w.rows, w.cols), [["40", "41"], ["46", "47"], ["52", "53"]], "cube window cells");
|
|
const pairs = h5.readHyperslab("/pairs", [1], [2]);
|
|
eq(lib.toRows(pairs.data, 2, 1, lib.perElement([2])), [["[2, 3]"], ["[4, 5]"]], "array-type cells");
|
|
eq(lib.formatValue(0.1 + 0.2), "0.3", "float formatting");
|
|
eq(lib.formatValue(2n ** 64n - 1n), "18446744073709551615", "bigint formatting");
|
|
eq(lib.formatValue("x"), '"x"', "string formatting");
|
|
eq(lib.formatBytes(0), "0 B", "bytes");
|
|
eq(lib.formatBytes(1536), "1.5 KiB", "KiB");
|
|
eq(lib.formatBytes(200 * 1024 * 1024), "200 MiB", "MiB");
|
|
eq(lib.formatStats({ lazy: true, requests: 3, bytesFetched: 2 << 20, size: 200 << 20 }),
|
|
"3 requests, 2 MiB of 200 MiB fetched (1.0%)", "stats line");
|
|
eq(lib.formatStats({ lazy: false, requests: 1, bytesFetched: 1024, size: 1024 }),
|
|
"downloaded whole (1 KiB): the server does not support range requests", "stats line, no ranges");
|
|
h5.free();
|
|
nc.free();
|
|
console.log(`wasm package: ${checks} checks passed`);
|
|
|
|
if (base) await remoteTests();
|
|
|
|
async function serverStats() {
|
|
return (await fetch(`${base}/__stats`)).json();
|
|
}
|
|
|
|
async function remoteTests() {
|
|
const before = checks;
|
|
await fetch(`${base}/__reset`);
|
|
|
|
// The same checks over HTTP range requests, at the default block size
|
|
// and at 512-byte blocks with a 4 KiB cache (almost every structure read
|
|
// a miss, evictions between calls).
|
|
for (const opts of [undefined, { blockSize: 512, cacheSize: 4096 }]) {
|
|
for (const [name, exp] of Object.entries(expected)) {
|
|
const f = await pkg.openUrl(`${base}/fix/${name}`, opts);
|
|
await checkFile(`${name} (openUrl ${JSON.stringify(opts ?? {})})`, exp, f);
|
|
const st = f.stats();
|
|
eq(st.lazy, true, "read by ranges");
|
|
eq(st.size, statSync(join(fixDir, name)).size, "size");
|
|
f.free();
|
|
}
|
|
}
|
|
const remote = await pkg.openUrl(`${base}/fix/fixture.h5`);
|
|
await checkErrors(remote);
|
|
eq((await (await pkg.openUrl(`${base}/fix/fixture.nc`)).info("/time")).maxshape, [null], "remote unlimited");
|
|
|
|
// What the page counts is what the server served.
|
|
await fetch(`${base}/__reset`);
|
|
const counted = await pkg.openUrl(`${base}/fix/fixture.nc`, { blockSize: 1024 });
|
|
await counted.read("/temp");
|
|
const server = await serverStats();
|
|
eq(counted.stats().requests, server.requests, "requests counted");
|
|
eq(counted.stats().bytesFetched, server.bytes, "bytes counted");
|
|
|
|
// A custom fetch is used for every request, with the caller's headers.
|
|
let calls = 0;
|
|
const seen = new Set();
|
|
const viaCustom = await pkg.openUrl(`${base}/fix/fixture.h5`, {
|
|
blockSize: 4096,
|
|
headers: { "X-Test": "1" },
|
|
fetch: (url, init) => { calls++; seen.add(init.headers["X-Test"]); return fetch(url, init); },
|
|
});
|
|
eq((await viaCustom.read("/sensors/temp")).data[0], 21.5, "custom fetch values");
|
|
eq(calls, viaCustom.stats().requests, "custom fetch calls");
|
|
eq([...seen], ["1"], "headers passed");
|
|
|
|
// Calls in flight at once share the cache (a 1 KiB budget: nothing is
|
|
// evicted while any of them runs) and each gets its own answer.
|
|
const both = await pkg.openUrl(`${base}/fix/fixture.h5`, { blockSize: 512, cacheSize: 1024 });
|
|
const [g, t, l, a] = await Promise.all([
|
|
both.read("/grid"), both.read("/sensors/temp"), both.list("/sensors"), both.attrs("/"),
|
|
]);
|
|
const exp = expected["fixture.h5"];
|
|
eq(Array.from(g.data), exp.datasets["/grid"].values, "concurrent /grid");
|
|
eq(Array.from(t.data), exp.datasets["/sensors/temp"].values, "concurrent /sensors/temp");
|
|
eq(l.map((c) => c.name).sort(), [...exp.lists["/sensors"].groups, ...exp.lists["/sensors"].datasets].sort(), "concurrent list");
|
|
eq(a.length > 0, true, "concurrent attrs");
|
|
assert.ok(both.stats().cachedBytes <= 1024, "trimmed to the budget when idle");
|
|
|
|
// Listing and reading small things of a large file fetches a few blocks,
|
|
// not the file.
|
|
if (existsSync(join(fixDir, "big.json"))) {
|
|
const big = JSON.parse(readFileSync(join(fixDir, "big.json"), "utf8"));
|
|
await fetch(`${base}/__reset`);
|
|
const f = await pkg.openUrl(`${base}/fix/big.h5`);
|
|
const list = await f.list("/");
|
|
eq(list.filter((c) => c.kind === "group").map((c) => c.name), big.list.groups, "big: groups");
|
|
eq(list.filter((c) => c.kind === "dataset").map((c) => c.name).sort(), big.list.datasets, "big: datasets");
|
|
eq(Array.from((await f.read("/small")).data), big.small, "big: /small");
|
|
eq(Array.from((await f.read("/meta/ids")).data, String), big.ids, "big: /meta/ids");
|
|
eq((await f.info("/big")).shape, big.big_shape, "big: shape");
|
|
eq((await f.attrs("/meta"))[0].value, "m", "big: group attribute");
|
|
const win = await f.readHyperslab("/big", [big.window.start], [big.window.count]);
|
|
eq(Array.from(win.data), big.window.values, "big: window");
|
|
const st = f.stats();
|
|
const server = await serverStats();
|
|
eq(st.requests, server.requests, "big: requests counted");
|
|
eq(st.bytesFetched, server.bytes, "big: bytes counted");
|
|
console.log(`big.h5 (${big.size} bytes): listed, 3 small reads and a window in ` +
|
|
`${server.requests} requests, ${server.bytes} bytes (${(100 * server.bytes / big.size).toFixed(2)}%)`);
|
|
assert.ok(server.requests <= 8, `big: ${server.requests} requests`);
|
|
assert.ok(server.bytes * 20 < big.size, `big: ${server.bytes} bytes fetched`);
|
|
checks += 2;
|
|
}
|
|
|
|
// A server without range support: downloaded whole (the default), or
|
|
// refused.
|
|
const whole = await pkg.openUrl(`${base}/norange/fix/fixture.h5`);
|
|
await checkFile("fixture.h5 (no range support)", expected["fixture.h5"], whole);
|
|
eq(whole.stats().lazy, false, "downloaded whole");
|
|
eq(whole.stats().requests, 1, "one request");
|
|
await fails(() => pkg.openUrl(`${base}/norange/fix/fixture.h5`, { fallback: "error" }),
|
|
/does not support HTTP range requests/, "fallback: error");
|
|
await fails(() => pkg.openUrl(`${base}/norange/fix/fixture.h5`, { maxDownload: 1000 }),
|
|
/more than maxDownload/, "maxDownload");
|
|
// Without a Content-Length the body is streamed, and stopped at the limit.
|
|
const undeclared = async (url, init) => {
|
|
const r = await fetch(url, init);
|
|
return new Response(r.body, { status: r.status });
|
|
};
|
|
await fails(() => pkg.openUrl(`${base}/norange/fix/fixture.h5`, { maxDownload: 1000, fetch: undeclared }),
|
|
/more than maxDownload/, "maxDownload, streamed");
|
|
const streamed = await pkg.openUrl(`${base}/norange/fix/fixture.h5`, { fetch: undeclared });
|
|
eq(Array.from((await streamed.read("/sensors/temp")).data), [21.5, 22, 22.25], "streamed download");
|
|
|
|
// Errors: HTTP status, not HDF5, bad options, a file that changes, a
|
|
// server that answers with the wrong bytes.
|
|
await fails(() => pkg.openUrl(`${base}/fix/missing.h5`), /HTTP 404/, "404");
|
|
await fails(() => pkg.openUrl(`${base}/fix/expected.json`), /./, "not HDF5");
|
|
await fails(() => pkg.openUrl(`${base}/fix/fixture.h5`, { blockSize: 100 }), /blockSize/, "blockSize");
|
|
const tamper = (edit) => async (url, init) => {
|
|
const r = await fetch(url, init);
|
|
return init.headers.Range === "bytes=0-511" ? r : edit(r);
|
|
};
|
|
const withHeaders = async (r, headers) => {
|
|
const h = new Headers(r.headers);
|
|
for (const [k, v] of Object.entries(headers)) h.set(k, v);
|
|
return new Response(await r.arrayBuffer(), { status: r.status, headers: h });
|
|
};
|
|
await fails(async () => {
|
|
const f = await pkg.openUrl(`${base}/fix/fixture.h5`, { blockSize: 512, fetch: tamper((r) => withHeaders(r, { ETag: '"other"' })) });
|
|
await f.read("/grid");
|
|
}, /changed on the server/, "changed file");
|
|
await fails(async () => {
|
|
const f = await pkg.openUrl(`${base}/fix/fixture.h5`, {
|
|
blockSize: 512,
|
|
fetch: tamper(async (r) => new Response((await r.arrayBuffer()).slice(1), { status: 206 })),
|
|
});
|
|
await f.read("/grid");
|
|
}, /got \d+/, "short answer");
|
|
await fails(async () => {
|
|
const f = await pkg.openUrl(`${base}/fix/fixture.h5`, {
|
|
blockSize: 512,
|
|
fetch: tamper(async (r) => new Response(await r.arrayBuffer(), { status: 200 })),
|
|
});
|
|
await f.read("/grid");
|
|
}, /stopped honouring range requests/, "200 mid-file");
|
|
await fails(async () => {
|
|
const f = await pkg.openUrl(`${base}/fix/fixture.h5`, {
|
|
blockSize: 512,
|
|
fetch: tamper(async (r) => withHeaders(r, { "Content-Range": "bytes 0-511/25752" })),
|
|
});
|
|
await f.read("/grid");
|
|
}, /the server sent 0-511/, "wrong range");
|
|
await fails(async () => {
|
|
const f = await pkg.openUrl(`${base}/fix/fixture.h5`, {
|
|
blockSize: 512,
|
|
fetch: tamper(async (r) => withHeaders(r, { "Content-Range": "bytes */25752" })),
|
|
});
|
|
await f.read("/grid");
|
|
}, /unusable Content-Range/, "unusable Content-Range");
|
|
|
|
// Corpus files: what the viewer can show of each is the same read by
|
|
// ranges as in memory (an error wherever it gives one).
|
|
const corpus = process.env.CLAWHDF5_WASM_CORPUS;
|
|
if (corpus) await corpusTests(corpus);
|
|
console.log(`openUrl: ${checks - before} checks passed`);
|
|
}
|
|
|
|
function hdf5Files(dir, out) {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name);
|
|
let st;
|
|
try {
|
|
st = statSync(p);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (st.isDirectory()) hdf5Files(p, out);
|
|
else if (st.size <= 16 << 20) {
|
|
const b = readFileSync(p);
|
|
const sig = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
for (let at = 0; at + 8 <= b.length; at = at === 0 ? 512 : at * 2) {
|
|
if (sig.every((x, i) => b[at + i] === x)) { out.push(p); break; }
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function show(v) {
|
|
return JSON.stringify(v, (_, x) => {
|
|
if (typeof x === "bigint") return `${x}n`;
|
|
if (ArrayBuffer.isView(x)) return Array.from(x, (y) => (typeof y === "bigint" ? `${y}n` : Number.isNaN(y) ? "NaN" : y));
|
|
return x;
|
|
});
|
|
}
|
|
|
|
async function transcript(f) {
|
|
const out = [];
|
|
const call = async (what, fn) => {
|
|
try {
|
|
out.push(`${what}: ${show(await fn())}`);
|
|
} catch {
|
|
out.push(`${what}: Err`);
|
|
}
|
|
};
|
|
const todo = [["/", 0]];
|
|
while (todo.length && out.length < 3000) {
|
|
const [path, depth] = todo.pop();
|
|
let kind = null;
|
|
await call(`${path} kind`, async () => (kind = await f.kind(path)));
|
|
await call(`${path} attrs`, () => f.attrs(path));
|
|
if (kind === "group") {
|
|
let list = [];
|
|
await call(`${path} list`, async () => (list = await f.list(path)));
|
|
if (depth < 12) for (const c of list.reverse()) todo.push([lib.joinPath(path, c.name), depth + 1]);
|
|
} else if (kind === "dataset") {
|
|
let info = null;
|
|
await call(`${path} info`, async () => (info = await f.info(path)));
|
|
if (info && [...info.shape, ...info.elementShape].reduce((a, b) => a * b, 1) <= 1 << 20) {
|
|
await call(`${path} read`, () => f.read(path));
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function corpusTests(corpus) {
|
|
const files = hdf5Files(corpus, []).sort();
|
|
let opened = 0;
|
|
let bytes = 0;
|
|
let size = 0;
|
|
for (const p of files) {
|
|
const rel = relative(corpus, p).split("/").map(encodeURIComponent).join("/");
|
|
let local;
|
|
try {
|
|
local = pkg.open(new Uint8Array(readFileSync(p)));
|
|
} catch {
|
|
await fails(() => pkg.openUrl(`${base}/corpus/${rel}`, { blockSize: 65536 }), /./, `${rel}: opens in neither`);
|
|
continue;
|
|
}
|
|
const remote = await pkg.openUrl(`${base}/corpus/${rel}`, { blockSize: 65536 });
|
|
const want = await transcript(local);
|
|
const got = await transcript(remote);
|
|
// Errors are compared as errors: a malformed file can fail at another
|
|
// check, with another message, when read by ranges.
|
|
eq(got, want, `${rel}: transcript`);
|
|
opened++;
|
|
bytes += remote.stats().bytesFetched;
|
|
size += remote.stats().size;
|
|
local.free();
|
|
remote.free();
|
|
}
|
|
console.log(`corpus: ${opened} of ${files.length} files agree over HTTP (${bytes} of ${size} bytes fetched)`);
|
|
}
|