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

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' })
);
});
});