Initial commit: Tonemark PWA
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s

Tonemark is a SvelteKit PWA for transcribing YouTube videos, audio
and video files, and microphone recordings using a local Whisper backend.

Features:
- Dark glassmorphic UI with electric-lime accent (5 switchable themes)
- Rail nav (desktop) / tab bar (mobile) layout
- Drop zone, YouTube URL input, and live audio recording inputs
- Audio mode waveform cards (none / standard / aggressive / auto)
- Real-time transcription progress with animated waveform
- Job queue with SSE streaming updates
- Push notifications on job completion
- PWA with native SvelteKit service worker
- SRT / TXT / MD / JSON transcript downloads

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giancarmine Salucci
2026-05-06 16:41:25 +02:00
commit 13a96b6efa
68 changed files with 9712 additions and 0 deletions

260
src/tests/audio.test.ts Normal file
View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
// ── Build execFileMock with proper {stdout,stderr} promisify support ───────────
// util.promisify(execFile) normally returns {stdout,stderr} via a custom symbol.
// vi.fn() doesn't carry that symbol, so we add it here via vi.hoisted so it is
// in place before audio.ts loads and calls promisify(execFile) at module level.
const execFileMock = vi.hoisted(() => {
const fn = vi.fn();
Object.defineProperty(fn, Symbol.for('nodejs.util.promisify.custom'), {
configurable: true,
value: (...args: unknown[]) =>
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
(fn as ReturnType<typeof vi.fn>)(
...args,
(err: Error | null, stdout: string, stderr: string) => {
if (err) reject(err);
else resolve({ stdout, stderr });
}
);
})
});
return fn;
});
vi.mock('child_process', () => ({ execFile: execFileMock }));
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
return { ...actual, mkdir: vi.fn().mockResolvedValue(undefined), unlink: vi.fn() };
});
import { buildFilterChain, analyzeVolume, prepareAudio } from '$lib/server/audio.js';
// Helper: make execFileMock call its callback with given stdout/stderr
function mockExecFile(stdout: string, stderr: string, err: Error | null = null) {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], callback: (e: Error | null, out: string, err: string) => void) => {
callback(err, stdout, stderr);
}
);
}
afterEach(() => {
vi.clearAllMocks();
});
// ── buildFilterChain (pure — no mocking needed) ───────────────────────────────
describe('buildFilterChain', () => {
describe('mode = none', () => {
it('returns null regardless of volume', () => {
expect(buildFilterChain('none', -10)).toBeNull();
expect(buildFilterChain('none', -50)).toBeNull();
});
});
describe('mode = standard', () => {
it('returns standard chain regardless of volume', () => {
const chain = buildFilterChain('standard', -10);
expect(chain).toBe('highpass=f=80,lowpass=f=8000,loudnorm=I=-16:LRA=11:TP=-1.5');
});
it('does not add volume boost even for quiet audio', () => {
const chain = buildFilterChain('standard', -50);
expect(chain).not.toContain('volume=');
expect(chain).not.toContain('dynaudnorm');
});
});
describe('mode = auto', () => {
it('returns lightweight chain for normal audio (mean > -30 dB)', () => {
const chain = buildFilterChain('auto', -20);
expect(chain).toBe('highpass=f=80,lowpass=f=8000,loudnorm=I=-16:LRA=11:TP=-1.5');
});
it('returns boost chain for quiet audio (mean < -30 dB)', () => {
const chain = buildFilterChain('auto', -35);
expect(chain).toContain('volume=24dB');
expect(chain).toContain('dynaudnorm');
expect(chain).toContain('afftdn');
});
it('uses -30 dB as the quiet threshold', () => {
expect(buildFilterChain('auto', -30)).not.toContain('volume=');
expect(buildFilterChain('auto', -30.1)).toContain('volume=24dB');
});
it('always includes highpass and lowpass in both paths', () => {
expect(buildFilterChain('auto', -20)).toContain('highpass=f=80');
expect(buildFilterChain('auto', -35)).toContain('highpass=f=80');
expect(buildFilterChain('auto', -20)).toContain('lowpass=f=8000');
expect(buildFilterChain('auto', -35)).toContain('lowpass=f=8000');
});
it('always includes loudnorm targeting EBU R128 -16 LUFS', () => {
expect(buildFilterChain('auto', -20)).toContain('loudnorm=I=-16');
expect(buildFilterChain('auto', -35)).toContain('loudnorm=I=-16');
});
});
describe('mode = aggressive', () => {
it('includes noise reduction (afftdn) and gate regardless of volume', () => {
const chain = buildFilterChain('aggressive', -20)!;
expect(chain).toContain('afftdn=nf=-30');
expect(chain).toContain('agate=');
});
it('adds volume boost for quiet audio', () => {
const chain = buildFilterChain('aggressive', -35)!;
expect(chain).toContain('volume=24dB');
expect(chain).toContain('dynaudnorm');
});
it('omits volume boost for normal-level audio', () => {
const chain = buildFilterChain('aggressive', -20)!;
expect(chain).not.toContain('volume=24dB');
});
it('always includes loudnorm', () => {
expect(buildFilterChain('aggressive', -20)).toContain('loudnorm=I=-16');
});
});
});
// ── analyzeVolume ─────────────────────────────────────────────────────────────
describe('analyzeVolume', () => {
it('parses mean_volume and max_volume from ffmpeg stderr', async () => {
mockExecFile(
'',
['[Parsed_volumedetect_0] mean_volume: -20.1 dB', '[Parsed_volumedetect_0] max_volume: -1.4 dB'].join(
'\n'
)
);
const result = await analyzeVolume('/fake/audio.wav');
expect(result.meanVolume).toBe(-20.1);
expect(result.maxVolume).toBe(-1.4);
});
it('returns -99 for both when ffmpeg produces no volume lines', async () => {
mockExecFile('', 'some other ffmpeg output with no volume info');
const result = await analyzeVolume('/fake/audio.wav');
expect(result.meanVolume).toBe(-99);
expect(result.maxVolume).toBe(-99);
});
it('handles negative volume values (normal speech levels)', async () => {
mockExecFile('', 'mean_volume: -33.7 dB\nmax_volume: -0.3 dB');
const result = await analyzeVolume('/fake/audio.wav');
expect(result.meanVolume).toBe(-33.7);
expect(result.maxVolume).toBe(-0.3);
});
it('calls ffmpeg with volumedetect filter', async () => {
mockExecFile('', 'mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
await analyzeVolume('/test/path.m4a');
const [cmd, args] = execFileMock.mock.calls[0];
expect(cmd).toBe('ffmpeg');
expect(args).toContain('volumedetect');
expect(args).toContain('/test/path.m4a');
});
});
// ── prepareAudio ffmpeg argument verification ─────────────────────────────────
describe('prepareAudio — ffmpeg arguments', () => {
// Calls in order: 1) volumedetect 2) silencedetect 3) conversion
function setupExecFileMock(volumeStderr: string) {
let callIndex = 0;
execFileMock.mockImplementation((_cmd: string, _args: string[], callback: Function) => {
callIndex++;
if (callIndex === 1) callback(null, '', volumeStderr);
else if (callIndex === 2) callback(null, '', ''); // silencedetect — no leading silence
else callback(null, '', ''); // conversion
});
}
it('always outputs 16 kHz sample rate (-ar 16000)', async () => {
setupExecFileMock('mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
await prepareAudio('/input.m4a', 'job-1', 'standard');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
expect(conversionArgs).toContain('-ar');
expect(conversionArgs[conversionArgs.indexOf('-ar') + 1]).toBe('16000');
});
it('always outputs mono (-ac 1)', async () => {
setupExecFileMock('mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
await prepareAudio('/input.m4a', 'job-2', 'standard');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
expect(conversionArgs).toContain('-ac');
expect(conversionArgs[conversionArgs.indexOf('-ac') + 1]).toBe('1');
});
it('always encodes as pcm_s16le WAV', async () => {
setupExecFileMock('mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
await prepareAudio('/input.m4a', 'job-3', 'standard');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
expect(conversionArgs).toContain('-c:a');
expect(conversionArgs[conversionArgs.indexOf('-c:a') + 1]).toBe('pcm_s16le');
});
it('applies -af filter chain for non-none modes', async () => {
setupExecFileMock('mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
await prepareAudio('/input.m4a', 'job-4', 'standard');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
expect(conversionArgs).toContain('-af');
});
it('omits -af entirely for mode=none', async () => {
setupExecFileMock('mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
await prepareAudio('/input.m4a', 'job-5', 'none');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
expect(conversionArgs).not.toContain('-af');
});
it('prepends -ss when leading silence is detected at the start', async () => {
let callIndex = 0;
execFileMock.mockImplementation((_cmd: string, _args: string[], callback: Function) => {
callIndex++;
if (callIndex === 1) callback(null, '', 'mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
else if (callIndex === 2)
callback(null, '', 'silence_start: 0\nsilence_end: 2.000 | silence_duration: 2.000');
else callback(null, '', '');
});
await prepareAudio('/input.m4a', 'job-6', 'standard');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
expect(conversionArgs).toContain('-ss');
expect(conversionArgs[conversionArgs.indexOf('-ss') + 1]).toBe('2.000');
});
it('omits -ss when silence starts well into the file (not leading)', async () => {
let callIndex = 0;
execFileMock.mockImplementation((_cmd: string, _args: string[], callback: Function) => {
callIndex++;
if (callIndex === 1) callback(null, '', 'mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
else if (callIndex === 2)
callback(null, '', 'silence_start: 281\nsilence_end: 283 | silence_duration: 2.0');
else callback(null, '', '');
});
await prepareAudio('/input.m4a', 'job-7', 'standard');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
expect(conversionArgs).not.toContain('-ss');
});
it('caps leading silence trim at 30 seconds', async () => {
let callIndex = 0;
execFileMock.mockImplementation((_cmd: string, _args: string[], callback: Function) => {
callIndex++;
if (callIndex === 1) callback(null, '', 'mean_volume: -20.0 dB\nmax_volume: -1.0 dB');
else if (callIndex === 2)
callback(null, '', 'silence_start: 0\nsilence_end: 45.000 | silence_duration: 45.000');
else callback(null, '', '');
});
await prepareAudio('/input.m4a', 'job-8', 'standard');
const conversionArgs: string[] = execFileMock.mock.calls.at(-1)![1];
const ssIdx = conversionArgs.indexOf('-ss');
expect(ssIdx).toBeGreaterThan(-1);
expect(parseFloat(conversionArgs[ssIdx + 1])).toBeLessThanOrEqual(30);
});
});

195
src/tests/db.test.ts Normal file
View File

@@ -0,0 +1,195 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { vi } from 'vitest';
import { mkdtemp, rm } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
// Set DATA_DIR before db module is imported (module-level DB init)
const TEST_DATA_DIR = join(tmpdir(), 'whisper-pwa-db-test-' + Date.now());
vi.stubEnv('DATA_DIR', TEST_DATA_DIR);
import {
createJob,
getJob,
listJobs,
updateJob,
setJobStatus,
savePushSubscription,
getAllSubscriptions,
deletePushSubscription
} from '$lib/server/db.js';
afterAll(async () => {
await rm(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── createJob / getJob ────────────────────────────────────────────────────────
describe('createJob', () => {
it('creates a job and returns it with a UUID', () => {
const job = createJob('https://youtu.be/abc', 'My Video', 'auto');
expect(job.id).toMatch(/^[0-9a-f-]{36}$/);
expect(job.source).toBe('https://youtu.be/abc');
expect(job.title).toBe('My Video');
expect(job.audioMode).toBe('auto');
});
it('defaults status to pending', () => {
const job = createJob('src', 'title', 'standard');
expect(job.status).toBe('pending');
});
it('defaults progress to 0', () => {
const job = createJob('src', 'title', 'none');
expect(job.progress).toBe(0);
});
it('assigns unique IDs to each job', () => {
const a = createJob('src', 'A', 'auto');
const b = createJob('src', 'B', 'auto');
expect(a.id).not.toBe(b.id);
});
});
describe('getJob', () => {
it('returns null for an unknown id', () => {
expect(getJob('00000000-0000-0000-0000-000000000000')).toBeNull();
});
it('returns the correct job by id', () => {
const created = createJob('https://youtu.be/xyz', 'Find Me', 'aggressive');
const found = getJob(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.title).toBe('Find Me');
});
});
// ── updateJob ─────────────────────────────────────────────────────────────────
describe('updateJob', () => {
it('updates only specified fields, preserving others', () => {
const job = createJob('src', 'Original Title', 'auto');
updateJob({ id: job.id, title: 'New Title' });
const updated = getJob(job.id)!;
expect(updated.title).toBe('New Title');
expect(updated.source).toBe('src'); // unchanged
expect(updated.audioMode).toBe('auto'); // unchanged
});
it('stores whisperJobId', () => {
const job = createJob('src', 'title', 'auto');
updateJob({ id: job.id, whisperJobId: 'whisper-uuid-123' });
expect(getJob(job.id)!.whisperJobId).toBe('whisper-uuid-123');
});
it('stores meanVolume', () => {
const job = createJob('src', 'title', 'auto');
updateJob({ id: job.id, meanVolume: -20.5 });
expect(getJob(job.id)!.meanVolume).toBe(-20.5);
});
it('stores segmentsJson', () => {
const job = createJob('src', 'title', 'auto');
const json = JSON.stringify([{ index: 0, start: 0, end: 5, text: 'Hello', words: [] }]);
updateJob({ id: job.id, segmentsJson: json });
expect(getJob(job.id)!.segmentsJson).toBe(json);
});
it('stores error message', () => {
const job = createJob('src', 'title', 'auto');
updateJob({ id: job.id, status: 'failed', error: 'something went wrong' });
const updated = getJob(job.id)!;
expect(updated.status).toBe('failed');
expect(updated.error).toBe('something went wrong');
});
it('does nothing for an unknown id', () => {
expect(() => updateJob({ id: 'no-such-id', title: 'Ghost' })).not.toThrow();
});
});
// ── setJobStatus ──────────────────────────────────────────────────────────────
describe('setJobStatus', () => {
it('updates status without touching other fields', () => {
const job = createJob('src', 'Status Test', 'auto');
setJobStatus(job.id, 'downloading', 5);
const updated = getJob(job.id)!;
expect(updated.status).toBe('downloading');
expect(updated.progress).toBe(5);
expect(updated.title).toBe('Status Test'); // unchanged
});
it('transitions through all valid statuses', () => {
const job = createJob('src', 'title', 'auto');
const statuses = ['downloading', 'preparing', 'transcribing', 'processing', 'done'] as const;
for (const status of statuses) {
setJobStatus(job.id, status, 50);
expect(getJob(job.id)!.status).toBe(status);
}
});
it('defaults progress to 0 if not supplied', () => {
const job = createJob('src', 'title', 'auto');
setJobStatus(job.id, 'preparing');
expect(getJob(job.id)!.progress).toBe(0);
});
});
// ── listJobs ──────────────────────────────────────────────────────────────────
describe('listJobs', () => {
it('returns jobs in descending creation order', () => {
const a = createJob('src', 'Alpha', 'auto');
const b = createJob('src', 'Beta', 'auto');
const jobs = listJobs();
const ids = jobs.map((j) => j.id);
expect(ids.indexOf(b.id)).toBeLessThan(ids.indexOf(a.id));
});
it('returns an array (possibly empty)', () => {
expect(Array.isArray(listJobs())).toBe(true);
});
});
// ── Push subscriptions ────────────────────────────────────────────────────────
describe('push subscriptions', () => {
const sub1 = { endpoint: 'https://push.example.com/abc', p256dh: 'p256dh-value-1', auth: 'auth-1' };
const sub2 = { endpoint: 'https://push.example.com/xyz', p256dh: 'p256dh-value-2', auth: 'auth-2' };
it('saves and retrieves a subscription', () => {
savePushSubscription(sub1);
const subs = getAllSubscriptions();
const found = subs.find((s) => s.endpoint === sub1.endpoint);
expect(found).toBeDefined();
expect(found!.p256dh).toBe(sub1.p256dh);
expect(found!.auth).toBe(sub1.auth);
});
it('upserts on duplicate endpoint (updates keys)', () => {
savePushSubscription(sub1);
const updated = { ...sub1, p256dh: 'new-p256dh', auth: 'new-auth' };
savePushSubscription(updated);
const subs = getAllSubscriptions().filter((s) => s.endpoint === sub1.endpoint);
expect(subs).toHaveLength(1);
expect(subs[0].p256dh).toBe('new-p256dh');
});
it('deletes by endpoint', () => {
savePushSubscription(sub2);
deletePushSubscription(sub2.endpoint);
const found = getAllSubscriptions().find((s) => s.endpoint === sub2.endpoint);
expect(found).toBeUndefined();
});
it('stores multiple subscriptions independently', () => {
savePushSubscription(sub1);
savePushSubscription(sub2);
const subs = getAllSubscriptions();
const endpoints = subs.map((s) => s.endpoint);
expect(endpoints).toContain(sub1.endpoint);
expect(endpoints).toContain(sub2.endpoint);
});
});

195
src/tests/formatter.test.ts Normal file
View File

@@ -0,0 +1,195 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm } from 'fs/promises';
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { buildSrt, buildTxt, buildMd, buildJson, writeOutputs } from '$lib/server/formatter.js';
import type { Segment } from '$lib/types.js';
// ── helpers ──────────────────────────────────────────────────────────────────
function seg(index: number, start: number, end: number, text: string): Segment {
return { index, start, end, text, words: [] };
}
const SAMPLE_SEGS: Segment[] = [
seg(0, 0, 4.5, ' Hello and welcome to the show.'),
seg(1, 4.5, 10, " Today we're going to discuss transcription."),
seg(2, 310, 315, ' This is after five minutes.'),
seg(3, 620, 625, ' And this is after ten minutes.')
];
let tmpDir: string;
beforeAll(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'whisper-fmt-test-'));
process.env.OUTPUT_DIR = tmpDir;
});
afterAll(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── secToSrtTime (via buildSrt) ───────────────────────────────────────────────
describe('buildSrt — timestamp formatting', () => {
it('formats zero seconds as 00:00:00,000', () => {
const srt = buildSrt([seg(0, 0, 1, ' test')]);
expect(srt).toContain('00:00:00,000 --> 00:00:01,000');
});
it('formats fractional seconds with milliseconds', () => {
const srt = buildSrt([seg(0, 4.5, 10.123, ' test')]);
expect(srt).toContain('00:00:04,500 --> 00:00:10,123');
});
it('formats hours correctly', () => {
const srt = buildSrt([seg(0, 3661.5, 3662, ' test')]);
expect(srt).toContain('01:01:01,500 --> 01:01:02,000');
});
it('returns empty string for empty segment list', () => {
expect(buildSrt([])).toBe('');
});
});
describe('buildSrt — structure', () => {
it('numbers entries starting from 1', () => {
const srt = buildSrt(SAMPLE_SEGS);
const lines = srt.split('\n\n');
expect(lines[0]).toMatch(/^1\n/);
expect(lines[1]).toMatch(/^2\n/);
});
it('includes segment text trimmed', () => {
const srt = buildSrt([seg(0, 0, 1, ' hello ')]);
expect(srt).toContain('\nhello');
});
it('separates entries with blank lines', () => {
const srt = buildSrt(SAMPLE_SEGS);
expect(srt).toContain('\n\n');
});
});
// ── buildTxt ─────────────────────────────────────────────────────────────────
describe('buildTxt', () => {
it('returns empty string for empty input', () => {
expect(buildTxt([])).toBe('');
});
it('contains no timestamp-like strings (hh:mm:ss)', () => {
const txt = buildTxt(SAMPLE_SEGS);
expect(txt).not.toMatch(/\d{2}:\d{2}:\d{2}/);
});
it('includes all segment texts', () => {
const txt = buildTxt(SAMPLE_SEGS);
expect(txt).toContain('Hello and welcome');
expect(txt).toContain('transcription');
});
it('creates paragraph breaks at sentence endings when para is long enough', () => {
// Build segments that together form >200 chars ending with '.'
const longSegs: Segment[] = [];
let text = '';
for (let i = 0; i < 15; i++) {
const t = ' This is sentence number ' + i + ' for our test.';
text += t;
longSegs.push(seg(i, i * 2, i * 2 + 2, t));
}
const txt = buildTxt(longSegs);
// Should have at least one paragraph break
expect(txt).toContain('\n\n');
});
it('skips empty segments', () => {
const segs = [seg(0, 0, 1, ' '), seg(1, 1, 2, ' Hello.')];
const txt = buildTxt(segs);
expect(txt).toBe('Hello.');
});
});
// ── buildMd ───────────────────────────────────────────────────────────────────
describe('buildMd', () => {
it('starts with an H1 title', () => {
const md = buildMd(SAMPLE_SEGS, 'My Title');
expect(md).toMatch(/^# My Title/);
});
it('adds H2 timestamp headings roughly every 5 minutes', () => {
const md = buildMd(SAMPLE_SEGS, 'Test');
// First heading at 0s (00:00:00), second at 310s (00:05:10), third at 620s (00:10:20)
const h2s = [...md.matchAll(/^## /gm)];
expect(h2s.length).toBeGreaterThanOrEqual(3);
});
it('does not produce a heading for every segment', () => {
// 4 segments but only 3 should get headings (every 5min)
const md = buildMd(SAMPLE_SEGS, 'Test');
const h2s = [...md.matchAll(/^## /gm)];
expect(h2s.length).toBeLessThan(SAMPLE_SEGS.length);
});
it('includes all segment texts', () => {
const md = buildMd(SAMPLE_SEGS, 'Test');
expect(md).toContain('Hello and welcome');
expect(md).toContain('after ten minutes');
});
});
// ── buildJson ─────────────────────────────────────────────────────────────────
describe('buildJson', () => {
it('produces valid JSON', () => {
const j = buildJson(SAMPLE_SEGS, 'My Transcript');
expect(() => JSON.parse(j)).not.toThrow();
});
it('includes title and segments array', () => {
const parsed = JSON.parse(buildJson(SAMPLE_SEGS, 'My Transcript'));
expect(parsed.title).toBe('My Transcript');
expect(Array.isArray(parsed.segments)).toBe(true);
expect(parsed.segments).toHaveLength(SAMPLE_SEGS.length);
});
});
// ── writeOutputs ──────────────────────────────────────────────────────────────
describe('writeOutputs', () => {
it('creates the output directory', async () => {
await writeOutputs(SAMPLE_SEGS, 'Create Dir Test', 'job-001');
const entries = await readdir(tmpDir);
expect(entries).toContain('Create_Dir_Test');
});
it('writes .srt, .txt, .md, and .json files', async () => {
const paths = await writeOutputs(SAMPLE_SEGS, 'All Files Test', 'job-002');
const srt = await readFile(paths.srt, 'utf8');
const txt = await readFile(paths.txt, 'utf8');
const md = await readFile(paths.md, 'utf8');
const json = await readFile(paths.json, 'utf8');
expect(srt).toContain('-->');
expect(txt).not.toContain('-->');
expect(md).toContain('# All Files Test');
expect(JSON.parse(json).title).toBe('All Files Test');
});
it('sanitises the title for use as a directory/filename', async () => {
const paths = await writeOutputs(SAMPLE_SEGS, 'Test: Special/Chars!', 'job-003');
// Check only the filename, not the directory path which always contains '/'
const filename = paths.srt.split('/').pop()!;
expect(filename).not.toMatch(/[:/!]/);
});
it('writes empty outputs when given no segments', async () => {
const paths = await writeOutputs([], 'Empty Segments', 'job-004');
const srt = await readFile(paths.srt, 'utf8');
const txt = await readFile(paths.txt, 'utf8');
expect(srt).toBe('');
expect(txt).toBe('');
});
});

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import {
deduplicateSegments
} from '$lib/server/postprocess.js';
import type { Segment } from '$lib/types.js';
// ── helpers ──────────────────────────────────────────────────────────────────
function seg(index: number, start: number, end: number, text: string): Segment {
return { index, start, end, text, words: [] };
}
// ── collapseRepeats (tested indirectly via deduplicateSegments) ───────────────
describe('deduplicateSegments — collapseRepeats', () => {
it('leaves text without repetition unchanged', () => {
const input = [seg(0, 0, 5, ' Hello world, this is a sentence.')];
const [out] = deduplicateSegments(input);
expect(out.text).toBe('Hello world, this is a sentence.');
});
it('collapses a consecutive repeated phrase inside a segment', () => {
const input = [seg(0, 0, 5, ' the quick brown fox the quick brown fox')];
const [out] = deduplicateSegments(input);
expect(out.text).not.toMatch(/the quick brown fox.*the quick brown fox/i);
});
it('handles multiple repetitions recursively', () => {
// "welcome everyone" = 16 chars — qualifies for the ≥10-char collapse regex
const input = [seg(0, 0, 5, ' welcome everyone welcome everyone welcome everyone')];
const result = deduplicateSegments(input);
const text = result[0]?.text ?? '';
expect((text.match(/welcome everyone/gi) ?? []).length).toBeLessThan(3);
});
});
// ── mergeConsecutive ──────────────────────────────────────────────────────────
describe('deduplicateSegments — mergeConsecutive', () => {
it('merges adjacent segments with identical text', () => {
const input = [
seg(0, 0, 2, ' Hello world.'),
seg(1, 2, 4, ' Hello world.')
];
const result = deduplicateSegments(input);
expect(result).toHaveLength(1);
expect(result[0].end).toBe(4);
});
it('keeps adjacent segments with different text', () => {
const input = [
seg(0, 0, 2, ' First sentence.'),
seg(1, 2, 4, ' Second sentence.')
];
const result = deduplicateSegments(input);
expect(result).toHaveLength(2);
});
it('normalises punctuation and case for merge comparison', () => {
const input = [
seg(0, 0, 2, ' Hello, World!'),
seg(1, 2, 4, ' hello world')
];
const result = deduplicateSegments(input);
expect(result).toHaveLength(1);
});
});
// ── ngramDedup ────────────────────────────────────────────────────────────────
describe('deduplicateSegments — ngramDedup', () => {
it('passes through completely unique segments', () => {
const input = [
seg(0, 0, 5, ' The cat sat on the mat quite happily today.'),
seg(1, 5, 10, ' Later the dog ran across the yard chasing a ball.')
];
expect(deduplicateSegments(input)).toHaveLength(2);
});
it('removes a segment that is highly similar to recent context', () => {
// Repeat a long sentence verbatim — should be caught as duplicate
const longText =
' This is a very specific and unique sentence about transcription quality matters greatly.';
const input = [seg(0, 0, 5, longText), seg(1, 5, 10, longText)];
// After mergeConsecutive the second one is already merged, so result is 1
expect(deduplicateSegments(input)).toHaveLength(1);
});
});
// ── deduplicateSegments — full pipeline ──────────────────────────────────────
describe('deduplicateSegments — full pipeline', () => {
it('returns empty array for empty input', () => {
expect(deduplicateSegments([])).toEqual([]);
});
it('removes segments whose text is empty after trimming', () => {
const input = [seg(0, 0, 1, ' '), seg(1, 1, 2, ' Hello.')];
const result = deduplicateSegments(input);
expect(result).toHaveLength(1);
expect(result[0].text).toBe('Hello.');
});
it('re-indexes output segments starting from 0', () => {
const input = [
seg(5, 0, 2, ' First unique sentence here.'),
seg(8, 2, 4, ' Second different sentence there.')
];
const result = deduplicateSegments(input);
result.forEach((s, i) => expect(s.index).toBe(i));
});
it('runs the full pipeline: trim → remove empty → merge → ngram → merge → reindex', () => {
const input = [
seg(0, 0, 2, ' Good morning everyone.'),
seg(1, 2, 3, ' '), // empty — removed
seg(2, 3, 5, ' Good morning everyone.'), // duplicate — merged
seg(3, 5, 7, ' Welcome to our presentation today.')
];
const result = deduplicateSegments(input);
expect(result).toHaveLength(2);
expect(result[0].text).toBe('Good morning everyone.');
expect(result[1].text).toBe('Welcome to our presentation today.');
expect(result[0].index).toBe(0);
expect(result[1].index).toBe(1);
});
});

139
src/tests/push.test.ts Normal file
View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
// ── Hoist mock functions so they're available inside vi.mock() factories ───────
const { mockSetVapidDetails, mockWebPushSend } = vi.hoisted(() => ({
mockSetVapidDetails: vi.fn(),
mockWebPushSend: vi.fn()
}));
// ── Set up DATA_DIR before db module is imported ──────────────────────────────
const TEST_DATA_DIR = `/tmp/whisper-push-test-${Date.now()}`;
vi.stubEnv('DATA_DIR', TEST_DATA_DIR);
vi.stubEnv('VAPID_PUBLIC_KEY', 'BFakePublicKeyForTesting1234567890ABCDEFGHIJKLMNOP=');
vi.stubEnv('VAPID_PRIVATE_KEY', 'FakePrivateKey1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ=');
// ── Mock web-push ─────────────────────────────────────────────────────────────
vi.mock('web-push', () => ({
default: {
setVapidDetails: mockSetVapidDetails,
sendNotification: mockWebPushSend
}
}));
import { sendNotification, getVapidPublicKey } from '$lib/server/push.js';
import { savePushSubscription, deletePushSubscription, getAllSubscriptions } from '$lib/server/db.js';
import { rm } from 'fs/promises';
afterEach(async () => {
mockSetVapidDetails.mockReset();
mockWebPushSend.mockReset();
// Remove all test subscriptions between tests
const subs = getAllSubscriptions();
for (const s of subs) deletePushSubscription(s.endpoint);
await rm(TEST_DATA_DIR, { recursive: true, force: true }).catch(() => {});
});
// ── getVapidPublicKey ─────────────────────────────────────────────────────────
describe('getVapidPublicKey', () => {
it('returns the VAPID_PUBLIC_KEY env var', () => {
expect(getVapidPublicKey()).toBe('BFakePublicKeyForTesting1234567890ABCDEFGHIJKLMNOP=');
});
it('returns null when VAPID_PUBLIC_KEY is not set', () => {
vi.stubEnv('VAPID_PUBLIC_KEY', '');
expect(getVapidPublicKey()).toBeNull();
vi.stubEnv('VAPID_PUBLIC_KEY', 'BFakePublicKeyForTesting1234567890ABCDEFGHIJKLMNOP=');
});
});
// ── sendNotification ──────────────────────────────────────────────────────────
describe('sendNotification', () => {
it('does nothing when there are no subscriptions', async () => {
await sendNotification('job-1', '✅ Transcript ready', 'My Video');
expect(mockWebPushSend).not.toHaveBeenCalled();
});
it('sends a push to each stored subscription', async () => {
savePushSubscription({ endpoint: 'https://fcm.example.com/push/a', p256dh: 'pk1', auth: 'auth1' });
savePushSubscription({ endpoint: 'https://fcm.example.com/push/b', p256dh: 'pk2', auth: 'auth2' });
mockWebPushSend.mockResolvedValue({});
await sendNotification('job-2', '✅ Done', 'My Video Title');
expect(mockWebPushSend).toHaveBeenCalledTimes(2);
});
it('sends payload containing jobId, title, and body', async () => {
savePushSubscription({ endpoint: 'https://fcm.example.com/push/c', p256dh: 'pk3', auth: 'auth3' });
mockWebPushSend.mockResolvedValue({});
await sendNotification('job-3', '✅ Transcript ready', 'The Video Title');
const [, payload] = mockWebPushSend.mock.calls[0];
const parsed = JSON.parse(payload);
expect(parsed.jobId).toBe('job-3');
expect(parsed.title).toBe('✅ Transcript ready');
expect(parsed.body).toBe('The Video Title');
});
it('sends to the correct push endpoint with keys', async () => {
const sub = { endpoint: 'https://fcm.example.com/push/d', p256dh: 'pk4', auth: 'auth4' };
savePushSubscription(sub);
mockWebPushSend.mockResolvedValue({});
await sendNotification('job-4', 'title', 'body');
const [pushSub] = mockWebPushSend.mock.calls[0];
expect(pushSub.endpoint).toBe(sub.endpoint);
expect(pushSub.keys.p256dh).toBe(sub.p256dh);
expect(pushSub.keys.auth).toBe(sub.auth);
});
it('removes a subscription that returns HTTP 410 Gone', async () => {
const endpoint = 'https://fcm.example.com/push/gone';
savePushSubscription({ endpoint, p256dh: 'pk', auth: 'auth' });
mockWebPushSend.mockRejectedValue({ statusCode: 410 });
await sendNotification('job-5', 'title', 'body');
// Subscription should be removed after 410
const remaining = getAllSubscriptions().find((s) => s.endpoint === endpoint);
expect(remaining).toBeUndefined();
});
it('removes a subscription that returns HTTP 404 Not Found', async () => {
const endpoint = 'https://fcm.example.com/push/notfound';
savePushSubscription({ endpoint, p256dh: 'pk', auth: 'auth' });
mockWebPushSend.mockRejectedValue({ statusCode: 404 });
await sendNotification('job-6', 'title', 'body');
const remaining = getAllSubscriptions().find((s) => s.endpoint === endpoint);
expect(remaining).toBeUndefined();
});
it('keeps subscriptions that fail with other errors', async () => {
const endpoint = 'https://fcm.example.com/push/transient';
savePushSubscription({ endpoint, p256dh: 'pk', auth: 'auth' });
mockWebPushSend.mockRejectedValue({ statusCode: 500 });
await sendNotification('job-7', 'title', 'body');
const remaining = getAllSubscriptions().find((s) => s.endpoint === endpoint);
expect(remaining).toBeDefined();
});
it('continues sending to other subscriptions if one fails', async () => {
savePushSubscription({ endpoint: 'https://fcm.example.com/push/ok1', p256dh: 'pk1', auth: 'a1' });
savePushSubscription({ endpoint: 'https://fcm.example.com/push/fail', p256dh: 'pk2', auth: 'a2' });
savePushSubscription({ endpoint: 'https://fcm.example.com/push/ok2', p256dh: 'pk3', auth: 'a3' });
mockWebPushSend
.mockResolvedValueOnce({})
.mockRejectedValueOnce({ statusCode: 500 })
.mockResolvedValueOnce({});
await sendNotification('job-8', 'title', 'body');
expect(mockWebPushSend).toHaveBeenCalledTimes(3);
});
});

300
src/tests/webhook.test.ts Normal file
View File

@@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Segment, WhisperJob } from '$lib/types.js';
// ── Hoist all mock functions so they're available inside vi.mock() factories ──
const {
mockGetJob,
mockUpdateJob,
mockSetJobStatus,
mockDeduplicateSegments,
mockWriteOutputs,
mockSendNotification,
mockCleanupJobTmp,
mockEmitProgress
} = vi.hoisted(() => ({
mockGetJob: vi.fn(),
mockUpdateJob: vi.fn(),
mockSetJobStatus: vi.fn(),
mockDeduplicateSegments: vi.fn((segs: Segment[]) => segs),
mockWriteOutputs: vi.fn(),
mockSendNotification: vi.fn(),
mockCleanupJobTmp: vi.fn(),
mockEmitProgress: vi.fn()
}));
vi.mock('$lib/server/db.js', () => ({
getJob: mockGetJob,
updateJob: mockUpdateJob,
setJobStatus: mockSetJobStatus
}));
vi.mock('$lib/server/postprocess.js', () => ({
deduplicateSegments: mockDeduplicateSegments
}));
vi.mock('$lib/server/formatter.js', () => ({
writeOutputs: mockWriteOutputs
}));
vi.mock('$lib/server/push.js', () => ({
sendNotification: mockSendNotification
}));
vi.mock('$lib/server/downloader.js', () => ({
cleanupJobTmp: mockCleanupJobTmp
}));
vi.mock('$lib/server/pipeline.js', () => ({
emitProgress: mockEmitProgress
}));
// Import the handler AFTER mocks are in place
import { POST } from '$lib/../routes/api/webhook/[jobId]/+server.js';
// ── Test helpers ──────────────────────────────────────────────────────────────
function makeEvent(jobId: string, body: unknown) {
return {
params: { jobId },
request: new Request(`http://localhost/api/webhook/${jobId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
};
}
function makeJob(id: string, title = 'Test Video') {
return { id, status: 'transcribing', title, source: 'url', audioMode: 'auto', progress: 10 };
}
function makeWhisperJob(overrides: Partial<WhisperJob> = {}): WhisperJob {
return {
id: 'whisper-id',
status: 'done',
language: 'en',
task: 'transcribe',
duration_secs: 60,
progress: 100,
segments: [],
error: null,
created_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
...overrides
};
}
function makeSeg(index: number, text: string): Segment {
return { index, start: index * 5, end: index * 5 + 5, text, words: [] };
}
beforeEach(() => {
vi.clearAllMocks();
mockDeduplicateSegments.mockImplementation((segs: Segment[]) => segs);
mockWriteOutputs.mockResolvedValue({
srt: '/out/dir/title.srt',
txt: '/out/dir/title.txt',
md: '/out/dir/title.md',
json: '/out/dir/title.json'
});
mockSendNotification.mockResolvedValue(undefined);
mockCleanupJobTmp.mockResolvedValue(undefined);
});
// ── 404 for unknown job ───────────────────────────────────────────────────────
describe('POST /api/webhook/[jobId] — job not found', () => {
it('throws 404 when the job does not exist in the database', async () => {
mockGetJob.mockReturnValue(null);
await expect(POST(makeEvent('ghost-id', makeWhisperJob()) as any)).rejects.toMatchObject({
status: 404
});
});
});
// ── Whisper job failed / cancelled ───────────────────────────────────────────
describe('POST /api/webhook/[jobId] — whisper failure', () => {
it('marks the job as failed when whisper status is "failed"', async () => {
mockGetJob.mockReturnValue(makeJob('job-1'));
const payload = makeWhisperJob({ status: 'failed', error: 'GPU OOM', segments: [] });
const res = await POST(makeEvent('job-1', payload) as any);
expect(res.status).toBe(200);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({ id: 'job-1', status: 'failed', error: 'GPU OOM' })
);
expect(mockWriteOutputs).not.toHaveBeenCalled();
expect(mockSendNotification).not.toHaveBeenCalled();
});
it('marks the job as failed when whisper status is "cancelled"', async () => {
mockGetJob.mockReturnValue(makeJob('job-2'));
const payload = makeWhisperJob({ status: 'cancelled', segments: [] });
await POST(makeEvent('job-2', payload) as any);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({ status: 'failed' })
);
});
it('uses the whisper error message when provided', async () => {
mockGetJob.mockReturnValue(makeJob('job-err'));
const payload = makeWhisperJob({ status: 'failed', error: 'CUDA device error', segments: [] });
await POST(makeEvent('job-err', payload) as any);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({ error: 'CUDA device error' })
);
});
it('emits an error progress event', async () => {
mockGetJob.mockReturnValue(makeJob('job-ev'));
await POST(makeEvent('job-ev', makeWhisperJob({ status: 'failed', segments: [] })) as any);
expect(mockEmitProgress).toHaveBeenCalledWith('job-ev', expect.objectContaining({ type: 'error' }));
});
});
// ── Successful transcription with segments ───────────────────────────────────
describe('POST /api/webhook/[jobId] — success with segments', () => {
const segments = [makeSeg(0, 'Hello world.'), makeSeg(1, 'This is a test.')];
it('runs deduplication on received segments', async () => {
mockGetJob.mockReturnValue(makeJob('job-3'));
await POST(makeEvent('job-3', makeWhisperJob({ segments })) as any);
expect(mockDeduplicateSegments).toHaveBeenCalledWith(segments);
});
it('calls writeOutputs with the deduplicated segments and job title', async () => {
mockGetJob.mockReturnValue(makeJob('job-4', 'My Lecture'));
const deduped = [makeSeg(0, 'Hello world.')];
mockDeduplicateSegments.mockReturnValue(deduped);
await POST(makeEvent('job-4', makeWhisperJob({ segments })) as any);
expect(mockWriteOutputs).toHaveBeenCalledWith(deduped, 'My Lecture', 'job-4');
});
it('stores serialised segments_json in the database', async () => {
mockGetJob.mockReturnValue(makeJob('job-5'));
const deduped = [makeSeg(0, 'Result text.')];
mockDeduplicateSegments.mockReturnValue(deduped);
await POST(makeEvent('job-5', makeWhisperJob({ segments })) as any);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({
id: 'job-5',
status: 'done',
segmentsJson: JSON.stringify(deduped)
})
);
});
it('sets job status to done with progress 100', async () => {
mockGetJob.mockReturnValue(makeJob('job-6'));
await POST(makeEvent('job-6', makeWhisperJob({ segments })) as any);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({ status: 'done', progress: 100 })
);
});
it('sets outputDir from the paths returned by writeOutputs', async () => {
mockGetJob.mockReturnValue(makeJob('job-7'));
mockWriteOutputs.mockResolvedValue({
srt: '/home/user/transcripts/My_Title/My_Title.srt',
txt: '/home/user/transcripts/My_Title/My_Title.txt',
md: '/home/user/transcripts/My_Title/My_Title.md',
json: '/home/user/transcripts/My_Title/My_Title.json'
});
await POST(makeEvent('job-7', makeWhisperJob({ segments })) as any);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({ outputDir: '/home/user/transcripts/My_Title' })
);
});
it('sends a push notification with the job title', async () => {
mockGetJob.mockReturnValue(makeJob('job-8', 'Awesome Lecture'));
await POST(makeEvent('job-8', makeWhisperJob({ segments })) as any);
expect(mockSendNotification).toHaveBeenCalledWith(
'job-8',
'✅ Transcript ready',
'Awesome Lecture'
);
});
it('cleans up tmp files after completion', async () => {
mockGetJob.mockReturnValue(makeJob('job-9'));
await POST(makeEvent('job-9', makeWhisperJob({ segments })) as any);
expect(mockCleanupJobTmp).toHaveBeenCalledWith('job-9');
});
it('emits a done progress event', async () => {
mockGetJob.mockReturnValue(makeJob('job-10'));
await POST(makeEvent('job-10', makeWhisperJob({ segments })) as any);
expect(mockEmitProgress).toHaveBeenCalledWith('job-10', expect.objectContaining({ type: 'done' }));
});
it('returns { ok: true } with status 200', async () => {
mockGetJob.mockReturnValue(makeJob('job-11'));
const res = await POST(makeEvent('job-11', makeWhisperJob({ segments })) as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ ok: true });
});
});
// ── Empty segments (whisper produced no output) ───────────────────────────────
describe('POST /api/webhook/[jobId] — empty segments', () => {
it('completes the job as done even with zero segments', async () => {
mockGetJob.mockReturnValue(makeJob('job-empty'));
await POST(makeEvent('job-empty', makeWhisperJob({ segments: [] })) as any);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({ status: 'done' })
);
});
it('writes empty outputs for an empty segment array', async () => {
mockGetJob.mockReturnValue(makeJob('job-empty-2'));
await POST(makeEvent('job-empty-2', makeWhisperJob({ segments: [] })) as any);
expect(mockWriteOutputs).toHaveBeenCalledWith([], expect.any(String), 'job-empty-2');
});
it('still sends a push notification for empty transcription', async () => {
mockGetJob.mockReturnValue(makeJob('job-empty-3'));
await POST(makeEvent('job-empty-3', makeWhisperJob({ segments: [] })) as any);
expect(mockSendNotification).toHaveBeenCalled();
});
});
// ── Internal error handling ───────────────────────────────────────────────────
describe('POST /api/webhook/[jobId] — internal errors', () => {
it('returns 500 and marks job failed when writeOutputs throws', async () => {
mockGetJob.mockReturnValue(makeJob('job-err-2'));
mockWriteOutputs.mockRejectedValue(new Error('disk full'));
const segments = [makeSeg(0, 'Hello.')];
const res = await POST(makeEvent('job-err-2', makeWhisperJob({ segments })) as any);
expect(res.status).toBe(500);
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({ status: 'failed', error: 'disk full' })
);
});
it('emits an error progress event on internal failure', async () => {
mockGetJob.mockReturnValue(makeJob('job-err-3'));
mockWriteOutputs.mockRejectedValue(new Error('oops'));
const segments = [makeSeg(0, 'Hello.')];
await POST(makeEvent('job-err-3', makeWhisperJob({ segments })) as any);
expect(mockEmitProgress).toHaveBeenCalledWith(
'job-err-3',
expect.objectContaining({ type: 'error' })
);
});
});

219
src/tests/whisper.test.ts Normal file
View File

@@ -0,0 +1,219 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Readable } from 'stream';
// ── Hoist mocks so they're available inside vi.mock() factories ───────────────
const mocks = vi.hoisted(() => ({
fetch: vi.fn(),
append: vi.fn(),
getHeaders: vi.fn(() => ({ 'content-type': 'multipart/form-data; boundary=test' }))
}));
vi.mock('node-fetch', () => ({ default: mocks.fetch }));
// FormData must be a proper constructor (regular function, not arrow function)
vi.mock('form-data', () => ({
default: vi.fn(function (this: Record<string, unknown>) {
this.append = mocks.append;
this.getHeaders = mocks.getHeaders;
})
}));
vi.mock('fs', () => ({ createReadStream: vi.fn(() => 'STREAM_PLACEHOLDER') }));
import { submitJob, streamJob } from '$lib/server/whisper.js';
afterEach(() => vi.clearAllMocks());
// ── submitJob ─────────────────────────────────────────────────────────────────
describe('submitJob', () => {
it('POSTs to /jobs and returns job_id', async () => {
mocks.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ job_id: 'whisper-job-abc' })
});
const id = await submitJob('/tmp/audio.wav', 'http://host/api/webhook/job-1');
expect(id).toBe('whisper-job-abc');
});
it('sends a POST request to the configured WHISPER_URL/jobs', async () => {
vi.stubEnv('WHISPER_URL', 'http://localhost:8091');
mocks.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ job_id: 'x' })
});
await submitJob('/tmp/audio.wav', 'http://host/api/webhook/job-1');
expect(mocks.fetch).toHaveBeenCalledWith(
'http://localhost:8091/jobs',
expect.objectContaining({ method: 'POST' })
);
vi.unstubAllEnvs();
});
it('includes task=transcribe in the form', async () => {
mocks.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ job_id: 'x' })
});
await submitJob('/tmp/audio.wav', 'http://host/webhook');
expect(mocks.append).toHaveBeenCalledWith('task', 'transcribe');
});
it('includes webhook_url in the form', async () => {
mocks.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ job_id: 'x' })
});
await submitJob('/tmp/audio.wav', 'http://192.168.1.10:3000/api/webhook/job-99');
expect(mocks.append).toHaveBeenCalledWith(
'webhook_url',
'http://192.168.1.10:3000/api/webhook/job-99'
);
});
it('includes language when provided', async () => {
mocks.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ job_id: 'x' })
});
await submitJob('/tmp/audio.wav', 'http://host/webhook', 'en');
expect(mocks.append).toHaveBeenCalledWith('language', 'en');
});
it('omits language field when not provided', async () => {
mocks.fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ job_id: 'x' })
});
await submitJob('/tmp/audio.wav', 'http://host/webhook');
const languageCalls = mocks.append.mock.calls.filter(([name]: string[]) => name === 'language');
expect(languageCalls).toHaveLength(0);
});
it('throws when the server returns a non-2xx status', async () => {
mocks.fetch.mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error')
});
await expect(submitJob('/tmp/audio.wav', 'http://host/webhook')).rejects.toThrow('500');
});
});
// ── streamJob SSE parsing ─────────────────────────────────────────────────────
function makeSSEResponse(lines: string[]) {
const body = Readable.from(lines.map((l) => l + '\n'));
return { ok: true, body };
}
describe('streamJob — SSE event parsing', () => {
it('calls onProgress for progress events with percent, chunk, total', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
mocks.fetch.mockResolvedValue(
makeSSEResponse([
'data: {"type":"progress","percent":42,"chunk":1,"total":3}',
'',
'data: {"type":"done","job":{}}',
''
])
);
await streamJob('whisper-id', onProgress, onDone, onError);
expect(onProgress).toHaveBeenCalledWith(42, 1, 3);
});
it('calls onDone when a done event is received and stops reading', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
mocks.fetch.mockResolvedValue(
makeSSEResponse([
'data: {"type":"done","job":{}}',
'',
// Lines after done should not trigger more callbacks
'data: {"type":"progress","percent":99,"chunk":3,"total":3}',
''
])
);
await streamJob('whisper-id', onProgress, onDone, onError);
expect(onDone).toHaveBeenCalledOnce();
expect(onProgress).not.toHaveBeenCalled();
});
it('calls onError for error events', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
mocks.fetch.mockResolvedValue(
makeSSEResponse([
'data: {"type":"error","message":"model crashed"}',
''
])
);
await streamJob('whisper-id', onProgress, onDone, onError);
expect(onError).toHaveBeenCalledWith('model crashed');
expect(onDone).not.toHaveBeenCalled();
});
it('ignores malformed JSON data lines without throwing', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
mocks.fetch.mockResolvedValue(
makeSSEResponse([
'data: not-valid-json',
'',
'data: {"type":"done","job":{}}',
''
])
);
await expect(streamJob('whisper-id', onProgress, onDone, onError)).resolves.not.toThrow();
expect(onDone).toHaveBeenCalled();
});
it('handles multiple progress events in sequence', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
mocks.fetch.mockResolvedValue(
makeSSEResponse([
'data: {"type":"progress","percent":25,"chunk":1,"total":4}',
'',
'data: {"type":"progress","percent":50,"chunk":2,"total":4}',
'',
'data: {"type":"progress","percent":75,"chunk":3,"total":4}',
'',
'data: {"type":"done","job":{}}',
''
])
);
await streamJob('whisper-id', onProgress, onDone, onError);
expect(onProgress).toHaveBeenCalledTimes(3);
expect(onProgress).toHaveBeenNthCalledWith(1, 25, 1, 4);
expect(onProgress).toHaveBeenNthCalledWith(3, 75, 3, 4);
});
it('defaults chunk and total to 0 when missing from progress event', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();
mocks.fetch.mockResolvedValue(
makeSSEResponse([
'data: {"type":"progress","percent":60}',
'',
'data: {"type":"done","job":{}}',
''
])
);
await streamJob('whisper-id', onProgress, onDone, vi.fn());
expect(onProgress).toHaveBeenCalledWith(60, 0, 0);
});
});