mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed contributor PR #3235 into backlog batch
Source-PR: https://github.com/affaan-m/ECC/pull/3235
Source-Head: aff9c64560
Local integration checkpoint; aggregate review and hosted acceptance pending.
This commit is contained in:
+117
-40
@@ -410,7 +410,7 @@ function renderControlPaneHtml() {
|
||||
</div>
|
||||
<div id="app" hidden></div>
|
||||
<script>
|
||||
const state = { query: '' };
|
||||
const state = { query: '', shownQuery: '' };
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
@@ -436,6 +436,28 @@ function renderControlPaneHtml() {
|
||||
target.textContent = '';
|
||||
}
|
||||
|
||||
// The board keeps the last snapshot on screen, so a failed refresh has to
|
||||
// say that the data is no longer live, and since when.
|
||||
let loadedAt = null;
|
||||
// Loads are numbered as they start and finish in any order. The board shows
|
||||
// the newest data any load brought, and the error box the outcome of the
|
||||
// newest load that has finished, so a load that finishes late can neither
|
||||
// replace newer data nor overrule a newer outcome.
|
||||
let loadsStarted = 0;
|
||||
let newestFinished = 0;
|
||||
let shownLoad = 0;
|
||||
let failure = null;
|
||||
function showRefreshFailure(error) {
|
||||
const since = loadedAt
|
||||
? ' The data below is from ' + loadedAt.toLocaleString() + '.'
|
||||
: '';
|
||||
showError('#app', 'Live refresh failed.' + since + '\\n' + formatError(error));
|
||||
}
|
||||
function showFailure() {
|
||||
if (failure.live) showRefreshFailure(failure.error);
|
||||
else showError('#app', failure.error);
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
let payload;
|
||||
try {
|
||||
@@ -471,18 +493,17 @@ function renderControlPaneHtml() {
|
||||
['Unread', summary.unreadMessages],
|
||||
['Tokens', fmt.format(summary.totalTokens || 0)],
|
||||
];
|
||||
$('#metrics').innerHTML = items.map(([label, value]) =>
|
||||
return items.map(([label, value]) =>
|
||||
'<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderSessions(sessions) {
|
||||
if (!sessions.length) {
|
||||
$('#sessions').innerHTML = '<div class="empty">No ECC2 sessions found.</div>';
|
||||
return;
|
||||
return '<div class="empty">No ECC2 sessions found.</div>';
|
||||
}
|
||||
|
||||
$('#sessions').innerHTML = '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
|
||||
return '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
|
||||
sessions.map(session => '<tr>' +
|
||||
'<td>' + statePill(session.state) + '</td>' +
|
||||
'<td><strong>' + escapeHtml(session.id) + '</strong><br><span class="subtle">' + escapeHtml(session.task) + '</span></td>' +
|
||||
@@ -493,13 +514,13 @@ function renderControlPaneHtml() {
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderWorkItems(workItems) {
|
||||
function renderWorkItems(workItems, allowActions) {
|
||||
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
|
||||
const items = Array.isArray(summary.items) ? summary.items : [];
|
||||
const kanban = summary.kanban || {};
|
||||
const needsAssignment = Array.isArray(summary.needsAssignment) ? summary.needsAssignment : [];
|
||||
const assignment = summary.assignment || { agent: 0, human: 0, unassigned: 0 };
|
||||
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
|
||||
const count = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
|
||||
+ ' / ' + (assignment.agent || 0) + ' agent / ' + (assignment.human || 0) + ' human'
|
||||
+ (needsAssignment.length ? ' / ' + needsAssignment.length + ' need owner' : '');
|
||||
|
||||
@@ -509,11 +530,10 @@ function renderControlPaneHtml() {
|
||||
).join('') + '</div>';
|
||||
|
||||
if (!items.length) {
|
||||
$('#work-items').innerHTML = laneHtml + '<div class="empty">No agent work items found.</div>';
|
||||
return;
|
||||
return { count, html: laneHtml + '<div class="empty">No agent work items found.</div>' };
|
||||
}
|
||||
|
||||
$('#work-items').innerHTML = laneHtml + items.slice(0, 8).map(item => {
|
||||
const html = laneHtml + items.slice(0, 8).map(item => {
|
||||
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
|
||||
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
|
||||
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
|
||||
@@ -526,7 +546,7 @@ function renderControlPaneHtml() {
|
||||
const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane =>
|
||||
'<button type="button" data-wi-action="move" data-wi-id="' + idAttr + '" data-wi-lane="' + lane + '">' + escapeHtml(lane) + '</button>'
|
||||
).join('');
|
||||
const controls = state.allowActions
|
||||
const controls = allowActions
|
||||
? '<div class="row">'
|
||||
+ (assigneeKind === 'unassigned' ? '<button type="button" data-wi-action="claim" data-wi-id="' + idAttr + '">Claim</button>' : '')
|
||||
+ moveButtons
|
||||
@@ -542,6 +562,10 @@ function renderControlPaneHtml() {
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return { count, html };
|
||||
}
|
||||
|
||||
function bindWorkItemActions() {
|
||||
document.querySelectorAll('#work-items [data-wi-action]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-wi-id');
|
||||
@@ -555,13 +579,12 @@ function renderControlPaneHtml() {
|
||||
}
|
||||
|
||||
function renderKnowledge(knowledge) {
|
||||
$('#knowledge-count').textContent = knowledge.entityCount + ' entities';
|
||||
const count = knowledge.entityCount + ' entities';
|
||||
if (!knowledge.results.length) {
|
||||
$('#knowledge').innerHTML = '<div class="empty">No recall results for this query.</div>';
|
||||
return;
|
||||
return { count, html: '<div class="empty">No recall results for this query.</div>' };
|
||||
}
|
||||
|
||||
$('#knowledge').innerHTML = knowledge.results.map(result => {
|
||||
const html = knowledge.results.map(result => {
|
||||
const entity = result.entity;
|
||||
const obs = result.latestObservation;
|
||||
return '<div class="result">' +
|
||||
@@ -572,16 +595,16 @@ function renderControlPaneHtml() {
|
||||
'<div class="subtle">terms: ' + escapeHtml((result.matchedTerms || []).join(', ') || '-') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return { count, html };
|
||||
}
|
||||
|
||||
function renderConnectors(connectors) {
|
||||
$('#connector-count').textContent = connectors.length + ' configured';
|
||||
const count = connectors.length + ' configured';
|
||||
if (!connectors.length) {
|
||||
$('#connectors').innerHTML = '<div class="empty">No memory connectors configured.</div>';
|
||||
return;
|
||||
return { count, html: '<div class="empty">No memory connectors configured.</div>' };
|
||||
}
|
||||
|
||||
$('#connectors').innerHTML = connectors.map(connector => {
|
||||
const html = connectors.map(connector => {
|
||||
const status = connector.syncedSources > 0 ? '<span class="pill good">synced</span>' : '<span class="pill warn">not synced</span>';
|
||||
return '<div class="connector">' +
|
||||
'<div class="row"><strong>' + escapeHtml(connector.name) + '</strong>' + status + '</div>' +
|
||||
@@ -589,16 +612,20 @@ function renderControlPaneHtml() {
|
||||
'<div class="subtle">sources ' + escapeHtml(connector.syncedSources) + ' - last ' + escapeHtml(connector.lastSyncedAt || '-') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return { count, html };
|
||||
}
|
||||
|
||||
function renderActions(actions) {
|
||||
$('#actions').innerHTML = actions.map(action => '<div class="action">' +
|
||||
return actions.map(action => '<div class="action">' +
|
||||
'<div class="row"><strong>' + escapeHtml(action.label) + '</strong>' +
|
||||
(action.executable ? '<button data-action="' + escapeHtml(action.id) + '">Run</button>' : '<span class="pill">copy</span>') + '</div>' +
|
||||
'<div class="subtle">' + escapeHtml(action.description) + '</div>' +
|
||||
'<code>' + escapeHtml(action.commandLine) + '</code>' +
|
||||
'</div>').join('');
|
||||
|
||||
}
|
||||
|
||||
function bindActions() {
|
||||
document.querySelectorAll('[data-action]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
runAction(button.dataset.action);
|
||||
@@ -611,10 +638,12 @@ function renderControlPaneHtml() {
|
||||
output.textContent = 'Running ' + actionId + '...';
|
||||
|
||||
try {
|
||||
// An action runs for the query its card on the board was built for.
|
||||
// After a failed load that is not always the query typed last.
|
||||
const response = await fetch('/api/actions/' + encodeURIComponent(actionId), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query: state.query })
|
||||
body: JSON.stringify({ query: state.shownQuery })
|
||||
});
|
||||
const payload = await readJsonResponse(response);
|
||||
output.textContent = JSON.stringify(payload, null, 2);
|
||||
@@ -624,27 +653,75 @@ function renderControlPaneHtml() {
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const url = new URL('/api/snapshot', window.location.href);
|
||||
if (state.query) url.searchParams.set('query', state.query);
|
||||
const response = await fetch(url);
|
||||
const snapshot = await readJsonResponse(response);
|
||||
$('#query').value = snapshot.knowledge.query || state.query;
|
||||
$('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing';
|
||||
state.allowActions = Boolean(snapshot.execution.allowActions);
|
||||
$('#action-status').textContent = state.allowActions ? 'local allowlist' : 'read-only';
|
||||
renderMetrics(snapshot.summary);
|
||||
renderSessions(snapshot.sessions);
|
||||
renderWorkItems(snapshot.workItems);
|
||||
renderKnowledge(snapshot.knowledge);
|
||||
renderConnectors(snapshot.connectors);
|
||||
renderActions(snapshot.actions.map(action => ({
|
||||
...action,
|
||||
executable: snapshot.execution.allowActions && action.executable
|
||||
})));
|
||||
async function load(live = false) {
|
||||
const id = ++loadsStarted;
|
||||
const query = state.query;
|
||||
try {
|
||||
const url = new URL('/api/snapshot', window.location.href);
|
||||
if (query) url.searchParams.set('query', query);
|
||||
const response = await fetch(url);
|
||||
const snapshot = await readJsonResponse(response);
|
||||
// A snapshot that cannot be shown fails its load like one that could
|
||||
// not be fetched, and older data may still take the board.
|
||||
if (id > shownLoad) {
|
||||
render(snapshot, query);
|
||||
shownLoad = id;
|
||||
}
|
||||
} catch (error) {
|
||||
if (id < newestFinished) return;
|
||||
newestFinished = id;
|
||||
failure = { error, live };
|
||||
showFailure();
|
||||
return;
|
||||
}
|
||||
if (id < newestFinished) {
|
||||
// A newer load failed first. This data is still the newest on the
|
||||
// board, so the failure stays, dated by it.
|
||||
if (failure && id === shownLoad) showFailure();
|
||||
return;
|
||||
}
|
||||
newestFinished = id;
|
||||
failure = null;
|
||||
clearError('#app');
|
||||
}
|
||||
|
||||
function render(snapshot, query) {
|
||||
// Prepare every fragment before changing the board. Malformed late
|
||||
// sections must not leave new results with the prior action context.
|
||||
const allowActions = Boolean(snapshot.execution.allowActions);
|
||||
const workItems = renderWorkItems(snapshot.workItems, allowActions);
|
||||
const knowledge = renderKnowledge(snapshot.knowledge);
|
||||
const connectors = renderConnectors(snapshot.connectors);
|
||||
const updates = [
|
||||
['#query', 'value', snapshot.knowledge.query ?? query],
|
||||
['#db-path', 'textContent', snapshot.database.exists ? snapshot.dbPath : 'database missing'],
|
||||
['#action-status', 'textContent', allowActions ? 'local allowlist' : 'read-only'],
|
||||
['#metrics', 'innerHTML', renderMetrics(snapshot.summary)],
|
||||
['#sessions', 'innerHTML', renderSessions(snapshot.sessions)],
|
||||
['#work-item-count', 'textContent', workItems.count],
|
||||
['#work-items', 'innerHTML', workItems.html],
|
||||
['#knowledge-count', 'textContent', knowledge.count],
|
||||
['#knowledge', 'innerHTML', knowledge.html],
|
||||
['#connector-count', 'textContent', connectors.count],
|
||||
['#connectors', 'innerHTML', connectors.html],
|
||||
['#actions', 'innerHTML', renderActions(snapshot.actions.map(action => ({
|
||||
...action,
|
||||
executable: allowActions && action.executable
|
||||
})))]
|
||||
].map(([selector, property, value]) => {
|
||||
const target = $(selector);
|
||||
if (!target) throw new Error('Missing control pane element: ' + selector);
|
||||
return { target, property, value };
|
||||
});
|
||||
const completedAt = new Date();
|
||||
updates.forEach(({ target, property, value }) => { target[property] = value; });
|
||||
state.allowActions = allowActions;
|
||||
state.shownQuery = query;
|
||||
loadedAt = completedAt;
|
||||
bindWorkItemActions();
|
||||
bindActions();
|
||||
}
|
||||
|
||||
$('#query-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
state.query = $('#query').value.trim();
|
||||
@@ -682,7 +759,7 @@ function renderControlPaneHtml() {
|
||||
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
|
||||
setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
load().catch(() => {});
|
||||
load(true).catch(error => showError('#app', error));
|
||||
}, 15000);
|
||||
|
||||
load().catch(error => showError('#app', error));
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Tests for the browser script the local ECC2 control pane serves.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const { buildControlPaneSnapshot } = require('../../scripts/lib/control-pane/state');
|
||||
const { renderControlPaneHtml } = require('../../scripts/lib/control-pane/ui');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function inlineScript(html) {
|
||||
const start = html.indexOf('<script>') + '<script>'.length;
|
||||
return html.slice(start, html.lastIndexOf('</script>'));
|
||||
}
|
||||
|
||||
// The page's clock. The page shows times with toLocaleString, which follows
|
||||
// the locale's calendar (a Thai locale counts Buddhist years), so a test
|
||||
// compares against the same call on this instant.
|
||||
const NOW = new Date(2026, 8, 25, 10, 30);
|
||||
|
||||
class PageDate extends Date {
|
||||
constructor(...args) {
|
||||
super(...(args.length > 0 ? args : [NOW.getTime()]));
|
||||
}
|
||||
}
|
||||
|
||||
// Runs the page script against a stand-in for the few browser APIs it uses:
|
||||
// elements looked up by selector, fetch, a fixed clock, and a setInterval
|
||||
// whose callback the test fires itself. While `hold` is set, a fetch waits in
|
||||
// `pending` until the test settles it, with the page's snapshot or another one.
|
||||
// With `hold`, the first load is held too. Listeners are kept per element, so a
|
||||
// test can press a button. Every request is recorded, and `page.script` holds
|
||||
// the page's own functions, such as the runAction a Run button calls.
|
||||
function openPage(snapshot, { hold = false } = {}) {
|
||||
const elements = new Map();
|
||||
const element = selector => {
|
||||
if (!elements.has(selector)) {
|
||||
elements.set(selector, {
|
||||
hidden: selector === '#app',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
value: '',
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
addEventListener(type, listener) {
|
||||
this.listeners[type] = listener;
|
||||
}
|
||||
});
|
||||
}
|
||||
return elements.get(selector);
|
||||
};
|
||||
const page = { online: true, hold, pending: [], requests: [], refresh: null, element, now: NOW };
|
||||
class Clock extends PageDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length > 0 ? args : [page.now.getTime()]));
|
||||
}
|
||||
}
|
||||
page.script = {
|
||||
document: { hidden: false, querySelector: element, querySelectorAll: () => [] },
|
||||
window: { location: { href: 'http://127.0.0.1:8765/' } },
|
||||
URL,
|
||||
Intl,
|
||||
Date: Clock,
|
||||
console,
|
||||
fetch: (url, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
page.requests = [...page.requests, { url: String(url), options }];
|
||||
const reply = {
|
||||
succeed: (data = snapshot) => resolve({ ok: true, status: 200, statusText: 'OK', json: async () => data }),
|
||||
fail: () => reject(new TypeError('Failed to fetch'))
|
||||
};
|
||||
if (page.hold) page.pending = [...page.pending, reply];
|
||||
else if (page.online) reply.succeed();
|
||||
else reply.fail();
|
||||
}),
|
||||
setInterval: callback => {
|
||||
page.refresh = callback;
|
||||
}
|
||||
};
|
||||
vm.runInNewContext(inlineScript(renderControlPaneHtml()), page.script);
|
||||
return page;
|
||||
}
|
||||
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
async function isolatedSnapshot() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-ui-'));
|
||||
try {
|
||||
// Explicit config and both database paths keep this UI test away from
|
||||
// user config, parent-directory config, and the default state store.
|
||||
return JSON.parse(JSON.stringify(await buildControlPaneSnapshot({
|
||||
config: {},
|
||||
dbPath: path.join(root, 'missing-ecc2.db'),
|
||||
stateDbPath: path.join(root, 'missing-state.db'),
|
||||
repoRoot: root,
|
||||
cwd: root,
|
||||
env: { HOME: root, USERPROFILE: root },
|
||||
query: ''
|
||||
})));
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function displayedBoard(page) {
|
||||
const fields = {
|
||||
'#query': 'value',
|
||||
'#db-path': 'textContent',
|
||||
'#action-status': 'textContent',
|
||||
'#metrics': 'innerHTML',
|
||||
'#sessions': 'innerHTML',
|
||||
'#work-item-count': 'textContent',
|
||||
'#work-items': 'innerHTML',
|
||||
'#knowledge-count': 'textContent',
|
||||
'#knowledge': 'innerHTML',
|
||||
'#connector-count': 'textContent',
|
||||
'#connectors': 'innerHTML',
|
||||
'#actions': 'innerHTML'
|
||||
};
|
||||
return Object.fromEntries(Object.entries(fields).map(([selector, field]) => [selector, page.element(selector)[field]]));
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing control-pane UI ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const snapshot = await isolatedSnapshot();
|
||||
|
||||
if (
|
||||
await test('a failed live refresh is reported, and cleared by the next one that succeeds', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#app').hidden, true, 'the first load succeeds');
|
||||
|
||||
page.online = false;
|
||||
page.refresh();
|
||||
await settle();
|
||||
const box = page.element('#app');
|
||||
assert.strictEqual(box.hidden, false, 'the failure is shown');
|
||||
assert.match(box.textContent, /Live refresh failed\. The data below is from /);
|
||||
assert.ok(box.textContent.includes(NOW.toLocaleString()), 'the time of the data includes its date');
|
||||
assert.match(box.textContent, /Failed to fetch/);
|
||||
|
||||
page.online = true;
|
||||
page.refresh();
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#app').hidden, true, 'a successful refresh clears it');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('a refresh that fails after a newer load succeeded is not reported', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
|
||||
page.hold = true;
|
||||
page.refresh();
|
||||
page.hold = false;
|
||||
page.refresh();
|
||||
await settle();
|
||||
page.pending[0].fail();
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#app').hidden, true, 'the newer data is not marked as stale');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('a failed refresh stays off the board while newer data is shown and another refresh runs', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
|
||||
page.hold = true;
|
||||
page.refresh();
|
||||
page.refresh();
|
||||
page.refresh();
|
||||
page.pending[1].succeed();
|
||||
await settle();
|
||||
page.pending[0].fail();
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#app').hidden, true, 'the board shows data from a load that started after the failed one');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('a refresh that fails after an older one succeeded is reported', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
|
||||
page.hold = true;
|
||||
page.refresh();
|
||||
page.refresh();
|
||||
page.pending[0].succeed();
|
||||
await settle();
|
||||
page.pending[1].fail();
|
||||
await settle();
|
||||
const box = page.element('#app');
|
||||
assert.strictEqual(box.hidden, false, 'the latest refresh failed, so the board is not live');
|
||||
assert.match(box.textContent, /Live refresh failed\. The data below is from /);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('an older refresh that succeeds after a newer one failed leaves the failure up', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
|
||||
page.hold = true;
|
||||
page.refresh();
|
||||
page.refresh();
|
||||
page.pending[1].fail();
|
||||
await settle();
|
||||
page.pending[0].succeed();
|
||||
await settle();
|
||||
const box = page.element('#app');
|
||||
assert.strictEqual(box.hidden, false, 'no load that started after the failed one has succeeded');
|
||||
assert.match(box.textContent, /Live refresh failed\. The data below is from /);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('an older response that arrives after a newer one does not replace its data', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
|
||||
|
||||
page.hold = true;
|
||||
page.refresh();
|
||||
page.refresh();
|
||||
page.pending[1].succeed(answer('newer'));
|
||||
await settle();
|
||||
page.pending[0].succeed(answer('older'));
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#query').value, 'newer');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('the first snapshot still shows when a live refresh fails before it arrives', async () => {
|
||||
const page = openPage(snapshot, { hold: true });
|
||||
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
|
||||
|
||||
page.refresh();
|
||||
page.pending[1].fail();
|
||||
await settle();
|
||||
page.pending[0].succeed(answer('first'));
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#query').value, 'first', 'the pane is not left empty');
|
||||
const box = page.element('#app');
|
||||
assert.strictEqual(box.hidden, false, 'the newer failure stays up');
|
||||
assert.match(box.textContent, /Live refresh failed\. The data below is from /);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('a manual refresh that fails after a newer load succeeded is not shown', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
|
||||
page.hold = true;
|
||||
page.element('#refresh').listeners.click();
|
||||
page.refresh();
|
||||
page.pending[1].succeed();
|
||||
await settle();
|
||||
page.pending[0].fail();
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#app').hidden, true, 'the newer success decides the board');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('an older load that succeeds after a newer manual refresh failed leaves the failure up', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
|
||||
page.hold = true;
|
||||
page.refresh();
|
||||
page.element('#refresh').listeners.click();
|
||||
page.pending[1].fail();
|
||||
await settle();
|
||||
page.pending[0].succeed();
|
||||
await settle();
|
||||
const box = page.element('#app');
|
||||
assert.strictEqual(box.hidden, false, 'the newer failure decides the board');
|
||||
assert.match(box.textContent, /Failed to fetch/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('a snapshot that cannot be shown fails its load, and older data can still take the board', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
|
||||
// The session table cannot list harnesses stored as an object.
|
||||
const unshowable = {
|
||||
...answer('newer'),
|
||||
sessions: [{ id: 'session-1', state: 'running', detectedHarnesses: { claude: true } }]
|
||||
};
|
||||
|
||||
page.hold = true;
|
||||
page.refresh();
|
||||
page.refresh();
|
||||
page.pending[1].succeed(unshowable);
|
||||
await settle();
|
||||
const box = page.element('#app');
|
||||
assert.strictEqual(box.hidden, false, 'the newer load failed');
|
||||
assert.match(box.textContent, /Live refresh failed\. The data below is from /);
|
||||
page.pending[0].succeed(answer('older'));
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#query').value, 'older', 'the older snapshot is shown');
|
||||
assert.strictEqual(box.hidden, false, 'the newer failure stays up');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('Run acts on the query whose results are on the board', async () => {
|
||||
const page = openPage(snapshot);
|
||||
await settle();
|
||||
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
|
||||
const search = query => {
|
||||
page.element('#query').value = query;
|
||||
page.element('#query-form').listeners.submit({ preventDefault() {} });
|
||||
};
|
||||
|
||||
page.hold = true;
|
||||
search('older');
|
||||
search('newer');
|
||||
page.pending[1].fail();
|
||||
await settle();
|
||||
page.pending[0].succeed(answer('older'));
|
||||
await settle();
|
||||
page.hold = false;
|
||||
await page.script.runAction('recall-knowledge');
|
||||
const run = page.requests.find(request => request.options.method === 'POST');
|
||||
assert.strictEqual(run.url, '/api/actions/recall-knowledge');
|
||||
assert.deepStrictEqual(JSON.parse(run.options.body), { query: 'older' }, 'the recall shown on the board');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
for (const [brokenSection, allowActions] of [
|
||||
['connectors', true], ['connectors', false], ['actions', true], ['actions', false]
|
||||
]) {
|
||||
if (
|
||||
await test(`a late ${brokenSection} failure preserves the board with actions ${allowActions ? 'enabled' : 'disabled'}`, async () => {
|
||||
const original = {
|
||||
...snapshot,
|
||||
knowledge: { ...snapshot.knowledge, query: 'prior', entityCount: 1,
|
||||
results: [{ entity: { name: 'prior result', entityType: 'note' }, score: 1 }] },
|
||||
execution: { allowActions },
|
||||
workItems: { ...snapshot.workItems, items: [{ id: 'prior-item', title: 'prior work' }] }
|
||||
};
|
||||
const page = openPage(original, { hold: true });
|
||||
// Finish the initial request, then establish the matching request query.
|
||||
page.pending[0].succeed(original);
|
||||
await settle();
|
||||
page.element('#query').value = 'prior';
|
||||
page.element('#query-form').listeners.submit({ preventDefault() {} });
|
||||
page.pending[1].succeed(original);
|
||||
await settle();
|
||||
const before = displayedBoard(page);
|
||||
page.now = new Date(NOW.getTime() + 60_000);
|
||||
page.refresh();
|
||||
const invalid = {
|
||||
...original,
|
||||
dbPath: 'new-database', database: { exists: true },
|
||||
execution: { allowActions: !allowActions },
|
||||
summary: { ...snapshot.summary, totalSessions: 99 },
|
||||
sessions: [{ id: 'new-session', state: 'running' }],
|
||||
workItems: { ...snapshot.workItems, items: [{ id: 'new-item', title: 'new work' }] },
|
||||
knowledge: { ...original.knowledge, query: 'new', entityCount: 2,
|
||||
results: [{ entity: { name: 'new result', entityType: 'note' }, score: 2 }] },
|
||||
connectors: [{ name: 'new connector', kind: 'test' }],
|
||||
actions: [{ id: 'new-action', label: 'new action', executable: true }],
|
||||
[brokenSection]: brokenSection === 'actions' ? {} : [null]
|
||||
};
|
||||
page.pending[2].succeed(invalid);
|
||||
await settle();
|
||||
assert.deepStrictEqual(displayedBoard(page), before, 'a failed snapshot changes no displayed section');
|
||||
const error = page.element('#app');
|
||||
assert.strictEqual(error.hidden, false);
|
||||
assert.ok(error.textContent.includes(NOW.toLocaleString()), 'the failure retains the prior successful snapshot time');
|
||||
assert.ok(!error.textContent.includes(page.now.toLocaleString()), 'the failed snapshot has no successful timestamp');
|
||||
|
||||
// Failed refreshes neither enable nor disable the prior work-item controls.
|
||||
page.script.window.eccMoveItem('prior-item', 'ready');
|
||||
const move = page.requests.find(request => request.url === '/api/work-items/prior-item/move');
|
||||
assert.strictEqual(Boolean(move), allowActions, 'work-item permission still matches the prior board');
|
||||
const run = page.script.runAction('recall-knowledge');
|
||||
const request = page.requests.find(request => request.url === '/api/actions/recall-knowledge');
|
||||
assert.deepStrictEqual(JSON.parse(request.options.body), { query: 'prior' });
|
||||
// Fail both fake action responses; no subsequent snapshot is requested.
|
||||
page.pending.slice(3).forEach(reply => reply.fail());
|
||||
await run;
|
||||
await settle();
|
||||
})
|
||||
) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
if (
|
||||
await test('an older empty-query response stays empty while a newer query is pending', async () => {
|
||||
const page = openPage(snapshot, { hold: true });
|
||||
page.element('#query').value = 'new request';
|
||||
page.element('#query-form').listeners.submit({ preventDefault() {} });
|
||||
page.pending[0].succeed(snapshot);
|
||||
await settle();
|
||||
assert.strictEqual(page.element('#query').value, '', 'the completed empty query is not replaced by the pending query');
|
||||
const run = page.script.runAction('recall-knowledge');
|
||||
const request = page.requests.find(item => item.options.method === 'POST');
|
||||
assert.deepStrictEqual(JSON.parse(request.options.body), { query: '' });
|
||||
page.pending[1].fail();
|
||||
page.pending[2].fail();
|
||||
await run;
|
||||
await settle();
|
||||
})
|
||||
) passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user