import test from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { writeFile } from 'node:fs/promises'; import { filesFixture } from './helpers/w15-files.mjs'; import { setup as conversationFixture, T, S } from './helpers/w15-conversations.mjs'; import { setup as requestFixture, state, REQUEST } from './helpers/w15-requests.mjs'; test('Files presents reviewed delivery and every names-only draft state at both widths', async t => { const { f, b, errors } = await filesFixture(t); const shots = process.env.MOSAIC_UX_SHOTS; const state = { version: 1, revision: 1, phase: 'complete', request: 'request-1', delivery: 'delivery-1', delivery_evidence: { id: 'delivery-1', request: 'request-1', artifact: { path: 'output/timer.html' } } }; const base = { available: true, entries: [], truncated: false, contentAvailable: false, observedAt: '2026-11-05T00:00:00.000Z' }; const scenarios = [ ['draft-list', { ...base, entries: [{ name: 'src/timer.js' }, { name: 'README.md' }] }, /Names only.*src\/timer.js.*README.md/s], ['draft-empty', base, /No draft files found/], ['draft-unavailable', { ...base, available: false }, /Draft workspace is unavailable/], ['draft-truncated', { ...base, entries: [{ name: 'src/timer.js' }], truncated: true }, /src\/timer.js.*truncated/s], ['draft-error', null, /Could not read draft workspace/], ]; for (const width of [1440, 390]) { await b.viewport(width, 1000); await b.navigate(f.base + '/#/'); await b.evaluate(`(async () => { const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = '/files.css'; document.head.append(css); await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/files.js'; s.onload = resolve; s.onerror = reject; document.head.append(s); }); window.fixtureState = ${JSON.stringify(state)}; const main = document.querySelector('#main'); main.replaceChildren(); const mount = document.createElement('div'); mount.id = 'slice-fixture'; main.append(mount); })()`); for (const [name, workspace, expected] of scenarios) { await b.evaluate(`(async () => { window.fixtureWorkspace = ${JSON.stringify(workspace)}; await MosaicFiles.mount({container: document.querySelector('#slice-fixture'), requestIds:['request-1']}); })()`); const text = await b.evaluate("document.querySelector('#slice-fixture').textContent"); assert.match(text, /Reviewed delivery.*timer.html.*Open as text.*Download.*Product workspace, draft/s); assert.match(text, expected); assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').getAttribute('href')"), '/api/requests/request-1/artifact/delivery-1/text'); assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').rel"), 'noopener noreferrer'); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true); await b.evaluate("document.querySelector('#slice-fixture a[download]').focus()"); await b.key('Tab'); assert.equal(await b.evaluate("document.activeElement.getAttribute('aria-label')"), 'Product workspace, draft'); await b.key('Tab', 'Tab', 8); assert.equal(await b.evaluate("document.activeElement.hasAttribute('download')"), true); await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 2, y: 2, button: 'left', clickCount: 1 }); await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 2, y: 2, button: 'left', clickCount: 1 }); if (shots) { await b.screenshot(join(shots, `files-${name}-${width}.png`)); const { cssContentSize } = await b.call('Page.getLayoutMetrics'); const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 } }); await writeFile(join(shots, `files-${name}-${width}-full.png`), Buffer.from(data, 'base64')); } } } await b.evaluate(`(async () => { window.fixtureState.request = 'foreign'; await MosaicFiles.mount({container: document.querySelector('#slice-fixture'), requestIds:['request-1']}); })()`); assert.equal(await b.evaluate("document.querySelectorAll('#slice-fixture a').length"), 0); assert.deepEqual(errors, []); }); async function polishFixture(t) { const fixture = await filesFixture(t); const { f, b } = fixture; await b.navigate(f.base + '/#/'); await b.evaluate(`(async () => { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/files.js'; s.onload = resolve; s.onerror = reject; document.head.append(s); }); const main = document.querySelector('#main'); const section = document.createElement('section'); section.id = 'files-block'; const heading = document.createElement('h3'); heading.textContent = 'Files'; const mount = document.createElement('div'); mount.id = 'slice-fixture'; section.append(heading, mount); main.append(section); window.fixtureState = { version: 1, revision: 1, phase: 'complete', request: 'request-1', delivery: 'delivery-1', delivery_evidence: { id: 'delivery-1', request: 'request-1', artifact: { path: 'output/old.html' } } }; window.fixtureWorkspace = { available: true, entries: [{ name: 'old-draft.txt' }], truncated: false, contentAvailable: false, observedAt: '2026-11-05T00:00:00.000Z' }; window.fixtureFetch = window.fetch; window.replaceFiles = () => { const old = document.querySelector('#slice-fixture'); const next = document.createElement('div'); next.id = old.id; old.replaceWith(next); return next; }; })()`); return fixture; } test('Files caller heading is the only Files heading for data and unavailable states', async t => { const { b, errors } = await polishFixture(t); await b.evaluate("MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1']})"); const count = "[...document.querySelectorAll('#files-block h1, #files-block h2, #files-block h3, #files-block h4, #files-block h5, #files-block h6')].filter(h => h.textContent === 'Files').length"; assert.equal(await b.evaluate(count), 1, 'data state has one Files heading'); await b.evaluate(`(async () => { window.fetch = (url, init) => String(url) === '/api/config' ? Promise.resolve(new Response('{}', {status: 503})) : window.fixtureFetch(url, init); await MosaicFiles.mount({container: window.replaceFiles(), requestIds:['request-1']}); })()`); assert.match(await b.evaluate("document.querySelector('#files-block').textContent"), /Files are unavailable\./); assert.equal(await b.evaluate(count), 1, 'unavailable state has one Files heading'); assert.deepEqual(errors, []); }); test('Files remount retains previous data while refreshing, then replaces it', async t => { const { b, errors } = await polishFixture(t); await b.evaluate("MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1']})"); await b.evaluate(`(() => { window.fixtureState.delivery_evidence.artifact.path = 'output/new.html'; window.fixtureWorkspace.entries = [{name:'new-draft.txt'}]; window.fetch = async (url, init) => { if (String(url) === '/api/config') await new Promise(resolve => { window.releaseFiles = resolve; }); return window.fixtureFetch(url, init); }; window.pendingFiles = MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1', 'request-1']}); })()`); const text = await b.evaluate("document.querySelector('#slice-fixture').textContent"); assert.match(text, /old\.html/); assert.match(text, /old-draft\.txt/); assert.doesNotMatch(text, /Reading files…/); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); const updated = await b.evaluate("document.querySelector('#slice-fixture').textContent"); assert.match(updated, /new\.html/); assert.match(updated, /new-draft\.txt/); assert.doesNotMatch(updated, /old\.html|old-draft\.txt/); assert.deepEqual(errors, []); }); test('Files new keys load initially and sign-out prevents cached content', async t => { const { b, errors } = await polishFixture(t); await b.evaluate("MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1']})"); await b.evaluate(`(() => { window.fetch = async (url, init) => { if (String(url) === '/api/config') await new Promise(resolve => { window.releaseFiles = resolve; }); return window.fixtureFetch(url, init); }; window.pendingFiles = MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1'], key:'new-thread'}); })()`); assert.match(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /Reading files…/); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); await b.evaluate(`(() => { window.pendingBeforeSignout = MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1'], key:'new-thread'}); document.dispatchEvent(new CustomEvent('mosaic-signed-out')); })()`); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingBeforeSignout; })()'); await b.evaluate(`(() => { window.pendingFiles = MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1'], key:'new-thread'}); })()`); assert.doesNotMatch(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /old\.html|old-draft\.txt/); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); assert.doesNotMatch(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /old\.html|old-draft\.txt/); assert.deepEqual(errors, []); }); test('Files cache rejects older settlements from detached containers for the same key', async t => { const { b, errors } = await polishFixture(t); await b.evaluate(`(() => { let first = true; window.fetch = async (url, init) => { if (String(url) === '/api/config' && first) { first = false; await new Promise(resolve => { window.releaseOld = resolve; }); } return window.fixtureFetch(url, init); }; window.oldMount = MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1'], key:'thread-1'}); })()`); await b.evaluate(`(async () => { window.fixtureState.delivery_evidence.artifact.path = 'output/newest.html'; await MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1'], key:'thread-1'}); window.fixtureState.delivery_evidence.artifact.path = 'output/stale.html'; window.releaseOld(); await window.oldMount; window.fetch = async (url, init) => { if (String(url) === '/api/config') await new Promise(resolve => { window.releaseFiles = resolve; }); return window.fixtureFetch(url, init); }; window.pendingFiles = MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1'], key:'thread-1'}); })()`); const text = await b.evaluate("document.querySelector('#slice-fixture').textContent"); assert.match(text, /newest\.html/); assert.doesNotMatch(text, /stale\.html|Reading files…/); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); assert.deepEqual(errors, []); }); test('Files caller key preserves content as request IDs change', async t => { const { b, errors } = await polishFixture(t); await b.evaluate("MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1'], key:'thread-1'})"); await b.evaluate(`(() => { window.fetch = async (url, init) => { if (String(url) === '/api/config') await new Promise(resolve => { window.releaseFiles = resolve; }); return window.fixtureFetch(url, init); }; window.pendingFiles = MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1','request-2'], key:'thread-1'}); })()`); assert.match(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /old\.html/); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); assert.deepEqual(errors, []); }); test('Files caches the latest completed mount even after its container is detached', async t => { const { b, errors } = await polishFixture(t); await b.evaluate(`(() => { window.fetch = async (url, init) => { if (String(url) === '/api/config') await new Promise(resolve => { window.releaseFiles = resolve; }); return window.fixtureFetch(url, init); }; window.pendingFiles = MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1'], key:'detached-thread'}); window.replaceFiles(); })()`); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); await b.evaluate("window.pendingFiles = MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1'], key:'detached-thread'}); true"); assert.match(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /old\.html.*old-draft\.txt/s); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); assert.deepEqual(errors, []); }); test('Files ignores invalid caller keys and never restores cache under signed-out body', async t => { const { b, errors } = await polishFixture(t); await b.evaluate("MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1'], key:{}})"); await b.evaluate(`(() => { window.fetch = async (url, init) => { if (String(url) === '/api/config') await new Promise(resolve => { window.releaseFiles = resolve; }); return window.fixtureFetch(url, init); }; window.pendingFiles = MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1'], key:'x'.repeat(321)}); })()`); assert.match(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /old\.html/); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; document.body.classList.add("signed-out"); window.pendingFiles = MosaicFiles.mount({container:window.replaceFiles(), requestIds:["request-1"]}); })()'); assert.doesNotMatch(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /old\.html|old-draft\.txt/); await b.evaluate('(async () => { window.releaseFiles(); await window.pendingFiles; })()'); assert.doesNotMatch(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /old\.html|old-draft\.txt/); assert.deepEqual(errors, []); }); test('project conversations link to Files and legacy chat mounts one Files heading', async t => { const { b, render, errors } = await conversationFixture(t); await b.evaluate(`(async () => { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/files.js'; s.onload = resolve; s.onerror = reject; document.head.append(s); }); })()`); await b.evaluate("window.__realFiles = window.MosaicFiles; window.MosaicFiles = {mount(o) { window.__filesKey = o.key; return window.__realFiles.mount(o); }}; true"); await render(`/projects/threads/${T}/sessions/${S}`); assert.equal(await b.evaluate(`!!document.querySelector('.project-tabs a[href$="/files"]') && !document.querySelector('[data-chat-files]')`), true); await b.evaluate(`history.replaceState(null,'',${JSON.stringify('#/chat/threads/'+T+'/sessions/'+S)})`); await render(`/chat/threads/${T}/sessions/${S}`); assert.equal(await b.evaluate('window.__filesKey'), `thread:${T}`); await b.evaluate(`(async () => { const end = Date.now() + 5000; while (!document.querySelector('[data-chat-files]')?.textContent.includes('Files are unavailable.')) { if (Date.now() > end) throw Error('Files did not settle'); await new Promise(r => setTimeout(r, 20)); } })()`); assert.equal(await b.evaluate("[...document.querySelectorAll('.chat-files h1, .chat-files h2, .chat-files h3, .chat-files h4, .chat-files h5, .chat-files h6')].filter(h => h.textContent === 'Files').length"), 1); assert.deepEqual(errors, []); }); test('real request wrapper and Files module render one heading', async t => { const { b, navigate, consoleErrors } = await requestFixture(t, {states:{[REQUEST]:state({})}}); await navigate(1440); await b.evaluate(`(async () => { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/files.js'; s.onload = resolve; s.onerror = reject; document.head.append(s); }); document.querySelector('[data-request-reload]').click(); })()`); await b.evaluate(`(async () => { const end = Date.now() + 5000; while (!document.querySelector('[data-request-files]')?.textContent.includes('Product workspace, draft')) { if (Date.now() > end) throw Error('Files did not settle'); await new Promise(r => setTimeout(r, 20)); } })()`); assert.equal(await b.evaluate("[...document.querySelectorAll('.request-files h1, .request-files h2, .request-files h3, .request-files h4, .request-files h5, .request-files h6')].filter(h => h.textContent === 'Files').length"), 1); assert.deepEqual(consoleErrors, []); }); test('Files keeps settled read errors visible on refresh', async t => { const { b, errors } = await polishFixture(t); await b.evaluate("MosaicFiles.mount({container:document.querySelector('#slice-fixture'), requestIds:['request-1']})"); await b.evaluate(`(async () => { window.fetch = (url, init) => String(url).startsWith('/api/requests/') ? Promise.resolve(new Response('{}', {status:503})) : window.fixtureFetch(url, init); await MosaicFiles.mount({container:window.replaceFiles(), requestIds:['request-1']}); })()`); const text = await b.evaluate("document.querySelector('#slice-fixture').textContent"); assert.match(text, /Reviewed files are unavailable\./); assert.match(text, /Could not read draft workspace\. Try again later\./); assert.doesNotMatch(text, /old\.html|old-draft\.txt/); assert.deepEqual(errors, []); }); // Review r1 F1/F2: per-key ordering across live containers (from the reviewer probes). async function orderFixture(t) { const fixture = await filesFixture(t); await fixture.b.navigate(fixture.f.base + '/#/'); await fixture.b.evaluate(`(() => { const section = document.createElement('section'); section.innerHTML = '

Files

'; document.querySelector('#main').append(section); window.reviewState = name => ({ version: 1, revision: 1, phase: 'complete', request: 'request-1', delivery: 'delivery-1', delivery_evidence: {id:'delivery-1', request:'request-1', artifact:{path:'output/' + name}} }); window.fixtureState = window.reviewState('seed.html'); window.fixtureWorkspace = {available:true, entries:[{name:'seed-draft.txt'}], truncated:false}; window.reviewFetch = window.fetch; window.reviewMount = (name, options = {}) => MosaicFiles.mount({container:document.querySelector('#review-' + name), requestIds:['request-1'], ...options}); })()`); return fixture; } test('Files two live same-key mounts reject a late older on-screen settlement', async t => { const { b, errors } = await orderFixture(t); await b.evaluate("reviewMount('a')"); await b.evaluate(`(() => { window.fixtureState = reviewState('stale.html'); window.fixtureWorkspace.entries = [{name:'stale-draft.txt'}]; let ready; window.reviewOldSnapshot = new Promise(resolve => ready = resolve); let first = true; window.fetch = async (url, init) => { if (String(url) === '/api/requests/request-1' && first) { first = false; const capturedResponse = await window.reviewFetch(url, init); await new Promise(resolve => { window.reviewReleaseOld = resolve; ready(); }); return capturedResponse; } return window.reviewFetch(url, init); }; window.reviewOld = reviewMount('a'); })()`); await b.evaluate('window.reviewOldSnapshot'); await b.evaluate(`(async () => { window.fixtureState = reviewState('newest.html'); window.fixtureWorkspace.entries = [{name:'newest-draft.txt'}]; await reviewMount('b'); window.reviewReleaseOld(); await window.reviewOld; window.fetch = async (url, init) => { if(String(url) === '/api/config') await new Promise(resolve => window.reviewReleaseNext = resolve); return window.reviewFetch(url, init); }; window.reviewNext = reviewMount('c'); })()`); const actual = await b.evaluate(`Object.fromEntries(['a','b','c'].map(id => [id, { connected:document.querySelector('#review-' + id).isConnected, text:document.querySelector('#review-' + id).textContent }]))`); await b.evaluate('(async () => { reviewReleaseNext(); await reviewNext; })()'); assert.deepEqual(errors, []); assert.match(actual.b.text, /newest\.html/); assert.match(actual.c.text, /newest\.html/, 'the shared cache correctly retains the newer mount'); assert.doesNotMatch(actual.a.text, /stale\.html|stale-draft\.txt/, 'the superseded live mount must not paint an older result'); }); test('Files a new container for the same key keeps a completed result while a later read is pending', async t => { const { b, errors } = await orderFixture(t); await b.evaluate(`(() => { window.fetch = async (url, init) => { if(String(url) === '/api/config') await new Promise(resolve => window.reviewReleaseFirst = resolve); return window.reviewFetch(url, init); }; window.reviewFirst = reviewMount('a'); })()`); await b.evaluate(`(() => { window.fetch = async (url, init) => { if(String(url) === '/api/config') await new Promise(resolve => window.reviewReleaseSecond = resolve); return window.reviewFetch(url, init); }; window.reviewSecond = reviewMount('b'); })()`); await b.evaluate('(async () => { reviewReleaseFirst(); await reviewFirst; })()'); await b.evaluate(`(() => { window.fetch = async (url, init) => { if(String(url) === '/api/config') await new Promise(resolve => window.reviewReleaseThird = resolve); return window.reviewFetch(url, init); }; window.reviewThird = reviewMount('c'); })()`); const actual = await b.evaluate(`({completed:document.querySelector('#review-a').textContent, newMount:document.querySelector('#review-c').textContent})`); await b.evaluate('(async () => { reviewReleaseSecond(); await reviewSecond; reviewReleaseThird(); await reviewThird; })()'); assert.deepEqual(errors, []); assert.match(actual.completed, /seed\.html/); assert.match(actual.newMount, /seed\.html/, 'a completed result exists for this request key'); });