@@ -124,7 +124,7 @@ test("serves a relocatable production artifact with hardened headers", async ({
|
||||
expect(manifest.headers()["content-type"]).toContain("application/json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.helper-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
entry: "./",
|
||||
icon: "./favicon.svg",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/helpers/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ByteCursor,
|
||||
HelperLimitError,
|
||||
WORKER_JOB_PROTOCOL,
|
||||
checkedByteRange,
|
||||
checkedOffsetAdd,
|
||||
checksumByteSource,
|
||||
createIncrementalChecksum,
|
||||
createWorkerJobMessageHandler,
|
||||
digestByteSourceHex,
|
||||
encodeText,
|
||||
iterateByteChunks,
|
||||
ownedBytes,
|
||||
startWorkerJob,
|
||||
WorkerJobTimeoutError,
|
||||
type WorkerJobCommand,
|
||||
type WorkerJobEndpoint,
|
||||
type WorkerJobResponse,
|
||||
} from "../../src/helpers";
|
||||
|
||||
describe("bounded byte access", () => {
|
||||
it("checks offset arithmetic before slicing", () => {
|
||||
expect(checkedOffsetAdd(4, 5, 9)).toBe(9);
|
||||
expect(checkedByteRange(9, 4, 5)).toEqual({
|
||||
offset: 4,
|
||||
length: 5,
|
||||
end: 9,
|
||||
});
|
||||
expect(() => checkedOffsetAdd(Number.MAX_SAFE_INTEGER, 1)).toThrow(
|
||||
HelperLimitError,
|
||||
);
|
||||
expect(() => checkedByteRange(8, 7, 2)).toThrow(HelperLimitError);
|
||||
expect(() => checkedByteRange(8, -1, 1)).toThrow(/non-negative/u);
|
||||
});
|
||||
|
||||
it("reads typed values without moving after a failed read", () => {
|
||||
const cursor = new ByteCursor(
|
||||
new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x41, 0x42]),
|
||||
);
|
||||
expect(cursor.readUint16()).toBe(0x0102);
|
||||
expect(cursor.readUint16(true)).toBe(0x0403);
|
||||
expect(cursor.readAscii(2)).toBe("AB");
|
||||
expect(cursor.done).toBe(true);
|
||||
expect(() => cursor.readUint8()).toThrow(HelperLimitError);
|
||||
expect(cursor.offset).toBe(6);
|
||||
});
|
||||
|
||||
it("supports bounded subcursors and 24-bit integers", () => {
|
||||
const cursor = new ByteCursor(new Uint8Array([1, 2, 3, 4]), {
|
||||
littleEndian: true,
|
||||
maximumBytes: 4,
|
||||
});
|
||||
expect(cursor.readUint24()).toBe(0x030201);
|
||||
expect(cursor.subcursor(1).readUint8()).toBe(4);
|
||||
expect(
|
||||
() => new ByteCursor(new Uint8Array(5), { maximumBytes: 4 }),
|
||||
).toThrow(HelperLimitError);
|
||||
});
|
||||
|
||||
it("does not advance after invalid ASCII and makes bounded owned copies", () => {
|
||||
const cursor = new ByteCursor(new Uint8Array([0x41, 0xff]));
|
||||
expect(() => cursor.readAscii(2)).toThrow(/ASCII/u);
|
||||
expect(cursor.offset).toBe(0);
|
||||
const source = new Uint8Array([1, 2]);
|
||||
const copy = ownedBytes(source, 2);
|
||||
source[0] = 9;
|
||||
expect(copy).toEqual(new Uint8Array([1, 2]));
|
||||
expect(() => ownedBytes(source, 1)).toThrow(HelperLimitError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incremental byte sources", () => {
|
||||
it("produces the same checksums across arbitrary chunk boundaries", async () => {
|
||||
async function* chunks() {
|
||||
yield encodeText("123");
|
||||
yield encodeText("456");
|
||||
yield encodeText("789");
|
||||
}
|
||||
await expect(
|
||||
checksumByteSource(chunks(), "CRC-32", { maximumBytes: 9 }),
|
||||
).resolves.toBe(0xcbf43926);
|
||||
const checksum = createIncrementalChecksum("Adler-32", 9);
|
||||
checksum.update(encodeText("1234")).update(encodeText("56789"));
|
||||
expect(checksum.digestHex()).toBe("091e01de");
|
||||
expect(checksum.bytesProcessed).toBe(9);
|
||||
expect(() => checksum.update(new Uint8Array([0]))).toThrow(
|
||||
HelperLimitError,
|
||||
);
|
||||
expect(checksum.reset().bytesProcessed).toBe(0);
|
||||
expect(() => createIncrementalChecksum("unknown" as "CRC-32")).toThrow(
|
||||
/Unsupported checksum/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes bounded Blob chunks and reports progress", async () => {
|
||||
const progress: number[] = [];
|
||||
await expect(
|
||||
digestByteSourceHex(new Blob(["abc"]), "SHA-256", {
|
||||
chunkBytes: 2,
|
||||
maximumBytes: 3,
|
||||
onProgress: (event) => progress.push(event.processedBytes),
|
||||
}),
|
||||
).resolves.toBe(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
);
|
||||
expect(progress).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("cancels streams and enforces a total ceiling", async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(3));
|
||||
controller.enqueue(new Uint8Array(3));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const consume = async () => {
|
||||
for await (const chunk of iterateByteChunks(stream, {
|
||||
maximumBytes: 5,
|
||||
})) {
|
||||
// Consume until the shared total limit rejects the second chunk.
|
||||
void chunk;
|
||||
}
|
||||
};
|
||||
await expect(consume()).rejects.toThrow(HelperLimitError);
|
||||
|
||||
async function* shortSource() {
|
||||
yield new Uint8Array(2);
|
||||
}
|
||||
const short = async () => {
|
||||
for await (const chunk of iterateByteChunks(shortSource(), {
|
||||
maximumBytes: 4,
|
||||
knownTotalBytes: 3,
|
||||
}))
|
||||
void chunk;
|
||||
};
|
||||
await expect(short()).rejects.toThrow(/expected 3/u);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const aborted = async () => {
|
||||
for await (const chunk of iterateByteChunks(new Blob(["x"]), {
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
// No chunk may escape after cancellation.
|
||||
void chunk;
|
||||
}
|
||||
};
|
||||
await expect(aborted()).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
|
||||
class FakeWorker implements WorkerJobEndpoint {
|
||||
readonly posted: unknown[] = [];
|
||||
terminated = 0;
|
||||
readonly #messages = new Set<(event: MessageEvent<unknown>) => void>();
|
||||
readonly #errors = new Set<(event: ErrorEvent) => void>();
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.posted.push(message);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated += 1;
|
||||
}
|
||||
|
||||
addEventListener(
|
||||
type: "message" | "error",
|
||||
listener:
|
||||
((event: MessageEvent<unknown>) => void) | ((event: ErrorEvent) => void),
|
||||
): void {
|
||||
if (type === "message")
|
||||
this.#messages.add(listener as (event: MessageEvent<unknown>) => void);
|
||||
else this.#errors.add(listener as (event: ErrorEvent) => void);
|
||||
}
|
||||
|
||||
removeEventListener(
|
||||
type: "message" | "error",
|
||||
listener:
|
||||
((event: MessageEvent<unknown>) => void) | ((event: ErrorEvent) => void),
|
||||
): void {
|
||||
if (type === "message")
|
||||
this.#messages.delete(listener as (event: MessageEvent<unknown>) => void);
|
||||
else this.#errors.delete(listener as (event: ErrorEvent) => void);
|
||||
}
|
||||
|
||||
respond(message: unknown): void {
|
||||
const event = new MessageEvent("message", { data: message });
|
||||
for (const listener of this.#messages) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
describe("disposable worker job protocol", () => {
|
||||
it("routes matching progress and result messages and disposes the worker", async () => {
|
||||
const worker = new FakeWorker();
|
||||
const progress = vi.fn();
|
||||
const task = startWorkerJob<{ value: number }, number, string>(
|
||||
worker,
|
||||
{ value: 2 },
|
||||
{ jobId: "job-a", onProgress: progress },
|
||||
);
|
||||
expect(worker.posted[0]).toMatchObject({
|
||||
protocol: WORKER_JOB_PROTOCOL,
|
||||
type: "run",
|
||||
jobId: "job-a",
|
||||
});
|
||||
worker.respond({
|
||||
protocol: WORKER_JOB_PROTOCOL,
|
||||
type: "progress",
|
||||
jobId: "someone-else",
|
||||
progress: "ignored",
|
||||
});
|
||||
worker.respond({
|
||||
protocol: WORKER_JOB_PROTOCOL,
|
||||
type: "progress",
|
||||
jobId: "job-a",
|
||||
progress: "half",
|
||||
});
|
||||
worker.respond({
|
||||
protocol: WORKER_JOB_PROTOCOL,
|
||||
type: "result",
|
||||
jobId: "job-a",
|
||||
result: 4,
|
||||
});
|
||||
await expect(task.promise).resolves.toBe(4);
|
||||
expect(progress).toHaveBeenCalledWith("half");
|
||||
expect(worker.terminated).toBe(1);
|
||||
});
|
||||
|
||||
it("hard-stops a worker at its deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const worker = new FakeWorker();
|
||||
const task = startWorkerJob(worker, "work", {
|
||||
jobId: "slow",
|
||||
timeoutMs: 25,
|
||||
});
|
||||
const rejected = expect(task.promise).rejects.toBeInstanceOf(
|
||||
WorkerJobTimeoutError,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await rejected;
|
||||
expect(worker.posted.at(-1)).toMatchObject({
|
||||
type: "cancel",
|
||||
jobId: "slow",
|
||||
});
|
||||
expect(worker.terminated).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("settles and disposes when posting or progress handling fails", async () => {
|
||||
const postingWorker = new FakeWorker();
|
||||
postingWorker.postMessage = () => {
|
||||
throw new DOMException("Not cloneable", "DataCloneError");
|
||||
};
|
||||
const postingTask = startWorkerJob(postingWorker, { invalid: true });
|
||||
await expect(postingTask.promise).rejects.toMatchObject({
|
||||
name: "DataCloneError",
|
||||
});
|
||||
expect(postingWorker.terminated).toBe(1);
|
||||
|
||||
const progressWorker = new FakeWorker();
|
||||
const progressTask = startWorkerJob<null, number, number>(
|
||||
progressWorker,
|
||||
null,
|
||||
{
|
||||
jobId: "progress-error",
|
||||
onProgress: () => {
|
||||
throw new Error("Progress consumer failed");
|
||||
},
|
||||
},
|
||||
);
|
||||
progressWorker.respond({
|
||||
protocol: WORKER_JOB_PROTOCOL,
|
||||
type: "progress",
|
||||
jobId: "progress-error",
|
||||
progress: 1,
|
||||
});
|
||||
await expect(progressTask.promise).rejects.toThrow(
|
||||
"Progress consumer failed",
|
||||
);
|
||||
expect(progressWorker.terminated).toBe(1);
|
||||
});
|
||||
|
||||
it("provides a worker-side progress/result/error adapter", async () => {
|
||||
type Response = WorkerJobResponse<number, string, { message: string }>;
|
||||
const responses: Response[] = [];
|
||||
const handler = createWorkerJobMessageHandler<
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
{ message: string }
|
||||
>(
|
||||
(payload: number, context) => {
|
||||
context.report("working");
|
||||
return payload * 2;
|
||||
},
|
||||
(response) => responses.push(response),
|
||||
{
|
||||
serializeError: (error) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
handler(
|
||||
new MessageEvent<WorkerJobCommand<number>>("message", {
|
||||
data: {
|
||||
protocol: WORKER_JOB_PROTOCOL,
|
||||
type: "run",
|
||||
jobId: "worker-side",
|
||||
payload: 3,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(responses).toHaveLength(2));
|
||||
expect(responses).toEqual([
|
||||
expect.objectContaining({ type: "progress", progress: "working" }),
|
||||
expect.objectContaining({ type: "result", result: 6 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
assertBoundedItems,
|
||||
assertBoundedText,
|
||||
createObjectUrlLease,
|
||||
createObjectUrlLeasePool,
|
||||
createSeededRandom,
|
||||
formatBytes,
|
||||
planBlobDownloads,
|
||||
sanitizeDownloadFilename,
|
||||
secureRandomBytes,
|
||||
secureRandomInt,
|
||||
shuffleSeeded,
|
||||
triggerBlobDownload,
|
||||
triggerBlobDownloads,
|
||||
} from "../../src/helpers";
|
||||
|
||||
describe("shared ceilings and download safety", () => {
|
||||
@@ -85,6 +89,113 @@ describe("shared ceilings and download safety", () => {
|
||||
vi.stubGlobal("URL", originalUrl);
|
||||
}
|
||||
});
|
||||
|
||||
it("formats IEC and SI byte quantities deterministically", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(1536)).toBe("1.50 KiB");
|
||||
expect(formatBytes(1500, { system: "si", fractionDigits: 1 })).toBe(
|
||||
"1.5 kB",
|
||||
);
|
||||
expect(formatBytes(-1024)).toBe("-1.00 KiB");
|
||||
expect(formatBytes(Number.NaN)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("replaces and revokes named object URL leases", () => {
|
||||
let sequence = 0;
|
||||
const revokeObjectURL = vi.fn();
|
||||
const pool = createObjectUrlLeasePool({
|
||||
createObjectURL: vi.fn(() => `blob:${++sequence}`),
|
||||
revokeObjectURL,
|
||||
});
|
||||
const first = pool.create("preview", new Blob(["one"]));
|
||||
const second = pool.create("preview", new Blob(["two"]));
|
||||
expect(first.revoked).toBe(true);
|
||||
expect(second.url).toBe("blob:2");
|
||||
expect(pool.size).toBe(1);
|
||||
expect(pool.revoke("missing")).toBe(false);
|
||||
pool.revokeAll();
|
||||
expect(second.revoked).toBe(true);
|
||||
expect(pool.size).toBe(0);
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("plans collision-free batch names and revokes every URL", () => {
|
||||
const items = [
|
||||
{ blob: new Blob(["a"]), filename: "Report.txt" },
|
||||
{ blob: new Blob(["b"]), filename: "report.txt" },
|
||||
{ blob: new Blob(["c"]), filename: "../unsafe?.txt" },
|
||||
];
|
||||
expect(planBlobDownloads(items).map((item) => item.filename)).toEqual([
|
||||
"Report.txt",
|
||||
"report (2).txt",
|
||||
"_unsafe_.txt",
|
||||
]);
|
||||
|
||||
const revokeObjectURL = vi.fn();
|
||||
let index = 0;
|
||||
const click = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => undefined);
|
||||
let scheduled: (() => void) | undefined;
|
||||
const batch = triggerBlobDownloads(items, {
|
||||
ownerDocument: document,
|
||||
urlApi: {
|
||||
createObjectURL: () => `blob:batch-${++index}`,
|
||||
revokeObjectURL,
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled = callback;
|
||||
},
|
||||
});
|
||||
expect(click).toHaveBeenCalledTimes(3);
|
||||
expect(batch.leases.every((lease) => !lease.revoked)).toBe(true);
|
||||
scheduled?.();
|
||||
expect(batch.leases.every((lease) => lease.revoked)).toBe(true);
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("bounds lazy download plans before consuming the whole iterable", () => {
|
||||
let yielded = 0;
|
||||
function* many() {
|
||||
while (true) {
|
||||
yielded += 1;
|
||||
yield { blob: new Blob(["x"]), filename: "same.txt" };
|
||||
}
|
||||
}
|
||||
expect(() => planBlobDownloads(many(), { maximumFiles: 2 })).toThrow(
|
||||
HelperLimitError,
|
||||
);
|
||||
expect(yielded).toBe(3);
|
||||
expect(() => planBlobDownloads([], { maximumFilenameLength: 7 })).toThrow(
|
||||
/at least 8/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("revokes already-created batch URLs when a click fails", () => {
|
||||
const revokeObjectURL = vi.fn();
|
||||
const click = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
expect(() =>
|
||||
triggerBlobDownloads(
|
||||
[
|
||||
{ blob: new Blob(["a"]), filename: "a.txt" },
|
||||
{ blob: new Blob(["b"]), filename: "b.txt" },
|
||||
],
|
||||
{
|
||||
ownerDocument: document,
|
||||
urlApi: {
|
||||
createObjectURL: () => `blob:test:${click.mock.calls.length}`,
|
||||
revokeObjectURL,
|
||||
},
|
||||
},
|
||||
),
|
||||
).toThrow("blocked");
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secure and seeded randomness", () => {
|
||||
|
||||
Reference in New Issue
Block a user