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();});