Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
307 lines
18 KiB
JavaScript
307 lines
18 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');
|
|
});
|
|
|
|
test('a running recording holds the screen awake and survives a glance away', () => {
|
|
const app = read('public/js/app.js');
|
|
// The screen sleeping suspends the recording, and the browser drops a wake
|
|
// lock whenever the page is hidden — so it has to be taken again on return,
|
|
// or one glance away ends it for the rest of the session.
|
|
assert.match(app, /navigator\.wakeLock\.request\('screen'\)/);
|
|
assert.match(app, /document\.addEventListener\('visibilitychange', function\(\) \{\s*\n\s*if \(document\.visibilityState === 'visible'\) _acquireWakeLock\(\);/);
|
|
assert.match(app, /if \(document\.visibilityState !== 'visible'\) return Promise\.resolve\(null\);/,
|
|
'requesting while hidden would just be rejected');
|
|
// Counted, so two recorders do not release each other's lock.
|
|
assert.match(app, /_wakeLockHolders = Math\.max\(0, _wakeLockHolders - 1\);/);
|
|
assert.match(app, /if \(_wakeLockHolders > 0 \|\| !_wakeLock\) return;/);
|
|
// Signing out must not leave the screen pinned awake.
|
|
assert.match(app, /window\.addEventListener\('account-boundary', function\(\) \{\s*\n\s*_wakeLockHolders = 0;/);
|
|
// Denied or unsupported must not stop the recording.
|
|
assert.match(app, /\.catch\(function\(\) \{ return null; \}\);/);
|
|
});
|
|
|
|
test('starting an already-running recorder does not throw away what it has', () => {
|
|
const app = read('public/js/app.js');
|
|
assert.match(app, /if \(self\.mediaRecorder && self\.mediaRecorder\.state === 'recording'\) return Promise\.resolve\(\);/);
|
|
assert.match(app, /if \(self\.heldWakeLock\) \{ self\.heldWakeLock = false; releaseWakeLock\(\); \}/, 'and stopping releases the lock');
|
|
});
|
|
|
|
test('a recording that ends by itself is still transcribed, and logging out stops it', () => {
|
|
const live = read('public/js/liveEncounter.js');
|
|
// Same path as pressing Stop, so the audio is transcribed and stored rather
|
|
// than left in a tab that still claims to be recording.
|
|
assert.match(live, /document\.addEventListener\('audio-recorder-failed', function\(e\) \{[\s\S]{0,320}recordBtn\.click\(\);/);
|
|
// The event is on document, so it reaches every listener. Without checking
|
|
// which recorder failed, the assistant's microphone dying ended the
|
|
// consultation being recorded in this tab.
|
|
assert.match(live, /if \(e && e\.detail && e\.detail\.recorder && e\.detail\.recorder !== recorder\) return;/,
|
|
'only this tab\'s own recorder may stop this tab\'s recording');
|
|
assert.match(read('public/js/app.js'), /detail: \{ message: message, recorder: this \}/,
|
|
'so the recorder has to travel with the event');
|
|
assert.match(live, /window\.addEventListener\('account-boundary', function\(\) \{[\s\S]{0,200}recorder\.stop\(\)/,
|
|
'signing out mid-recording stops it');
|
|
});
|
|
|
|
test('every recording is kept for 24 hours, not only the failures', () => {
|
|
const transcribe = read('src/routes/transcribe.js');
|
|
const store = read('src/utils/audioBackupStore.js');
|
|
const db = read('src/db/database.js');
|
|
|
|
// The audio is already on the server for transcription, so keeping it costs
|
|
// no second upload.
|
|
assert.match(transcribe, /require\('\.\.\/utils\/audioBackupStore'\)\.save\(req\.user\.id, req\.body\.module \|\| 'recording', req\.file\.buffer, mimeType\)/);
|
|
assert.match(transcribe, /console\.warn\('\[Transcribe\] backup failed \(transcription continues\)/,
|
|
'a storage failure must not lose the transcription someone is waiting for');
|
|
|
|
// Object storage when configured, the encrypted database column otherwise.
|
|
// Which one, and with what credentials, is resolved centrally now.
|
|
assert.match(store, /_client = objectStorage\.storeFor\('audio-backups', env\);/);
|
|
assert.match(store, /objectStorage\.isConfigured\('audio-backups', env\)/);
|
|
assert.match(store, /cryptoUtil\.encryptBuffer\(compressed\)/, 'compressed and encrypted either way');
|
|
assert.match(store, /'recordings\/' \+ userId \+ '\/'/, 'keys are scoped to their owner');
|
|
assert.match(store, /WHERE id = \$1 AND user_id = \$2 AND expires_at > NOW\(\)/, 'ownership and expiry are in the query');
|
|
assert.match(store, /cryptoUtil\.isEncryptedBuffer\(stored\) \? cryptoUtil\.decryptBuffer\(stored\) : stored/,
|
|
'rows written before encryption still read back');
|
|
|
|
// An expired row must take its object with it.
|
|
assert.match(db, /DELETE FROM audio_backups WHERE expires_at < NOW\(\) RETURNING storage_key/);
|
|
assert.match(db, /await store\.removeObject\(audio\.rows\[i\]\.storage_key\)/);
|
|
assert.match(db, /ALTER TABLE audio_backups ADD COLUMN IF NOT EXISTS storage_key TEXT;/,
|
|
'existing installations get the column too');
|
|
});
|
|
|
|
test('signing out mid-recording warns, and keeps the audio with its encounter', () => {
|
|
const app = read('public/js/app.js');
|
|
const auth = read('public/js/auth.js');
|
|
// Every running recorder is registered, so anything about to end the session
|
|
// can find one instead of discarding minutes of a consultation.
|
|
assert.match(app, /var _activeRecorders = new Set\(\);/);
|
|
assert.match(app, /_activeRecorders\.add\(self\);/);
|
|
assert.match(app, /_activeRecorders\.delete\(self\);/);
|
|
assert.match(app, /window\.rescueActiveRecordings = function\(\)/);
|
|
// Tagged with the module that produced it, which is what makes it findable
|
|
// afterwards: 'encounter', 'soap', 'dictation'.
|
|
assert.match(app, /var module = recorder\._module \|\| 'recording';/);
|
|
assert.match(app, /return saveAudioBackup\(blob, module\);/);
|
|
// The caller is on its way out of the app, so this must never reject.
|
|
assert.match(app, /\.catch\(function\(\) \{ return \{ module: module, id: null \}; \}\);/);
|
|
|
|
assert.match(auth, /activeRecordingCount\(\) > 0/);
|
|
assert.match(auth, /showConfirm\('A recording is still running\./);
|
|
assert.match(auth, /rescueActiveRecordings\(\)\.then/);
|
|
assert.match(auth, /\.finally\(function\(\) \{\s*\n\s*exitApp\(\);/, 'sign-out completes whether or not the save worked');
|
|
});
|
|
|
|
test('switching to the Assistant does not reload the page out from under a recording', () => {
|
|
const app = read('public/js/app.js');
|
|
const handler = app.slice(app.indexOf('if (!onAssistant) {'), app.indexOf('// Inside the assistant, Workspace opens the launcher'));
|
|
// window.location reloads the document, which ends any running recording.
|
|
assert.match(handler, /if \(typeof window\.activateTab === 'function'\) window\.activateTab\('assistant'\);/);
|
|
assert.match(handler, /else window\.location\.href = '\/assistant';/, 'the reload stays as a fallback');
|
|
// activateTab already rewrites the URL, so the address still reads /assistant.
|
|
assert.match(app, /var target = tabName === 'assistant' \? '\/assistant' : '\/';/);
|
|
});
|
|
|
|
test('one codec chain, so Safari can record at all', () => {
|
|
const app = read('public/js/app.js');
|
|
// Safari supports none of the webm types and throws NotSupportedError when
|
|
// handed one. Every module used to build its own recorder on resume with
|
|
// "opus, else audio/webm" — which meant resuming a recording threw there.
|
|
assert.match(app, /var candidates = \['audio\/webm;codecs=opus', 'audio\/webm', 'audio\/mp4'\];/);
|
|
assert.match(app, /AudioRecorder\.prototype\.resumeCapture = function\(\)/);
|
|
assert.match(app, /self\.mediaRecorder = new MediaRecorder\(self\.stream, AudioRecorder\.encoding\(\)\);/,
|
|
'the rebuild on resume uses the same chain');
|
|
|
|
for (const file of ['liveEncounter', 'soap', 'voiceDictation', 'ed-encounters', 'sickVisit', 'shadess']) {
|
|
const source = read('public/js/' + file + '.js');
|
|
assert.doesNotMatch(source, /new MediaRecorder\(/,
|
|
file + ' must not build a recorder of its own');
|
|
assert.match(source, /resumeCapture\(\)/, file + ' resumes through the shared recorder');
|
|
}
|
|
});
|
|
|
|
test('a transcription that finds no words leaves the live transcript alone', () => {
|
|
// The server answers {success:true, text:''} for silence. Assigning that
|
|
// emptied the box the browser had been filling all along, which reads as a
|
|
// recording that vanished.
|
|
const cases = [
|
|
['liveEncounter', /if \(data\.success && data\.text\) \{ transcript\.textContent = data\.text;/],
|
|
['voiceDictation', /if \(data\.success && data\.text\) \{ transcript\.textContent = data\.text;/],
|
|
['soap', /if \(data\.success && data\.text\) transcript\.textContent = data\.text;/],
|
|
['sickVisit', /if \(data\.success && data\.text\) \{ if \(transcriptEl\)/],
|
|
['shadess', /if \(data\.success && data\.text\) \{ if \(transcriptEl\)/],
|
|
['ed-encounters', /if \(data && data\.success && data\.text\) \{/]
|
|
];
|
|
for (const [file, pattern] of cases) {
|
|
const source = read('public/js/' + file + '.js');
|
|
assert.match(source, pattern, file + ' must require text before overwriting');
|
|
assert.match(source, /No speech detected in the recording/, file + ' says so instead of blanking');
|
|
}
|
|
// And a recording that captured nothing at all has to say that much, rather
|
|
// than resetting the button and leaving an empty box.
|
|
assert.match(read('public/js/liveEncounter.js'),
|
|
/showToast\('No audio was captured — check the microphone and try again', 'error'\);/);
|
|
});
|
|
|
|
test('a retried transcript goes back to the tab the audio came from', () => {
|
|
// The module travels with the recording, so the app already knows which tab
|
|
// and which box. Handing someone the clipboard and leaving them to find the
|
|
// right tab was work the app could do itself.
|
|
const modules = read('public/js/recordingModules.js');
|
|
for (const [name, tab, target] of [
|
|
['encounter', 'encounter', 'enc-transcript'],
|
|
['dictation', 'dictation', 'dict-transcript'],
|
|
['soap', 'soap', 'soap-transcript'],
|
|
['sick', 'sickvisit', 'sick-transcript'],
|
|
['wellvisit', 'wellvisit', 'wv-transcript'],
|
|
['ed', 'ed', 'ed-transcript'],
|
|
]) {
|
|
const row = new RegExp(name + ":\\s*\\{\\s*tab:\\s*'" + tab + "',\\s*target:\\s*'" + target + "'");
|
|
assert.match(modules, row, name + ' maps to its own tab and box');
|
|
}
|
|
// The recorders and the recording-started events used different names for the
|
|
// same module — 'encounter' against 'enc' — so the aliases are resolved here
|
|
// rather than left for each caller to guess.
|
|
assert.match(modules, /enc: 'encounter', dict: 'dictation'/);
|
|
|
|
const backup = read('public/js/audioBackup.js');
|
|
assert.match(backup, /function deliverTranscript\(module, text\)/);
|
|
assert.match(backup, /if \(!window\.activateTab\(entry\.tab\)\) return false;/, 'it opens that tab');
|
|
// A retry usually recovers text ON TOP of a live transcript, so replacing the
|
|
// box would lose the words the browser did hear.
|
|
assert.match(backup, /box\.textContent = existing \? existing \+ ' ' \+ text\.trim\(\) : text\.trim\(\);/,
|
|
'existing text is appended to after its last word, never replaced');
|
|
// The box only exists once the tab's markup has been fetched.
|
|
assert.match(backup, /if \(attempts\+\+ > 40\)/, 'it waits for the component rather than guessing a delay');
|
|
assert.match(backup, /showToast\('Transcript copied — could not open '/, 'and falls back to the clipboard');
|
|
assert.match(backup, /if \(!data\.text\) \{[\s\S]{0,120}still transcribes to nothing/,
|
|
'an empty result says so instead of claiming success');
|
|
});
|
|
|
|
test('every recording is uploaded with the module it came from', () => {
|
|
// Without this the server stored "recording" for all of them — 28 rows of it —
|
|
// and a retry had nowhere to send the text back to.
|
|
const app = read('public/js/app.js');
|
|
assert.match(app, /function transcribeAudio\(blob, module\)/);
|
|
assert.match(app, /if \(moduleName\) formData\.append\('module', moduleName\);/);
|
|
|
|
for (const [file, module] of [
|
|
['liveEncounter', 'encounter'], ['soap', 'soap'], ['voiceDictation', 'dictation'],
|
|
['sickVisit', 'sick'], ['ed-encounters', 'ed'], ['shadess', 'wellvisit'],
|
|
]) {
|
|
assert.match(read('public/js/' + file + '.js'), new RegExp("transcribeAudio\\(blob, '" + module + "'\\)"),
|
|
file + ' tags its uploads');
|
|
}
|
|
});
|