Files
VoiceStudio/frontend/src/utils/watchFolder.test.js
T
Matt Van Horn 1515b46adb feat(batch): watch-folder auto-ingest (#1768)
Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with the last Add-to-queue settings, with pause/stop controls and copy-in-progress protection. Files stream to the loopback backend as bytes; paths never leave the app. Also gives the batch queue a reachable UI entry point and streams multipart uploads to disk. Maintainer fix: the watched directory handle is opened with full share mode on Windows so users can rename or delete the folder while it is watched, matching macOS/Linux behaviour, with a cross-platform regression test. Thanks @mvanhorn!
2026-09-03 18:47:32 +05:30

237 lines
9.1 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const invoke = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) }));
import {
createIngestTracker,
entryKey,
isVideoFile,
openWatchSource,
videoMimeFor,
} from './watchFolder';
const entry = (name, size = 100, mtime = 1000) => ({ name, size, mtime });
describe('isVideoFile', () => {
it('accepts common video containers, case-insensitively', () => {
for (const name of ['a.mp4', 'B.MOV', 'c.mkv', 'd.WebM', 'e.avi', 'f.m4v']) {
expect(isVideoFile(name), name).toBe(true);
}
});
it('rejects non-video and extension-less names', () => {
for (const name of ['notes.txt', 'audio.wav', 'subs.srt', 'noext', '.mp4', '']) {
expect(isVideoFile(name), name).toBe(false);
}
});
});
describe('entryKey — dedup identity is name+size+mtime', () => {
it('is stable for identical entries and distinct when any part changes', () => {
expect(entryKey(entry('a.mp4', 10, 1))).toBe(entryKey(entry('a.mp4', 10, 1)));
const base = entryKey(entry('a.mp4', 10, 1));
expect(entryKey(entry('b.mp4', 10, 1))).not.toBe(base);
expect(entryKey(entry('a.mp4', 11, 1))).not.toBe(base);
expect(entryKey(entry('a.mp4', 10, 2))).not.toBe(base);
});
});
describe('createIngestTracker', () => {
it('never re-ingests primed (pre-existing) files', () => {
const tracker = createIngestTracker();
tracker.prime([entry('old.mp4')]);
expect(tracker.next([entry('old.mp4')])).toEqual([]);
expect(tracker.next([entry('old.mp4')])).toEqual([]);
});
it('releases a new file only after two scans agree (copy-in-progress guard)', () => {
const tracker = createIngestTracker();
tracker.prime([]);
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([]); // first sighting
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([entry('new.mp4', 10, 1)]);
// …and exactly once: later identical scans are deduped.
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([]);
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([]);
});
it('keeps waiting while a file is still growing', () => {
const tracker = createIngestTracker();
expect(tracker.next([entry('big.mp4', 10, 1)])).toEqual([]);
expect(tracker.next([entry('big.mp4', 20, 2)])).toEqual([]); // changed → not stable
expect(tracker.next([entry('big.mp4', 30, 3)])).toEqual([]);
expect(tracker.next([entry('big.mp4', 30, 3)])).toEqual([entry('big.mp4', 30, 3)]);
});
it('ignores non-video files entirely', () => {
const tracker = createIngestTracker();
expect(tracker.next([entry('readme.txt')])).toEqual([]);
expect(tracker.next([entry('readme.txt')])).toEqual([]);
});
it('a rewritten file (same name, new size/mtime) is ingested again', () => {
const tracker = createIngestTracker();
tracker.prime([entry('take.mp4', 10, 1)]);
expect(tracker.next([entry('take.mp4', 99, 2)])).toEqual([]);
expect(tracker.next([entry('take.mp4', 99, 2)])).toEqual([entry('take.mp4', 99, 2)]);
});
it('forgets a pending file that vanishes before settling', () => {
const tracker = createIngestTracker();
expect(tracker.next([entry('gone.mp4', 10, 1)])).toEqual([]);
expect(tracker.next([])).toEqual([]); // vanished mid-copy
// Reappears: must settle across two fresh scans again.
expect(tracker.next([entry('gone.mp4', 10, 1)])).toEqual([]);
expect(tracker.next([entry('gone.mp4', 10, 1)])).toEqual([entry('gone.mp4', 10, 1)]);
});
it('unsee() hands a released entry back so it re-releases on the next scan', () => {
const tracker = createIngestTracker();
tracker.next([entry('raced.mp4', 10, 1)]);
expect(tracker.next([entry('raced.mp4', 10, 1)])).toEqual([entry('raced.mp4', 10, 1)]);
// Released but never enqueued (e.g. paused mid-poll) → given back…
tracker.unsee(entry('raced.mp4', 10, 1));
// …and released again on the very next scan, exactly once.
expect(tracker.next([entry('raced.mp4', 10, 1)])).toEqual([entry('raced.mp4', 10, 1)]);
expect(tracker.next([entry('raced.mp4', 10, 1)])).toEqual([]);
});
it('retries a stable file after its asynchronous ingest fails (full re-settle)', () => {
const tracker = createIngestTracker();
const clip = entry('retry.mp4', 10, 1);
expect(tracker.next([clip])).toEqual([]);
expect(tracker.next([clip])).toEqual([clip]);
tracker.retry(clip);
expect(tracker.next([clip])).toEqual([]);
expect(tracker.next([clip])).toEqual([clip]);
});
});
describe('openWatchSource — Tauri backend', () => {
beforeEach(() => {
vi.clearAllMocks();
window.__TAURI_INTERNALS__ = {};
});
afterEach(() => {
delete window.__TAURI_INTERNALS__;
});
it('returns null when the native picker is cancelled', async () => {
invoke.mockResolvedValueOnce(null);
expect(await openWatchSource()).toBeNull();
expect(invoke).toHaveBeenCalledWith('watch_folder_pick');
});
it('returns a capability descriptor without sending file bytes through IPC', async () => {
const token = 'f'.repeat(64);
invoke.mockImplementation(async (cmd) => {
if (cmd === 'watch_folder_pick') return { token, path: '/Users/me/Watched Drop' };
if (cmd === 'watch_folder_scan') return [{ name: 'clip.mp4', size: 4, mtime: 1234 }];
return undefined;
});
const source = await openWatchSource();
expect(source.label).toBe('Watched Drop');
const entries = await source.listEntries();
expect(entries).toEqual([{ name: 'clip.mp4', size: 4, mtime: 1234 }]);
const file = await source.readFile(entries[0]);
expect(file.__voiceStudioNativeWatchUpload).toBe(true);
expect(file.token).toBe(token);
expect(file.name).toBe('clip.mp4');
expect(file.size).toBe(4);
expect(file.type).toBe('video/mp4');
expect(file.mtime).toBe(1234);
source.close();
expect(invoke).toHaveBeenCalledWith('watch_folder_forget', { token });
// Scan/forget IPC calls carry only the opaque token. File bytes are never
// copied into the renderer; enqueueBatchJob gives the descriptor to the
// native streaming command later.
for (const [cmd, args] of invoke.mock.calls) {
if (cmd === 'watch_folder_pick') continue;
expect(JSON.stringify(args)).not.toContain('/Users/me');
expect(JSON.stringify(args)).not.toContain('Watched Drop');
}
expect(invoke.mock.calls.some(([cmd]) => cmd === 'watch_folder_read')).toBe(false);
});
it('keeps descriptor memory constant for multi-gigabyte watched files', async () => {
const token = 'a'.repeat(64);
invoke.mockImplementation(async (cmd) => {
if (cmd === 'watch_folder_pick') return { token, path: '/w/Drop' };
return undefined;
});
const source = await openWatchSource();
const size = 8 * 1024 * 1024 * 1024;
const file = await source.readFile({ name: 'big.mp4', size, mtime: 7 });
expect(file).toMatchObject({
__voiceStudioNativeWatchUpload: true,
token,
name: 'big.mp4',
size,
mtime: 7,
});
expect(invoke.mock.calls.some(([cmd]) => cmd === 'watch_folder_read')).toBe(false);
});
});
describe('openWatchSource — File System Access backend', () => {
beforeEach(() => {
vi.clearAllMocks();
delete window.__TAURI_INTERNALS__;
});
afterEach(() => {
delete window.showDirectoryPicker;
});
function fakeDirectoryHandle(files) {
return {
name: 'Drop',
kind: 'directory',
async *values() {
for (const file of files) yield { kind: 'file', getFile: async () => file };
},
async getFileHandle(name) {
const file = files.find((f) => f.name === name);
if (!file) throw new Error('NotFound');
return { getFile: async () => file };
},
};
}
it('refuses a file whose bytes changed after the scan settled', async () => {
const settled = new File(['settled'], 'clip.mp4', { type: 'video/mp4', lastModified: 111 });
window.showDirectoryPicker = vi.fn(async () => fakeDirectoryHandle([settled]));
const source = await openWatchSource();
const [entry] = await source.listEntries();
expect(entry).toEqual({ name: 'clip.mp4', size: settled.size, mtime: 111 });
// Read against the settled snapshot works…
expect(await source.readFile(entry)).toBe(settled);
// …but a stale snapshot (the file was replaced after settling) is refused.
await expect(source.readFile({ ...entry, size: entry.size + 5 })).rejects.toThrow(/changed/);
await expect(source.readFile({ ...entry, mtime: 999 })).rejects.toThrow(/changed/);
});
});
describe('openWatchSource — unsupported web context', () => {
it('throws an identifiable watch-unsupported error where no folder access exists', async () => {
delete window.__TAURI_INTERNALS__;
delete window.showDirectoryPicker;
await expect(openWatchSource()).rejects.toMatchObject({ code: 'watch-unsupported' });
});
});
describe('videoMimeFor', () => {
it('maps known containers and falls back to octet-stream', () => {
expect(videoMimeFor('a.mp4')).toBe('video/mp4');
expect(videoMimeFor('a.mov')).toBe('video/quicktime');
expect(videoMimeFor('a.unknown')).toBe('application/octet-stream');
});
});