From b4b1bbfc7b389fb3f1821e86719aa2a3290de1e0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:21:24 +0200 Subject: [PATCH 1/2] test(worker-bus): export v2 duckdb/inference handlers + parity gate tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither workers/v2/duckdb.worker.ts's handlers nor workers/v2/inference.worker.ts's handleInference were exported or unit-tested against real logic (only webllm.worker.ts had that, tests/unit/webllmWorkerHandler.test.ts) despite both already being registered as live WorkerBus v2 pools with zero production callers — docs/adr/0014-worker-generation-duplication.md's deferred migration needs these to actually match v1 before any consumer cuts over. Testing surfaced a real gap: initDuckDb()'s OPFS-unavailable catch block silently swallowed the failure, unlike v1's out-of-band OPFS_FALLBACK message that lets the UI warn users their analytics won't persist. Fixed by threading ctx.emitProgress into initDuckDb() and emitting an 'opfs-fallback' progress stage from the catch block — reuses the existing progress channel WebLLM already uses for download progress, no new message-protocol/schema change needed. First PR of the ADR-0014 consolidation sprint (5 PRs total, see .claude/plans/worker-generation-v1-vs-enumerated-fox.md). Co-Authored-By: Claude Sonnet 5 --- tests/unit/duckdbWorkerHandler.test.ts | 233 ++++++++++++++++++++ tests/unit/inferenceWorkerHandlerV2.test.ts | 174 +++++++++++++++ workers/v2/duckdb.worker.ts | 35 ++- workers/v2/inference.worker.ts | 4 +- 4 files changed, 436 insertions(+), 10 deletions(-) create mode 100644 tests/unit/duckdbWorkerHandler.test.ts create mode 100644 tests/unit/inferenceWorkerHandlerV2.test.ts diff --git a/tests/unit/duckdbWorkerHandler.test.ts b/tests/unit/duckdbWorkerHandler.test.ts new file mode 100644 index 00000000..c1e0f10e --- /dev/null +++ b/tests/unit/duckdbWorkerHandler.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +// QNBS-v3: parity gate (PR 0, worker-generation consolidation, docs/adr/0014-worker-generation- +// duplication.md) — unit tests for the v2 DuckDB worker's handler functions, exercised +// directly (not via a real worker/WorkerBus) against a mocked @duckdb/duckdb-wasm, to +// catch response-shape / OPFS-fallback-signal drift from v1 before any consumer cuts over. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkerHandlerContext } from '../../packages/worker-bus/src/workerBootstrap'; + +class MockWorker { + postMessage = vi.fn(); + terminate = vi.fn(); + addEventListener = vi.fn(); +} +vi.stubGlobal('Worker', MockWorker); + +// QNBS-v3: vi.hoisted — the worker module is imported statically below, so the mock factory runs +// during the hoisted import phase; plain consts would hit a TDZ ReferenceError. +const { + mockSelectBundle, + mockInstantiate, + mockRegisterFileHandle, + mockConnect, + mockDbTerminate, + MockAsyncDuckDB, +} = vi.hoisted(() => { + const mockInstantiate = vi.fn(); + const mockRegisterFileHandle = vi.fn(); + const mockConnect = vi.fn(); + const mockDbTerminate = vi.fn(); + // QNBS-v3: must be a `function`, not an arrow — arrow functions aren't constructable, and + // `initDuckDb()` calls `new AsyncDuckDB(...)`. + const MockAsyncDuckDB = vi.fn().mockImplementation(function MockAsyncDuckDBCtor() { + return { + instantiate: mockInstantiate, + registerFileHandle: mockRegisterFileHandle, + connect: mockConnect, + terminate: mockDbTerminate, + }; + }); + return { + mockSelectBundle: vi.fn(), + mockInstantiate, + mockRegisterFileHandle, + mockConnect, + mockDbTerminate, + MockAsyncDuckDB, + }; +}); + +vi.mock('@duckdb/duckdb-wasm', () => ({ + AsyncDuckDB: MockAsyncDuckDB, + selectBundle: mockSelectBundle, + ConsoleLogger: vi.fn(), + DuckDBDataProtocol: { BROWSER_FSACCESS: 'browser-fsaccess' }, +})); + +// QNBS-v3: imported AFTER the mock is declared so the worker picks up the mocked module. +import { + handleExec, + handleQuery, + handleShutdown, + initDuckDb, +} from '../../workers/v2/duckdb.worker'; + +function makeCtx(overrides: Partial = {}): WorkerHandlerContext { + return { + taskId: 't1', + taskType: 'db.duckdb.query', + payload: {}, + signal: new AbortController().signal, + emitProgress: vi.fn(), + ...overrides, + }; +} + +function stubOpfsDirectory(getFileHandle: ReturnType): void { + Object.defineProperty(globalThis.navigator, 'storage', { + value: { getDirectory: vi.fn().mockResolvedValue({ getFileHandle }) }, + configurable: true, + }); +} + +function stubNoOpfs(): void { + Object.defineProperty(globalThis.navigator, 'storage', { + value: undefined, + configurable: true, + }); +} + +// QNBS-v3: always include `close` — the shared beforeEach calls the real handleShutdown() to +// reset module state, which unconditionally calls `connection?.close()`. +function mockConnection(overrides: { query?: ReturnType } = {}) { + return { query: overrides.query ?? vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; +} + +describe('duckdb.worker handlers', () => { + beforeEach(async () => { + // QNBS-v3: connection/db are module-scoped in duckdb.worker.ts and persist across tests in + // this file — reset via handleShutdown() before each test so "not initialized" + // assertions aren't polluted by a previous test's initDuckDb() call. + await handleShutdown(); + vi.clearAllMocks(); + mockSelectBundle.mockResolvedValue({ mainModule: 'mvp.wasm', mainWorker: 'mvp.worker.js' }); + mockInstantiate.mockResolvedValue(undefined); + }); + + afterEach(() => { + stubNoOpfs(); + }); + + describe('initDuckDb', () => { + it('attaches OPFS persistence and does not emit opfs-fallback when it succeeds', async () => { + const connectionQuery = vi.fn().mockResolvedValue(undefined); + mockConnect.mockResolvedValue(mockConnection({ query: connectionQuery })); + mockRegisterFileHandle.mockResolvedValue(undefined); + stubOpfsDirectory(vi.fn().mockResolvedValue({})); + const emitProgress = vi.fn(); + + await initDuckDb(emitProgress); + + expect(connectionQuery).toHaveBeenCalledWith(expect.stringContaining('ATTACH')); + expect(emitProgress).not.toHaveBeenCalled(); + }); + + it('emits opfs-fallback and still connects when registerFileHandle throws', async () => { + mockConnect.mockResolvedValue(mockConnection()); + mockRegisterFileHandle.mockRejectedValue(new Error('registerFileHandle denied')); + stubOpfsDirectory(vi.fn().mockResolvedValue({})); + const emitProgress = vi.fn(); + + await initDuckDb(emitProgress); + + expect(emitProgress).toHaveBeenCalledWith('opfs-fallback', 1, 'registerFileHandle denied'); + // Falls back to a plain (in-memory) connect() despite the OPFS failure. + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('does not emit opfs-fallback when OPFS is simply unsupported (no navigator.storage)', async () => { + mockConnect.mockResolvedValue(mockConnection()); + stubNoOpfs(); + const emitProgress = vi.fn(); + + await initDuckDb(emitProgress); + + expect(emitProgress).not.toHaveBeenCalled(); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it('works when emitProgress is omitted (registerTaskHandler always passes ctx.emitProgress, but keep the param optional/safe)', async () => { + mockConnect.mockResolvedValue( + mockConnection({ query: vi.fn().mockRejectedValue(new Error('boom')) }), + ); + mockRegisterFileHandle.mockResolvedValue(undefined); + stubOpfsDirectory(vi.fn().mockResolvedValue({})); + + await expect(initDuckDb()).resolves.toBeUndefined(); + }); + }); + + describe('handleQuery', () => { + it('throws when DuckDB is not initialized', async () => { + await expect(handleQuery(makeCtx({ payload: { sql: 'select 1' } }))).rejects.toThrow( + 'DuckDB not initialized', + ); + }); + + it('returns a raw array of plain-object rows (not a {ok,rows} wrapper)', async () => { + const row = { toJSON: () => ({ id: 1 }) }; + mockConnect.mockResolvedValue( + mockConnection({ query: vi.fn().mockResolvedValue({ toArray: () => [row] }) }), + ); + stubNoOpfs(); + await initDuckDb(); + + const result = await handleQuery(makeCtx({ payload: { sql: 'select 1' } })); + + expect(result).toEqual([{ id: 1 }]); + }); + + it('throws (does not resolve {ok:false}) when the query rejects', async () => { + mockConnect.mockResolvedValue( + mockConnection({ query: vi.fn().mockRejectedValue(new Error('bad sql')) }), + ); + stubNoOpfs(); + await initDuckDb(); + + await expect(handleQuery(makeCtx({ payload: { sql: 'garbage' } }))).rejects.toThrow( + 'bad sql', + ); + }); + }); + + describe('handleExec', () => { + it('throws when DuckDB is not initialized', async () => { + await expect( + handleExec(makeCtx({ payload: { sql: 'insert into t values (1)' } })), + ).rejects.toThrow('DuckDB not initialized'); + }); + + it('returns {ok:true} on success', async () => { + const connectionQuery = vi.fn().mockResolvedValue(undefined); + mockConnect.mockResolvedValue(mockConnection({ query: connectionQuery })); + stubNoOpfs(); + await initDuckDb(); + + const result = await handleExec(makeCtx({ payload: { sql: 'create table t (id int)' } })); + + expect(result).toEqual({ ok: true }); + expect(connectionQuery).toHaveBeenCalledWith('create table t (id int)'); + }); + }); + + describe('handleShutdown', () => { + it('closes the connection and terminates the db, returning {ok:true}', async () => { + const close = vi.fn().mockResolvedValue(undefined); + mockConnect.mockResolvedValue({ query: vi.fn(), close }); + stubNoOpfs(); + await initDuckDb(); + + const result = await handleShutdown(); + + expect(result).toEqual({ ok: true }); + expect(close).toHaveBeenCalled(); + expect(mockDbTerminate).toHaveBeenCalled(); + + // A query after shutdown must throw 'DuckDB not initialized' again. + await expect(handleQuery(makeCtx({ payload: { sql: 'select 1' } }))).rejects.toThrow( + 'DuckDB not initialized', + ); + }); + }); +}); diff --git a/tests/unit/inferenceWorkerHandlerV2.test.ts b/tests/unit/inferenceWorkerHandlerV2.test.ts new file mode 100644 index 00000000..aadb8001 --- /dev/null +++ b/tests/unit/inferenceWorkerHandlerV2.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +// QNBS-v3: parity gate (PR 0, worker-generation consolidation, docs/adr/0014-worker-generation- +// duplication.md) — unit tests for the v2 inference worker's handler, exercised directly +// (not via a real worker/WorkerBus) against a mocked @huggingface/transformers, to catch +// drift from v1 (workers/inference.worker.ts) before any consumer cuts over. File name is +// distinct from the existing v1 tests/unit/inferenceWorker.test.ts until v1 is deleted. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkerHandlerContext } from '../../packages/worker-bus/src/workerBootstrap'; + +const { mockPipelineFactory } = vi.hoisted(() => ({ mockPipelineFactory: vi.fn() })); + +vi.mock('@huggingface/transformers', () => ({ + pipeline: mockPipelineFactory, +})); + +// QNBS-v3: imported AFTER the mock is declared so the worker picks up the mocked factory. +import { handleInference } from '../../workers/v2/inference.worker'; + +function makeCtx(overrides: Partial = {}): WorkerHandlerContext { + return { + taskId: 't1', + taskType: 'inference.embed', + payload: { task: 'feature-extraction', modelId: 'm', input: 'hello world' }, + signal: new AbortController().signal, + emitProgress: vi.fn(), + ...overrides, + }; +} + +describe('inference.worker (v2) handleInference', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws Aborted when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + await expect(handleInference(makeCtx({ signal: controller.signal }))).rejects.toThrow( + 'Aborted', + ); + expect(mockPipelineFactory).not.toHaveBeenCalled(); + }); + + it('throws Aborted when the signal aborts between model load and inference', async () => { + const controller = new AbortController(); + const pipe = vi.fn(); + mockPipelineFactory.mockImplementation(async () => { + controller.abort(); + return pipe; + }); + + await expect( + handleInference( + makeCtx({ + signal: controller.signal, + payload: { task: 'feature-extraction', modelId: 'abort-model', input: 'x' }, + }), + ), + ).rejects.toThrow('Aborted'); + expect(pipe).not.toHaveBeenCalled(); + }); + + it('feature-extraction: normalizes a Float32Array result to a flat number[]', async () => { + const pipe = vi.fn().mockResolvedValue(new Float32Array([0.1, 0.2, 0.3])); + mockPipelineFactory.mockResolvedValue(pipe); + const emitProgress = vi.fn(); + + const result = await handleInference( + makeCtx({ + payload: { task: 'feature-extraction', modelId: 'embed-1', input: 'x' }, + emitProgress, + }), + ); + + expect(result).toEqual([Math.fround(0.1), Math.fround(0.2), Math.fround(0.3)]); + expect(pipe).toHaveBeenCalledWith( + 'x', + expect.objectContaining({ pooling: 'mean', normalize: true }), + ); + expect(emitProgress).toHaveBeenNthCalledWith(1, 'loading', 0.2, 'Loading model'); + expect(emitProgress).toHaveBeenNthCalledWith(2, 'inference', 0.6, 'Running inference'); + expect(emitProgress).toHaveBeenNthCalledWith(3, 'done', 1.0, 'Complete'); + }); + + it('feature-extraction: normalizes a {data: Float32Array} result to a flat number[]', async () => { + const pipe = vi.fn().mockResolvedValue({ data: new Float32Array([1, 2]) }); + mockPipelineFactory.mockResolvedValue(pipe); + + const result = await handleInference( + makeCtx({ payload: { task: 'feature-extraction', modelId: 'embed-2', input: 'x' } }), + ); + + expect(result).toEqual([1, 2]); + }); + + it('feature-extraction: normalizes a plain iterable result to a flat number[]', async () => { + const pipe = vi.fn().mockResolvedValue([4, 5, 6]); + mockPipelineFactory.mockResolvedValue(pipe); + + const result = await handleInference( + makeCtx({ payload: { task: 'feature-extraction', modelId: 'embed-3', input: 'x' } }), + ); + + expect(result).toEqual([4, 5, 6]); + }); + + it('sentiment-analysis: returns "LABEL:score" formatted to 4 decimals', async () => { + const pipe = vi.fn().mockResolvedValue([{ label: 'POSITIVE', score: 0.5 }]); + mockPipelineFactory.mockResolvedValue(pipe); + + const result = await handleInference( + makeCtx({ payload: { task: 'sentiment-analysis', modelId: 'sent-1', input: 'great!' } }), + ); + + expect(result).toBe('POSITIVE:0.5000'); + expect(pipe).toHaveBeenCalledWith('great!', {}); + }); + + it('sentiment-analysis: defaults to NEUTRAL:0.0000 when the pipeline result is empty', async () => { + const pipe = vi.fn().mockResolvedValue([]); + mockPipelineFactory.mockResolvedValue(pipe); + + const result = await handleInference( + makeCtx({ payload: { task: 'sentiment-analysis', modelId: 'sent-2', input: 'x' } }), + ); + + expect(result).toBe('NEUTRAL:0.0000'); + }); + + it('summarization: returns summary_text', async () => { + const pipe = vi.fn().mockResolvedValue([{ summary_text: 'a short summary' }]); + mockPipelineFactory.mockResolvedValue(pipe); + + const result = await handleInference( + makeCtx({ payload: { task: 'summarization', modelId: 'sum-1', input: 'long text...' } }), + ); + + expect(result).toBe('a short summary'); + }); + + it('text-generation: returns generated_text', async () => { + const pipe = vi.fn().mockResolvedValue([{ generated_text: 'once upon a time' }]); + mockPipelineFactory.mockResolvedValue(pipe); + + const result = await handleInference( + makeCtx({ payload: { task: 'text-generation', modelId: 'gen-1', input: 'prompt' } }), + ); + + expect(result).toBe('once upon a time'); + }); + + it('passes inferenceOptions through to the pipeline call for non-embedding tasks', async () => { + const pipe = vi.fn().mockResolvedValue([{ generated_text: 'x' }]); + mockPipelineFactory.mockResolvedValue(pipe); + + await handleInference( + makeCtx({ + payload: { + task: 'text-generation', + modelId: 'gen-2', + input: 'prompt', + inferenceOptions: { max_new_tokens: 32 }, + }, + }), + ); + + expect(pipe).toHaveBeenCalledWith('prompt', { max_new_tokens: 32 }); + }); +}); diff --git a/workers/v2/duckdb.worker.ts b/workers/v2/duckdb.worker.ts index 228e384e..2743ce7c 100644 --- a/workers/v2/duckdb.worker.ts +++ b/workers/v2/duckdb.worker.ts @@ -42,7 +42,11 @@ const SELF_HOSTED_BUNDLES = { }, }; -async function initDuckDb(): Promise { +// QNBS-v3: exported for tests/unit/duckdbWorkerHandler.test.ts (parity gate, PR 0 of the +// worker-generation consolidation) — verifies real handler logic without a live worker. +export async function initDuckDb( + emitProgress?: (stage: string, progress: number, message?: string) => void, +): Promise { const { AsyncDuckDB, selectBundle, ConsoleLogger } = await getDuckDb(); const bundle = await selectBundle(SELF_HOSTED_BUNDLES); @@ -68,7 +72,16 @@ async function initDuckDb(): Promise { ); connection = await newDb.connect(); await connection.query("ATTACH 'worldscript_analytics.duckdb' AS analytics (TYPE duckdb)"); - } catch { + } catch (opfsErr) { + // QNBS-v3: mirrors legacy workers/duckdbWorker.ts's out-of-band OPFS_FALLBACK message — + // v2 has no bare postMessage escape hatch from inside a task handler, so this + // reuses the existing progress channel instead (duckdbClient's INIT adapter + // listens for the 'opfs-fallback' stage and forwards it to setOpfsFallbackHandler). + emitProgress?.( + 'opfs-fallback', + 1, + opfsErr instanceof Error ? opfsErr.message : String(opfsErr), + ); connection = await newDb.connect(); } } else { @@ -78,7 +91,7 @@ async function initDuckDb(): Promise { db = newDb; } -async function handleQuery(ctx: WorkerHandlerContext): Promise { +export async function handleQuery(ctx: WorkerHandlerContext): Promise { const { payload } = ctx; const req = payload as { sql?: string }; if (!connection) throw new Error('DuckDB not initialized'); @@ -86,7 +99,7 @@ async function handleQuery(ctx: WorkerHandlerContext): Promise { return result.toArray().map((row: { toJSON(): Record }) => row.toJSON()); } -async function handleExec(ctx: WorkerHandlerContext): Promise { +export async function handleExec(ctx: WorkerHandlerContext): Promise { const { payload } = ctx; const req = payload as { sql?: string }; if (!connection) throw new Error('DuckDB not initialized'); @@ -94,7 +107,7 @@ async function handleExec(ctx: WorkerHandlerContext): Promise { return { ok: true }; } -async function handleShutdown(): Promise { +export async function handleShutdown(): Promise { await connection?.close(); await db?.terminate(); connection = null; @@ -102,10 +115,14 @@ async function handleShutdown(): Promise { return { ok: true }; } -registerTaskHandler('db.duckdb.init', async () => { - await initDuckDb(); - return { ok: true }; -}, ['db.duckdb']); +registerTaskHandler( + 'db.duckdb.init', + async (ctx) => { + await initDuckDb(ctx.emitProgress); + return { ok: true }; + }, + ['db.duckdb'], +); registerTaskHandler('db.duckdb.query', handleQuery, ['db.duckdb']); registerTaskHandler('db.duckdb.exec', handleExec, ['db.duckdb']); diff --git a/workers/v2/inference.worker.ts b/workers/v2/inference.worker.ts index 261eb717..a0eb7e3a 100644 --- a/workers/v2/inference.worker.ts +++ b/workers/v2/inference.worker.ts @@ -42,7 +42,9 @@ async function loadPipeline(task: string, modelId: string, quantized = true) { }); } -async function handleInference(ctx: WorkerHandlerContext): Promise { +// QNBS-v3: exported for tests/unit/inferenceWorkerHandlerV2.test.ts (parity gate, PR 0 of the +// worker-generation consolidation) — verifies real handler logic without a live worker. +export async function handleInference(ctx: WorkerHandlerContext): Promise { const { payload, signal, emitProgress } = ctx; const req = payload as { task: string; From bcc41c2757ba6fd9ba6e1d10d3a7fb3dddf7a47f Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:44:35 +0200 Subject: [PATCH 2/2] fix(worker-bus): close leaked OPFS connection on ATTACH failure + QNBS-v3 format CodeRabbit review on #286 found a real bug: initDuckDb()'s OPFS catch block overwrote `connection` with the fallback connection without closing the first (partially-attached) one, leaking a DuckDB connection on every OPFS-attach failure. Fixed by tracking the OPFS attempt in its own local variable and closing it in the catch path before falling back. New regression test asserts close() is called. Also collapsed several multi-line QNBS-v3 comments in workers/v2/duckdb.worker.ts, workers/v2/inference.worker.ts, and both new test files to the required single bracketed-line format. Co-Authored-By: Claude Sonnet 5 --- tests/unit/duckdbWorkerHandler.test.ts | 37 +++++++++++++-------- tests/unit/inferenceWorkerHandlerV2.test.ts | 8 ++--- workers/v2/duckdb.worker.ts | 18 +++++----- workers/v2/inference.worker.ts | 3 +- 4 files changed, 36 insertions(+), 30 deletions(-) diff --git a/tests/unit/duckdbWorkerHandler.test.ts b/tests/unit/duckdbWorkerHandler.test.ts index c1e0f10e..5ebe5fa3 100644 --- a/tests/unit/duckdbWorkerHandler.test.ts +++ b/tests/unit/duckdbWorkerHandler.test.ts @@ -1,8 +1,5 @@ // @vitest-environment jsdom -// QNBS-v3: parity gate (PR 0, worker-generation consolidation, docs/adr/0014-worker-generation- -// duplication.md) — unit tests for the v2 DuckDB worker's handler functions, exercised -// directly (not via a real worker/WorkerBus) against a mocked @duckdb/duckdb-wasm, to -// catch response-shape / OPFS-fallback-signal drift from v1 before any consumer cuts over. +// QNBS-v3: [Parity gate for the v2 DuckDB worker handlers (docs/adr/0014) — catches response-shape/OPFS-fallback drift from v1 before any consumer cuts over.] import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkerHandlerContext } from '../../packages/worker-bus/src/workerBootstrap'; @@ -14,8 +11,7 @@ class MockWorker { } vi.stubGlobal('Worker', MockWorker); -// QNBS-v3: vi.hoisted — the worker module is imported statically below, so the mock factory runs -// during the hoisted import phase; plain consts would hit a TDZ ReferenceError. +// QNBS-v3: [vi.hoisted — the mock factory runs during the hoisted import phase, so plain consts would hit a TDZ ReferenceError.] const { mockSelectBundle, mockInstantiate, @@ -28,8 +24,7 @@ const { const mockRegisterFileHandle = vi.fn(); const mockConnect = vi.fn(); const mockDbTerminate = vi.fn(); - // QNBS-v3: must be a `function`, not an arrow — arrow functions aren't constructable, and - // `initDuckDb()` calls `new AsyncDuckDB(...)`. + // QNBS-v3: [Must be a `function`, not an arrow — arrow functions aren't constructable, and initDuckDb() calls `new AsyncDuckDB(...)`.] const MockAsyncDuckDB = vi.fn().mockImplementation(function MockAsyncDuckDBCtor() { return { instantiate: mockInstantiate, @@ -55,7 +50,7 @@ vi.mock('@duckdb/duckdb-wasm', () => ({ DuckDBDataProtocol: { BROWSER_FSACCESS: 'browser-fsaccess' }, })); -// QNBS-v3: imported AFTER the mock is declared so the worker picks up the mocked module. +// QNBS-v3: [Imported after the mock is declared so the worker picks up the mocked module.] import { handleExec, handleQuery, @@ -88,17 +83,14 @@ function stubNoOpfs(): void { }); } -// QNBS-v3: always include `close` — the shared beforeEach calls the real handleShutdown() to -// reset module state, which unconditionally calls `connection?.close()`. +// QNBS-v3: [Always include `close` — the shared beforeEach calls handleShutdown() to reset state, which unconditionally calls `connection?.close()`.] function mockConnection(overrides: { query?: ReturnType } = {}) { return { query: overrides.query ?? vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; } describe('duckdb.worker handlers', () => { beforeEach(async () => { - // QNBS-v3: connection/db are module-scoped in duckdb.worker.ts and persist across tests in - // this file — reset via handleShutdown() before each test so "not initialized" - // assertions aren't polluted by a previous test's initDuckDb() call. + // QNBS-v3: [connection/db are module-scoped and persist across tests — reset first so "not initialized" assertions aren't polluted by a prior test's initDuckDb().] await handleShutdown(); vi.clearAllMocks(); mockSelectBundle.mockResolvedValue({ mainModule: 'mvp.wasm', mainWorker: 'mvp.worker.js' }); @@ -136,6 +128,23 @@ describe('duckdb.worker handlers', () => { expect(mockConnect).toHaveBeenCalledTimes(1); }); + it('closes the partially-initialized OPFS connection before falling back when ATTACH fails', async () => { + const opfsClose = vi.fn().mockResolvedValue(undefined); + const opfsQuery = vi.fn().mockRejectedValue(new Error('ATTACH failed')); + mockConnect + .mockResolvedValueOnce({ query: opfsQuery, close: opfsClose }) + .mockResolvedValueOnce(mockConnection()); + mockRegisterFileHandle.mockResolvedValue(undefined); + stubOpfsDirectory(vi.fn().mockResolvedValue({})); + const emitProgress = vi.fn(); + + await initDuckDb(emitProgress); + + expect(opfsClose).toHaveBeenCalled(); + expect(emitProgress).toHaveBeenCalledWith('opfs-fallback', 1, 'ATTACH failed'); + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + it('does not emit opfs-fallback when OPFS is simply unsupported (no navigator.storage)', async () => { mockConnect.mockResolvedValue(mockConnection()); stubNoOpfs(); diff --git a/tests/unit/inferenceWorkerHandlerV2.test.ts b/tests/unit/inferenceWorkerHandlerV2.test.ts index aadb8001..07f16091 100644 --- a/tests/unit/inferenceWorkerHandlerV2.test.ts +++ b/tests/unit/inferenceWorkerHandlerV2.test.ts @@ -1,9 +1,5 @@ // @vitest-environment jsdom -// QNBS-v3: parity gate (PR 0, worker-generation consolidation, docs/adr/0014-worker-generation- -// duplication.md) — unit tests for the v2 inference worker's handler, exercised directly -// (not via a real worker/WorkerBus) against a mocked @huggingface/transformers, to catch -// drift from v1 (workers/inference.worker.ts) before any consumer cuts over. File name is -// distinct from the existing v1 tests/unit/inferenceWorker.test.ts until v1 is deleted. +// QNBS-v3: [Parity gate for the v2 inference worker handler (docs/adr/0014) — catches drift from v1 before any consumer cuts over; named distinctly from tests/unit/inferenceWorker.test.ts until v1 is deleted.] import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkerHandlerContext } from '../../packages/worker-bus/src/workerBootstrap'; @@ -14,7 +10,7 @@ vi.mock('@huggingface/transformers', () => ({ pipeline: mockPipelineFactory, })); -// QNBS-v3: imported AFTER the mock is declared so the worker picks up the mocked factory. +// QNBS-v3: [Imported after the mock is declared so the worker picks up the mocked factory.] import { handleInference } from '../../workers/v2/inference.worker'; function makeCtx(overrides: Partial = {}): WorkerHandlerContext { diff --git a/workers/v2/duckdb.worker.ts b/workers/v2/duckdb.worker.ts index 2743ce7c..891886ea 100644 --- a/workers/v2/duckdb.worker.ts +++ b/workers/v2/duckdb.worker.ts @@ -42,8 +42,7 @@ const SELF_HOSTED_BUNDLES = { }, }; -// QNBS-v3: exported for tests/unit/duckdbWorkerHandler.test.ts (parity gate, PR 0 of the -// worker-generation consolidation) — verifies real handler logic without a live worker. +// QNBS-v3: [Exported for tests/unit/duckdbWorkerHandler.test.ts to verify real handler logic without a live worker.] export async function initDuckDb( emitProgress?: (stage: string, progress: number, message?: string) => void, ): Promise { @@ -58,6 +57,8 @@ export async function initDuckDb( const useOpfs = await isOPFSSupported(); if (useOpfs) { + // QNBS-v3: [Tracked separately from `connection` so a failed ATTACH can close this partial connection instead of leaking it.] + let opfsConnection: import('@duckdb/duckdb-wasm').AsyncDuckDBConnection | null = null; try { const { DuckDBDataProtocol } = await getDuckDb(); const opfsRoot = await navigator.storage.getDirectory(); @@ -70,13 +71,14 @@ export async function initDuckDb( DuckDBDataProtocol.BROWSER_FSACCESS, true, ); - connection = await newDb.connect(); - await connection.query("ATTACH 'worldscript_analytics.duckdb' AS analytics (TYPE duckdb)"); + opfsConnection = await newDb.connect(); + await opfsConnection.query( + "ATTACH 'worldscript_analytics.duckdb' AS analytics (TYPE duckdb)", + ); + connection = opfsConnection; } catch (opfsErr) { - // QNBS-v3: mirrors legacy workers/duckdbWorker.ts's out-of-band OPFS_FALLBACK message — - // v2 has no bare postMessage escape hatch from inside a task handler, so this - // reuses the existing progress channel instead (duckdbClient's INIT adapter - // listens for the 'opfs-fallback' stage and forwards it to setOpfsFallbackHandler). + // QNBS-v3: [No bare postMessage from inside a task handler — OPFS-unavailable is surfaced via the progress channel duckdbClient's INIT adapter listens on instead.] + await opfsConnection?.close(); emitProgress?.( 'opfs-fallback', 1, diff --git a/workers/v2/inference.worker.ts b/workers/v2/inference.worker.ts index a0eb7e3a..88633733 100644 --- a/workers/v2/inference.worker.ts +++ b/workers/v2/inference.worker.ts @@ -42,8 +42,7 @@ async function loadPipeline(task: string, modelId: string, quantized = true) { }); } -// QNBS-v3: exported for tests/unit/inferenceWorkerHandlerV2.test.ts (parity gate, PR 0 of the -// worker-generation consolidation) — verifies real handler logic without a live worker. +// QNBS-v3: [Exported for tests/unit/inferenceWorkerHandlerV2.test.ts to verify real handler logic without a live worker.] export async function handleInference(ctx: WorkerHandlerContext): Promise { const { payload, signal, emitProgress } = ctx; const req = payload as {