Add Broker client and writer quick dialogs

This commit is contained in:
2026-09-13 14:40:51 +02:00
parent 9f6ebf2053
commit 7ccc8799e9
9 changed files with 274 additions and 32 deletions
+92 -3
View File
@@ -8,6 +8,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const n = (b,id) => b.nodes['broker-'+id];
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
function liveTimer(b) { const entry=[...b.timers].find(([,t])=>t.ms===5000&&!t.interval); assert.ok(entry, 'missing live/deadline timer'); b.timers.delete(entry[0]); entry[1].fn(); }
const reads = b => b.calls.filter(c => c.url === path);
async function open(value=fixture()) {
const b=await adminBrowser(); b.click('select-settings'); await tick();
@@ -15,6 +16,94 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
}
function select(b,id=9) { n(b,'target').value=String(id); n(b,'target').change(); }
async function submit(b) { select(b); b.window.confirm=()=>true; b.queues[op].push(reply('pending',42,202)); b.click('broker-assign'); await tick(); }
async function quick(domain='broker', mode='serial') {
const b=await adminBrowser(); b.click('select-'+mode);
b.nodes['serial-settings'].appendChild(b.nodes['quick-close']);
b.queues[path].push(json(fixture())); b.click('quick-'+domain); await tick(); return b;
}
await test('Contextual broker/writer native triggers support pointer keyboard touch, admin-only management and terminal input/focus isolation',async()=>{
for(const domain of ['broker','writer']) {
const u=browser();u.start();await tick();assert.ok(u.nodes['quick-'+domain].disabled);
u.nodes['quick-'+domain].pointerenter({pointerType:'mouse'});u.nodes['quick-'+domain].focus();u.click('quick-'+domain);await tick();assert.equal(reads(u).length,0);
for(const mode of ['serial','admin']) {
const b=await quick(domain,mode), trigger=b.nodes['quick-'+domain], host=b.nodes['serial-settings'];
assert.equal(host.role,'dialog');assert.equal(trigger['aria-expanded'],'true');assert.equal(b.document.activeElement,b.nodes['quick-close']);
assert.equal(posts(b).length,0);select(b);assert.equal(posts(b).length,0);
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(255,0,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[255,0,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
const focus=b.terminals.map(t=>t.focusCalls);
b.emit('keydown',{key:'Escape',preventDefault(){},stopPropagation(){}});
assert.equal(b.document.activeElement,trigger);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);assert.equal(host['data-quick'],'false');
trigger.pointerenter({pointerType:'touch'});await tick();assert.equal(host['data-quick'],'false');
b.queues[path].push(json(fixture()));b.click('quick-'+domain);await tick();assert.equal(host['data-quick'],'true');
b.emit('pointerdown',{target:b.nodes['connection-toggle']});b.nodes['connection-toggle'].focus();assert.equal(b.document.activeElement,b.nodes['connection-toggle']);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);
trigger.pointerleave();b.queues[path].push(json(fixture()));trigger.focus();await tick();assert.equal(trigger['aria-expanded'],'true');
b.nodes['quick-close'].focus();trigger.pointerleave();b.fire(250);assert.equal(host['data-quick'],'true');
}
}
});
await test('Live refresh is single-flight, preserves explicit selection across reorder/counters and never rebases lease/identity tokens',async()=>{
const b=await quick();select(b);const d=deferred();b.queues[path].push(d.promise);liveTimer(b);await tick();
assert.equal(reads(b).length,2);assert.ok(n(b,'assign').disabled);assert.equal(n(b,'target').disabled,false);
b.click('broker-refresh');b.click('quick-broker');await tick();assert.equal(reads(b).length,2);
d.resolve(json(fixture({clients:[{...row(9,3),pending:0},row(8)]})));await tick();
assert.equal(n(b,'target').value,'9');assert.equal(n(b,'assign').disabled,false);
b.queues[path].push(json(fixture({generation:8})));liveTimer(b);await tick();assert.equal(n(b,'target').value,'9');assert.ok(n(b,'assign').disabled);assert.match(n(b,'detail').textContent,/Selection stale/);
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);b.click('broker-assign');await tick();assert.equal(posts(b).length,0);assert.match(n(b,'operation-detail').textContent,/Selection stale/);
select(b);assert.equal(n(b,'assign').disabled,false);
b.queues[path].push(json(fixture({clients:[row(8),{...row(9,3),name_hex:'61'}]})));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);
select(b);b.queues[path].push(json(fixture({clients:[row(8)]})));liveTimer(b);await tick();assert.equal(n(b,'target').value,'');
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.equal(n(b,'target').value,'');assert.ok(n(b,'assign').disabled);assert.equal(posts(b).length,0);
});
await test('Live refresh preserves focused action/select controls with aria-disabled gating and visible stale rejection',async()=>{
for(const id of ['assign','refresh','result','target']) {
const b=await quick();select(b);b.nodes['serial-settings'].appendChild(n(b,id));n(b,id).focus();
const focus=b.terminals.map(t=>t.focusCalls), d=deferred();b.queues[path].push(d.promise);liveTimer(b);await tick();
assert.equal(b.document.activeElement,n(b,id));assert.equal(n(b,id).disabled,false);
if(id!=='target'){assert.equal(n(b,id)['aria-disabled'],'true');b.click('broker-'+id);await tick();assert.equal(reads(b).length,2);assert.equal(posts(b).length,0);assert.equal(gets(b).length,0);}
d.resolve(json(fixture({generation:8})));await tick();assert.equal(b.document.activeElement,n(b,id));assert.equal(n(b,id).disabled,false);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);
if(id==='assign'){assert.equal(n(b,id)['aria-disabled'],'true');b.click('broker-assign');await tick();assert.match(n(b,'operation-detail').textContent,/Selection stale/);assert.equal(posts(b).length,0);}
}
});
await test('Live timeout/error stops automatic refresh and stale late responses cannot restore selection or close a newer view',async()=>{
for(const response of [failure(503),json({invalid:true})]) {
const b=await quick();select(b);b.queues[path].push(response);liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
}
for(const stage of ['session','snapshot']) {
const b=await quick();select(b);const d=deferred();b.queues[stage==='session'?'/api/session':path].push(d.promise);liveTimer(b);await tick();liveTimer(b);await tick();
assert.match(n(b,'detail').textContent,/timed out/);assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
b.queues[path].push(json(fixture()));b.click('broker-refresh');await tick();assert.ok(n(b,'assign').disabled);
d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();assert.ok(n(b,'assign').disabled);select(b);
b.queues[op].push(reply('pending',42,202));b.click('broker-assign');await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
}
});
await test('Context triggers preserve every full-page draft and coordinate sole DOM ownership with Serial/WiFi quick views',async()=>{
const b=await adminBrowser();b.click('select-settings');await tick();b.nodes['edit-baud'].value='12345';
for(const domain of ['broker','writer']) {b.nodes['quick-'+domain].pointerenter({pointerType:'mouse'});b.nodes['quick-'+domain].focus();b.click('quick-'+domain);await tick();assert.equal(b.nodes['edit-baud'].value,'12345');assert.equal(reads(b).length,0);}
b.click('settings-accounts');await tick();b.nodes['account-password'].value='unsubmitted';b.click('quick-writer');await tick();assert.equal(b.nodes['account-password'].value,'unsubmitted');
b.click('settings-network');await tick();b.nodes['network-password'].value='unsubmitted';b.click('quick-broker');await tick();assert.equal(b.nodes['network-password'].value,'unsubmitted');
b.click('select-serial');b.click('quick-serial');await tick();b.nodes['quick-serial'].focusout();b.queues[path].push(json(fixture()));b.nodes['quick-writer'].focus();await tick();assert.equal(b.document.activeElement,b.nodes['quick-writer']);assert.equal(b.nodes['quick-serial']['aria-expanded'],'false');
select(b);const count=reads(b).length;b.nodes['quick-full'].click({preventDefault(){}});await tick();assert.equal(n(b,'target').value,'9');assert.equal(reads(b).length,count);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
});
await test('Context stale confirmation retains captured lease through delayed validation; conflict visible and no replay on dismiss/reopen',async()=>{
const b=await quick('writer');select(b);const d=deferred();b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('broker-assign');await tick();assert.equal(posts(b).length,0);
b.click('broker-refresh');await tick();d.resolve(session({role:'admin'}));await tick();assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9});
b.queues[op].push(reply('conflict'));b.queues[path].push(json(fixture({generation:10})));b.fire(1000);await tick();assert.match(n(b,'operation-detail').textContent,/No lease change/);assert.ok(n(b,'assign').disabled);
b.click('quick-close');b.queues[path].push(json(fixture()));b.click('quick-writer');await tick();assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
select(b);b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick();b.click('quick-close');b.queues[path].push(json(fixture()));b.click('quick-writer');await tick();assert.equal(posts(b).length,2);assert.match(n(b,'operation-detail').textContent,/unknown/);assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)||t.ms===1000));
});
await test('Context read/operation/session identity fencing covers expiry pagehide late401 and newer quick ownership',async()=>{
for(const stage of ['read','post','result']) {
const b=await quick();const d=deferred();
if(stage==='read'){b.queues[path].push(d.promise);liveTimer(b);}else if(stage==='post'){select(b);b.queues[op].push(d.promise);b.click('broker-assign');}else{await submit(b);b.queues[op].push(d.promise);b.fire(1000);}
await tick();b.click('quick-serial');await tick();d.resolve(failure(401));await tick();assert.deepEqual(b.redirects,[]);assert.equal(b.nodes['quick-serial']['aria-expanded'],'true');assert.ok(b.sockets.every(s=>!s.closed));
}
for(const action of ['expiry','pagehide','identity','401']) {
const b=await quick();select(b);
if(action==='expiry')b.window.sakSessionExpired();else if(action==='pagehide')b.emit('pagehide');else{if(action==='identity')b.queues['/api/session'].push(session({role:'admin',username:'other'}));else b.queues[path].push(failure(401));liveTimer(b);}
await tick();assert.ok(b.sockets.every(s=>s.closed));assert.equal(n(b,'target').value,'');assert.equal(posts(b).length,0);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
}
});
await test('Broker admin-only full page, label/value style, bounded safe rows; view/selection do not mutate or close either terminal', async()=>{
for(const id of ['settings-broker','broker-values','broker-target','broker-assign','broker-refresh','broker-result']) assert.ok(html.includes('id="'+id+'"'));
const u=browser(); u.start(); await tick(); u.click('settings-broker'); await tick(); assert.equal(reads(u).length,0);
@@ -33,13 +122,13 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const empty=await open(fixture({writer:0,clients:[]})); assert.match(n(empty,'detail').textContent,/0 connected/);
const c=await open(); c.queues[path].push(new Response('x'.repeat(2049))); c.click('broker-refresh'); await tick(); select(c); assert.ok(n(c,'assign').disabled);
});
await test('Broker explicit confirmation captures target and lease generation, cancel/current-writer/selection never POST; refresh clears selection',async()=>{
await test('Broker explicit confirmation captures target and lease generation, cancel/current-writer/selection never POST; refresh retains stale selection',async()=>{
const b=await open(); select(b,8); b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0);
select(b); let prompt=''; b.window.confirm=text=>{prompt=text;return false;}; b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0); assert.match(prompt,/9 \/ SSH/); assert.match(prompt,/Current writer: 8/);
await submit(b); assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9}); assert.equal(posts(b)[0].headers['X-CSRF-Token'],token);
b.click('broker-assign'); await tick(); assert.equal(posts(b).length,1);
b.queues[op].push(reply('ok')); b.queues[path].push(json(fixture({generation:8,writer:9}))); b.fire(1000); await tick();
assert.equal(n(b,'target').value,''); assert.ok(n(b,'assign').disabled); assert.match(n(b,'operation-detail').textContent,/completed/); assert.equal(posts(b).length,1);
assert.equal(n(b,'target').value,'9'); assert.ok(n(b,'assign').disabled); assert.match(n(b,'detail').textContent,/Selection stale/); assert.match(n(b,'operation-detail').textContent,/completed/); assert.equal(posts(b).length,1);
});
await test('Broker stale/failed/cancelled completion refreshes without retry; stale target removal clears explicit selection',async()=>{
for(const state of ['conflict','failed','cancelled']) {
@@ -52,7 +141,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const b=await open(); await submit(b);
for(let i=0;i<10;++i){b.queues[op].push(reply());b.fire(1000);await tick();}
assert.equal(gets(b).length,10);assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
const c=await open();await submit(c);const d=deferred();c.queues['/api/session'].push(d.promise);c.fire(1000);await tick();c.elapse(15000);c.fire(15000);await tick();d.resolve(session({role:'admin',username:'alice'}));await tick();assert.equal(gets(c).length,0);
const c=await open();await submit(c);const d=deferred();c.queues['/api/session'].push(d.promise);c.fire(1000);await tick();c.elapse(15000);c.fire(15000);await tick();d.resolve(session({role:'admin'}));await tick();assert.equal(gets(c).length,0);
});
await test('Broker lost acknowledgement/replaced result/invalid state preserve uncertainty and never replay',async()=>{
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick();
+1 -1
View File
@@ -45,7 +45,7 @@ def check_layout(html):
if cls in classes(node):
return node
raise AssertionError(cls)
for domain in ('serial', 'network'):
for domain in ('serial', 'network', 'broker', 'writer'):
trigger = ids['quick-' + domain]
assert trigger['tag'] == 'button' and trigger['attrs']['type'] == 'button'
assert trigger['attrs']['aria-controls'] == 'serial-settings'