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-checklist-ui.cjs

/* Checklist + interrupted-read regression checks in jsdom, not browser 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 tick = async () => {await new Promise(r => setTimeout(r,5)); await new Promise(r => setTimeout(r,5));};
const clone = x => JSON.parse(JSON.stringify(x));
const deferred = () => {let resolve,reject; const promise = new Promise((ok,no) => {resolve=ok;reject=no;}); return {promise,resolve,reject};};
const board = {id:'b1',title:'Launch',role:'owner',members:[{email:'me@example.test',role:'owner'}],lists:['To do','Doing','Done'],cards:[
  {id:'c1',reference:'RB-1234ABCD',title:'Ship it',description:'Keep my context',list_name:'Doing',assignee:'me@example.test',due_date:null,labels:[],blocked_by:['c2'],revision:1,checklist:[{id:'step1',text:'Confirm scope',done:true}]},
  {id:'c2',title:'Review',description:'',list_name:'To do',assignee:null,due_date:null,labels:[],blocked_by:[],revision:1,checklist:[]}
]};
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,$=id=>d.getElementById(id);
w.HTMLDialogElement.prototype.showModal=function(){this.open=true;};
w.HTMLDialogElement.prototype.close=function(){this.open=false;this.dispatchEvent(new w.Event('close'));};
const queues = {checklist:[],similar:[],blockers:[],summary:[]};
const requestCounts = {checklist:0,similar:0,blockers:0,summary:0};
let patchCount=0,blockerSaveCount=0,failPatch=false,holdBlocker=null,holdPatch=null;
const response = (data,status=200) => ({ok:status<400,status,json:async()=>clone(data)});
const defaults = {checklist:{suggested_checklist:['Confirm scope','Draft launch note','<img src=x onerror=alert(1)>'],source_revision:1},similar:{matches:[{card:{title:'Related task',list_name:'Doing'}}]},blockers:{is_blocked:true,blockers:[{title:'Review',list_name:'To do'}]},summary:{summary:'New briefing'}};
w.fetch = async(url,options={}) => {
  const body=options.body?JSON.parse(options.body):null;
  if(url==='/api/me')return response({email:'me@example.test'});
  if(url==='/api/boards')return response([{id:'b1',title:board.title,role:board.role}]);
  if(url==='/api/boards/b1')return response(board);
  if(url==='/api/cards/c1'&&options.method==='PATCH'){
    patchCount++;if(failPatch)return response({detail:'card changed; reload and try again'},409);
    if(holdPatch)await holdPatch.promise;
    assert.equal(body.expected_revision,board.cards[0].revision);
    body.checklist=body.checklist.map((step,index)=>({...step,id:step.id||`saved${index}`}));
    Object.assign(board.cards[0],body,{revision:board.cards[0].revision+1});return response(board.cards[0]);
  }
  if(url==='/api/cards/c1/blockers'&&options.method==='POST'){
    blockerSaveCount++;if(holdBlocker)await holdBlocker.promise;
    board.cards[0].revision++;board.cards[0].blocked_by=body.blocked_by;return response(board.cards[0]);
  }
  for(const kind of Object.keys(queues))if(url.endsWith('/'+kind)){
    requestCounts[kind]++;const item=queues[kind].shift();if(item)return response(await item.promise);
    return response(defaults[kind]);
  }
  throw Error('Unexpected request '+url);
};
const open = () => d.querySelector('[data-id=c1] .card-title').click();
const save = () => $('card-form').dispatchEvent(new w.Event('submit',{bubbles:true,cancelable:true}));
const click = id => $(id).click();
const input = (element,value) => {element.value=value;element.dispatchEvent(new w.Event('input',{bubbles:true}));};
const reload = async () => {d.querySelector('#boards button').click();await tick();};
(async()=>{
  w.eval(fs.readFileSync(path.join(root,'app.js'),'utf8') + '\nwindow.__editorRevision = () => state.editing?.revision;');await tick();
  assert.match(d.querySelector('[data-id=c1] .card-checklist-progress').textContent,/1\/1/);
  assert.match(d.querySelector('[data-id=c1] .blocked-badge').textContent,/Blocked by 1 card/);
  board.cards[1].list_name='Done';await reload();assert.equal(d.querySelectorAll('.blocked-badge').length,0,'completed prerequisites not flagged');board.cards[1].list_name='To do';await reload();
  open();assert.equal($('task-checklist').querySelectorAll('.checklist-step').length,1);
  click('checklist');await tick();assert.equal(patchCount,0,'suggestion is read-only');
  assert.equal($('checklist-result').querySelectorAll('img,script').length,0,'AI markup stays text');
  const choices=$('checklist-result').querySelectorAll('input');choices[2].checked=false;choices[2].dispatchEvent(new w.Event('change'));
  click('use-checklist');click('use-checklist');assert.equal($('task-checklist').querySelectorAll('.checklist-step').length,2,'only selected nonduplicate suggestion added once');
  assert.equal(patchCount,0,'apply only changes draft');assert.match($('checklist-feedback').textContent,/Choose Save card/);
  assert.ok($('task-checklist').querySelector('input[type=checkbox]').checked,'completion preserved');
  $('editor').close();open();assert.equal($('task-checklist').querySelectorAll('.checklist-step').length,1,'cancel discards unsaved AI additions');
  // Manual changes and AI additions save atomically with normal fields.
  input($('checklist-new-step'),'Manual next step');$('checklist-new-step').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));
  assert.equal(patchCount,0,'Enter in add-step input never saves card');
  click('checklist');await tick();click('use-checklist');
  const stepInputs=$('task-checklist').querySelectorAll('.checklist-text');input(stepInputs[1],'Edited manual step');
  const checkboxes=$('task-checklist').querySelectorAll('input[type=checkbox]');checkboxes[1].click();
  save();save();await tick();assert.equal(patchCount,1);assert.equal(board.cards[0].description,'Keep my context');
  assert.equal(board.cards[0].checklist.length,4);assert.equal(board.cards[0].checklist[0].id,'step1');assert.equal(board.cards[0].checklist[1].done,true);assert.equal(board.cards[0].checklist[1].text,'Edited manual step');
  assert.equal(d.querySelectorAll('.card img').length,0);assert.match(d.querySelector('.card-checklist-progress').textContent,/2\/4/);
  open();assert.equal($('task-checklist').querySelectorAll('.checklist-step').length,4,'saved progress reloads');
  $('task-checklist').querySelectorAll('button')[1].click();assert.equal($('task-checklist').querySelectorAll('.checklist-step').length,3);
  failPatch=true;save();await tick();assert.ok($('editor').open);assert.equal($('task-checklist').querySelectorAll('.checklist-step').length,3,'conflict preserves draft');assert.match($('card-form').querySelector('.form-error').textContent,/changed/);failPatch=false;
  $('editor').close();
  // A pending save freezes all editable controls; late toggles/removes cannot be lost silently.
  open();holdPatch=deferred();const savedBefore=clone(board.cards[0].checklist);save();
  assert.ok([...$('card-form').querySelectorAll('input,select,textarea,button')].every(control=>control.disabled),'all controls frozen while save pending');
  const pendingCheckbox=$('task-checklist').querySelector('input[type=checkbox]');pendingCheckbox.click();
  assert.equal(pendingCheckbox.checked,savedBefore[0].done);assert.ok($('task-checklist').querySelector('button').disabled);
  holdPatch.resolve();await tick();assert.deepEqual(board.cards[0].checklist,savedBefore);assert.equal($('editor').open,false);holdPatch=null;
  // Completion of an old save cannot unlock a newly opened editor's in-flight AI button.
  open();holdPatch=deferred();save();$('editor').close();open();const newRead=deferred();queues.checklist.push(newRead);click('checklist');const readsBefore=requestCounts.checklist;
  holdPatch.resolve();await tick();holdPatch=null;assert.ok($('checklist').disabled,'old save cannot unlock newer read');
  click('checklist');$('checklist').onclick();await tick();assert.equal(requestCounts.checklist,readsBefore,'duplicate async read suppressed even if directly invoked');
  newRead.resolve({suggested_checklist:['Only current suggestion']});await tick();assert.equal($('checklist-result').textContent,'Only current suggestion');$('editor').close();
  // A model answer arriving during a failed save remains frozen, then is usable after failure.
  open();const answerDuringSave=deferred();queues.checklist.push(answerDuringSave);click('checklist');holdPatch=deferred();save();
  answerDuringSave.resolve({suggested_checklist:['Review after conflict']});await tick();assert.ok($('checklist-result').querySelector('input').disabled);assert.ok($('use-checklist').disabled);
  holdPatch.reject(Error('Synthetic save failure'));await tick();holdPatch=null;
  assert.equal($('checklist').disabled,false,'completed read re-enabled after save failure');assert.equal($('checklist-result').querySelector('input').disabled,false);assert.equal($('use-checklist').disabled,false);assert.ok($('editor').open);$('editor').close();
  // Every read ignores both late data and late errors after the same card is reopened.
  for(const [kind,button,result] of [['checklist','checklist','checklist-result'],['similar','similar','similar-result'],['blockers','show-blockers','blocker-result']]){
    const old=deferred();queues[kind].push(old);open();click(button);$('editor').close();open();click(button);await tick();
    const expected=$(result).textContent;old.resolve(kind==='checklist'?{suggested_checklist:['STALE SUGGESTION']}:kind==='similar'?{matches:[{card:{title:'STALE MATCH'}}]}:{is_blocked:true,blockers:[{title:'STALE BLOCKER'}]});await tick();
    assert.equal($(result).textContent,expected,kind+' stale success ignored');assert.equal($(button).disabled,false);$('editor').close();
    const failure=deferred();queues[kind].push(failure);open();click(button);$('editor').close();open();click(button);await tick();failure.reject(Error('STALE ERROR'));await tick();
    assert.ok($('card-form').querySelector('.form-error').hidden,kind+' stale error ignored');$('editor').close();
  }
  const oldSummary=deferred();queues.summary.push(oldSummary);click('summary-button');const summariesBefore=requestCounts.summary;click('summary-button');$('summary-button').onclick();await tick();assert.equal(requestCounts.summary,summariesBefore,'repeated briefing clicks do not consume another inference');$('summary-dialog').close();click('summary-button');await tick();oldSummary.resolve({summary:'STALE SUMMARY'});await tick();assert.equal($('summary-result').textContent,'New briefing');$('summary-dialog').close();
  const failedSummary=deferred();queues.summary.push(failedSummary);click('summary-button');$('summary-dialog').close();click('summary-button');await tick();failedSummary.reject(Error('STALE BRIEFING ERROR'));await tick();assert.equal($('summary-result').textContent,'New briefing');$('summary-dialog').close();
  // Saving dependencies in an old editor does not mutate a reopened editor's revision or draft.
  holdBlocker=deferred();open();click('save-blockers');$('editor').close();open();const reopenedRevision=w.__editorRevision();holdBlocker.resolve();await tick();
  assert.equal(blockerSaveCount,1);assert.equal(w.__editorRevision(),reopenedRevision);assert.equal($('blocker-result').textContent,'');$('editor').close();holdBlocker=null;
  // A bounded draft refuses additions atomically, with a clear error.
  board.cards[0].checklist=Array.from({length:50},(_,i)=>({id:`full${i}`,text:`Existing ${i}`,done:false}));await reload();open();input($('checklist-new-step'),'Too many');click('add-checklist-step');assert.equal($('task-checklist').querySelectorAll('.checklist-step').length,50);assert.match($('card-form').querySelector('.form-error').textContent,/at most 50/);$('editor').close();
  board.role='viewer';await reload();open();assert.ok($('checklist-add').hidden);assert.equal($('task-checklist').querySelectorAll('button').length,0);assert.ok([...$('task-checklist').querySelectorAll('input')].every(x=>x.disabled));
  click('checklist');await tick();assert.ok($('use-checklist').hidden);assert.ok([...$('checklist-result').querySelectorAll('input')].every(x=>x.disabled));assert.ok($('card-form').querySelector('[type=submit]').hidden);
  assert.equal(d.querySelectorAll('script:not([src]),[style]').length,0,'CSP-compatible additions');
  console.log('PASS: checklist draft/select/apply/cancel/save/completion/stable IDs/conflict/limits/viewer/XSS, completed blocker badges, and save control-freeze/token ownership and same-card reopened AI/dependency/summary success/error/deduplication regressions (simulated DOM only).');
  w.close();
})().catch(e=>{console.error(e);process.exitCode=1;w.close();});