mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed contributor PR #3243 into backlog batch
Source-PR: https://github.com/affaan-m/ECC/pull/3243
Source-Head: cfe6d90d9f
Local integration checkpoint; aggregate review and hosted acceptance pending.
This commit is contained in:
@@ -49,7 +49,9 @@ function renderControlPlaneViewHtml() {
|
||||
.empty { color: #6e7681; font-size: 12px; }
|
||||
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
|
||||
#meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
||||
.shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; }
|
||||
/* Off-screen, not display:none, so assistive tech still reads the node. */
|
||||
.sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -60,13 +62,14 @@ function renderControlPlaneViewHtml() {
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
<canvas id="c"></canvas>
|
||||
<canvas id="c" role="img" aria-label="Control-plane projection. See the Lanes panel for a text alternative.">Control-plane projection; see the Lanes panel for per-task risk.</canvas>
|
||||
<div id="meta"></div>
|
||||
<div id="legend">
|
||||
<div><span class="dot" style="background:#3fb950"></span>clear</div>
|
||||
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
|
||||
<div><span class="dot" style="background:#ff7b72"></span>resolution advisory (steer)</div>
|
||||
<div><span class="shape" style="color:#2ea043">●</span>clear</div>
|
||||
<div><span class="shape" style="color:#e3b341">■</span>traffic advisory (transmit)</div>
|
||||
<div><span class="shape" style="color:#ff7b72">▲</span>resolution advisory (steer)</div>
|
||||
</div>
|
||||
<div id="announce" class="sr" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
<div id="side">
|
||||
<h2>Events</h2>
|
||||
@@ -80,6 +83,9 @@ function renderControlPlaneViewHtml() {
|
||||
var canvas = document.getElementById('c');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } };
|
||||
// Last message handed to the live region, so a poll that changes nothing
|
||||
// stays silent.
|
||||
var lastSpoken = null;
|
||||
|
||||
function resize() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
@@ -91,10 +97,35 @@ function renderControlPlaneViewHtml() {
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// The previous clear/resolution palette had similar relative luminance.
|
||||
// Separate luminance values plus redundant shapes and text reduce reliance
|
||||
// on hue; palette math alone does not establish a user's visual experience.
|
||||
function riskLevel(risk) {
|
||||
if (risk >= view.thresholds.ra) return 'resolution';
|
||||
if (risk >= view.thresholds.ta) return 'traffic';
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
function riskColor(risk) {
|
||||
if (risk >= view.thresholds.ra) return '#ff7b72';
|
||||
if (risk >= view.thresholds.ta) return '#e3b341';
|
||||
return '#3fb950';
|
||||
return '#2ea043';
|
||||
}
|
||||
|
||||
// Shape is the second, non-colour channel: circle / square / triangle.
|
||||
function drawRiskMarker(x, y, radius, level) {
|
||||
ctx.beginPath();
|
||||
if (level === 'resolution') {
|
||||
ctx.moveTo(x, y - radius);
|
||||
ctx.lineTo(x + radius, y + radius);
|
||||
ctx.lineTo(x - radius, y + radius);
|
||||
ctx.closePath();
|
||||
} else if (level === 'traffic') {
|
||||
ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
|
||||
} else {
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Fit the projected points into the canvas with a margin. The PCA scores
|
||||
@@ -142,8 +173,9 @@ function renderControlPlaneViewHtml() {
|
||||
var t = taskById[a.agentId] || {};
|
||||
var files = (t.workingSet && t.workingSet.fileCount) || 1;
|
||||
var radius = 6 + Math.sqrt(files) * 3;
|
||||
ctx.fillStyle = riskColor(a.maxRisk || 0);
|
||||
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
|
||||
var risk = a.maxRisk || 0;
|
||||
ctx.fillStyle = riskColor(risk);
|
||||
drawRiskMarker(p[0], p[1], radius, riskLevel(risk));
|
||||
ctx.fillStyle = '#c9d1d9';
|
||||
ctx.font = '11px -apple-system, system-ui, sans-serif';
|
||||
ctx.fillText(String(a.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
|
||||
@@ -198,7 +230,9 @@ function renderControlPlaneViewHtml() {
|
||||
var st = document.createElement('span'); st.textContent = t.harness + ' / ' + t.state + ' / ' + (t.workingSet.fileCount || 0) + ' files';
|
||||
var risk = document.createElement('span'); risk.className = 'risk';
|
||||
risk.style.color = riskColor(t.projection.maxRisk || 0);
|
||||
risk.textContent = t.projection.point ? Math.round((t.projection.maxRisk || 0) * 100) + '%' : 'no pair';
|
||||
risk.textContent = (t.projection.point
|
||||
? Math.round((t.projection.maxRisk || 0) * 100) + '% - ' + riskLevel(t.projection.maxRisk || 0)
|
||||
: 'no pair');
|
||||
row.appendChild(idEl); row.appendChild(st); row.appendChild(risk);
|
||||
el.appendChild(row);
|
||||
});
|
||||
@@ -206,6 +240,40 @@ function renderControlPlaneViewHtml() {
|
||||
});
|
||||
}
|
||||
|
||||
// Wording shared by the canvas label and the live region so an outage reads
|
||||
// the same way however the operator reaches it.
|
||||
var UNAVAILABLE = 'Control-plane data is unavailable. Advisories and steering are unknown.';
|
||||
// Polls are not sequenced, so a slow request can settle out of order. Only
|
||||
// the newest poll that has already settled may update the view: a success
|
||||
// from a superseded poll would show older counts, and a failure from a
|
||||
// superseded poll would erase newer counts. Anchoring to the last settled
|
||||
// poll rather than the last started one also lets a failure land while a
|
||||
// newer poll is still pending, instead of leaving stale guidance on screen.
|
||||
var settledPoll = 0;
|
||||
// Monotonic id handed to each poll as it starts.
|
||||
var pollSeq = 0;
|
||||
// A poll that never answers must not stay pending forever, or the last
|
||||
// steering guidance stays on screen indefinitely.
|
||||
var TIMEOUT_MS = 10000;
|
||||
|
||||
// Polling runs every few seconds, so only speak when the advisory and
|
||||
// steering counts actually move. Repeating an unchanged summary would talk
|
||||
// over the operator without telling them anything new.
|
||||
function announce(message) {
|
||||
if (message === lastSpoken) return;
|
||||
lastSpoken = message;
|
||||
document.getElementById('announce').textContent = message;
|
||||
}
|
||||
|
||||
function unavailable() {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
// The last guidance is now stale, so replace it rather than leaving the
|
||||
// live region claiming the airspace is clear. The canvas label goes with
|
||||
// it, or it would still report the last successful counts.
|
||||
canvas.setAttribute('aria-label', UNAVAILABLE);
|
||||
announce(UNAVAILABLE);
|
||||
}
|
||||
|
||||
function apply(data) {
|
||||
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
|
||||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
|
||||
@@ -216,19 +284,50 @@ function renderControlPlaneViewHtml() {
|
||||
view = Object.assign({}, data);
|
||||
renderEvents(); renderLanes(); draw();
|
||||
var c = view.counts || {};
|
||||
var summary = (c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes, ' +
|
||||
(c.advisories || 0) + ' advisories, ' + (c.resolutions || 0) + ' steering. ' +
|
||||
'See the Lanes panel for per-task risk.';
|
||||
canvas.setAttribute('aria-label', summary);
|
||||
document.getElementById('status').textContent =
|
||||
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' +
|
||||
(c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' +
|
||||
(view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : '');
|
||||
|
||||
announce((c.advisories || 0) + ' advisories, ' +
|
||||
(c.resolutions || 0) + ' steering. ' +
|
||||
((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.'));
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/control-plane').then(function (r) {
|
||||
var token = ++pollSeq;
|
||||
var timer = null;
|
||||
var controller = new AbortController();
|
||||
// Reject on a timer so a hung request cannot keep the previous guidance on
|
||||
// screen forever. The abort is what wakes this poll up, so a timeout is
|
||||
// reported as an outage rather than swallowed.
|
||||
timer = setTimeout(function () { controller.abort(); }, TIMEOUT_MS);
|
||||
// Claim before rendering or reporting failure: two JSON bodies may settle
|
||||
// in the same turn, before a later cleanup continuation can run. Equality
|
||||
// lets a render failure report unavailable for the token that just claimed.
|
||||
function claim() {
|
||||
if (token < settledPoll) return false;
|
||||
settledPoll = token;
|
||||
return true;
|
||||
}
|
||||
function settle() {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
fetch('/api/control-plane', { signal: controller.signal }).then(function (r) {
|
||||
if (!r.ok) throw new Error('Control-plane request failed');
|
||||
return r.json();
|
||||
}).then(apply).catch(function () {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
});
|
||||
}).then(function (data) {
|
||||
// A newer poll already owns the view, so do not resurrect older counts.
|
||||
if (!claim()) return;
|
||||
apply(data);
|
||||
}).catch(function () {
|
||||
if (!claim()) return;
|
||||
unavailable();
|
||||
}).then(settle, settle);
|
||||
}
|
||||
|
||||
resize();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const { renderControlPlaneViewHtml } = require('../../scripts/lib/control-pane/control-plane-view-ui');
|
||||
|
||||
// Count successful assertion executions, including each palette-loop iteration.
|
||||
// These are source/palette assertions, not browser accessibility test cases.
|
||||
let assertions = 0;
|
||||
const countedAssert = {};
|
||||
for (const method of ['ok', 'strictEqual', 'deepStrictEqual']) {
|
||||
countedAssert[method] = (...args) => { assert[method](...args); assertions += 1; };
|
||||
}
|
||||
|
||||
const html = renderControlPlaneViewHtml();
|
||||
|
||||
countedAssert.ok(html.includes('role="img"'), 'canvas should expose an image role');
|
||||
countedAssert.ok(html.includes('aria-label='), 'canvas should carry a text alternative');
|
||||
countedAssert.ok(html.includes("function riskLevel(risk)"), 'risk levels should be named independently of color');
|
||||
countedAssert.ok(html.includes("ctx.rect(x - radius"), 'traffic advisories should use a square marker');
|
||||
countedAssert.ok(html.includes("ctx.lineTo(x + radius"), 'resolution advisories should use a triangular marker');
|
||||
countedAssert.ok(html.includes('●</span>clear'), 'legend should show the clear circle marker');
|
||||
countedAssert.ok(html.includes('■</span>traffic advisory'), 'legend should show the advisory square marker');
|
||||
countedAssert.ok(html.includes('▲</span>resolution'), 'legend should show the resolution triangle marker');
|
||||
countedAssert.ok(!html.includes('class="dot"'), 'legend should not render color-only dots');
|
||||
|
||||
// Counts are polled every few seconds, so a screen-reader user needs a polite
|
||||
// live region to hear an advisory move. The canvas keeps its own label as the
|
||||
// on-demand description.
|
||||
countedAssert.ok(html.includes('role="status" aria-live="polite"'),
|
||||
'polled counts should be announced through a polite live region');
|
||||
countedAssert.ok(html.includes('class="sr"'), 'the live region should be hidden visually but not removed from the tree');
|
||||
|
||||
// Check numerical palette separation as an additional channel. This does not
|
||||
// simulate color vision, establish glyph visibility, or test assistive tools.
|
||||
function relativeLuminance(hex) {
|
||||
const channels = hex.replace('#', '').match(/../g).map(part => parseInt(part, 16) / 255)
|
||||
.map(value => (value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4)));
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(left, right) {
|
||||
const a = relativeLuminance(left);
|
||||
const b = relativeLuminance(right);
|
||||
const [lighter, darker] = a > b ? [a, b] : [b, a];
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
const BACKGROUND = html.match(/body \{[^}]*background: (#[0-9a-f]{6})/)[1];
|
||||
|
||||
// Read the palette back out of the rendered view instead of hard-coding it, so
|
||||
// a colour change cannot leave these contrast assertions quietly passing.
|
||||
const legend = [...html.matchAll(
|
||||
/<span class="shape" style="color:(#[0-9a-f]{6})">([\u25cf\u25a0\u25b2])<\/span>/g
|
||||
)].map(match => ({ color: match[1], glyph: match[2] }));
|
||||
|
||||
countedAssert.strictEqual(legend.length, 3, 'the legend should declare three risk levels');
|
||||
countedAssert.deepStrictEqual(legend.map(entry => entry.glyph), ['\u25cf', '\u25a0', '\u25b2'],
|
||||
'clear, traffic, and resolution should be marked circle, square, and triangle');
|
||||
|
||||
const [clear, traffic, resolution] = legend.map(entry => entry.color);
|
||||
|
||||
// The legend and the canvas must agree, otherwise the operator reads a different
|
||||
// colour from the one the marker is drawn in.
|
||||
const riskColorBody = html.match(/function riskColor\(risk\) \{([\s\S]*?)\n {2}\}/)[1];
|
||||
const canvasColors = [...riskColorBody.matchAll(/return '(#[0-9a-f]{6})';/g)].map(match => match[1]);
|
||||
countedAssert.deepStrictEqual(canvasColors, [resolution, traffic, clear],
|
||||
'the legend palette and the riskColor palette must match');
|
||||
|
||||
// Check numerical palette separation as an additional channel. This does not
|
||||
// simulate color vision, establish glyph visibility, or test assistive tools.
|
||||
countedAssert.ok(contrastRatio(clear, resolution) >= 1.3,
|
||||
`clear and resolution must differ by luminance, got ${contrastRatio(clear, resolution).toFixed(2)}:1`);
|
||||
for (const level of legend) {
|
||||
countedAssert.ok(contrastRatio(level.color, BACKGROUND) >= 4.5,
|
||||
`the ${level.color} marker must meet 4.5:1 against the page background, got ${contrastRatio(level.color, BACKGROUND).toFixed(2)}:1`);
|
||||
}
|
||||
|
||||
console.log('Reporting unit: source/palette assertions; no browser or assistive-tool acceptance.');
|
||||
console.log(`Results: Passed: ${assertions}, Failed: 0`);
|
||||
@@ -13,17 +13,100 @@ const proximityHtml = renderProximityVizHtml();
|
||||
assert.ok(proximityHtml.includes('grid-template-rows: minmax(0, 1fr)'));
|
||||
assert.ok(proximityHtml.includes('#stage { position: relative; height: 100%; min-height: 0;'));
|
||||
|
||||
async function renderResponse(ok, data) {
|
||||
const elements = new Map();
|
||||
const context = new Proxy({}, { get: () => () => {} });
|
||||
function element() {
|
||||
return { textContent: '', style: {}, appendChild() {}, getContext: () => context,
|
||||
clientWidth: 640, clientHeight: 480,
|
||||
parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) } };
|
||||
// A recording 2D context, so a test can assert what the view actually drew
|
||||
// rather than only what the template happens to contain.
|
||||
function createContext() {
|
||||
const log = [];
|
||||
const context = { fillStyle: '', strokeStyle: '', lineWidth: 1, globalAlpha: 1, font: '', log };
|
||||
const record = fn => (...args) => { log.push({ fn, args, fillStyle: context.fillStyle }); };
|
||||
for (const fn of ['setTransform', 'clearRect', 'beginPath', 'moveTo', 'lineTo', 'stroke',
|
||||
'arc', 'rect', 'closePath', 'fill', 'fillText', 'save', 'restore']) {
|
||||
context[fn] = record(fn);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Group the draw calls into paths and keep the filled ones: those are the risk
|
||||
// markers, and the axis and pair-link paths only stroke.
|
||||
function markerShapes(context) {
|
||||
const paths = [];
|
||||
let current = null;
|
||||
for (const entry of context.log) {
|
||||
if (entry.fn === 'beginPath') {
|
||||
if (current) paths.push(current);
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
if (!current) current = [];
|
||||
current.push(entry);
|
||||
}
|
||||
if (current) paths.push(current);
|
||||
return paths.filter(path => path.some(entry => entry.fn === 'fill')).map(path => {
|
||||
const shape = path.some(e => e.fn === 'arc') ? 'circle'
|
||||
: path.some(e => e.fn === 'rect') ? 'square' : 'triangle';
|
||||
return { shape, color: path.find(e => e.fn === 'fill').fillStyle };
|
||||
});
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
return (node.textContent || '') + node.children.map(textOf).join('');
|
||||
}
|
||||
|
||||
function findAll(node, className) {
|
||||
const found = node.className === className ? [node] : [];
|
||||
for (const child of node.children) found.push(...findAll(child, className));
|
||||
return found;
|
||||
}
|
||||
|
||||
function element(tag, context) {
|
||||
const node = {
|
||||
tag: tag || 'div',
|
||||
className: '',
|
||||
children: [],
|
||||
style: {},
|
||||
attributes: {},
|
||||
clientWidth: 640,
|
||||
clientHeight: 480,
|
||||
parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) },
|
||||
appendChild(child) { node.children.push(child); return child; },
|
||||
setAttribute(name, value) { node.attributes[name] = value; },
|
||||
getContext: () => context
|
||||
};
|
||||
let text = '';
|
||||
let writes = 0;
|
||||
Object.defineProperty(node, 'textContent', {
|
||||
get() { return text; },
|
||||
set(value) { text = String(value); node.children = []; writes += 1; },
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(node, 'writes', { get() { return writes; }, configurable: true });
|
||||
return node;
|
||||
}
|
||||
|
||||
// A poll settles through several chained promise callbacks, so draining needs a
|
||||
// few turns of the event loop rather than a single tick.
|
||||
function settle() {
|
||||
return new Promise(resolve => {
|
||||
let remaining = 5;
|
||||
const step = () => (remaining-- > 0 ? setImmediate(step) : resolve());
|
||||
step();
|
||||
});
|
||||
}
|
||||
|
||||
// Drives the view's inline script against a queue of poll responses, so one run
|
||||
// can cover several polls and the state each one leaves behind. A response may
|
||||
// carry a `hold` promise to park until the test releases it, or `never: true` to
|
||||
// stay pending so the request timeout can be exercised.
|
||||
async function render(responses) {
|
||||
const context = createContext();
|
||||
const elements = new Map();
|
||||
const timers = [];
|
||||
const timeouts = [];
|
||||
const queue = responses.slice();
|
||||
let jsonReads = 0;
|
||||
const document = {
|
||||
getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); },
|
||||
createElement: element
|
||||
getElementById(id) { if (!elements.has(id)) elements.set(id, element(null, context)); return elements.get(id); },
|
||||
createElement: tag => element(tag, context)
|
||||
};
|
||||
const html = renderControlPlaneViewHtml();
|
||||
const start = html.indexOf('<script>');
|
||||
@@ -31,30 +114,415 @@ async function renderResponse(ok, data) {
|
||||
assert.ok(start >= 0 && end > start, 'fixed renderer template must contain its inline script');
|
||||
const code = html.slice(start + '<script>'.length, end);
|
||||
vm.runInNewContext(code, {
|
||||
document, window: { addEventListener() {}, devicePixelRatio: 1 }, setInterval() {},
|
||||
fetch: async () => ({ ok, json: async () => data })
|
||||
document,
|
||||
window: { addEventListener() {}, devicePixelRatio: 1 },
|
||||
AbortController,
|
||||
setInterval(fn) { timers.push(fn); },
|
||||
// Timers are collected rather than run, so a test can fire the request
|
||||
// timeout on demand instead of waiting ten seconds for it.
|
||||
setTimeout(fn, ms) { const entry = { fn, ms, fired: false }; timeouts.push(entry); return entry; },
|
||||
clearTimeout(entry) { if (entry) entry.fired = true; },
|
||||
fetch: async (url, options) => {
|
||||
const next = queue.length > 1 ? queue.shift() : queue[0];
|
||||
if (next.never) {
|
||||
// A request that never answers. Honour the abort the view sends, so the
|
||||
// timeout can drive it to a failure.
|
||||
return new Promise((_, reject) => {
|
||||
if (!options || !options.signal) return;
|
||||
options.signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
});
|
||||
}
|
||||
if (next.hold) await next.hold;
|
||||
if (options && options.signal && options.signal.aborted) throw new Error('aborted');
|
||||
return { ok: next.ok, json: () => {
|
||||
jsonReads += 1;
|
||||
return next.body || Promise.resolve(next.data);
|
||||
} };
|
||||
}
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return elements;
|
||||
return {
|
||||
elements,
|
||||
context,
|
||||
jsonReads() { return jsonReads; },
|
||||
writesTo(id) { return elements.get(id).writes; },
|
||||
labelOf(id) { return elements.get(id).attributes['aria-label']; },
|
||||
// Fire every pending request timeout, then let the rejections propagate.
|
||||
async fireTimeouts() {
|
||||
for (const entry of timeouts) {
|
||||
if (!entry.fired) { entry.fired = true; entry.fn(); }
|
||||
}
|
||||
await settle();
|
||||
},
|
||||
timeoutBudgetMs() { return timeouts.length ? timeouts[0].ms : null; },
|
||||
async pollAgain() {
|
||||
for (const fn of timers) fn();
|
||||
await settle();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function populatedView(overrides) {
|
||||
return Object.assign({
|
||||
schemaVersion: 'ecc.control-plane.view.v1',
|
||||
tasks: [
|
||||
{ id: 'task-clear', harness: 'claude', state: 'running', workingSet: { fileCount: 1 },
|
||||
projection: { maxRisk: 0.1, point: [1, 0] } },
|
||||
{ id: 'task-traffic', harness: 'codex', state: 'running', workingSet: { fileCount: 4 },
|
||||
projection: { maxRisk: 0.5, point: [0, 1] } },
|
||||
{ id: 'task-resolution', harness: 'gemini', state: 'blocked', workingSet: { fileCount: 9 },
|
||||
projection: { maxRisk: 0.9, point: [-1, -1] } }
|
||||
],
|
||||
lanes: [{ label: 'main', kind: 'lane', taskIds: ['task-clear', 'task-traffic', 'task-resolution'] }],
|
||||
pairs: [{ a: 'task-clear', b: 'task-traffic', risk: 0.5 }],
|
||||
events: [{ level: 'resolution', kind: 'pair', message: 'overlap', risk: 0.9 }],
|
||||
projection: {
|
||||
agents: [
|
||||
{ agentId: 'task-clear', point: [1, 0], maxRisk: 0.1 },
|
||||
{ agentId: 'task-traffic', point: [0, 1], maxRisk: 0.5 },
|
||||
{ agentId: 'task-resolution', point: [-1, -1], maxRisk: 0.9 }
|
||||
],
|
||||
normalization: 'raw'
|
||||
},
|
||||
thresholds: { ta: 0.35, ra: 0.7 },
|
||||
counts: { tasks: 3, lanes: 1, agents: 2, advisories: 2, resolutions: 1 }
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failures = 0;
|
||||
(async () => {
|
||||
const failed = await renderResponse(false, { ok: false, error: 'snapshot unavailable' });
|
||||
assert.strictEqual(failed.get('status').textContent, 'offline', 'HTTP errors must not display a healthy empty view');
|
||||
const failed = await render([{ ok: false, data: { ok: false, error: 'snapshot unavailable' } }]);
|
||||
assert.strictEqual(failed.elements.get('status').textContent, 'offline', 'HTTP errors must not display a healthy empty view');
|
||||
passed += 1;
|
||||
const malformed = await renderResponse(true, { schemaVersion: 'wrong' });
|
||||
assert.strictEqual(malformed.get('status').textContent, 'offline', 'invalid schemas must be rejected');
|
||||
|
||||
const malformed = await render([{ ok: true, data: { schemaVersion: 'wrong' } }]);
|
||||
assert.strictEqual(malformed.elements.get('status').textContent, 'offline', 'invalid schemas must be rejected');
|
||||
passed += 1;
|
||||
const valid = await renderResponse(true, {
|
||||
schemaVersion: 'ecc.control-plane.view.v1', tasks: [], lanes: [], pairs: [], events: [],
|
||||
projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 }, counts: {}
|
||||
|
||||
const valid = await render([{ ok: true, data: populatedView({ tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, counts: {} }) }]);
|
||||
assert.ok(valid.elements.get('status').textContent.includes('0 tasks'));
|
||||
passed += 1;
|
||||
|
||||
// A populated view has to name each task's risk level in words, not leave the
|
||||
// level encoded only in the marker colour. Assert on the rendered risk cells
|
||||
// rather than the whole panel, so a level word cannot be satisfied by a task
|
||||
// id that happens to contain it.
|
||||
const populated = await render([{ ok: true, data: populatedView() }]);
|
||||
const riskCells = findAll(populated.elements.get('lanes'), 'risk').map(node => textOf(node));
|
||||
assert.deepStrictEqual(riskCells,
|
||||
['10% - clear', '50% - traffic', '90% - resolution'],
|
||||
'each task must state its risk level in words beside the percentage');
|
||||
passed += 1;
|
||||
|
||||
// Each risk level draws its own shape, so a colour-blind operator still sees
|
||||
// the three levels apart on the canvas.
|
||||
assert.deepStrictEqual(markerShapes(populated.context).map(marker => marker.shape),
|
||||
['circle', 'square', 'triangle'], 'the three risk levels must draw circle, square, and triangle');
|
||||
passed += 1;
|
||||
|
||||
const canvasLabel = populated.elements.get('c').attributes['aria-label'];
|
||||
assert.ok(canvasLabel.includes('3 tasks') && canvasLabel.includes('2 advisories') && canvasLabel.includes('1 steering'),
|
||||
`the canvas label must carry the polled counts, got ${canvasLabel}`);
|
||||
passed += 1;
|
||||
|
||||
const announced = populated.elements.get('announce').textContent;
|
||||
assert.ok(announced.includes('2 advisories') && announced.includes('Steering is required.'),
|
||||
`a populated view must announce the steering state, got ${announced}`);
|
||||
passed += 1;
|
||||
|
||||
// Polling every few seconds must not repeat an unchanged announcement. Setting
|
||||
// the same text again is a no-op for a real DOM, but the view should not even
|
||||
// attempt the write, so count the assignments rather than comparing strings.
|
||||
await populated.pollAgain();
|
||||
assert.strictEqual(populated.writesTo('announce'), 1,
|
||||
'an unchanged poll must not write the live region again');
|
||||
passed += 1;
|
||||
|
||||
const changed = await render([
|
||||
{ ok: true, data: populatedView() },
|
||||
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 3, resolutions: 0 } }) }
|
||||
]);
|
||||
await changed.pollAgain();
|
||||
assert.ok(changed.elements.get('announce').textContent.includes('3 advisories')
|
||||
&& changed.elements.get('announce').textContent.includes('No steering is required.'),
|
||||
`a changed poll must announce the new counts, got ${changed.elements.get('announce').textContent}`);
|
||||
passed += 1;
|
||||
|
||||
// An outage must not leave the live region holding the last known guidance,
|
||||
// which would read as a current "airspace is clear" after data stopped.
|
||||
const outage = await render([
|
||||
{ ok: true, data: populatedView() },
|
||||
{ ok: false, data: { ok: false, error: 'snapshot unavailable' } }
|
||||
]);
|
||||
const beforeOutage = outage.elements.get('announce').textContent;
|
||||
await outage.pollAgain();
|
||||
assert.strictEqual(outage.elements.get('status').textContent, 'offline');
|
||||
assert.ok(outage.elements.get('announce').textContent !== beforeOutage,
|
||||
'a failed poll must not leave the previous guidance in the live region');
|
||||
assert.ok(/unavailable/i.test(outage.elements.get('announce').textContent)
|
||||
&& /unknown/i.test(outage.elements.get('announce').textContent),
|
||||
`an outage must say the counts are unknown, got ${outage.elements.get('announce').textContent}`);
|
||||
passed += 1;
|
||||
|
||||
// The canvas label is the on-demand description, so an outage has to clear
|
||||
// the last counts there too, not just in the live region.
|
||||
assert.ok(/unavailable/i.test(outage.labelOf('c')) && /unknown/i.test(outage.labelOf('c')),
|
||||
`the canvas label must not keep the last counts during an outage, got ${outage.labelOf('c')}`);
|
||||
passed += 1;
|
||||
|
||||
// A repeated failure stays silent, but recovering online must speak again.
|
||||
const writesAfterOutage = outage.writesTo('announce');
|
||||
await outage.pollAgain();
|
||||
assert.strictEqual(outage.writesTo('announce'), writesAfterOutage,
|
||||
'a repeated failure must not re-announce the same outage');
|
||||
passed += 1;
|
||||
|
||||
const recovered = await render([
|
||||
{ ok: true, data: populatedView() },
|
||||
{ ok: false, data: { ok: false, error: 'snapshot unavailable' } },
|
||||
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 1, resolutions: 1 } }) }
|
||||
]);
|
||||
await recovered.pollAgain();
|
||||
await recovered.pollAgain();
|
||||
assert.ok(recovered.elements.get('status').textContent.includes('3 tasks'),
|
||||
'a recovered poll must restore the live status');
|
||||
assert.ok(recovered.elements.get('announce').textContent.includes('1 advisories')
|
||||
&& recovered.elements.get('announce').textContent.includes('Steering is required.'),
|
||||
`a recovered poll must announce the restored counts, got ${recovered.elements.get('announce').textContent}`);
|
||||
passed += 1;
|
||||
|
||||
// apply() must put the counts back on the canvas once data flows again.
|
||||
assert.ok(recovered.labelOf('c').includes('1 advisories') && recovered.labelOf('c').includes('1 steering'),
|
||||
`a recovered poll must restore the count label on the canvas, got ${recovered.labelOf('c')}`);
|
||||
passed += 1;
|
||||
|
||||
// Polls are not sequenced. An older poll that settles after a newer one must
|
||||
// not overwrite it, or the live region and the canvas disagree. The initial
|
||||
// poll takes the first response, the second response is the slow older poll,
|
||||
// and the third is the newer success that lands while the older is parked.
|
||||
let releaseOlder;
|
||||
const olderSettles = new Promise(resolve => { releaseOlder = resolve; });
|
||||
const overlapping = await render([
|
||||
{ ok: true, data: populatedView() },
|
||||
{ ok: false, data: { ok: false, error: 'snapshot unavailable' }, hold: olderSettles },
|
||||
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 4, resolutions: 0 } }) }
|
||||
]);
|
||||
await overlapping.pollAgain();
|
||||
await overlapping.pollAgain();
|
||||
assert.ok(overlapping.labelOf('c').includes('4 advisories'),
|
||||
`the newer success should land first, got ${overlapping.labelOf('c')}`);
|
||||
releaseOlder();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.ok(overlapping.labelOf('c').includes('4 advisories')
|
||||
&& !/unavailable/i.test(overlapping.labelOf('c')),
|
||||
`a superseded failure must not clear the newer canvas counts, got ${overlapping.labelOf('c')}`);
|
||||
assert.ok(!/unavailable/i.test(overlapping.elements.get('announce').textContent),
|
||||
`a superseded failure must not announce an outage, got ${overlapping.elements.get('announce').textContent}`);
|
||||
assert.notStrictEqual(overlapping.elements.get('status').textContent, 'offline',
|
||||
'a superseded failure must not mark the view offline');
|
||||
passed += 1;
|
||||
|
||||
// The mirror of the case above. An older SUCCESS settling after a newer
|
||||
// success must not drag the view back to the older counts.
|
||||
let releaseStale;
|
||||
const staleSettles = new Promise(resolve => { releaseStale = resolve; });
|
||||
const staleSuccess = await render([
|
||||
{ ok: true, data: populatedView() },
|
||||
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 2, resolutions: 1 } }), hold: staleSettles },
|
||||
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 7, resolutions: 0 } }) }
|
||||
]);
|
||||
await staleSuccess.pollAgain();
|
||||
await staleSuccess.pollAgain();
|
||||
assert.ok(staleSuccess.labelOf('c').includes('7 advisories'),
|
||||
`the newer success should land first, got ${staleSuccess.labelOf('c')}`);
|
||||
releaseStale();
|
||||
await settle();
|
||||
assert.ok(staleSuccess.labelOf('c').includes('7 advisories'),
|
||||
`a superseded success must not overwrite the newer counts, got ${staleSuccess.labelOf('c')}`);
|
||||
assert.ok(!staleSuccess.elements.get('announce').textContent.includes('Steering is required.'),
|
||||
`a superseded success must not re-announce the older guidance, got ${staleSuccess.elements.get('announce').textContent}`);
|
||||
passed += 1;
|
||||
|
||||
// A failure must still surface while a newer poll is already in flight and has
|
||||
// not answered. The older failure is still the newest thing to have settled,
|
||||
// so a guard anchored to the last settled poll lets it through, while one
|
||||
// anchored to the last poll started would silently drop it and leave stale
|
||||
// steering guidance on screen.
|
||||
let releaseFailure;
|
||||
const failureSettles = new Promise(resolve => { releaseFailure = resolve; });
|
||||
let releasePending;
|
||||
const pendingSettles = new Promise(resolve => { releasePending = resolve; });
|
||||
const failureWhilePending = await render([
|
||||
{ ok: true, data: populatedView() },
|
||||
{ ok: false, data: { ok: false, error: 'snapshot unavailable' }, hold: failureSettles },
|
||||
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 9, resolutions: 0 } }), hold: pendingSettles }
|
||||
]);
|
||||
// Start the failing poll, then the newer pending one, so the failure settles
|
||||
// with a newer request still in flight.
|
||||
await failureWhilePending.pollAgain();
|
||||
await failureWhilePending.pollAgain();
|
||||
releaseFailure();
|
||||
await settle();
|
||||
assert.strictEqual(failureWhilePending.elements.get('status').textContent, 'offline',
|
||||
'a failure must surface while a newer poll is still pending');
|
||||
assert.ok(/unavailable/i.test(failureWhilePending.labelOf('c')),
|
||||
`a failure must clear the canvas counts while a newer poll is pending, got ${failureWhilePending.labelOf('c')}`);
|
||||
assert.ok(/unavailable/i.test(failureWhilePending.elements.get('announce').textContent),
|
||||
`a failure must announce the outage while a newer poll is pending, got ${failureWhilePending.elements.get('announce').textContent}`);
|
||||
passed += 1;
|
||||
|
||||
// The newer poll that was pending must still be able to restore the view.
|
||||
await failureWhilePending.pollAgain();
|
||||
releasePending();
|
||||
await settle();
|
||||
assert.ok(failureWhilePending.labelOf('c').includes('9 advisories')
|
||||
&& !/unavailable/i.test(failureWhilePending.labelOf('c')),
|
||||
`the pending poll must still restore the counts, got ${failureWhilePending.labelOf('c')}`);
|
||||
passed += 1;
|
||||
|
||||
// A poll that never answers must not leave the last steering guidance on
|
||||
// screen forever.
|
||||
const hung = await render([
|
||||
{ ok: true, data: populatedView() },
|
||||
{ ok: false, data: { ok: false, error: 'snapshot unavailable' }, never: true }
|
||||
]);
|
||||
await hung.pollAgain();
|
||||
assert.ok(!/unavailable/i.test(hung.labelOf('c')),
|
||||
'the hung poll should not have reported anything yet');
|
||||
assert.ok(hung.timeoutBudgetMs() !== null && hung.timeoutBudgetMs() <= 15000,
|
||||
`a request timeout should be bounded, got ${hung.timeoutBudgetMs()}`);
|
||||
await hung.fireTimeouts();
|
||||
assert.strictEqual(hung.elements.get('status').textContent, 'offline',
|
||||
'a poll that never answers must time out into the offline state');
|
||||
assert.ok(/unavailable/i.test(hung.labelOf('c')),
|
||||
`a timed out poll must clear the canvas label, got ${hung.labelOf('c')}`);
|
||||
assert.ok(/unavailable/i.test(hung.elements.get('announce').textContent),
|
||||
`a timed out poll must announce the outage, got ${hung.elements.get('announce').textContent}`);
|
||||
passed += 1;
|
||||
|
||||
// Three polls settling out of order as #3, #1, #2. The ignored #1 must not
|
||||
// pull the staleness mark back to 1, or the even older #2 would then be let
|
||||
// through on top of #3's counts.
|
||||
async function settleOutOfOrder(second, third) {
|
||||
let releaseFirst, releaseSecond, releaseThird;
|
||||
const holdOne = new Promise(resolve => { releaseFirst = resolve; });
|
||||
const holdTwo = new Promise(resolve => { releaseSecond = resolve; });
|
||||
const holdThree = new Promise(resolve => { releaseThird = resolve; });
|
||||
const view = await render([
|
||||
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 1, resolutions: 0 } }), hold: holdOne },
|
||||
second(holdTwo),
|
||||
third(holdThree)
|
||||
]);
|
||||
// Start all three, then settle them newest first.
|
||||
await view.pollAgain();
|
||||
await view.pollAgain();
|
||||
await view.pollAgain();
|
||||
releaseThird();
|
||||
await settle();
|
||||
releaseFirst();
|
||||
await settle();
|
||||
releaseSecond();
|
||||
await settle();
|
||||
return view;
|
||||
}
|
||||
|
||||
const newest = { tasks: 3, lanes: 1, agents: 2, advisories: 9, resolutions: 0 };
|
||||
// The ignored #1 is a success, and the older #2 is also a success.
|
||||
const outOfOrderSuccess = await settleOutOfOrder(
|
||||
hold => ({ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 2, resolutions: 1 } }), hold }),
|
||||
hold => ({ ok: true, data: populatedView({ counts: newest }), hold })
|
||||
);
|
||||
assert.ok(outOfOrderSuccess.labelOf('c').includes('9 advisories')
|
||||
&& !outOfOrderSuccess.labelOf('c').includes('2 advisories'),
|
||||
`an older success must not lower the staleness mark, got ${outOfOrderSuccess.labelOf('c')}`);
|
||||
passed += 1;
|
||||
|
||||
// The ignored #1 is a success, and the older #2 is a failure, which would
|
||||
// otherwise mark a healthy view offline.
|
||||
const outOfOrderFailure = await settleOutOfOrder(
|
||||
hold => ({ ok: false, data: { ok: false, error: 'snapshot unavailable' }, hold }),
|
||||
hold => ({ ok: true, data: populatedView({ counts: newest }), hold })
|
||||
);
|
||||
assert.notStrictEqual(outOfOrderFailure.elements.get('status').textContent, 'offline',
|
||||
'a superseded failure must not mark a healthy view offline');
|
||||
assert.ok(outOfOrderFailure.labelOf('c').includes('9 advisories')
|
||||
&& !/unavailable/i.test(outOfOrderFailure.labelOf('c')),
|
||||
`the newest counts must survive an ignored poll, got ${outOfOrderFailure.labelOf('c')}`);
|
||||
passed += 1;
|
||||
|
||||
// Both response headers have arrived before independently held JSON bodies
|
||||
// resolve newest-first in the SAME turn. No drain separates those resolves.
|
||||
// This specifically exercises the promise-continuation watermark gap.
|
||||
function deferredBody() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const prior = populatedView({ lanes: [{ label: 'prior', kind: 'lane', taskIds: ['task-clear'] }] });
|
||||
const oldBody = populatedView({
|
||||
lanes: [{ label: 'older', kind: 'lane', taskIds: ['task-clear'] }],
|
||||
projection: { agents: [{ agentId: 'task-clear', point: [1, 0], maxRisk: 0.1 }] },
|
||||
counts: { tasks: 1, lanes: 1, agents: 1, advisories: 0, resolutions: 0 }
|
||||
});
|
||||
assert.ok(valid.get('status').textContent.includes('0 tasks'));
|
||||
passed += 1;
|
||||
console.log(`Results: Passed: ${passed}, Failed: 0`);
|
||||
const newBody = populatedView({
|
||||
lanes: [{ label: 'newest', kind: 'lane', taskIds: ['task-resolution'] }],
|
||||
projection: { agents: [{ agentId: 'task-resolution', point: [-1, -1], maxRisk: 0.9 }] },
|
||||
counts: { tasks: 1, lanes: 1, agents: 1, advisories: 1, resolutions: 1 }
|
||||
});
|
||||
for (const [name, body, unavailable] of [
|
||||
['newest valid body', newBody, false],
|
||||
['newest invalid body', { schemaVersion: 'invalid' }, true],
|
||||
['newest render failure', populatedView({ events: [null] }), true]
|
||||
]) {
|
||||
try {
|
||||
const older = deferredBody();
|
||||
const newer = deferredBody();
|
||||
const sameTurn = await render([
|
||||
{ ok: true, data: prior },
|
||||
{ ok: true, body: older.promise },
|
||||
{ ok: true, body: newer.promise }
|
||||
]);
|
||||
await sameTurn.pollAgain();
|
||||
await sameTurn.pollAgain();
|
||||
assert.strictEqual(sameTurn.jsonReads(), 3, 'both deferred JSON bodies must be pending after headers');
|
||||
const priorLanes = textOf(sameTurn.elements.get('lanes'));
|
||||
const priorDraws = sameTurn.context.log.length;
|
||||
newer.resolve(body);
|
||||
older.resolve(oldBody);
|
||||
await settle();
|
||||
const laneText = textOf(sameTurn.elements.get('lanes'));
|
||||
const label = sameTurn.labelOf('c');
|
||||
const announcement = sameTurn.elements.get('announce').textContent;
|
||||
const status = sameTurn.elements.get('status').textContent;
|
||||
if (unavailable) {
|
||||
assert.strictEqual(laneText, priorLanes, `${name}: older body must not replace retained lanes`);
|
||||
assert.strictEqual(sameTurn.context.log.length, priorDraws, `${name}: older body must not render markers`);
|
||||
assert.strictEqual(status, 'offline', `${name}: the claimed token must report its failure`);
|
||||
assert.match(label, /unavailable.*unknown/i);
|
||||
assert.match(announcement, /unavailable.*unknown/i);
|
||||
} else {
|
||||
assert.match(laneText, /newest/, 'newest lanes must survive same-turn body completion');
|
||||
assert.doesNotMatch(laneText, /older/);
|
||||
assert.deepStrictEqual(markerShapes({ log: sameTurn.context.log.slice(priorDraws) }),
|
||||
[{ shape: 'triangle', color: '#ff7b72' }], 'only the newest marker may be drawn');
|
||||
assert.match(label, /1 tasks.*1 advisories.*1 steering/);
|
||||
assert.match(announcement, /1 advisories.*1 steering.*Steering is required/);
|
||||
assert.match(status, /1 tasks.*1 advisories.*1 steering/);
|
||||
}
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
failures += 1;
|
||||
console.error(`${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Results: Passed: ${passed}, Failed: ${failures}`);
|
||||
process.exitCode = failures > 0 ? 1 : 0;
|
||||
|
||||
})().catch(error => {
|
||||
console.error(error.message);
|
||||
console.log(`Results: Passed: ${passed}, Failed: 1`);
|
||||
console.log(`Results: Passed: ${passed}, Failed: ${failures + 1}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user