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-revision-ui.cjs
/* Revision-bound browser actions. Simulated DOM, not a real-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 clone = value => JSON.parse(JSON.stringify(value));
const tick = async () => {await new Promise(r => setTimeout(r,5)); await new Promise(r => setTimeout(r,5));};
const deferred = () => {let resolve; const promise = new Promise(ok => resolve=ok); return {promise,resolve};};
const board = {id:'b1',title:'Launch',role:'owner',members:[{email:'me@example.test',role:'owner'}],lists:['To do','Doing','Done'],cards:[
{id:'c1',board_id:'b1',title:'Ship',description:'Context',list_name:'To do',assignee:null,due_date:null,labels:[],blocked_by:[],revision:1,checklist:[]},
{id:'c2',board_id:'b1',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 requests=[],events=[];
let heldMove=null,heldBlockers=null;
const reply=(body,status=200)=>({ok:status<400,status,json:async()=>clone(body)});
w.fetch=async(url,options={})=>{
if(url==='/api/me')return reply({email:'me@example.test'});
if(url==='/api/boards')return reply([{id:'b1',title:board.title,role:board.role}]);
if(url==='/api/boards/b1')return reply(board);
if(url.startsWith('/api/cards/c1') && ['POST','PATCH'].includes(options.method)){
const body=JSON.parse(options.body);requests.push({url,body:clone(body)});
if(url.endsWith('/move')&&heldMove)await heldMove.promise;
if(url.endsWith('/blockers')&&heldBlockers)await heldBlockers.promise;
const card=board.cards[0];
if(!Number.isInteger(body.expected_revision))return reply({detail:'expected_revision must be an integer'},422);
if(body.expected_revision!==card.revision)return reply({detail:'card changed since you opened it; reload and try again'},409);
const changes={...body};delete changes.expected_revision;
Object.assign(card,changes,{revision:card.revision+1});events.push(clone(card));return reply(card);
}
throw Error('Unexpected request '+url);
};
const reload=async()=>{d.querySelector('#boards button').click();await tick();};
const open=()=>d.querySelector('[data-id=c1] .card-title').click();
const save=()=>$('card-form').dispatchEvent(new w.Event('submit',{bubbles:true,cancelable:true}));
const field=name=>$('card-form').elements.namedItem(name);
const select=()=>d.querySelector('[data-id=c1] .move-control select');
const move=async(list)=>{const menu=select();menu.value=list;menu.dispatchEvent(new w.Event('change'));await tick();};
const last=()=>requests.at(-1);
const failMessage=()=>assert.match($('card-form').querySelector('.form-error').textContent,/changed/);
(async()=>{
w.eval(fs.readFileSync(path.join(root,'app.js'),'utf8')+'\nwindow.editorRevision=()=>state.editing?.revision;');await tick();
await move('Doing');assert.equal(last().body.expected_revision,1);assert.equal(board.cards[0].list_name,'Doing');
// Retained stale menu must use its rendered revision, not a refreshed board's revision.
const oldMenu=select(),oldRevision=board.cards[0].revision;board.cards[0].revision++;await reload();
const beforeEvents=events.length;oldMenu.value='Done';oldMenu.onchange();await tick();
assert.equal(last().body.expected_revision,oldRevision);assert.equal(events.length,beforeEvents);
assert.equal(select().value,'Doing');assert.match($('notice').textContent,/changed/);
// Drag captures the source revision even if a fresh board arrives before drop.
const data={};const transfer={setData:(key,value)=>data[key]=value,getData:key=>data[key]||''};
const article=d.querySelector('[data-id=c1]');const dragRevision=board.cards[0].revision;
article.ondragstart({target:article,dataTransfer:transfer,preventDefault(){}});
board.cards[0].revision++;await reload();
d.querySelector('.column[aria-label="Done"]').ondrop({dataTransfer:transfer,preventDefault(){}});await tick();
assert.equal(last().body.expected_revision,dragRevision);assert.equal(events.length,beforeEvents);assert.equal(board.cards[0].list_name,'Doing');
// A fresh drag succeeds with the observed revision.
const freshArticle=d.querySelector('[data-id=c1]');freshArticle.ondragstart({target:freshArticle,dataTransfer:transfer,preventDefault(){}});
d.querySelector('.column[aria-label="Done"]').ondrop({dataTransfer:transfer,preventDefault(){}});await tick();
assert.equal(board.cards[0].list_name,'Done');assert.equal(events.length,beforeEvents+1);
// Repeated move while pending must not issue another write or leave a false selection.
heldMove=deferred();const requestCount=requests.length;const menu=select();menu.value='To do';menu.onchange();menu.value='Doing';menu.onchange();await tick();
assert.equal(requests.length,requestCount+1);assert.equal(select().value,'Done');heldMove.resolve();await tick();heldMove=null;assert.equal(select().value,'To do');
// Refreshing the board never upgrades a still-open editor's revision or loses drafts.
open();const editorRevision=w.editorRevision();field('title').value='Unsaved draft';$('checklist-new-step').value='Review carefully';$('add-checklist-step').click();
board.cards[0].revision++;board.cards[0].description='Someone else updated this';await reload();
const snapshot=clone(board),eventCount=events.length;save();await tick();
assert.equal(last().body.expected_revision,editorRevision);assert.deepEqual(board,snapshot);assert.equal(events.length,eventCount);
assert.ok($('editor').open);assert.equal(field('title').value,'Unsaved draft');assert.equal($('task-checklist').querySelector('.checklist-text').value,'Review carefully');failMessage();
const attempts=requests.length;await tick();assert.equal(requests.length,attempts,'conflict never retries automatically');$('editor').close();
// Dependencies advance only this editor's revision and preserve unsaved field/checklist drafts.
open();field('title').value='Keep this draft';$('checklist-new-step').value='Draft step';$('add-checklist-step').click();$('blocker-select').options[0].selected=true;
const dependencyRevision=w.editorRevision();$('save-blockers').click();await tick();
assert.equal(last().body.expected_revision,dependencyRevision);assert.equal(w.editorRevision(),dependencyRevision+1);assert.match($('blocker-result').textContent,/Dependencies saved/);
assert.equal(field('title').value,'Keep this draft');assert.equal($('task-checklist').querySelector('.checklist-text').value,'Draft step');
save();await tick();assert.equal(last().body.expected_revision,dependencyRevision+1);assert.equal(board.cards[0].title,'Keep this draft');assert.equal(board.cards[0].checklist[0].text,'Draft step');
// A later refused dependency save clears an earlier success message without discarding the draft.
open();$('save-blockers').click();await tick();const bound=w.editorRevision();board.cards[0].revision++;$('blocker-select').options[0].selected=false;
const beforeRefused=clone(board),historyBefore=events.length;$('save-blockers').click();await tick();
assert.equal(last().body.expected_revision,bound);assert.equal(w.editorRevision(),bound);assert.deepEqual(board,beforeRefused);assert.equal(events.length,historyBefore);
assert.equal($('blocker-result').textContent,'');assert.equal($('blocker-select').options[0].selected,false);failMessage();$('editor').close();
// A completed dependency request cannot upgrade a reopened editor's stale snapshot.
await reload();open();heldBlockers=deferred();$('save-blockers').click();$('editor').close();open();const reopened=w.editorRevision();
heldBlockers.resolve();await tick();heldBlockers=null;assert.equal(w.editorRevision(),reopened);assert.equal($('blocker-result').textContent,'');$('editor').close();
// Missing, coerced, fractional, or unsafe JS revisions never issue blind writes.
const validRevision=board.cards[0].revision;
for(const revision of [undefined,null,true,'1',1.5,0,Number.MAX_SAFE_INTEGER+1]){
board.cards[0].revision=revision;await reload();const count=requests.length;
await move('Doing');assert.equal(requests.length,count);assert.match($('notice').textContent,/revision/);
open();field('title').value='Do not save';save();await tick();assert.equal(requests.length,count);assert.ok($('editor').open);assert.match($('card-form').querySelector('.form-error').textContent,/revision/);
$('save-blockers').click();await tick();assert.equal(requests.length,count);assert.equal($('blocker-result').textContent,'');$('editor').close();
}
board.cards[0].revision=validRevision;await reload();
// A removed card's stale control cannot submit a mutation with an absent snapshot.
const missingMenu=select(),removed=board.cards.shift();await reload();const count=requests.length;missingMenu.value='Doing';missingMenu.onchange();await tick();assert.equal(requests.length,count);assert.match($('notice').textContent,/no longer/);board.cards.unshift(removed);
console.log('PASS: revision-bound menu/drag/editor/dependency actions, stale conflicts with no retry or fake success, draft preservation, duplicate suppression, reopened-editor isolation, and missing/invalid revision/card refusal (simulated DOM only).');
w.close();
})().catch(error=>{console.error(error);process.exitCode=1;w.close();});