Export - Both stores already held the audio (server /audio-backups/:id/audio and the local IndexedDB record) but nothing exposed it, so a recording could not be taken out of the app. Each backup row now has a download, naming the file by its timestamp and using the extension actually recorded (webm, or m4a on iOS). A local record is only handed over to the account that owns it. Robustness - MediaRecorder had no onerror and nothing watched the audio track, so a recorder that failed, or a microphone claimed by another app, unplugged, or revoked, left the tab saying "recording" while capturing nothing. Both are now reported once, with the chunks captured so far kept, so stopping still returns the audio up to the failure. Deliberately not added: a wake lock. Stopping when the screen sleeps or the session ends is the intended behaviour — recording is meant to be deliberate, and nothing is left behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
115 lines
6.2 KiB
JavaScript
115 lines
6.2 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const root = path.join(__dirname, '..');
|
|
|
|
function read(relativePath) {
|
|
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
|
}
|
|
|
|
test('AI memory context is limited to saved template categories', () => {
|
|
const route = read('src/routes/memories.js');
|
|
assert.match(route, /var AI_CONTEXT_CATEGORIES = \[/);
|
|
assert.match(route, /'physical_exam'/);
|
|
assert.match(route, /'template_ed'/);
|
|
assert.match(route, /rows = rows\.filter\(function\(r\) \{ return AI_CONTEXT_CATEGORIES\.indexOf\(r\.category\) !== -1; \}\)/);
|
|
assert.doesNotMatch(route.match(/var AI_CONTEXT_CATEGORIES = \[[\s\S]*?\];/)[0], /'custom'/);
|
|
});
|
|
|
|
test('template settings do not offer new custom AI memories', () => {
|
|
const settings = read('public/components/settings.html');
|
|
const memories = read('public/js/memories.js');
|
|
assert.doesNotMatch(settings, /<option value="custom">/);
|
|
assert.match(settings, /Only template categories are sent to AI/);
|
|
assert.match(memories, /custom: 'Custom \(not used by AI\)'/);
|
|
assert.match(memories, /window\.getUserMemoryContext = function\(\)/);
|
|
});
|
|
|
|
test('note refine corrections still call AI without storing learning memories', () => {
|
|
const app = read('public/js/app.js');
|
|
const refine = read('src/routes/refine.js');
|
|
assert.match(app, /function refineDocument\(outputElementId, inputElementId\)/);
|
|
assert.match(app, /fetch\('\/api\/refine'/);
|
|
assert.match(app, /currentDocument: docText, instructions: instructions/);
|
|
assert.match(refine, /router\.post\('\/refine'/);
|
|
assert.match(refine, /PROMPTS\.refine \+ INJECTION_GUARD/);
|
|
assert.match(refine, /sourceContext/);
|
|
assert.doesNotMatch(refine, /INSERT INTO user_memories|correction_/);
|
|
});
|
|
|
|
test('browser Whisper is removed from public runtime and user settings', () => {
|
|
assert.equal(fs.existsSync(path.join(root, 'public/js/browserWhisper.js')), false);
|
|
assert.equal(fs.existsSync(path.join(root, 'public/js/whisperWorker.js')), false);
|
|
assert.equal(fs.existsSync(path.join(root, 'public/js/whisperWorkerV2.js')), false);
|
|
assert.equal(fs.existsSync(path.join(root, 'docs/browser-whisper-setup.md')), false);
|
|
assert.equal(fs.existsSync(path.join(root, 'docs/browser-whisper-troubleshooting.md')), false);
|
|
assert.equal(fs.existsSync(path.join(root, 'scripts/download-whisper-models.sh')), false);
|
|
const publicRuntimeFiles = [
|
|
'public/index.html',
|
|
'public/js/app.js',
|
|
'public/js/transcriptionSettings.js',
|
|
'public/components/settings.html',
|
|
'public/components/faq.html'
|
|
];
|
|
publicRuntimeFiles.forEach((file) => {
|
|
assert.doesNotMatch(read(file), /BrowserWhisper|browser-whisper|Browser Whisper|Xenova\/whisper|transformers\.min\.js/, file);
|
|
});
|
|
const dockerfile = read('Dockerfile');
|
|
assert.doesNotMatch(dockerfile, /Xenova\/whisper|transformers\.min\.js|Browser Whisper/);
|
|
const server = read('server.js');
|
|
assert.doesNotMatch(server, /wasm-unsafe-eval|unsafe-eval|huggingface\.co|cdn-lfs|transformers/);
|
|
});
|
|
|
|
test('browser speech recognition is gated by explicit user setting', () => {
|
|
const app = read('public/js/app.js');
|
|
const speechFactory = app.match(/function createSpeechRecognition\(\) \{[\s\S]*?\n\}/)[0];
|
|
assert.match(speechFactory, /window\.WebSpeechRecognition && !window\.WebSpeechRecognition\.isEnabled\(\)/);
|
|
});
|
|
|
|
test('audio backup settings render without dynamic HTML templates', () => {
|
|
const audioBackup = read('public/js/audioBackup.js');
|
|
const renderer = audioBackup.match(/window\.renderAudioBackups = function\(\) \{[\s\S]*?\n \};/)[0];
|
|
assert.doesNotMatch(renderer, /innerHTML/);
|
|
assert.match(renderer, /document\.createElement\('button'\)/);
|
|
assert.match(renderer, /textContent =/);
|
|
});
|
|
|
|
// The Settings picker offered six hardcoded ids. On this gateway none of them
|
|
// resolve, and /api/transcribe prefers the user's choice over the admin
|
|
// default — so choosing one broke every recording with "Invalid model name".
|
|
test('the STT picker offers what the gateway has, not a hardcoded list', () => {
|
|
const stt = read('src/utils/sttProvider.js');
|
|
const prefs = read('src/routes/userPreferences.js');
|
|
|
|
assert.match(stt, /async function discoverSTTModels\(options\)/);
|
|
assert.match(stt, /getLiteLLMSTTModels\(resp\.data && resp\.data\.data\)/, 'filtered by audio_transcription mode');
|
|
assert.match(stt, /STT_DISCOVERY_TTL_MS = 5 \* 60 \* 1000;/, 'cached, so a user-facing page does not hit the gateway every load');
|
|
assert.match(stt, /module\.exports = \{[\s\S]{0,80}discoverSTTModels,/);
|
|
|
|
assert.match(prefs, /var sttIds = await discoverSTTModels\(\);/);
|
|
assert.match(prefs, /if \(!sttIds\.length\) sttIds = getSTTModelLists\(\)\.litellm\.slice\(\);/,
|
|
'the built-in list survives only as a fallback');
|
|
assert.match(prefs, /model === adminSttModel \? ' \(default\)' : ''/, 'the admin default is marked');
|
|
});
|
|
|
|
// A recording is significant clinical material: it must be possible to take a
|
|
// copy out of the app, and a recording that has silently stopped must say so.
|
|
test('recordings can be exported, and a dead recorder is reported', () => {
|
|
const backup = read('public/js/audioBackup.js');
|
|
assert.match(backup, /window\.downloadAudioBackup = function\(id, stamp\)/);
|
|
assert.match(backup, /'\/api\/audio-backups\/' \+ id\.replace\('server_', ''\) \+ '\/audio'/, 'server-side copies');
|
|
assert.match(backup, /objectStore\(STORE_NAME\)\.get\(localId\)/, 'and local ones');
|
|
// A local record belongs to one account; another must not be able to pull it.
|
|
assert.match(backup, /!boundary\.valid\(owner\) \|\| record\.owner !== owner/);
|
|
assert.match(backup, /audio-backup-download/, 'the list offers it');
|
|
assert.match(backup, /indexOf\('mp4'\) !== -1 \? 'm4a'/, 'the extension matches what was recorded');
|
|
|
|
const app = read('public/js/app.js');
|
|
assert.match(app, /self\.mediaRecorder\.onerror = function\(event\)/);
|
|
assert.match(app, /track\.addEventListener\('ended'/, 'the microphone being taken away is a failure too');
|
|
assert.match(app, /AudioRecorder\.prototype\.notifyFailure/);
|
|
assert.match(app, /if \(this\.notified\) return;/, 'reported once, not per chunk');
|
|
assert.match(app, /audio-recorder-failed/, 'callers can react');
|
|
});
|