feat(webui): update request presentation (#797)
This commit is contained in:
1 parent
05fdfef9ea
commit
ea0b676541
5 files changed
+251
-20
No files matched your search
@@ -0,0 +1,15 @@
|
||||
/* Request presentation. Shell loads this after the shared styles. */
|
||||
.request-panel { border-radius: 6px; padding: 1rem; overflow-wrap: anywhere; }
|
||||
.request-panel .request-head { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; }
|
||||
.request-panel .request-head b { font-size: 1.1rem; }
|
||||
.request-panel .request-phase, .request-panel .request-mode, .request-observed { font-size: .875rem; }
|
||||
.request-panel .request-question, .request-panel .request-delivery, .request-panel .request-files { border: 1px solid var(--line); border-radius: 4px; padding: .75rem; margin-block: .75rem; }
|
||||
.request-panel .request-question-text { font-family: inherit; font-weight: 600; }
|
||||
.request-panel .request-choices { display: grid; gap: .5rem; }
|
||||
.request-panel .request-choices .btn { text-align: start; }
|
||||
.request-panel .btn, .request-panel summary, .request-return { min-height: 44px; }
|
||||
.request-panel summary, .request-return { display: flex; align-items: center; cursor: pointer; }
|
||||
.request-panel pre { max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.request-panel details { margin-block: .5rem; }
|
||||
.request-panel .request-foot { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
||||
.request-panel h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||
@@ -46,7 +46,7 @@
|
||||
};
|
||||
const prdDecision = s => s.phase === 'prd-review' && s.wait?.kind === 'decision' && s.wait.code === 'prd-approval' && text(s.wait.subject);
|
||||
|
||||
const panels = new Map();
|
||||
const panels = new Map(), summaryRuns = new WeakMap(), summaries = new Set();
|
||||
let signedOut = false, timer = null, config = null, loading = null;
|
||||
const out = () => signedOut || document.body.classList.contains('signed-out');
|
||||
const opId = prefix => prefix + '-' + [...crypto.getRandomValues(new Uint8Array(12))].map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
@@ -73,6 +73,8 @@
|
||||
function purge() {
|
||||
forget(); dropAll(); config = null; loading = null;
|
||||
for (const el of document.querySelectorAll('.request-mount')) el.replaceChildren();
|
||||
for (const el of summaries) el.replaceChildren();
|
||||
summaries.clear();
|
||||
}
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
|
||||
document.addEventListener('mosaic-signed-out', () => { signedOut = true; purge(); });
|
||||
@@ -97,7 +99,7 @@
|
||||
let p = panels.get(id);
|
||||
if (!p) {
|
||||
const stored = load(id);
|
||||
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, reading: null, stored, sig: '',
|
||||
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, observedAt: null, reading: null, stored, sig: '',
|
||||
ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } };
|
||||
panels.set(id, p);
|
||||
}
|
||||
@@ -120,15 +122,22 @@
|
||||
if (p.ui.draft === rec.text) p.ui.draft = '';
|
||||
if (!p.ui.receipt) p.ui.receipt = q && q.id === rec.question && q.answer !== null ? 'The question was answered. Your earlier answer is not sent again.' : 'That question is closed. Your unsent answer was discarded.';
|
||||
}
|
||||
function adopt(p, s) {
|
||||
function adopt(p, s, observedAt = null) {
|
||||
if (p.state && s.revision < p.state.revision) return false;
|
||||
if (p.state && (s.revision !== p.state.revision || !same(s.candidate, p.state.candidate))) p.ui.confirm = null;
|
||||
if (p.state && s.snapshot !== p.state.snapshot) p.ui.revokeAsk = false;
|
||||
if (p.state && (s.snapshot !== p.state.snapshot || !same(s.candidate, p.state.candidate))) {
|
||||
p.material = null; p.materialError = '';
|
||||
}
|
||||
if (!observedAt && p.state && !same(s, p.state)) p.observedAt = null;
|
||||
if (!p.state || s.revision >= p.state.revision) p.state = s;
|
||||
p.missing = false; p.failed = false; p.stale = false;
|
||||
reconcile(p);
|
||||
if (observedAt && p.state === s) {
|
||||
p.observedAt = observedAt;
|
||||
document.dispatchEvent(new CustomEvent('mosaic-request-state', { detail: { state: structuredClone(s), observedAt } }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const WAITS = {
|
||||
@@ -153,9 +162,9 @@
|
||||
if (!m.candidate) return '<p class="request-prd-empty">No current PRD candidate or approved snapshot. Waiting for a PRD submission.</p>';
|
||||
const c = m.candidate;
|
||||
let h = `<section class="request-prd" aria-label="Exact product PRD"><p><b>Product PRD ${txt(c.prd_version)}</b></p>
|
||||
<p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p>
|
||||
<details><summary>PRD identity and provenance</summary><p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p></details>
|
||||
<pre class="request-prd-text">${txt(m.prd)}</pre><div class="request-requirements" aria-label="Requirements">${m.requirements.map((r, i) =>
|
||||
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
|
||||
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><details><summary>Requirement identity</summary><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p></details><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
|
||||
if (m.snapshot.id) {
|
||||
h += `<p class="request-snapshot">Approved snapshot: <code>${txt(m.snapshot.id)}</code></p>`;
|
||||
if (can) h += `<div class="request-revoke">${p.ui.revokeAsk
|
||||
@@ -173,26 +182,35 @@
|
||||
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
|
||||
const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale;
|
||||
const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null;
|
||||
let body = `<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
|
||||
const returnHash = /^#\/chat\/threads\//.test(location.hash) || /^#\/projects\/threads\//.test(location.hash) ? location.hash : '';
|
||||
let body = `${returnHash ? `<a class="request-return" href="${esc(returnHash)}">Return to conversation</a>` : ''}<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
|
||||
if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
|
||||
if (p.observedAt) body += `<p class="request-observed small">Observed: <time datetime="${esc(p.observedAt)}">${txt(p.observedAt)}</time></p>`;
|
||||
body += waitHtml(s);
|
||||
if (Array.isArray(s.tasks) && s.tasks.length) body += `<p class="request-tasks">Tasks: ${s.tasks.filter(text).map(t => `<a href="#/tasks/${enc(t)}">${txt(t)}</a>`).join(', ')}</p>`;
|
||||
if (s.phase === 'complete' && !s.delivery) body += '<p class="request-legacy-complete">Completed before delivery evidence.</p>';
|
||||
const d = s.delivery_evidence;
|
||||
if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.artifact && d.verification) {
|
||||
if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.project === s.project && d.snapshot === s.snapshot && d.artifact && d.verification) {
|
||||
const pr = d.pull_request;
|
||||
let prUrl = null;
|
||||
try {
|
||||
const u = new URL(pr?.url);
|
||||
if (pr.request === s.request && pr.project === s.project && u.protocol === 'https:' && !u.username && !u.password) prUrl = u.href;
|
||||
} catch { /* No valid bound PR URL. */ }
|
||||
body += `<section class="request-delivery" aria-label="Reviewed delivery"><p><b>Reviewed delivery</b> — not landed or deployed.</p>
|
||||
<p><a href="/api/requests/${enc(s.request)}/artifact/${enc(d.id)}" download>Download reviewed HTML</a></p>
|
||||
<p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
|
||||
<p>${prUrl ? `<a href="${esc(prUrl)}" target="_blank" rel="noopener noreferrer">Open pull request</a>` : 'PR link unavailable.'}</p><details><summary>Delivery evidence</summary><p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
|
||||
Code commit: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.commit)}</code></a><br>Review request: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.review_request)}</code></a><br>Verdict: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.verdict)}</code></a><br>
|
||||
Verification record: <a href="#/docs/${enc(d.project)}/${enc(d.verification.record_path)}">${txt(d.verification.record_path)}</a> at <code>${txt(d.verification.record_commit)}</code><br>
|
||||
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></section>`;
|
||||
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></details></section>`;
|
||||
}
|
||||
body += prdHtml(p, busy, can);
|
||||
if (q) {
|
||||
body += q.answer === null
|
||||
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
|
||||
: `<p class="request-question answered">Answered: ${txt(q.text)}</p>`;
|
||||
: `<p class="request-question answered">Answer received: ${txt(q.text)}. Application is not established by this receipt.</p>`;
|
||||
}
|
||||
if (!can) body += '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
|
||||
if (!can) body += p.stale ? '<p class="request-readonly small muted">Writes are unavailable until the request is read again.</p>' : '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
|
||||
if (can && ui.confirm) {
|
||||
const c = ui.confirm;
|
||||
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
|
||||
@@ -202,7 +220,7 @@
|
||||
<button type="button" class="btn" data-request-unconfirm${busy}>Back</button></div>`;
|
||||
} else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) {
|
||||
body += `<div class="request-choices" role="group" aria-label="${prdDecision(s) ? 'Review this exact product PRD version' : 'Choose how to run this request'}"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
|
||||
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`;
|
||||
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${s.phase === 'selection' && ['guided', 'autonomous', 'existing'].includes(o.key) ? `<b>${o.key === 'existing' ? 'Existing project' : o.key === 'guided' ? 'Guided' : 'Autonomous'}</b>: ` : ''}${txt(o.text)}</button>`).join(' ')}</div>`;
|
||||
}
|
||||
if (can && pickerOpen(s)) {
|
||||
const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null;
|
||||
@@ -211,6 +229,7 @@
|
||||
`<button type="button" class="btn" data-request-project="${esc(x.project)}" aria-pressed="${x.project === picked}"${busy}>${txt(x.name)} <small>(${txt(x.project)})</small></button>`).join(' ')}` : '<p>No project is available to you for this request.</p>'}</div>`
|
||||
: '<p class="request-picker-note">The projects you may use could not be read. Use Read again.</p>';
|
||||
}
|
||||
if (p.stale && openQuestion(s) && ui.draft) body += `<p>Unsent answer retained:</p><pre class="request-retained-draft">${txt(ui.draft)}</pre>`;
|
||||
if (can && openQuestion(s)) {
|
||||
const lost = p.stored && p.stored.question === s.question.id;
|
||||
body += `<form class="request-answer" novalidate><label for="chat-request-answer-${esc(p.id)}">Your answer</label>
|
||||
@@ -231,8 +250,9 @@
|
||||
<button type="button" class="btn" data-request-cancel-confirm${busy}>Yes, cancel the request</button> <button type="button" class="btn" data-request-cancel-keep${busy}>Keep it going</button>`
|
||||
: `<button type="button" class="btn" data-request-cancel${busy}>Cancel this request</button>`}</div>`;
|
||||
}
|
||||
body += `<section class="request-files" aria-label="Files"><h3>Files</h3><div data-request-files><p>Files unavailable.</p></div></section>`;
|
||||
body += `<p class="request-receipt small" role="status" tabindex="-1">${txt(ui.receipt)}</p>`;
|
||||
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}<button type="button" class="btn" data-request-reload${busy}>Read again</button></p>`;
|
||||
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}${p.reading ? '<span role="status">Refreshing request…</span>' : `<button type="button" class="btn" data-request-reload${busy}>Read again</button>`}</p>`;
|
||||
return `<section class="request-panel" data-request="${esc(p.id)}" aria-label="Request">${body}</section>`;
|
||||
}
|
||||
|
||||
@@ -246,6 +266,8 @@
|
||||
const area = inside && a.matches('textarea') ? { id: a.id, start: a.selectionStart, end: a.selectionEnd } : null;
|
||||
const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null;
|
||||
el.innerHTML = html(p);
|
||||
const files = el.querySelector('[data-request-files]');
|
||||
if (files && typeof window.MosaicFiles?.mount === 'function') window.MosaicFiles.mount({ container: files, requestIds: [p.id] });
|
||||
if (!inside) continue;
|
||||
let focus = null;
|
||||
if (area) {
|
||||
@@ -294,9 +316,9 @@
|
||||
try {
|
||||
const { res, json } = await call(`/api/requests/${enc(p.id)}`);
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
if (res?.ok && isState(json, p.id)) { adopt(p, json); await side(p); }
|
||||
else if (res?.status === 404) { p.state = null; p.missing = true; p.failed = false; }
|
||||
else if (p.state) { p.stale = true; p.material = null; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; }
|
||||
if (res?.ok && isState(json, p.id) && adopt(p, json, new Date().toISOString())) { await side(p); }
|
||||
else if (res?.status === 404 && !p.state) { p.state = null; p.missing = true; p.failed = false; }
|
||||
else if (p.state) { p.stale = true; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; }
|
||||
else p.failed = true;
|
||||
p.at = Date.now();
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
@@ -426,7 +448,7 @@
|
||||
// One use, whatever came back.
|
||||
ui.pending = null; ui.confirm = null;
|
||||
ui.receipt = res?.ok && json?.id === c.id && json.op === 'resolved' && json.choice === c.choice
|
||||
? `Recorded: you chose “${c.text}”. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown'));
|
||||
? `Recorded: you chose “${c.text}” for decision ${c.id}${c.candidate ? `, product PRD ${c.candidate.prd_version}` : ''}. Execution owner unavailable. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown'));
|
||||
draw(p);
|
||||
void read(p);
|
||||
}
|
||||
@@ -436,7 +458,7 @@
|
||||
const el = button?.closest('.request-panel');
|
||||
if (!button || !el || out()) return;
|
||||
const p = panels.get(el.dataset.request);
|
||||
if (!p || button.disabled || p.ui.pending || p.reading) return;
|
||||
if (!p || button.disabled || p.ui.pending || p.reading || (p.stale && !('requestReload' in button.dataset))) return;
|
||||
const d = button.dataset;
|
||||
if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); }
|
||||
else if ('requestChoice' in d) void choose(p, d.requestChoice);
|
||||
@@ -459,7 +481,7 @@
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
const p = panels.get(form.closest('.request-panel')?.dataset.request);
|
||||
if (p && !out()) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); }
|
||||
if (p && !out() && !p.stale && !p.reading) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); }
|
||||
});
|
||||
document.addEventListener('input', event => {
|
||||
const area = event.target.closest?.('.request-answer textarea');
|
||||
@@ -506,5 +528,22 @@
|
||||
else void settings().then(run);
|
||||
}
|
||||
|
||||
window.MosaicRequests = Object.freeze({ mount, forget });
|
||||
// The project page supplies known IDs; each is reread through the existing authorized boundary.
|
||||
async function projectSummary({ container, requestIds, project } = {}) {
|
||||
if (!container || out()) return;
|
||||
container.replaceChildren();
|
||||
const run = {}; summaryRuns.set(container, run); summaries.add(container);
|
||||
if (!text(project) || !ID.test(project) || !Array.isArray(requestIds)) return;
|
||||
const ids = [...new Set(requestIds.filter(id => text(id) && ID.test(id)))];
|
||||
const cfg = config || await settings();
|
||||
if (out() || !cfg?.on) return;
|
||||
const rows = await Promise.all(ids.map(async id => {
|
||||
const { res, json } = await call(`/api/requests/${enc(id)}`);
|
||||
return res?.ok && isState(json, id) && json.project === project ? json : null;
|
||||
}));
|
||||
if (out() || !container.isConnected || summaryRuns.get(container) !== run) return;
|
||||
const valid = rows.filter(Boolean);
|
||||
container.innerHTML = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
|
||||
}
|
||||
window.MosaicRequests = Object.freeze({ mount, forget, projectSummary });
|
||||
})();
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
|
||||
export const THREAD = '20000000-0000-4000-8000-000000000002';
|
||||
export const MESSAGE = '40000000-0000-4000-8000-000000000004';
|
||||
export const REQUEST = '50000000-0000-4000-8000-000000000005';
|
||||
export const DECISION = '60000000-0000-4000-8000-000000000006';
|
||||
export const state = extra => ({ version: 1, request: REQUEST, revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null, ...extra });
|
||||
export const candidate = { request: REQUEST, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'REQ-1', title: 'Timer', path: 'req.json', blob: 'd'.repeat(40) }] };
|
||||
export const draft = { candidate, prd: 'A timer that counts down.', requirements: [{ id: 'REQ-1', title: 'Timer', text: 'Count down.' }], snapshot: { id: null, revision: 2 } };
|
||||
export const evidence = { id: 'delivery-1', request: REQUEST, project: 'factory', snapshot: 'snapshot-1', task_ref: 'vikunja:2/41', commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } };
|
||||
export const wait = (kind, code, subject) => ({ kind, code, subject });
|
||||
export const question = { id: 'q-1', text: 'Which platform?', answer: null, options: [] };
|
||||
|
||||
export async function setup(t, boot) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const css = await readFile(new URL('../../src/public/requests.css', import.meta.url), 'utf8');
|
||||
const source = `(() => {
|
||||
const RealDate = Date; window.Date = class extends RealDate { constructor(...args) { super(...(args.length ? args : ['2026-11-05T12:00:00Z'])); } };
|
||||
const boot = ${JSON.stringify(boot)}; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = [];
|
||||
document.addEventListener('mosaic-request-state', e => window.__events.push(e.detail));
|
||||
window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } };
|
||||
document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const path = String(url), method = init.method || 'GET';
|
||||
const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}});
|
||||
if (method !== 'GET') { window.__writes.push({path, method}); if (path === '/api/decisions/${DECISION}/confirmation' && boot.allowConfirmation) return json({id:'${DECISION}',action:'answer',kind:'decision',choice:'yes',text:'Approve exact PRD',question:'Approve the exact PRD?',confirmation:'fixture-token'}); return json({error:'fixture-refusal'}, 403); }
|
||||
if (path === '/api/config') return json({send:true, requests:true, requestWrites:true});
|
||||
if (path === '/api/bus/inbox') return json({rows:boot.inbox || [], unread:0, at:'2026-11-05T12:00:00Z'});
|
||||
if (path.startsWith('/api/bus/')) return json({rows:[], unread:0, at:'2026-11-05T12:00:00Z'});
|
||||
if (path === '/api/business') return json({projects:[]});
|
||||
if (path === '/api/chat/instances') return json({rows:[{instance:'assistant',name:'assistant'}]});
|
||||
if (path === '/api/chat/projects') return json({rows:[]});
|
||||
if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${THREAD}',name:'default',default:true}]});
|
||||
if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${THREAD}',project:'${THREAD}',name:'Project',instance:'assistant'}]});
|
||||
if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${REQUEST}'}],after:'${MESSAGE}',hasMore:false});
|
||||
const m=/^\\/api\\/requests\\/([^/]+)(?:\\/(projects|draft))?$/.exec(path);
|
||||
if (m) { if (m[2] === 'projects') return json([]); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); }
|
||||
window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403);
|
||||
};
|
||||
})();`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
|
||||
await b.call('Fetch.enable', { patterns: [{urlPattern:'*'}] });
|
||||
const network = [], consoleErrors = [];
|
||||
b.on('Runtime.exceptionThrown', e => consoleErrors.push(e.exceptionDetails?.text || 'Runtime exception'));
|
||||
b.on('Runtime.consoleAPICalled', e => { if (e.type === 'error') consoleErrors.push(e.args.map(a => a.value || a.description || '').join(' ')); });
|
||||
b.on('Fetch.requestPaused', e => {
|
||||
const url = e.request.url;
|
||||
if (url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:')) void b.call('Fetch.continueRequest', {requestId:e.requestId}).catch(() => {});
|
||||
else { network.push(url); void b.call('Fetch.failRequest', {requestId:e.requestId,errorReason:'BlockedByClient'}).catch(() => {}); }
|
||||
});
|
||||
const route = `#/chat/threads/${THREAD}`;
|
||||
const navigate = async width => {
|
||||
await b.viewport(width, 1000); await b.evaluate('window.__beforeNavigate=true;true'); await b.navigate(f.base + '/?width=' + width + route);
|
||||
await b.evaluate(`(async()=>{const end=Date.now()+10000;while(window.__beforeNavigate || !document.querySelector('.request-panel')){if(Date.now()>end)throw Error('request panel missing');await new Promise(r=>setTimeout(r,30))}return true})()`);
|
||||
};
|
||||
const shot = async (name, width) => {
|
||||
if (!process.env.MOSAIC_UX_SHOTS) return [];
|
||||
await mkdir(process.env.MOSAIC_UX_SHOTS, {recursive:true});
|
||||
const viewport = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}.png`);
|
||||
const full = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-full.png`);
|
||||
await b.evaluate("document.querySelector('.request-panel').scrollIntoView({block:'start'});window.scrollBy(0,-90);true");
|
||||
await b.screenshot(viewport);
|
||||
const {cssContentSize} = await b.call('Page.getLayoutMetrics');
|
||||
const {data} = await b.call('Page.captureScreenshot', {format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(cssContentSize.width),height:Math.ceil(cssContentSize.height),scale:1}});
|
||||
await writeFile(full, Buffer.from(data,'base64'));
|
||||
const continuation = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-continuation.png`);
|
||||
await b.evaluate("(()=>{let e=document.querySelector('.request-panel').parentElement;while(e && !(e.scrollHeight > e.clientHeight && /(auto|scroll)/.test(getComputedStyle(e).overflowY))) e=e.parentElement;if(e)e.scrollTop=e.scrollHeight;return true})()");
|
||||
await b.screenshot(continuation);
|
||||
return [viewport,full,continuation];
|
||||
};
|
||||
return {f,b,route,navigate,shot,network,consoleErrors};
|
||||
}
|
||||
@@ -578,3 +578,17 @@ test('reviewed delivery shows a download and bound evidence separately from depl
|
||||
for (const value of [evidence.snapshot, evidence.task_ref, evidence.commit, evidence.review_request, evidence.verdict, evidence.verification.record_commit, evidence.artifact.sha256]) assert.ok(panel.includes(value));
|
||||
assert.equal(await b.evaluate(`document.querySelector('${P} .request-delivery a').getAttribute('href')`), `/api/requests/${R}/artifact/delivery-1`);
|
||||
});
|
||||
|
||||
test('reviewed delivery refuses evidence bound to another project or snapshot', async t => {
|
||||
const evidence = { id: 'delivery-1', request: R, project: 'other', snapshot: 'prd-approved', task_ref: 'vikunja:2/41',
|
||||
artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } };
|
||||
const complete = state({ ...APPROVED, phase: 'complete', delivery: evidence.id, delivery_evidence: evidence });
|
||||
const e = await setup(t, { boot: { states: { [R]: complete }, drafts: { [R]: ACTIVE_DRAFT } } });
|
||||
await e.w(`document.querySelector('${P} .request-snapshot')`);
|
||||
assert.equal(await e.count(`${P} .request-delivery`), 0);
|
||||
await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...complete, revision: 15, delivery_evidence: { ...evidence, project: 'factory', snapshot: 'other' } })};true`);
|
||||
await e.click(`${P} [data-request-reload]`);
|
||||
await e.w(`window.__rreads.filter(p => p === '/api/requests/${R}').length >= 2 && document.querySelector('${P} [data-request-reload]')`);
|
||||
assert.equal(await e.count(`${P} .request-delivery`), 0);
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { setup, state, candidate, draft, evidence, wait, question, REQUEST, DECISION } from './helpers/w15-requests.mjs';
|
||||
|
||||
const choices = [{key:'guided',text:'New project guided'}, {key:'autonomous',text:'New project autonomous'}, {key:'existing',text:'Add to existing project'}];
|
||||
const activeDraft = {...draft, snapshot:{id:'snapshot-1',revision:2}};
|
||||
const inbox = [{id:DECISION,question:'Choose a mode',console:{kind:'decision',options:choices}}];
|
||||
const scenarios = [
|
||||
['mode-choice', state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)}), {inbox}],
|
||||
['project-picker-empty', state({project:null,phase:'selection',route:'existing',wait:wait('setup','request-existing-project',REQUEST)}), {}],
|
||||
['pm-question', state({question,wait:wait('question','question','q-1')}), {}],
|
||||
['prd-confirmation', state({phase:'prd-review',candidate,wait:wait('decision','prd-approval',DECISION)}), {draft,allowConfirmation:true,inbox:[{id:DECISION,question:'Approve exact PRD?',console:{kind:'decision',options:[{key:'yes',text:'Approve exact PRD'},{key:'no',text:'Request changes'}]}}]}],
|
||||
['request-progress', state({phase:'implementation',snapshot:'snapshot-1',candidate,question:{...question,answer:'answer-1'},tasks:['vikunja:2/41']}), {draft:activeDraft}],
|
||||
['request-refresh-failed', state({question,wait:wait('question','question','q-1')}), {}],
|
||||
['reviewed-result', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}],
|
||||
['result-evidence', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}],
|
||||
['legacy-complete', state({phase:'complete'}), {}],
|
||||
['pr-link-present-and-absent', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'https://example.org/repo/pull/1'}}}), {draft:activeDraft}],
|
||||
];
|
||||
|
||||
test('W15 requests fixture scenarios and screenshots', async t => {
|
||||
const manifest = [];
|
||||
for (const [name, current, extra] of scenarios) {
|
||||
await t.test(name, async t => {
|
||||
const {b, route, navigate, shot, network, consoleErrors} = await setup(t, {states:{[REQUEST]:current}, ...extra});
|
||||
for (const width of [1440,390]) {
|
||||
await navigate(width);
|
||||
const waitFor = async expression => b.evaluate(`(async()=>{const end=Date.now()+10000;while(!(${expression})){if(Date.now()>end)throw Error('state missing');await new Promise(r=>setTimeout(r,30))}return true})()`);
|
||||
if (name === 'mode-choice') { await waitFor("document.querySelector('[data-request-choice]')"); assert.match(await b.evaluate("document.querySelector('[data-request-choice=guided]').textContent"), /^Guided/); }
|
||||
if (name === 'project-picker-empty') { await waitFor("document.querySelector('.request-picker')"); assert.match(await b.evaluate("document.querySelector('.request-picker').textContent"), /No project/); }
|
||||
if (name === 'prd-confirmation') { await waitFor("document.querySelector('[data-request-choice=yes]')"); await b.evaluate("document.querySelector('[data-request-choice=yes]').click();true"); await waitFor("document.querySelector('[data-request-confirm]')"); }
|
||||
if (name === 'request-progress') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Answer received.*not established/s);
|
||||
if (name === 'legacy-complete') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Completed before delivery evidence/);
|
||||
if (name === 'reviewed-result') assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /not landed or deployed.*PR link unavailable/s);
|
||||
if (name === 'result-evidence') await b.evaluate("document.querySelector('.request-delivery details').open=true;true");
|
||||
if (name === 'pr-link-present-and-absent') assert.equal(await b.evaluate("document.querySelector('.request-delivery a[target=_blank]').href"), 'https://example.org/repo/pull/1');
|
||||
if (name === 'request-refresh-failed') {
|
||||
const observed = await b.evaluate("document.querySelector('.request-observed time').dateTime");
|
||||
await b.evaluate("(()=>{window.__failure=true;const a=document.querySelector('.request-answer textarea');a.value='Draft kept';a.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('[data-request-reload]').click();return true})()");
|
||||
await waitFor("document.querySelector('.request-retained-draft')");
|
||||
assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Could not refresh.*last state/s);
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-retained-draft').textContent"), 'Draft kept');
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-observed time').dateTime"), observed);
|
||||
assert.equal(await b.evaluate('window.__events.length'), 1);
|
||||
assert.equal(await b.evaluate("document.querySelectorAll('.request-panel form').length"), 0);
|
||||
}
|
||||
assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"), true, 'no document overflow');
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-return').getAttribute('href')"), route);
|
||||
assert.deepEqual(await b.evaluate('window.__fileCalls.at(-1)'), [REQUEST]);
|
||||
const files = await shot(name, width);
|
||||
manifest.push({scenario:name, route, width, height:1000, observation:'Fixture read at 2026-11-05T12:00:00.000Z', files, networkErrors:[...network], consoleErrors:[...consoleErrors], refusedFixtureRequests:await b.evaluate('window.__unexpected')});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'), JSON.stringify({candidate:'set after commit', command:'node --test --test-concurrency=1 packages/webui/tests/w15-requests-browser.test.mjs', result:'see w15-tests.txt', scenarios:manifest}, null, 2));
|
||||
});
|
||||
|
||||
test('project summary rereads known IDs, refuses foreign project, and sign-out clears it', async t => {
|
||||
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({})}}); await navigate(1440);
|
||||
assert.deepEqual(await b.evaluate(`(async()=>{const el=document.createElement('div');document.body.append(el);window.__summary=el;await MosaicRequests.projectSummary({container:el,requestIds:['${REQUEST}'],project:'other'});return [el.textContent,window.__events.length]})()`), ['Request information unavailable.',1]);
|
||||
assert.match(await b.evaluate(`(async()=>{await MosaicRequests.projectSummary({container:window.__summary,requestIds:['${REQUEST}'],project:'factory'});return window.__summary.textContent})()`), /factory|discovery/);
|
||||
await b.evaluate("document.dispatchEvent(new Event('mosaic-signed-out'));true");
|
||||
assert.equal(await b.evaluate('window.__summary.textContent'), '');
|
||||
});
|
||||
|
||||
test('PR URLs require exact request and project binding and safe HTTPS', async t => {
|
||||
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({phase:'complete',snapshot:'snapshot-1',delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'javascript:alert(1)'}}})}}); await navigate(1440);
|
||||
assert.equal(await b.evaluate("document.querySelectorAll('.request-delivery a[target=_blank]').length"),0);
|
||||
assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /PR link unavailable/);
|
||||
});
|
||||
|
||||
|
||||
test('optional Files module can be absent and keyboard refresh preserves focus', async t => {
|
||||
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({question,wait:wait('question','question','q-1')})}}); await navigate(390);
|
||||
await b.evaluate("delete window.MosaicFiles;document.querySelector('[data-request-reload]').focus();true");
|
||||
await b.key('Enter');
|
||||
await b.evaluate(`(async()=>{const end=Date.now()+10000;while(!document.querySelector('[data-request-reload]') || document.querySelector('[data-request-reload]').disabled){if(Date.now()>end)throw Error('refresh did not finish');await new Promise(r=>setTimeout(r,30))}return true})()`);
|
||||
assert.match(await b.evaluate("document.querySelector('[data-request-files]').textContent"), /unavailable/i);
|
||||
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
|
||||
await b.key('Tab'); await b.key('Tab','Tab',8); await b.key('Escape');
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-panel button[type=submit]').getBoundingClientRect().height >= 44"), true);
|
||||
await b.evaluate("document.documentElement.dataset.mode='dark';true");
|
||||
await b.call('Emulation.setPageScaleFactor',{pageScaleFactor:2});
|
||||
assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"),true);
|
||||
});
|
||||
Reference in new issue
Block a user