north-scape/relayboard
Relayboard: accessible team boards with audited actions, dependency maps, and shared web/MCP use cases.
A remix template · 0 remixes · Remix north-scape/relayboard
scripts/test-ui.cjs
/* Simulated-DOM checks, NOT real browser/layout/accessibility certification. */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {JSDOM} = require('jsdom');
const root=path.join(__dirname,'../app/boards/doors/static');
const wait=()=>new Promise(r=>setTimeout(r,5));
const clone=x=>JSON.parse(JSON.stringify(x));
const utc=new Date().toISOString().slice(0,10);
const yesterday=new Date(Date.parse(utc)-86400000).toISOString().slice(0,10);
const board={id:'b1',title:'Team launch',role:'owner',members:[{email:'me@example.test',role:'owner'},{email:'reader@example.test',role:'viewer'}],lists:['To do','Doing','Done'],cards:[
{id:'c1',reference:'RB-1234ABCD',board_id:'b1',title:'Send invoice',description:'Context',list_name:'To do',assignee:'me@example.test',due_date:yesterday,labels:['Ops'],blocked_by:[],revision:1,is_overdue:true},
{id:'c2',board_id:'b1',title:'Unscheduled work',description:'',list_name:'Doing',assignee:'me@example.test',due_date:null,labels:[],blocked_by:[],revision:1,is_overdue:false},
{id:'c3',board_id:'b1',title:'A completed step',description:'',list_name:'Done',assignee:null,due_date:null,labels:[],blocked_by:[],revision:1,is_overdue:false}
]};
const otherBoard={id:'b2',title:'Other project',role:'owner',members:[{email:'me@example.test',role:'owner'}],lists:['To do','Doing','Done'],cards:[]};
const dom=new JSDOM(fs.readFileSync(path.join(root,'index.html'),'utf8'),{url:'http://relay.test',runScripts:'outside-only',pretendToBeVisual:true});
const w=dom.window,d=w.document;
w.HTMLDialogElement.prototype.showModal=function(){this.open=true;};
w.HTMLDialogElement.prototype.close=function(){this.open=false;this.dispatchEvent(new w.Event('close'));};
let activityEvents=[{actor:'me@example.test',action:'updated card',event_at:new Date().toISOString(),changes:{title:{before:'Old title',after:'<img src=x onerror=alert(1)>'},assignee:{before:'member@example.test',after:null},membership:{before:{email:'<script>alert(1)</script>',role:'member'},after:null},labels:{before:['Ops','Launch'],after:[]},description:{before:null,after:''}}}],deferActivity=null;
let failPatch=false,deferPatch=null,patchCount=0,failRemove=false,deferRemove=null,removeCount=0,deferMember=null;
const reply=(data,status=200)=>({ok:status<400,status,json:async()=>clone(data)});
w.fetch=async(url,opt={})=>{
const method=opt.method||'GET';const body=opt.body?JSON.parse(opt.body):null;
if(url==='/api/boards')return reply([{id:'b1',title:board.title,role:board.role},{id:'b2',title:otherBoard.title,role:otherBoard.role}]);
if(url==='/api/boards/b1')return reply(board);
if(url==='/api/boards/b2')return reply(otherBoard);
if(url==='/api/boards/b1/members'&&method==='DELETE'){
removeCount++;if(failRemove)return reply({detail:'Removal failed; please retry'},503);
if(deferRemove)await deferRemove;
board.members=board.members.filter(m=>m.email!==body.email);
let unassigned=0;board.cards.forEach(c=>{if(c.assignee===body.email){c.assignee=null;c.revision++;unassigned++;}});
return reply({board_id:'b1',email:body.email,removed:true,unassigned_cards:unassigned});
}
if(url==='/api/boards/b1/members'&&method==='POST'){
if(deferMember)await deferMember;
board.members.push(body);return reply({board_id:'b1',...body});
}
if(url==='/api/me')return reply({email:'me@example.test'});
if(url==='/api/cards/c1'&&method==='PATCH'){
patchCount++;if(failPatch)return reply({detail:'card changed since you opened it'},409);
if(deferPatch)await deferPatch;
assert.equal(body.expected_revision,board.cards[0].revision);
Object.assign(board.cards[0],body,{revision:board.cards[0].revision+1});return reply(board.cards[0]);
}
if(url==='/api/cards/c1/move'){
assert.equal(body.expected_revision,board.cards[0].revision);
board.cards[0].list_name=body.list_name;board.cards[0].revision++;return reply(board.cards[0]);
}
if(url.endsWith('/checklist'))return reply({suggested_checklist:['Plan','Verify']});
if(url.endsWith('/activity')){const events=clone(activityEvents);if(deferActivity)await deferActivity;return reply(events);}
throw Error('Unexpected test request '+method+' '+url);
};
const click=id=>d.getElementById(id).click();
const submit=()=>d.getElementById('card-form').dispatchEvent(new w.Event('submit',{bubbles:true,cancelable:true}));
const titleButton=()=>d.querySelector('.card-title');
const field=name=>d.getElementById('card-form').elements.namedItem(name);
(async()=>{
w.eval(fs.readFileSync(path.join(root,'app.js'),'utf8'));await wait();await wait();
assert.equal(d.querySelectorAll('.card').length,3,'initial render');
assert.equal(d.querySelector('[data-id=c1] .card-reference').textContent,'RB-1234ABCD');
d.getElementById('search').value='rb-1234abcd';d.getElementById('search').dispatchEvent(new w.Event('input'));
assert.equal(d.querySelectorAll('.card').length,1,'stable reference is searchable case-insensitively');
d.getElementById('search').value='';d.getElementById('search').dispatchEvent(new w.Event('input'));
d.querySelector('[data-filter=overdue]').click();assert.equal(d.querySelectorAll('.card').length,1,'overdue');
click('mine-filter');assert.equal(d.querySelectorAll('.card').length,2,'my week includes overdue and undated');
d.querySelector('[data-filter=all]').click();
titleButton().click();assert.ok(d.getElementById('editor').open);assert.equal(d.getElementById('editor-reference').textContent,'RB-1234ABCD');
assert.equal(field('assignee').querySelectorAll('option').length,2,'viewers not assignable');
click('checklist');await wait();assert.equal(d.querySelectorAll('#checklist-result li').length,2);
field('title').value='<img src=x onerror=alert(1)>';
submit();submit();await wait();await wait();assert.equal(patchCount,1,'repeat submit suppressed');
assert.equal(d.querySelectorAll('.card img').length,0,'XSS remains inert');assert.equal(titleButton().textContent,'<img src=x onerror=alert(1)>');
assert.equal(d.getElementById('editor').open,false,'save dismisses old editor');
const move=d.querySelector('.move-control select');move.value='Done';move.dispatchEvent(new w.Event('change'));await wait();await wait();assert.equal(board.cards[0].list_name,'Done','keyboard move');
// Failed save preserves editor and reports a clear error.
d.querySelector('[data-id=c1] .card-title').click();failPatch=true;field('title').value='Must not save';submit();await wait();
assert.ok(d.getElementById('editor').open);assert.ok(d.querySelector('#card-form .form-error').textContent.includes('changed'));
d.getElementById('editor').close();failPatch=false;
// A slow old save must not dismiss a newly opened editor after Escape/Close.
d.querySelector('[data-id=c1] .card-title').click();let release;deferPatch=new Promise(r=>release=r);field('title').value='Saved slowly';submit();
d.getElementById('editor').close();d.querySelector('[data-id=c2] .card-title').click();release();await wait();await wait();
assert.ok(d.getElementById('editor').open,'newer editor stays open');assert.equal(field('title').value,'Unscheduled work');deferPatch=null;d.getElementById('editor').close();
// Removal is owner-only, explicitly confirmed, and cancellation is non-mutating.
click('members-button');assert.equal(d.querySelectorAll('.remove-member').length,1,'owner itself has no removal control');
const removeForm=d.getElementById('remove-member-form');
const confirmRemove=()=>removeForm.dispatchEvent(new w.Event('submit',{bubbles:true,cancelable:true}));
d.querySelector('.remove-member').click();assert.equal(removeCount,0,'selection does not remove');
assert.match(d.getElementById('remove-member-message').textContent,/lose board access.*unassigned/);
assert.equal(d.activeElement.id,'cancel-member-removal','safe action receives focus');
click('cancel-member-removal');confirmRemove();await wait();assert.equal(removeCount,0,'cancel never calls server');
d.querySelector('.remove-member').click();failRemove=true;confirmRemove();await wait();
assert.equal(removeCount,1);assert.ok(!removeForm.hidden);assert.match(removeForm.querySelector('.form-error').textContent,/Removal failed/);
assert.equal(d.querySelectorAll('.remove-member').length,1,'failed removal preserves member');failRemove=false;
let releaseRemove;deferRemove=new Promise(r=>releaseRemove=r);confirmRemove();confirmRemove();await wait();assert.equal(removeCount,2,'repeated confirm sends one request');
d.getElementById('people-dialog').close();releaseRemove();await wait();await wait();
assert.equal(d.getElementById('people-dialog').open,false,'slow removal does not reopen dismissed dialog');deferRemove=null;
click('members-button');assert.equal(d.querySelectorAll('.remove-member').length,0,'successful removal refreshes members');
// A slow add must also not reopen the people dialog after dismissal.
let releaseMember;deferMember=new Promise(r=>releaseMember=r);const memberForm=d.getElementById('member-form');
memberForm.elements.email.value='new@example.test';memberForm.dispatchEvent(new w.Event('submit',{bubbles:true,cancelable:true}));
d.getElementById('people-dialog').close();releaseMember();await wait();await wait();
assert.equal(d.getElementById('people-dialog').open,false,'slow member save does not reopen dismissed dialog');deferMember=null;
board.cards[1].assignee='new@example.test';
// Closing/reopening during removal must preserve the new dialog generation.
click('members-button');d.querySelector('.remove-member').click();deferRemove=new Promise(r=>releaseRemove=r);confirmRemove();
d.getElementById('people-dialog').close();click('members-button');assert.ok(removeForm.hidden);
releaseRemove();await wait();await wait();assert.ok(d.getElementById('people-dialog').open);assert.ok(removeForm.hidden,'stale removal does not restore old confirmation');deferRemove=null;
assert.equal(board.cards[1].assignee,null,'removal unassigns cards after server refresh');
d.getElementById('people-dialog').close();
// An old removal must not replace a newer board selection or reopen its dialog.
board.members.push({email:'later@example.test',role:'member'});d.querySelector('#boards button').click();await wait();await wait();
click('members-button');d.querySelector('.remove-member').click();deferRemove=new Promise(r=>releaseRemove=r);confirmRemove();
d.getElementById('people-dialog').close();d.querySelectorAll('#boards button')[1].click();await wait();await wait();
releaseRemove();await wait();await wait();assert.equal(d.getElementById('board-title').textContent,'Other project');assert.equal(d.getElementById('people-dialog').open,false);deferRemove=null;
board.role='member';d.querySelector('#boards button').click();await wait();await wait();click('members-button');
assert.equal(d.querySelectorAll('.remove-member').length,0,'member cannot remove people');assert.ok(memberForm.hidden);d.getElementById('people-dialog').close();
board.role='viewer';d.querySelector('#boards button').click();await wait();await wait();
assert.ok(d.getElementById('add-card').hidden);assert.equal(d.querySelectorAll('.move-control').length,0);
click('members-button');assert.ok(d.getElementById('member-form').hidden,'viewer membership controls hidden');assert.equal(d.querySelectorAll('.remove-member').length,0,'viewer cannot remove people');
d.getElementById('people-dialog').close();
// Audit details are collapsed, human-readable and always literal text.
click('activity-button');await wait();const audit=d.querySelector('#activity-list details');
assert.ok(audit);assert.equal(audit.open,false,'large audit details are collapsed by default');
assert.equal(audit.querySelector('summary').textContent,'View 5 changed fields');
assert.equal(audit.querySelectorAll('dt').length,5);assert.equal(audit.querySelectorAll('img,script').length,0,'audit payload remains inert');
assert.match(audit.textContent,/<img src=x onerror=alert\(1\)>/);assert.match(audit.textContent,/After: Unassigned/);
assert.match(audit.textContent,/After: No access/);assert.match(audit.textContent,/"role": "member"/);
assert.match(audit.textContent,/Before: \[\n "Ops",/);assert.match(audit.textContent,/After: None/);assert.match(audit.textContent,/Before: Not set/);assert.match(audit.textContent,/After: Empty/);
// Old responses, including failures, must not replace reopened activity.
d.getElementById('activity-dialog').close();let releaseActivity;deferActivity=new Promise(r=>releaseActivity=r);click('activity-button');
d.getElementById('activity-dialog').close();activityEvents=[{actor:'me@example.test',action:'Newer activity',event_at:new Date().toISOString(),changes:{}}];deferActivity=null;click('activity-button');await wait();
releaseActivity();await wait();assert.match(d.getElementById('activity-list').textContent,/Newer activity/);assert.equal(d.querySelectorAll('#activity-list details').length,0,'no empty detail toggle');
d.getElementById('activity-dialog').close();let rejectActivity;deferActivity=new Promise((_,reject)=>rejectActivity=reject);click('activity-button');
d.getElementById('activity-dialog').close();deferActivity=null;click('activity-button');await wait();rejectActivity(new Error('Old request failed'));await wait();
assert.match(d.getElementById('activity-list').textContent,/Newer activity/);assert.doesNotMatch(d.getElementById('activity-list').textContent,/Old request failed/);
d.getElementById('activity-dialog').close();
assert.equal(d.querySelectorAll('script:not([src]),[style]').length,0,'CSP-compatible markup');
console.log('PASS: simulated-DOM interaction checks (render/filter/my-week, searchable references, role controls, AI suggestion, edit/XSS, duplicate suppression, keyboard move, conflict, interrupted save, confirmed membership removal/cancel/failure/repeat/close/reopen, interrupted membership save, expandable audit/XSS/null/structured values/stale responses, CSP). No browser/layout claim.');
w.close();
})().catch(e=>{console.error(e);process.exitCode=1;w.close();});