Initial commit: Tonemark PWA
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s
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:
260
src/tests/audio.test.ts
Normal file
260
src/tests/audio.test.ts
Normal 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
195
src/tests/db.test.ts
Normal 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
195
src/tests/formatter.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
127
src/tests/postprocess.test.ts
Normal file
127
src/tests/postprocess.test.ts
Normal 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
139
src/tests/push.test.ts
Normal 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
300
src/tests/webhook.test.ts
Normal 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
219
src/tests/whisper.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user