297 lines
15 KiB
JavaScript
297 lines
15 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { JSDOM } = require('jsdom');
|
|
const read = file => fs.readFileSync(path.join(__dirname, '..', '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 image = 'data:image/png;base64,c3ludGhldGlj';
|
|
const remote = 'https://example.test/synthetic.png';
|
|
|
|
function ui(t, { native = false, inline = true } = {}) {
|
|
const dom = new JSDOM('<body><div id="unrelated">unrelated feature</div></body>', { url: 'https://example.test', runScripts: 'outside-only' });
|
|
const w = dom.window;
|
|
// Real boundary code, synthetic storage/identity, no auth/network services.
|
|
w.eval(read('accountBoundary.js'));
|
|
assert.equal(w.AccountBoundary.enter({ id: 'synthetic-A' }, true), true);
|
|
const calls = { native: [], writes: [], shares: [], web: [], anchors: [], toasts: [], prints: [], fetches: [], urls: [] };
|
|
const timers = [];
|
|
w.matchMedia = () => ({ matches: inline });
|
|
w.showToast = (...args) => calls.toasts.push(args);
|
|
w.print = () => calls.prints.push('browser');
|
|
if (native) w.Capacitor = { isNativePlatform: () => true, Plugins: {} };
|
|
w.URL.createObjectURL = () => { calls.urls.push('create'); return 'blob:synthetic'; };
|
|
w.URL.revokeObjectURL = () => calls.urls.push('revoke');
|
|
w.HTMLAnchorElement.prototype.click = function() { calls.anchors.push(this.download); };
|
|
w.fetch = async (url, options) => { calls.fetches.push({ url, options }); return { ok: true, blob: async () => new w.Blob(['synthetic'], { type: 'image/png' }) }; };
|
|
w.setTimeout = fn => { timers.push(fn); return timers.length; };
|
|
w.clearTimeout = id => { timers[id - 1] = () => {}; };
|
|
for (const file of ['assistant/citations.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/images.js', 'assistant/export.js']) {
|
|
if (file.endsWith('sharing.js') && !fs.existsSync(path.join(__dirname, '..', 'public/js', file))) continue;
|
|
vm.runInContext(read(file).replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), dom.getInternalVMContext());
|
|
}
|
|
const store = w.createAssistantImageStore();
|
|
const exporter = w.createAssistantExporter({ showToast: w.showToast });
|
|
function freezeAndReplace() {
|
|
const original = w.AccountBoundary;
|
|
original.freeze();
|
|
// A real document cannot re-enter. A replacement object models a fresh B
|
|
// becoming visible to stale callbacks; they must retain A's boundary.
|
|
const b = new JSDOM('<body></body>', { url: 'https://example.test', runScripts: 'outside-only' });
|
|
b.window.eval(read('accountBoundary.js'));
|
|
assert.equal(b.window.AccountBoundary.enter({ id: 'synthetic-B' }, true), true);
|
|
w.AccountBoundary = b.window.AccountBoundary;
|
|
t.after(() => b.window.close());
|
|
}
|
|
function download(src = image, url = '') {
|
|
const html = store.renderGeneratedImage(src, 'synthetic', url);
|
|
return store.downloadImage(html.match(/data-assistant-download-image="([^"]+)"/)[1]);
|
|
}
|
|
function exported(answer = 'Synthetic answer A') {
|
|
exporter.exportAnswerPdf({ lastAnswer: answer, lastGeneratedImageSrc: image });
|
|
return w.document.querySelector('#assistant-export-modal');
|
|
}
|
|
function plugins() {
|
|
return { Filesystem: { writeFile: async args => { calls.writes.push(args); return { uri: 'file://' + args.path }; } },
|
|
Share: { share: async args => { calls.shares.push(args); } } };
|
|
}
|
|
t.after(() => w.close());
|
|
return { w, calls, timers, store, exporter, freezeAndReplace, download, exported, plugins };
|
|
}
|
|
|
|
for (const stage of ['fetch', 'blob', 'reader']) {
|
|
for (const server of [false, true]) {
|
|
test(`image ${server ? 'server' : 'legacy'} ${stage} continuation rejects A -> freeze -> B`, async t => {
|
|
const app = ui(t, { native: true });
|
|
const pending = deferred();
|
|
app.w.Capacitor.Plugins = app.plugins();
|
|
app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return 'saved:ok'; } };
|
|
if (stage === 'fetch') app.w.fetch = () => pending.promise;
|
|
if (stage === 'blob') app.w.fetch = async () => ({ ok: true, blob: () => pending.promise });
|
|
if (stage === 'reader') app.w.FileReader = class { readAsDataURL() { pending.promise.then(() => { this.result = image; this.onload(); }); } };
|
|
const job = app.download(remote, server ? '/api/synthetic-download' : '');
|
|
await tick();
|
|
app.freezeAndReplace();
|
|
pending.resolve(stage === 'fetch' ? { ok: true, blob: async () => new app.w.Blob(['synthetic']) } : new app.w.Blob(['synthetic']));
|
|
await job;
|
|
assert.deepEqual(app.calls.native, []);
|
|
assert.deepEqual(app.calls.writes, []);
|
|
assert.deepEqual(app.calls.shares, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
});
|
|
}
|
|
}
|
|
|
|
test('NativeFiles failure cannot recapture B for Share-only fallback', async t => {
|
|
const app = ui(t, { native: true });
|
|
app.w.NativeFiles = { saveImage() { app.freezeAndReplace(); throw new Error('bridge unavailable'); } };
|
|
app.w.Capacitor.Plugins = { Share: app.plugins().Share };
|
|
await app.download(remote);
|
|
assert.deepEqual(app.calls.shares, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
});
|
|
|
|
for (const server of [false, true]) {
|
|
test(`late ${server ? 'server' : 'legacy'} Filesystem write cannot share/toast or overwrite B's filename`, async t => {
|
|
const app = ui(t, { native: true });
|
|
const pending = deferred();
|
|
app.w.Capacitor.Plugins = app.plugins();
|
|
const paths = [];
|
|
app.w.Capacitor.Plugins.Filesystem.writeFile = args => { paths.push(args.path); return paths.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); };
|
|
const job = app.download(image, server ? '/api/synthetic-download' : '');
|
|
for (let i = 0; i < 100 && !paths.length; i++) await tick();
|
|
assert.equal(paths.length, 1, 'first write was admitted');
|
|
app.freezeAndReplace();
|
|
await app.download(image, server ? '/api/synthetic-download' : '');
|
|
const admitted = app.calls.shares.length;
|
|
app.calls.toasts.length = 0;
|
|
pending.resolve({ uri: 'file://' + paths[0] });
|
|
await job;
|
|
assert.equal(app.calls.shares.length, admitted);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
assert.notEqual(paths[0], paths[1]);
|
|
});
|
|
}
|
|
|
|
for (const mode of ['web conversion', 'web reject', 'browser conversion', 'share-only reject']) {
|
|
test(`${mode} cannot fall through to effects/toasts under B`, async t => {
|
|
const app = ui(t, { native: mode === 'share-only reject' });
|
|
const pending = deferred();
|
|
if (mode.startsWith('web')) {
|
|
app.w.navigator.canShare = () => true;
|
|
app.w.navigator.share = args => { app.calls.web.push(args); return mode === 'web reject' ? pending.promise : Promise.resolve(); };
|
|
}
|
|
if (mode.endsWith('conversion')) app.w.fetch = async () => ({ ok: true, blob: () => pending.promise });
|
|
if (mode === 'share-only reject') app.w.Capacitor.Plugins.Share = { share: args => { app.calls.shares.push(args); return pending.promise; } };
|
|
const job = app.download(remote);
|
|
await tick();
|
|
app.freezeAndReplace();
|
|
if (mode.endsWith('reject')) pending.reject(new Error('capability rejected'));
|
|
else pending.resolve(new app.w.Blob(['synthetic']));
|
|
await job;
|
|
assert.deepEqual(app.calls.anchors, []);
|
|
assert.deepEqual(app.calls.urls, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
assert.equal(app.calls.web.length, mode === 'web reject' ? 1 : 0);
|
|
assert.equal(app.calls.shares.length, mode === 'share-only reject' ? 1 : 0);
|
|
});
|
|
}
|
|
|
|
test('an active-owner policy AbortError is not a capability fallback', async t => {
|
|
const app = ui(t, { native: true });
|
|
app.w.NativeFiles = { saveImage() { throw new app.w.DOMException('Synthetic policy abort', 'AbortError'); } };
|
|
app.w.Capacitor.Plugins = app.plugins();
|
|
await app.download(image);
|
|
assert.deepEqual(app.calls.writes, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
});
|
|
|
|
test('download and export fail closed when boundary is absent, incomplete or inactive', async t => {
|
|
for (const boundary of [undefined, {}, { capture: () => 'A' }]) {
|
|
const app = ui(t, { native: true });
|
|
app.w.AccountBoundary = boundary;
|
|
app.w.Capacitor.Plugins = app.plugins();
|
|
await app.download();
|
|
assert.equal(app.exported(), null);
|
|
assert.deepEqual(app.calls.writes, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
}
|
|
const app = ui(t, { native: true });
|
|
app.w.AccountBoundary.freeze();
|
|
await app.download();
|
|
assert.equal(app.exported(), null);
|
|
});
|
|
|
|
for (const mode of ['native', 'filesystem', 'web', 'browser', 'share-only', 'native fallback', 'server native', 'server filesystem', 'server web', 'server browser']) {
|
|
test(`same-owner image succeeds: ${mode}`, async t => {
|
|
const app = ui(t, { native: /native|filesystem|share-only/.test(mode) });
|
|
if (/native/.test(mode)) app.w.NativeFiles = { saveImage: (...args) => { app.calls.native.push(args); return mode === 'native fallback' ? 'error:unavailable' : 'saved:ok'; } };
|
|
if (/filesystem|fallback/.test(mode)) app.w.Capacitor.Plugins = app.plugins();
|
|
if (mode === 'share-only') app.w.Capacitor.Plugins = { Share: app.plugins().Share };
|
|
if (/web/.test(mode)) { app.w.navigator.canShare = () => true; app.w.navigator.share = async args => { app.calls.web.push(args); }; }
|
|
await app.download(mode === 'share-only' ? remote : image, mode.startsWith('server') ? '/api/synthetic-download' : '');
|
|
assert.equal(app.calls.native.length + app.calls.shares.length + app.calls.web.length + app.calls.anchors.length, mode === 'native fallback' ? 2 : 1);
|
|
if (mode.startsWith('server')) {
|
|
assert.equal(app.calls.fetches[0].options.credentials, 'same-origin');
|
|
assert.equal(app.calls.fetches[0].options.signal, app.w.AccountBoundary.signal());
|
|
}
|
|
});
|
|
}
|
|
|
|
test('inline Print retains original export owner and closes only its preview on freeze', async t => {
|
|
const app = ui(t, { native: true });
|
|
app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) };
|
|
app.w.Capacitor.Plugins = app.plugins();
|
|
const modal = app.exported();
|
|
const print = modal.querySelector('#assistant-export-print');
|
|
app.freezeAndReplace();
|
|
assert.equal(modal.isConnected, false);
|
|
const newer = app.exported('Synthetic answer B');
|
|
print.click();
|
|
await tick();
|
|
assert.deepEqual(app.calls.prints, []);
|
|
assert.deepEqual(app.calls.writes, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
assert.equal(newer.isConnected, true);
|
|
assert.ok(app.w.document.querySelector('#unrelated'));
|
|
});
|
|
|
|
test('late inline Filesystem completion has no Share, print or toast and uses a distinct filename', async t => {
|
|
const app = ui(t, { native: true });
|
|
const pending = deferred();
|
|
app.w.Capacitor.Plugins = app.plugins();
|
|
app.w.Capacitor.Plugins.Filesystem.writeFile = args => { app.calls.writes.push(args); return app.calls.writes.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); };
|
|
const old = app.exported();
|
|
old.querySelector('#assistant-export-print').click();
|
|
await tick();
|
|
app.freezeAndReplace();
|
|
assert.equal(old.isConnected, false);
|
|
app.exported('Synthetic answer B').querySelector('#assistant-export-print').click();
|
|
await tick();
|
|
const shares = app.calls.shares.length;
|
|
app.calls.toasts.length = 0;
|
|
pending.resolve({ uri: 'file://' + app.calls.writes[0].path });
|
|
await tick();
|
|
assert.equal(app.calls.shares.length, shares);
|
|
assert.deepEqual(app.calls.prints, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
assert.notEqual(app.calls.writes[0].path, app.calls.writes[1].path);
|
|
});
|
|
|
|
for (const stage of ['native rejection', 'share rejection', 'native return', 'browser fallback']) {
|
|
test(`inline ${stage} checks original owner before fallbacks`, async t => {
|
|
const app = ui(t, { native: true });
|
|
app.w.Capacitor.Plugins = app.plugins();
|
|
if (stage.startsWith('native')) app.w.NativePrint = { printHtml() { app.freezeAndReplace(); if (stage.endsWith('rejection')) throw new Error('unavailable'); } };
|
|
if (stage === 'share rejection') app.w.Capacitor.Plugins.Share.share = async () => { app.freezeAndReplace(); throw new Error('unavailable'); };
|
|
if (stage === 'browser fallback') {
|
|
app.w.Capacitor.Plugins = {};
|
|
// The click handler yields on its first helper even if there is no bridge.
|
|
}
|
|
const modal = app.exported();
|
|
modal.querySelector('#assistant-export-print').click();
|
|
if (stage === 'browser fallback') app.freezeAndReplace();
|
|
await tick();
|
|
assert.deepEqual(app.calls.prints, []);
|
|
assert.deepEqual(app.calls.toasts, []);
|
|
assert.equal(app.calls.writes.length, stage === 'share rejection' ? 1 : 0);
|
|
assert.equal(modal.isConnected, false);
|
|
});
|
|
}
|
|
|
|
for (const mode of ['native', 'filesystem', 'browser']) {
|
|
test(`same-owner inline export succeeds: ${mode}`, async t => {
|
|
const app = ui(t, { native: true });
|
|
if (mode === 'native') app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) };
|
|
if (mode === 'filesystem') app.w.Capacitor.Plugins = app.plugins();
|
|
app.exported().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;
|
|
if (encoded) {
|
|
const html = Buffer.from(encoded, 'base64').toString();
|
|
assert.match(html, /Synthetic answer A/);
|
|
assert.ok(html.includes(image));
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const frozen of [false, true]) {
|
|
test(`desktop export ${frozen ? 'closes on freeze without delayed/click print' : 'prints on timer and click'}`, t => {
|
|
const app = ui(t, { inline: false });
|
|
const popup = new JSDOM('<body></body>', { url: 'https://example.test' });
|
|
const p = popup.window;
|
|
let closed = 0;
|
|
p.focus = () => {};
|
|
p.print = () => app.calls.prints.push('popup');
|
|
const dispose = p.close.bind(p);
|
|
t.after(dispose);
|
|
p.close = () => { closed++; }; // retain detached callbacks to exercise them
|
|
app.w.open = () => p;
|
|
app.exported();
|
|
const print = p.document.querySelector('#assistant-export-print');
|
|
if (frozen) app.freezeAndReplace();
|
|
for (const timer of app.timers) timer();
|
|
print.click();
|
|
assert.equal(app.calls.prints.length, frozen ? 0 : 2);
|
|
assert.equal(closed > 0, frozen);
|
|
assert.ok(app.w.document.querySelector('#unrelated'));
|
|
});
|
|
}
|
|
|
|
test('image preview closes only its owned modal on boundary closure', t => {
|
|
const app = ui(t);
|
|
const unrelated = app.w.document.createElement('div');
|
|
unrelated.className = 'assistant-image-modal';
|
|
app.w.document.body.append(unrelated);
|
|
app.store.renderGeneratedImage(image);
|
|
app.store.openImagePreview('img-1');
|
|
assert.equal(unrelated.isConnected, true);
|
|
const owned = app.w.document.querySelector('.assistant-image-modal[role="dialog"]');
|
|
assert.ok(owned);
|
|
app.freezeAndReplace();
|
|
assert.equal(owned.isConnected, false);
|
|
assert.equal(unrelated.isConnected, true);
|
|
});
|