pediatric-ai-scribe-v3/test/assistant-export-owner.test.js

271 lines
19 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { webcrypto, createHash } = require('node:crypto');
const read = file => fs.readFileSync('public/js/' + file, 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
const id = '12345678-1234-1234-1234-123456789abc';
const src = '/api/generated-images/' + id;
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
const image = 'data:image/png;base64,' + png.toString('base64');
function ui(t, { privateAsset = true, inline = true } = {}) {
const dom = new JSDOM('<body><div id="unrelated">Unrelated</div></body>', { url: 'https://synthetic.test', runScripts: 'outside-only' });
const w = dom.window;
Object.defineProperty(w, 'crypto', { value: webcrypto });
w.Blob = Blob; w.TextEncoder = TextEncoder;
const observers = []; const Observer = w.MutationObserver;
w.MutationObserver = class extends Observer { constructor(callback) { super(callback); observers.push(this); } };
// Actual account-boundary implementation with disposable in-memory identity/storage.
w.eval(read('accountBoundary.js'));
assert.equal(w.AccountBoundary.enter({ id: '101' }, true), true);
w.getAuthHeaders = () => ({ Authorization: 'Bearer synthetic-only' });
const calls = { writes: [], shares: [], prints: [], toasts: [], fetches: [], focus: [] };
const timers = [];
w.setTimeout = fn => { timers.push(fn); return timers.length; };
w.clearTimeout = timer => { timers[timer - 1] = () => {}; };
w.matchMedia = () => ({ matches: inline });
w.showToast = (...args) => calls.toasts.push(args);
w.print = () => calls.prints.push('browser');
w.FileReader = class { readAsDataURL(blob) { blob.arrayBuffer().then(bytes => { this.result = 'data:' + blob.type + ';base64,' + Buffer.from(bytes).toString('base64'); this.onload(); }); } };
function response(url) {
if (url.includes('/jobs/')) return new Response(JSON.stringify({ success: true, status: 'done', jobId: id, imageUrl: src, context: { includedTurns: 1, totalTurns: 2, used: 40, limit: 32000, unit: 'UTF-16 code units' } }));
return new Response(png, { headers: { 'content-type': 'image/png', 'content-length': String(png.length), 'x-image-owner': '101', 'x-image-sha256': createHash('sha256').update(png).digest('hex') } });
}
w.fetch = async (url, options) => { calls.fetches.push({ url, options }); return response(url); };
for (const file of ['generatedImages.js', 'assistant/citations.js', 'assistant/sharing.js', 'assistant/export.js']) {
// Allows the failing-first run against the frozen image input without this core helper.
if (file.endsWith('sharing.js') && !fs.existsSync('public/js/' + file)) continue;
vm.runInContext(read(file).replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), dom.getInternalVMContext());
}
const exporter = w.createAssistantExporter({ showToast: w.showToast });
const state = { lastAnswer: 'Exact body [3].', lastSources: [{ number: 3, title: 'Synthetic source', page: 19 }],
lastGeneratedImageSrc: privateAsset ? src : image,
generatedImageJobs: privateAsset ? [{ jobId: id }] : [],
messages: [{ role: 'assistant', content: 'Exact body [3].', ...(privateAsset ? { imageJobs: [{ jobId: id }] } : {}) }] };
async function exported() {
await exporter.exportAnswerPdf(state);
if (!privateAsset) await exporter.exportAnswerPdf(state); // Exercise the legacy cache-hit branch too.
return w.document.querySelector('#assistant-export-modal');
}
function plugins() {
w.Capacitor = { isNativePlatform: () => true, Plugins: {
Filesystem: { writeFile: async args => { calls.writes.push(args); return { uri: 'file://' + args.path }; } },
Share: { share: async args => { calls.shares.push(args); } }
} };
return w.Capacitor.Plugins;
}
function replace({ freeze = true, sameId = false } = {}) {
if (freeze) w.AccountBoundary.freeze();
const b = new JSDOM('<body></body>', { url: 'https://synthetic.test', runScripts: 'outside-only' });
b.window.eval(read('accountBoundary.js'));
assert.equal(b.window.AccountBoundary.enter({ id: sameId ? '101' : '102' }, true), true);
w.AccountBoundary = b.window.AccountBoundary;
t.after(() => b.window.close());
}
function popup() {
const p = new JSDOM('<body></body>', { url: 'https://synthetic.test', runScripts: 'outside-only' });
let closed = false;
const target = { document: p.window.document, get closed() { return closed; }, focus: () => calls.focus.push('popup'), print: () => calls.prints.push('popup'), close() { closed = true; } };
w.open = () => target;
t.after(() => p.window.close());
return target;
}
t.after(() => { observers.forEach(observer => observer.disconnect()); w.close(); });
return { w, context: dom.getInternalVMContext(), calls, timers, state, exporter, exported, plugins, replace, popup, response };
}
for (const privateAsset of [true, false]) {
const branch = privateAsset ? 'private assets' : 'legacy cache';
for (const freeze of [true, false]) for (const stage of ['filesystem', 'share', 'native resolve', 'native reject']) {
test(`${branch}: deferred ${stage} -> ${freeze ? 'freeze' : 'same-ID object replacement without freeze'} -> resolve/reject has no later effects or UI writes`, async t => {
const app = ui(t, { privateAsset }); const p = app.plugins(); const pending = deferred();
if (stage === 'filesystem') p.Filesystem.writeFile = args => { app.calls.writes.push(args); return pending.promise; };
if (stage === 'share') p.Share.share = args => { app.calls.shares.push(args); return pending.promise; };
if (stage.startsWith('native')) app.w.NativePrint = { printHtml(...args) { app.calls.prints.push(args); pending.promise.catch(() => {}); return pending.promise; } };
const modal = await app.exported(); const print = modal.querySelector('#assistant-export-print');
print.click(); await tick();
assert.equal(stage.startsWith('native') ? app.calls.prints.length : app.calls.writes.length, 1, 'first OS operation admitted');
assert.equal(print.disabled, true, 'await the admitted operation before updating UI');
app.replace({ freeze, sameId: !freeze });
assert.equal(modal.isConnected, !freeze);
const before = JSON.stringify(app.calls); const text = print.textContent; const disabled = print.disabled;
if (stage === 'native reject') pending.reject(new Error('Synthetic native capability failure'));
else pending.resolve({ uri: 'file://synthetic-owned' });
await tick();
assert.equal(JSON.stringify(app.calls), before, 'no follow-up native calls, browser fallback, fetches or toasts');
assert.equal(print.textContent, text, 'no late finally text reset'); assert.equal(print.disabled, disabled, 'no late finally enable');
});
}
for (const replacement of ['freeze', 'same-id object']) {
test(`${branch}: original Print callback rejects ${replacement} replacement and cannot close newer preview`, async t => {
const app = ui(t, { privateAsset }); app.plugins();
const old = await app.exported(); const print = old.querySelector('#assistant-export-print');
app.replace({ freeze: replacement === 'freeze', sameId: true });
const newer = await app.exported(); const before = JSON.stringify(app.calls);
print.click(); await tick();
assert.equal(JSON.stringify(app.calls), before); assert.equal(newer.isConnected, true);
assert.equal(app.w.document.body.classList.contains('assistant-export-open'), true);
assert.ok(app.w.document.querySelector('#unrelated'));
});
}
for (const stage of ['native sync failure', 'browser fallback', 'native AbortError', 'share AbortError']) {
test(`${branch}: ${stage} does not become a capability fallback after abort`, async t => {
const app = ui(t, { privateAsset }); const p = app.plugins();
if (stage === 'native sync failure') app.w.NativePrint = { printHtml() { app.replace(); throw new Error('Unavailable'); } };
if (stage === 'native AbortError') app.w.NativePrint = { printHtml() { throw new app.w.DOMException('Policy abort', 'AbortError'); } };
if (stage === 'share AbortError') p.Share.share = async args => { app.calls.shares.push(args); throw new app.w.DOMException('Policy abort', 'AbortError'); };
if (stage === 'browser fallback') app.w.Capacitor.Plugins = {};
const modal = await app.exported(); modal.querySelector('#assistant-export-print').click();
if (stage === 'browser fallback') app.replace();
await tick();
assert.equal(app.calls.writes.length, stage === 'share AbortError' ? 1 : 0);
assert.deepEqual(app.calls.prints, []); assert.deepEqual(app.calls.toasts, []);
});
}
for (const mode of ['native', 'filesystem', 'browser', 'native failure fallback', 'share cancel']) {
test(`${branch}: same owner ${mode} still succeeds with exact embedded bytes and source/page`, async t => {
const app = ui(t, { privateAsset }); const p = app.plugins();
if (mode === 'native') app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) };
if (mode === 'native failure fallback') app.w.NativePrint = { printHtml: async () => { throw new Error('Unavailable'); } };
if (mode === 'browser') app.w.Capacitor.Plugins = {};
if (mode === 'share cancel') p.Share.share = async args => { app.calls.shares.push(args); throw new Error('User canceled'); };
const original = JSON.stringify(app.state); const modal = await app.exported();
modal.querySelector('#assistant-export-print').click(); await tick();
assert.equal(app.calls.prints.length + app.calls.shares.length, 1);
const encoded = mode === 'native' ? app.calls.prints[0][1] : app.calls.writes[0]?.data;
const html = encoded ? Buffer.from(encoded, 'base64').toString() : modal.innerHTML;
assert.ok(html.includes(image)); assert.match(html, /Exact body \[3\]\./); assert.match(html, /Synthetic source, page 19/);
assert.equal(JSON.stringify(app.state), original); assert.equal(modal.querySelector('#assistant-export-print').disabled, false);
if (privateAsset) assert.equal(app.calls.fetches[0].options.headers.Authorization, 'Bearer synthetic-only');
});
}
test(`${branch}: delayed desktop print and original popup button keep their owner`, async t => {
const app = ui(t, { privateAsset, inline: false }); const target = app.popup();
await app.exporter.exportAnswerPdf(app.state);
const print = target.document.querySelector('#assistant-export-print');
const delayed = app.timers.slice(); app.replace();
print.click(); delayed.forEach(fn => fn());
assert.equal(target.closed, true); assert.deepEqual(app.calls.focus, []); assert.deepEqual(app.calls.prints, []); assert.deepEqual(app.calls.toasts, []);
});
test(`${branch}: same-owner desktop print works`, async t => {
const app = ui(t, { privateAsset, inline: false }); const target = app.popup();
await app.exporter.exportAnswerPdf(app.state); app.timers.forEach(fn => fn());
target.document.querySelector('#assistant-export-print').click();
assert.deepEqual(app.calls.prints, ['popup', 'popup']); assert.equal(target.closed, false);
});
}
for (const freeze of [true, false]) for (const stage of ['job resolve', 'job reject', 'asset resolve']) {
for (const inline of [true, false]) {
test(`private prepare ${stage} after boundary replacement is silent (${inline ? 'inline' : 'desktop'})${freeze ? '' : ' without freeze'}`, async t => {
const app = ui(t, { inline }); const target = inline ? null : app.popup(); const pending = deferred();
let admitted;
app.w.fetch = (url, options) => { app.calls.fetches.push({ url, options }); if (!admitted && ((stage.startsWith('job') && url.includes('/jobs/')) || (stage === 'asset resolve' && url === src))) { admitted = url; return pending.promise; } return Promise.resolve(app.response(url)); };
const job = app.exporter.exportAnswerPdf(app.state); await tick(); assert.ok(admitted);
app.replace({ freeze, sameId: true }); const before = JSON.stringify(app.calls);
if (stage.endsWith('reject')) pending.reject(new Error('Synthetic transport failure'));
else pending.resolve(app.response(admitted));
await job; await tick();
assert.equal(JSON.stringify(app.calls), before); assert.equal(app.w.document.querySelector('#assistant-export-modal'), null);
if (target) assert.equal(target.closed, true);
});
}
}
test('late filesystem completion has a unique owned path and cannot close or update a newer export', async t => {
const app = ui(t, { privateAsset: false }); const p = app.plugins(); const pending = deferred();
app.w.Date = class extends app.w.Date { constructor() { super('2026-01-02T03:04:05Z'); } };
p.Filesystem.writeFile = args => { app.calls.writes.push(args); return app.calls.writes.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); };
const old = await app.exported(); old.querySelector('#assistant-export-print').click(); await tick(); app.replace();
const newer = await app.exported(); newer.querySelector('#assistant-export-print').click(); await tick();
assert.equal(app.calls.writes.length, 2); assert.notEqual(app.calls.writes[0].path, app.calls.writes[1].path);
const before = JSON.stringify(app.calls); pending.resolve({ uri: 'file://' + app.calls.writes[0].path }); await tick();
assert.equal(JSON.stringify(app.calls), before); assert.equal(newer.isConnected, true);
});
test('export invocation fails closed without a verified boundary, before even an empty-answer toast', async t => {
const app = ui(t);
for (const boundary of [undefined, {}, { capture: () => '101' }]) {
app.w.AccountBoundary = boundary;
await app.exporter.exportAnswerPdf({});
assert.equal(app.w.document.querySelector('#assistant-export-modal'), null); assert.deepEqual(app.calls.toasts, []);
}
});
for (const freeze of [true, false]) for (const stage of ['fetch', 'body', 'reader', 'filesystem', 'share']) {
test(`private image download ${stage} retains original object/ticket/signal (${freeze ? 'freeze' : 'same-ID replacement without freeze'})`, { timeout: 5000 }, async t => {
const app = ui(t); const pending = deferred(); const admitted = deferred(); const p = app.plugins();
app.calls.native = []; app.calls.anchors = [];
app.w.HTMLAnchorElement.prototype.click = function() { app.calls.anchors.push(this.download); };
app.w.NativeFiles = { saveImage(...args) { app.calls.native.push(args); return 'saved:synthetic'; } };
const originalSignal = app.w.AccountBoundary.signal();
if (stage === 'fetch' || stage === 'body') app.w.fetch = (url, options) => {
app.calls.fetches.push({ url, options });
if (stage === 'fetch') { admitted.resolve(); return pending.promise; }
const response = app.response(url);
return Promise.resolve({ ok: true, headers: response.headers, body: { getReader: () => ({ read: () => { admitted.resolve(); return pending.promise; } }) } });
};
if (stage === 'reader') app.w.FileReader = class { readAsDataURL() { admitted.resolve(); pending.promise.then(() => { this.result = image; this.onload(); }); } };
if (stage === 'filesystem' || stage === 'share') delete app.w.NativeFiles;
if (stage === 'filesystem') p.Filesystem.writeFile = args => { app.calls.writes.push(args); admitted.resolve(); return pending.promise; };
if (stage === 'share') p.Share.share = args => { app.calls.shares.push(args); admitted.resolve(); return pending.promise; };
vm.runInContext(read('assistant/images.js').replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), app.context);
const store = app.w.createAssistantImageStore(); store.renderGeneratedImage(src);
const job = store.downloadImage('img-1'); await admitted.promise;
assert.equal(app.calls.fetches.length, 1); assert.equal(app.calls.fetches[0].options.signal, originalSignal);
assert.equal(app.calls.fetches[0].options.redirect, 'error');
if (stage === 'filesystem' || stage === 'share') assert.equal(app.calls.writes.length, 1, 'paid bytes admitted only under the original owner');
if (stage === 'share') assert.equal(app.calls.shares.length, 1);
app.replace({ freeze, sameId: true }); const before = JSON.stringify(app.calls);
pending.resolve(stage === 'fetch' ? app.response(src) : stage === 'body' ? { done: false, value: new Uint8Array(png) } : { uri: 'file://original-only' });
await job; await tick();
assert.equal(JSON.stringify(app.calls), before, 'no native, Filesystem, Share, anchor or toast after replacement');
});
}
test('late private preview load and detached close cannot hydrate or close a newer same-ID preview', async t => {
const app = ui(t); const pending = deferred();
app.w.fetch = () => pending.promise;
vm.runInContext(read('assistant/images.js').replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), app.context);
const store = app.w.createAssistantImageStore(); store.renderGeneratedImage(src);
store.openImagePreview('img-1'); const old = app.w.document.querySelector('.assistant-image-modal');
assert.ok(old); assert.equal(old.querySelector('img').getAttribute('src'), null);
const close = old.querySelector('.assistant-image-modal-close');
app.replace({ freeze: false, sameId: true });
store.renderGeneratedImage(image); store.openImagePreview('img-2');
const newer = app.w.document.querySelector('.assistant-image-modal');
pending.resolve(app.response(src)); await tick(); await tick(); close.click();
assert.equal(newer.isConnected, true); assert.equal(newer.querySelector('img').getAttribute('src'), image);
assert.equal(app.w.document.body.classList.contains('assistant-image-preview-open'), true);
assert.equal(old.isConnected, false); assert.deepEqual(app.calls.toasts, []);
});
test('a superseded boundary abort cleans up only its export, never a newer same-ID export', async t => {
const app = ui(t); const originalBoundary = app.w.AccountBoundary;
const old = await app.exported();
app.replace({ freeze: false, sameId: true });
const newer = await app.exported();
originalBoundary.freeze();
assert.equal(old.isConnected, false);
assert.equal(newer.isConnected, true);
assert.equal(app.w.document.body.classList.contains('assistant-export-open'), true);
});
test('private transient cleanup revokes only the obsolete owner URLs and preserves newer and unrelated blobs', async t => {
const app = ui(t); const revoked = []; let seq = 0;
app.w.URL.createObjectURL = () => 'blob:synthetic-' + ++seq;
app.w.URL.revokeObjectURL = url => revoked.push(url);
const originalBoundary = app.w.AccountBoundary;
const oldUrl = app.w.transientImageUrl(new Blob([png]));
app.replace({ freeze: false, sameId: true });
const newUrl = app.w.transientImageUrl(new Blob([png]));
app.w.document.body.insertAdjacentHTML('beforeend', '<img src="' + oldUrl + '"><img src="' + newUrl + '"><img src="blob:unrelated">');
originalBoundary.freeze();
assert.deepEqual(revoked, [oldUrl]);
assert.ok(app.w.document.querySelector('img[src="' + newUrl + '"]'));
assert.ok(app.w.document.querySelector('img[src="blob:unrelated"]'));
});