Files
stack/packages/webui/tests/w15-files-browser.test.mjs

310 lines
22 KiB
JavaScript

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 = '<h3>Files</h3><div id="review-a"></div><div id="review-b"></div><div id="review-c"></div>';
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');
});