Add cancel + progress UI, EngineToggle, and Docker deployment

This commit is contained in:
Lucas Winther
2026-07-14 19:32:10 +02:00
parent be82ecfe57
commit 0084e0435a
16 changed files with 972 additions and 60 deletions
+77
View File
@@ -77,6 +77,44 @@ describe("BrowserEngine", () => {
expect("modelId" in message).toBe(false);
});
test("asks the worker to stream only when a consumer listens", () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
void engine.transcribe(new Float32Array(4));
void engine.transcribe(new Float32Array(4), { onPartial: () => {} });
void engine.transcribe(new Float32Array(4), { onProgress: () => {} });
expect("stream" in worker.posted[0]!.message).toBe(false);
expect(worker.posted[1]!.message.stream).toBe(true);
expect(worker.posted[2]!.message.stream).toBe(true);
});
test("routes transcribe-progress to onProgress, ignoring other ids", async () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
const updates: { processedSeconds: number; totalSeconds: number }[] = [];
const promise = engine.transcribe(new Float32Array(4), {
onProgress: (progress) => updates.push(progress),
});
const requestId = worker.lastRequestId();
worker.emit({ type: "transcribe-progress", processedSeconds: 5, totalSeconds: 60 });
worker.emit({
type: "transcribe-progress",
processedSeconds: 7,
totalSeconds: 60,
requestId: "other",
});
worker.emit({ type: "transcribe-progress", processedSeconds: 0, totalSeconds: 90, requestId });
worker.emit({ type: "transcribe-progress", processedSeconds: 30, totalSeconds: 90, requestId });
worker.emit({ type: "result", text: "done", chunks: [], requestId });
expect((await promise).text).toBe("done");
expect(updates).toEqual([
{ processedSeconds: 0, totalSeconds: 90 },
{ processedSeconds: 30, totalSeconds: 90 },
]);
});
test("resolves on the matching result and routes partials to onPartial", async () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
@@ -265,6 +303,45 @@ describe("ServerEngine", () => {
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toThrow(/could not reach the server/i);
});
test("passes the abort signal to fetch and rethrows AbortError as-is", async () => {
// Cancel (D-018 amendment): the UI aborts the controller; the rejection
// must stay an AbortError, not be rewrapped as a network failure.
let seenSignal: AbortSignal | undefined;
const engine = new ServerEngine(async (_url, init) => {
seenSignal = init?.signal ?? undefined;
throw new DOMException("The user aborted a request.", "AbortError");
});
const controller = new AbortController();
const promise = engine.transcribe(new Float32Array(4), {
modelId: "onnx-community/whisper-tiny",
signal: controller.signal,
});
expect(promise).rejects.toMatchObject({ name: "AbortError" });
await promise.catch(() => {});
expect(seenSignal).toBe(controller.signal);
});
test("an abort mid-stream surfaces as the stream's rejection", () => {
// Once the 200 arrived, an abort tears down the body stream; whatever
// error the reader throws must propagate out of transcribe().
const body = new ReadableStream<Uint8Array>({
start(streamController) {
streamController.enqueue(
new TextEncoder().encode(
`${JSON.stringify({ type: "segment", text: " a", start: 0, end: 1 })}\n`,
),
);
streamController.error(
new DOMException("The user aborted a request.", "AbortError"),
);
},
});
const engine = new ServerEngine(async () => new Response(body, { status: 200 }));
expect(
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toMatchObject({ name: "AbortError" });
});
});
// ---------------------------------------------------------------------------