feat(webui): show decision origin and receipt evidence (#797)
This commit is contained in:
1 parent
05fdfef9ea
commit
7a2dc5bad1
5 files changed
+159
-6
No files matched your search
@@ -260,7 +260,7 @@
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
${window.MosaicInbox?.cardMeta(d) ?? `<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}</p>`}<p class="small muted">${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
// Row 76 (#1570, lead decision 95): a human message to a held role, only when the server says
|
||||
// a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the
|
||||
@@ -337,11 +337,11 @@
|
||||
let body;
|
||||
if (a.result) {
|
||||
const r = a.result;
|
||||
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>`;
|
||||
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>${window.MosaicInbox?.receipt(r, a.decision) ?? ''}`;
|
||||
} else if (a.confirm) {
|
||||
const c = a.confirm, label = `Confirm ${c.action}`;
|
||||
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
|
||||
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
|
||||
${window.MosaicInbox?.detailMeta(a.decision) ?? ''}${window.MosaicInbox?.receipt(c, a.decision) ?? ''}<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
|
||||
<p class="small muted">${c.kind === 'decision' ? 'Confirm to record this choice.' : c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
|
||||
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
|
||||
} else {
|
||||
@@ -406,7 +406,7 @@
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
|
||||
a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
|
||||
a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null, grantDetails: c.grant ?? null };
|
||||
paintDecide('#decide-status'); announce(c.kind === 'decision' ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
|
||||
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
}
|
||||
@@ -419,7 +419,7 @@
|
||||
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
|
||||
return `${head('Inbox', `${readNote()} ${answered}`)}
|
||||
${await composer(get)}
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<h2 id="b-for-you">Open decisions <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
}
|
||||
async function messagesView(get, q) {
|
||||
@@ -453,6 +453,7 @@
|
||||
answer.console = eligible;
|
||||
}
|
||||
else if (!eligible && !answer.pending && !answer.result) answer = null;
|
||||
if (answer?.id === d.id) answer.decision = d;
|
||||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||||
@@ -471,7 +472,7 @@
|
||||
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
|
||||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>${window.MosaicInbox?.detailMeta(d) ?? ''}
|
||||
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
${answer?.id === d.id ? decideHtml() : ''}
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Scoped decision evidence; colors inherit the active appearance. */
|
||||
.inbox-meta, .inbox-receipt-meta { font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.inbox-meta { margin: .75rem 0; }
|
||||
.inbox-meta p { margin: .2rem 0; }
|
||||
.inbox-receipt-meta { margin: .75rem 0; }
|
||||
.s1-dec-open { display: inline-block; padding: .5rem 0; min-height: 44px; overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,21 @@
|
||||
// Presentation of decision evidence. No request binding is inferred from a decision row.
|
||||
(() => {
|
||||
'use strict';
|
||||
const safe = value => String(value ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(/[\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const fact = v => typeof v === 'string' && v.trim() ? safe(v) : 'unknown';
|
||||
function metadata(d) {
|
||||
const raw = d?.at, ms = typeof raw === 'string' ? Date.parse(raw) : NaN;
|
||||
const date = Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||
const age = !date ? 'unknown' : seconds < 60 ? `${seconds} s ago` : seconds < 3600 ? `${Math.floor(seconds / 60)} min ago` : seconds < 86400 ? `${Math.floor(seconds / 3600)} h ago` : `${Math.floor(seconds / 86400)} d ago`;
|
||||
const role = d?.raisedBy?.role ?? d?.raised_by_role, run = d?.raisedBy?.run ?? d?.raised_by_run;
|
||||
return `<div class="inbox-meta"><p>Raised ${date ? `<time datetime="${safe(date)}">${safe(date)}</time>` : 'date unknown'} · ${age}</p><p>Raised by ${fact(role)} · run ${fact(run)}</p><p>Not linked to a request</p></div>`;
|
||||
}
|
||||
function receipt(result, decision) {
|
||||
const target = decision?.authorization?.target;
|
||||
const grant = result?.grantDetails ?? result?.grant;
|
||||
const destination = typeof grant?.host === 'string' && grant.host.trim() ? grant.host : Array.isArray(grant?.projects) && grant.projects.length ? grant.projects.join(', ') : null;
|
||||
return `<p class="inbox-receipt-meta">Affected object: ${typeof target === 'string' && target.trim() ? safe(target) : 'unavailable'}. Decision routed to: ${fact(decision?.routeTo)}. Destination: ${typeof destination === 'string' && destination.trim() ? safe(destination) : 'unavailable'}. ${result?.kind === 'decision' ? 'Execution or publication is unknown until separately evidenced.' : 'Grant execution or publication requires separate evidence.'}</p>`;
|
||||
}
|
||||
window.MosaicInbox = { cardMeta: metadata, detailMeta: metadata, receipt };
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
|
||||
export const oldId = 'ec3c0ac1-030b-495c-b2de-3711acc0646d';
|
||||
export const requestId = 'b1869a13-b328-408a-b4f7-50ded79aeb15';
|
||||
export const currentId = '89cc5c95-31e8-4d0d-a2ca-25f5c3f3253e';
|
||||
export const old = { id: oldId, at: '2026-11-03T22:17:22.717Z', raised_by_role: 'pm', raised_by_run: 'rcfdc22434131', class: 'within-role', action: 'prd.approve', route_to: 'human', question: 'Historic decision: choose a path', options: [{ key: 'keep', text: 'Keep original' }], blocking: false, console: { kind: 'decision', digest: 'a'.repeat(64), options: [{ key: 'keep', text: 'Keep original' }] } };
|
||||
export const current = { ...old, id: currentId, at: '2026-11-06T12:00:00.000Z', raised_by_role: 'chief', raised_by_run: 'current-run', question: 'Current request: choose a mode', options: [{ key: 'guided', text: 'Guided' }], console: { kind: 'decision', digest: 'b'.repeat(64), options: [{ key: 'guided', text: 'Guided' }] } };
|
||||
export async function setup(t) {
|
||||
const f = await fixture(), b = await browser({ machine: false });
|
||||
t.after(async () => { await b.close(); await f.close(); });
|
||||
const script = await readFile(new URL('../../src/public/inbox.js', import.meta.url), 'utf8');
|
||||
const css = await readFile(new URL('../../src/public/inbox.css', import.meta.url), 'utf8');
|
||||
const source = `(() => {
|
||||
${script}
|
||||
document.addEventListener('DOMContentLoaded', () => { const style = document.createElement('style'); style.textContent = ${JSON.stringify(css)}; document.head.append(style); });
|
||||
const rows = ${JSON.stringify([old, current])};
|
||||
window.__request = { id: ${JSON.stringify(requestId)}, currentWaitSubject: ${JSON.stringify(currentId)} };
|
||||
window.__posts = []; window.__outcome = 'success';
|
||||
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: {'content-type':'application/json'} });
|
||||
const real = window.fetch.bind(window);
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = new URL(String(url), location.href);
|
||||
if (u.origin !== location.origin) throw new Error('External network refused');
|
||||
if (!u.pathname.startsWith('/api/')) return real(url, init);
|
||||
if (u.pathname === '/api/processing' || u.pathname === '/api/bus-status') return json({error:'unavailable', message:'Fixture service unavailable'}, 503);
|
||||
if (u.pathname === '/api/config') return json({sso:true, send:true, decide:true});
|
||||
if (u.pathname === '/api/bus/inbox') return json({rows, at:'2026-11-06T13:00:00.000Z'});
|
||||
if (u.pathname === '/api/bus/agents' || u.pathname === '/api/bus/messages') return json({rows:[], unread:0, at:'2026-11-06T13:00:00.000Z'});
|
||||
if (u.pathname === '/api/bus/trail') { const row = rows.find(x => x.id === u.searchParams.get('subject')); return json({ rows: row ? [{ ...row, table:'decisions' }] : [], at:'2026-11-06T13:00:00.000Z' }); }
|
||||
if (u.pathname.startsWith('/api/decisions/')) {
|
||||
window.__posts.push({path:u.pathname, body:JSON.parse(init.body)});
|
||||
const id = u.pathname.split('/')[3], row = rows.find(x => x.id === id), choice = JSON.parse(init.body).choice, option = row?.options.find(x => x.key === choice);
|
||||
if (!option) return json({error:'refused',message:'Unknown choice'}, 403);
|
||||
if (u.pathname.endsWith('/confirmation')) return json({id,kind:'decision',action:'answer',question:row.question,choice,text:option.text,confirmation:'c'.repeat(64)});
|
||||
if (window.__outcome === 'unknown') return json({error:'outcome-unknown',message:'The answer may have reached the bus. Refresh before trying again.'}, 503);
|
||||
return json({id,op:'resolved',choice});
|
||||
}
|
||||
window.__unexpected = (window.__unexpected || []).concat(u.pathname); return json({error:'unexpected',message:'Fixture refused'}, 403);
|
||||
};
|
||||
})();`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
|
||||
await b.call('Network.enable');
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
||||
const failures = [], consoleErrors = [];
|
||||
b.on('Runtime.exceptionThrown', p => consoleErrors.push(p.exceptionDetails.text));
|
||||
b.on('Runtime.consoleAPICalled', p => { if (p.type === 'error') consoleErrors.push(p.args.map(a => a.value ?? a.description ?? '').join(' ')); });
|
||||
b.on('Fetch.requestPaused', p => { const url = p.request.url; const allowed = url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:'); if (!allowed) failures.push(url); b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? {requestId:p.requestId} : {requestId:p.requestId,errorReason:'BlockedByClient'}).catch(() => {}); });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
|
||||
async function page(route, width) { await b.viewport(width,1000); await b.navigate(f.base + '/#' + route); await wait('document.querySelector("#bus-view h1") && !document.querySelector("#bus-view [aria-busy]")'); await b.evaluate('document.fonts.ready'); }
|
||||
async function shot(state, width) { if (!process.env.MOSAIC_UX_SHOTS) return; const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir,{recursive:true}); const name = `inbox-${state}-${width}`; await b.screenshot(`${dir}/${name}.png`); const {contentSize} = await b.call('Page.getLayoutMetrics'); const {data} = await b.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(contentSize.width),height:Math.ceil(contentSize.height),scale:1}}); await writeFile(`${dir}/${name}-full.png`,Buffer.from(data,'base64')); }
|
||||
return { f,b,wait,page,shot,failures,consoleErrors };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setup, oldId, currentId, requestId } from './helpers/w15-inbox.mjs';
|
||||
|
||||
for (const width of [1440, 390]) test(`decision evidence and two-step answer at ${width}px`, { timeout: 120000 }, async t => {
|
||||
const s = await setup(t), { b, wait } = s;
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? ''`);
|
||||
await s.page('/inbox', width);
|
||||
await wait('document.querySelectorAll(".s1-decs > li").length === 2');
|
||||
assert.match(await text('#b-for-you'), /Open decisions/);
|
||||
const cards = await b.evaluate('[...document.querySelectorAll(".s1-decs > li")].map(e => e.textContent)');
|
||||
assert.match(cards[0], /2026-11-03T22:17:22\.717Z.*\d+ d ago.*pm.*rcfdc22434131.*Not linked to a request/s);
|
||||
assert.match(cards[1], /current-run/);
|
||||
assert.doesNotMatch(cards[0], /current-run|b1869a13/);
|
||||
assert.deepEqual(await b.evaluate('window.__request'), { id: requestId, currentWaitSubject: currentId });
|
||||
assert.notEqual(currentId, requestId);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
await s.shot('open-decisions-old-and-current', width);
|
||||
await s.page(`/inbox/${oldId}`, width);
|
||||
await wait('document.querySelector("[data-decide=option-0]")');
|
||||
assert.match(await text('#bus-view'), /rcfdc22434131/);
|
||||
await s.shot('historic-decision-detail', width);
|
||||
await b.evaluate('document.querySelector("[data-decide=option-0]").click()');
|
||||
await wait('document.querySelector("[data-decide=confirm]")');
|
||||
assert.match(await text('.s1-confirm'), /2026-11-03T22:17:22\.717Z.*rcfdc22434131.*Not linked to a request.*Affected object: unavailable.*Decision routed to: human.*Destination: unavailable.*keep.*Keep original/s);
|
||||
assert.equal(await b.evaluate('window.__posts.length'), 1);
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'confirm');
|
||||
await b.key('Tab', 'Tab');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'cancel');
|
||||
await b.key('Tab', 'Tab', 8);
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'confirm');
|
||||
await b.key('Escape');
|
||||
assert.equal(await b.evaluate('window.__posts.length'), 1);
|
||||
await s.shot('historic-confirmation', width);
|
||||
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');
|
||||
await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
|
||||
assert.match(await text('#decide-section'), /Recorded: you chose “keep”, Keep original.*Affected object: unavailable.*Decision routed to: human.*Destination: unavailable.*unknown/s);
|
||||
assert.equal(await b.evaluate('window.__posts.length'), 2);
|
||||
await s.shot('decision-recorded', width);
|
||||
await s.page(`/inbox/${currentId}`, width);
|
||||
await wait('document.querySelector("[data-decide=option-0]")');
|
||||
assert.match(await text('#bus-view'), /Current request: choose a mode/);
|
||||
assert.doesNotMatch(await text('#bus-view'), /rcfdc22434131/);
|
||||
await s.shot('current-decision', width);
|
||||
await b.evaluate('window.__outcome = "unknown"; document.querySelector("[data-decide=option-0]").click()');
|
||||
await wait('document.querySelector("[data-decide=confirm]")');
|
||||
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');
|
||||
await wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
|
||||
assert.match(await text('#decide-status'), /Nothing was sent again/);
|
||||
await s.shot('decision-outcome-unknown', width);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected || []'), []);
|
||||
assert.deepEqual(s.failures, []);
|
||||
assert.deepEqual(s.consoleErrors, []);
|
||||
});
|
||||
|
||||
test('metadata handles missing and hostile origins without HTML or bidi injection', { timeout: 120000 }, async t => {
|
||||
const { b, page } = await setup(t);
|
||||
await page('/inbox', 1440);
|
||||
const result = await b.evaluate(`(() => { const host = document.createElement('div'); host.innerHTML = window.MosaicInbox.cardMeta({at:'bad', raised_by_role:'<img src=x onerror=alert(1)>\\u202e', raised_by_run:''}); return {text:host.textContent, images:host.querySelectorAll('img').length}; })()`);
|
||||
assert.match(result.text, /date unknown.*unknown.*\[U\+202E\].*run unknown.*Not linked to a request/s);
|
||||
assert.equal(result.images, 0);
|
||||
});
|
||||
|
||||
|
||||
test('receipt uses recorded target and destination without interpreting hostile text', { timeout: 120000 }, async t => {
|
||||
const { b, page } = await setup(t);
|
||||
await page('/inbox', 1440);
|
||||
const result = await b.evaluate(`(() => { const host = document.createElement('div'); host.innerHTML = window.MosaicInbox.receipt({kind:'decision',grantDetails:{host:'web1'}}, {authorization:{target:'workspace <img src=x>\\u202e'},routeTo:'human'}); return {text:host.textContent, images:host.querySelectorAll('img').length}; })()`);
|
||||
assert.match(result.text, /Affected object: workspace <img src=x>\[U\+202E\].*Decision routed to: human.*Destination: web1.*unknown/s);
|
||||
assert.equal(result.images, 0);
|
||||
});
|
||||
Reference in new issue
Block a user