150 lines
16 KiB
JavaScript
150 lines
16 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { setup, state, REQUEST, THREAD, DECISION, question, wait, draft, candidate } from './helpers/w15-requests.mjs';
|
|
|
|
const archived = { id: THREAD, name: 'Archived', repo: 'library', archived: true };
|
|
const live = { id: THREAD, name: 'Live', repo: 'library' };
|
|
const active = state({ question, candidate, snapshot: 'snapshot-1', wait: wait('question', 'question', 'q-1') });
|
|
const material = { ...draft, snapshot: { id: 'snapshot-1', revision: 2 } };
|
|
const inbox = [{ id: DECISION, question: 'Choose', console: { kind: 'decision', options: [{ key: 'yes', text: 'Approve exact PRD' }] } }];
|
|
const until = (b, expression) => b.evaluate(`(async()=>{const end=Date.now()+10000;while(!(${expression})){if(Date.now()>end)throw Error('timed out: '+${JSON.stringify(expression)});await new Promise(r=>setTimeout(r,25))}return true})()`);
|
|
|
|
async function instrument(b) {
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(()=>{
|
|
window.__trace=[]; const original=window.fetch;
|
|
window.fetch=async(url,init={})=>{const path=String(url),method=(init.method||'GET').toUpperCase();window.__trace.push({path,method});
|
|
if(method==='GET' && path.includes('/api/chat/threads/') && path.includes('/messages')) {
|
|
const r=await original(url,init),data=await r.json();data.rows.push({id:'70000000-0000-4000-8000-000000000007',from_role:'assistant',at:'2026-11-05T12:00:00Z',body:JSON.stringify({notice:'library-write',op:'write',path:'notes.md',commit:'${'a'.repeat(40)}'})});return new Response(JSON.stringify(data));
|
|
}
|
|
if(path.startsWith('/api/library/status'))return new Response(JSON.stringify({served:'${'b'.repeat(40)}',problems:[]}));
|
|
return original(url,init);
|
|
}; window.confirm=()=>true;
|
|
})()` });
|
|
}
|
|
async function force(b, markup, selector, submit = false) {
|
|
await b.evaluate(`(()=>{const root=document.querySelector('.request-panel')||document.querySelector('.chat-area');root.insertAdjacentHTML('beforeend',${JSON.stringify(markup)});const e=root.querySelector(${JSON.stringify(selector)});${submit ? "e.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}))" : 'e.click()'};return true})()`);
|
|
}
|
|
|
|
test('known archived conversation suppresses unguarded controls and rejects forced events; live restores them', async t => {
|
|
const { b, navigate } = await setup(t, { states: { [REQUEST]: active }, draft: material, shares: [{ to: 'user:reader' }], archiveRows: [archived] });
|
|
await instrument(b);
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `sessionStorage.setItem('mosaic-request-answer:${REQUEST}',JSON.stringify({op:'answer-retained',text:'answer',question:'q-1',revision:7}));` });
|
|
await navigate(1000);
|
|
await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('.chat-log').textContent.includes('wrote notes.md')`);
|
|
assert.match(await b.evaluate(`document.querySelector('.chat-conversation').textContent`), /Build a countdown timer/);
|
|
assert.match(await b.evaluate(`document.querySelector('.request-panel').textContent`), /Which platform/);
|
|
for (const selector of ['#chat-send-form', '#chat-resume', '#chat-share-form', '[data-unshare]', '[data-revert]', '.request-answer', '[data-request-resend]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]']) {
|
|
assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), false, selector);
|
|
}
|
|
for (const selector of ['#chat-session-form button', '#chat-clear', '#chat-cancel']) assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector);
|
|
await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
|
|
assert.equal(await b.evaluate(`document.querySelector('#chat-cancel').disabled`), true);
|
|
await b.evaluate(`(()=>{const e=document.querySelector('#chat-cancel');e.disabled=false;e.click();const f=document.querySelector('#chat-session-form');f.querySelector('input').value='new';f.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));return true})()`);
|
|
await force(b, `<button data-revert="${'a'.repeat(40)}">Revert</button>`, '[data-revert]');
|
|
await force(b, '<form class="request-answer"><textarea>answer</textarea><button>Send</button></form>', '.request-answer', true);
|
|
for (const [attr, value] of [['resend',''],['mode','autonomous'],['cancel',''],['cancel-confirm',''],['revoke','']]) await force(b, `<button data-request-${attr}="${value}">Go</button>`, `[data-request-${attr}]`);
|
|
await force(b, '<form class="request-revoke-form"><textarea>reason</textarea><button>Revoke</button></form>', '.request-revoke-form', true);
|
|
assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []);
|
|
await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
|
|
await until(b, `document.querySelector('.request-answer') && document.querySelector('[data-revert]') && document.querySelector('[data-request-resend]')`);
|
|
for (const selector of ['#chat-session-form button', '#chat-share-form button', '[data-unshare]', '[data-revert]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-request-resend]']) assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector);
|
|
await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
|
|
assert.equal(await b.evaluate(`document.querySelector('#chat-cancel').disabled`), false);
|
|
});
|
|
|
|
test('known archived request rejects stale decision confirmation and restores it on live lookup', async t => {
|
|
const selection = state({ project: null, phase: 'selection', selection: { decision: DECISION }, wait: wait('decision', 'request-mode', DECISION) });
|
|
const { b, navigate } = await setup(t, { states: { [REQUEST]: selection }, inbox, allowConfirmation: true });
|
|
await instrument(b);
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__liveProjects=[${JSON.stringify(live)}];` });
|
|
await navigate(1000);
|
|
await until(b, `document.querySelector('[data-request-choice=yes]')`);
|
|
await b.evaluate(`document.querySelector('[data-request-choice=yes]').click();true`);
|
|
await until(b, `document.querySelector('[data-request-confirm]')`);
|
|
await b.evaluate(`window.__trace=[];window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
|
|
await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived')`);
|
|
assert.equal(await b.evaluate(`!!document.querySelector('[data-request-choice], [data-request-confirm]')`), false);
|
|
await force(b, '<button data-request-choice="yes">Choose</button>', '[data-request-choice]');
|
|
await force(b, '<button data-request-confirm>Confirm</button>', '[data-request-confirm]');
|
|
assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []);
|
|
await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
|
|
await until(b, `document.querySelector('[data-request-confirm]')`);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-request-confirm]').disabled`), false);
|
|
});
|
|
|
|
test('known archived settings remain readable and reject a retained repository save form', async t => {
|
|
const { b, navigate } = await setup(t, { states: { [REQUEST]: active }, archiveRows: [archived] });
|
|
await instrument(b);
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(()=>{const original=window.fetch;window.fetch=async(url,init={})=>{const path=String(url);if(path==='/api/config'){const r=await original(url,init),v=await r.json();return new Response(JSON.stringify({...v,projectRemotes:true,projectRemoteWrites:true}));}if(path==='/api/project-remotes/library'){window.__trace.push({path,method:init.method||'GET'});return new Response(JSON.stringify({project:'library',remote:null,revision:'${'a'.repeat(64)}',firstSaveAllowed:true}));}return original(url,init)};})()` });
|
|
await navigate(1000);
|
|
await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];location.hash='#/projects/${THREAD}/settings';true`);
|
|
await until(b, `document.querySelector('#project-remote-url')`);
|
|
await b.evaluate(`window.__oldRemoteForm=document.querySelector('.project-remote form');const e=document.querySelector('#project-remote-url');e.value='https://git.example/owner/repo';e.dispatchEvent(new Event('input',{bubbles:true}));window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];window.MosaicChat.render({path:'/projects/${THREAD}/settings',container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`);
|
|
await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive'`);
|
|
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /Repository URL/);
|
|
assert.match(await b.evaluate(`document.querySelector('.chat-area').textContent`), /archived/i);
|
|
assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false);
|
|
await b.evaluate(`window.__trace=[];window.__oldRemoteForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));true`);
|
|
assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []);
|
|
await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.MosaicChat.render({path:'/projects/${THREAD}/settings',container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`);
|
|
await until(b, `document.querySelector('#project-remote-url')`);
|
|
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), false);
|
|
await b.evaluate(`(()=>{window.__oldRemoteForm=document.querySelector('.project-remote form');const e=document.querySelector('#project-remote-url');e.value='https://git.example/owner/repo';e.dispatchEvent(new Event('input',{bubbles:true}));window.__liveProjects=[];window.__archiveRows=[${JSON.stringify({ ...archived, repo: 'other' })}];window.MosaicChat.render({path:'/projects/${THREAD}/settings',container:document.querySelector('#bus-view'),canSend:true,navigated:false});return true})()`);
|
|
await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive' && document.querySelector('.chat-area')?.textContent.includes('Repository: other')`);
|
|
await b.evaluate(`window.__trace=[];window.__oldRemoteForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));true`);
|
|
assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []);
|
|
});
|
|
|
|
test('archiving blocks valid retained cancel and revoke confirmations until live again', async t => {
|
|
const { b, navigate } = await setup(t, { states: { [REQUEST]: active }, draft: material });
|
|
await instrument(b);
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__liveProjects=[${JSON.stringify(live)}];` });
|
|
await navigate(1000);
|
|
await until(b, `document.querySelector('[data-request-revoke]') && document.querySelector('[data-request-cancel]')`);
|
|
await b.evaluate(`document.querySelector('[data-request-revoke]').click();const e=document.querySelector('.request-revoke textarea');e.value='valid reason';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('[data-request-cancel]').click();true`);
|
|
await until(b, `document.querySelector('.request-revoke-form') && document.querySelector('[data-request-cancel-confirm]')`);
|
|
await b.evaluate(`window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
|
|
await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('.request-panel')`);
|
|
await b.evaluate(`new Promise(resolve=>setTimeout(()=>resolve(true),50))`);
|
|
assert.equal(await b.evaluate(`!!document.querySelector('.request-revoke-form, [data-request-cancel-confirm]')`), false);
|
|
await b.evaluate(`window.__trace=[];true`);
|
|
await force(b, '<button data-request-cancel-confirm>Cancel</button>', '[data-request-cancel-confirm]');
|
|
await force(b, '<form class="request-revoke-form"><textarea>valid reason</textarea><button>Revoke</button></form>', '.request-revoke-form', true);
|
|
assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []);
|
|
await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
|
|
await until(b, `document.querySelector('.request-revoke-form') && document.querySelector('[data-request-cancel-confirm]')`);
|
|
assert.equal(await b.evaluate(`document.querySelector('.request-revoke textarea').value`), 'valid reason');
|
|
});
|
|
|
|
test('known archived origin retains guarded picker and shows a real refusal', async t => {
|
|
const { atPicker } = await import('./helpers/archive-origin.mjs');
|
|
const { BusReadError } = await import('../src/bus.mjs');
|
|
const { startServer } = await import('../src/serve.mjs');
|
|
const { close } = await import('./fixture.mjs');
|
|
const { createSsoSession } = await import('./helpers/sso-session.mjs');
|
|
const { request } = await import('node:http');
|
|
const x = await atPicker(t), project = x.store.get('SELECT project FROM threads WHERE id=?', x.state.thread).project;
|
|
await x.call('human', 'project.archive', { project });
|
|
const auth = await createSsoSession(t, { host: 'chat.example.com' });
|
|
const bus = async (verb, args) => { try { return await x.call('console', verb, args); } catch (e) { throw new BusReadError(e.code); } };
|
|
const server = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), requestRead: bus, requestWrite: bus, log: () => {} });
|
|
t.after(() => close(server));
|
|
const headers = auth.writeHeaders(await auth.signIn(server.address().port));
|
|
let response;
|
|
const forwardSend = req => new Promise((resolve, reject) => {
|
|
const out = request({ host: '127.0.0.1', port: server.address().port, path: new URL(req.url).pathname, method: 'POST', headers }, res => {
|
|
let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { response = { status: res.statusCode, body: JSON.parse(data) }; resolve(response); });
|
|
}); out.on('error', reject); out.end(req.postData);
|
|
});
|
|
const { b, navigate } = await setup(t, { request: x.source.request, thread: x.state.thread, project, states: { [x.source.request]: x.state }, projects: [{ project: 'old', name: 'Old' }], archiveRows: [{ id: project, name: 'Archived', repo: null, archived: true }], forwardSend: true }, { forwardSend });
|
|
await navigate(1000);
|
|
await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('[data-request-project=old]')`);
|
|
assert.equal(await b.evaluate(`!!document.querySelector('[data-request-cancel]')`), false);
|
|
await b.evaluate(`document.querySelector('[data-request-project=old]').click();true`);
|
|
await until(b, `document.querySelector('.request-receipt')?.textContent.includes('Not picked')`);
|
|
assert.equal(response.status, 409);
|
|
assert.equal(response.body.error, 'project-archived');
|
|
assert.match(await b.evaluate(`document.querySelector('.request-receipt').textContent`), /Not picked.*project-archived/);
|
|
assert.equal(x.journal.readRequest('demo', x.source.request).revision, x.state.revision);
|
|
});
|