271 lines
19 KiB
JavaScript
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"]'));
|
|
});
|