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('
', { 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('', { 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('', { 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); });