diff --git a/packages/webui/src/public/panes.js b/packages/webui/src/public/panes.js index 70f954c69..da71ec7b4 100644 --- a/packages/webui/src/public/panes.js +++ b/packages/webui/src/public/panes.js @@ -269,7 +269,7 @@ restore(p, body, kept); } const content = body.querySelector('[data-project-content]'); - if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) }); + if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org), idPrefix: `pane-${p.key}-` }); return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`; } // A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js index 4d6509e65..4ee0daf57 100644 --- a/packages/webui/src/public/projects.js +++ b/packages/webui/src/public/projects.js @@ -205,14 +205,15 @@ // state lives as long as the container. const paneViews = new WeakMap(); const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);}; - function requirementView(data,project,selected,expandedSet=expandedPrds) { + function requirementView(data,project,selected,expandedSet=expandedPrds,idPrefix='') { + const pid=esc(idPrefix); const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft; const north=typeof data.north?.content==='string'?data.north.content:null; - const select=options.length>1?``:''; + const select=options.length>1?``:''; const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id); const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null; const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`
`).join('') : 'Unavailable until a PRD is read.
'; - return `${r?`${clean(r.id)}`} · ${chip(r.state)} · from ${clean(r.thread.name)} Open conversation${window.MosaicRecords.markup(project.repo)}
`:data.northMissing?'No north star yet.
':project.repo?'North star unavailable. Docs could not read this project’s repository.
':`This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.
`}${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}
${clean(req.text)}
Requirement identity: ${clean(candidate.requirements[i]?.path)} · ${clean(candidate.requirements[i]?.blob)}
${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}
`}${d && data.requests.some(x=>x.materialFailed)?'Some request PRDs could not be read. Refresh to try again.
':''}${clean(r.id)}`} · ${chip(r.state)} · from ${clean(r.thread.name)} Open conversation${window.MosaicRecords.markup(project.repo)}
`:data.northMissing?'No north star yet.
':project.repo?'North star unavailable. Docs could not read this project’s repository.
':`This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.
`}${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}
${clean(req.text)}
Requirement identity: ${clean(candidate.requirements[i]?.path)} · ${clean(candidate.requirements[i]?.blob)}
${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}
`}${d && data.requests.some(x=>x.materialFailed)?'Some request PRDs could not be read. Refresh to try again.
':''}Some request data could not be read; this list may be incomplete.
'); const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id)); const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); } const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); } - container.querySelector('#project-request-select')?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector('#project-request-select')?.focus({preventScroll:true});}); + const requestSelect='#'+CSS.escape(idPrefix+'project-request-select'); + container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});}); }; draw(selection); restore=liveRestore || restore; @@ -298,7 +302,7 @@ restoreView(container,restore); } const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section'); - if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'}); + if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'}); } // Cut at a word boundary, unless that would leave only a markdown marker (a long heading). function previewCut(content,max) { diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 966a2fc5d..04bc93e42 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -373,6 +373,8 @@ } async function submit(form) { const kind = form.dataset.tbForm, btns = form.querySelectorAll('button'); + // A form whose Save is disabled is sending, or waiting on the current task: it takes no second save. + if (form.querySelector('[data-tb-submit]')?.disabled) return; if (!form.reportValidity()) return; let body, t = null; if (kind === 'create') { @@ -388,16 +390,32 @@ } btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…'); const r = await write(body); + // The task changed under the form: the form reopens on the current task and keeps the refusal. + // Until the current task is read the old form stays disabled and does not show the refusal, so + // the refusal never stands beside the old task and no save goes out against its digest. + if (!r.ok && r.error === 'task-conflict' && t) { + formStatus(form, 'Reading the current task…'); + // A read a poll overtook leaves the board's old read in place, so only a read that landed counts. + const fresh = await reload().catch(() => false); + const now = (fresh && find(key(t))) || await current(t); + // Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open` + // at once but queues the event that empties the dialog, so `open` is the check that holds. + const d = dialogEl(); + if (!d.open || !d.contains(form)) { paint(); return; } + if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, r.message); } + else { + // The current task could not be read: the form still holds the old one, so it takes no save. + opened = null; + formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.'); + form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; }); + } + paint(); + return; + } btns.forEach(b => { b.disabled = false; }); if (!r.ok) { formStatus(form, r.message); - // The task changed under the form: the form shows the current task and keeps the refusal. - if (r.error === 'task-conflict' && t) { - try { await reload(); } catch {} - const now = find(key(t)) || await current(t); - if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); } - paint(); - } else if (r.unknown) { try { await reload(); } catch {} paint(); } + if (r.unknown) { try { await reload(); } catch {} paint(); } return; } const label = orgOf(body.org).label; diff --git a/packages/webui/tests/side-by-side-browser.test.mjs b/packages/webui/tests/side-by-side-browser.test.mjs index 8f2a69a28..5174f257c 100644 --- a/packages/webui/tests/side-by-side-browser.test.mjs +++ b/packages/webui/tests/side-by-side-browser.test.mjs @@ -259,14 +259,16 @@ test('the same project in both panes keeps a request choice per pane', async t = await b.evaluate('window.MosaicPanes.refresh()'); await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`); - const values = `[...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value)`; - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`); + const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`; + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']); - await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); + await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); await wait(`${values}[0] === 'request-2'`); + // The redrawn select gets focus back through the pane's own id. + assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select'); await b.evaluate('window.MosaicPanes.refresh()'); await settled(b); - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`); + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']); }); @@ -279,10 +281,10 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); // The same organization and project in both panes. await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`); - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`); - const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`; - await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); - await wait(`document.querySelector('#pane-a #project-request-select').value === 'request-2'`); + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`); + const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`; + await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); + await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`); await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`); await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`); // A new conversation changes the header's count, so each pane replaces its project content. @@ -290,7 +292,7 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`); await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`); await settled(b); - await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`); + await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`); assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] }); }); @@ -338,3 +340,68 @@ test('each Agents pane keeps its own project choice for its organization', async assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']); assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true); }); + +// Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id +// appears twice, and every label, ARIA reference and in-page link resolves inside its own pane. +const idAudit = `(() => { + const count = new Map(); + for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1); + const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort(); + const stray = []; + for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) { + for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) { + for (const el of pane.querySelectorAll('[' + attr + ']')) { + for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) { + const target = document.getElementById(id); + if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id); + } + } + } + for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) { + const id = decodeURIComponent(a.getAttribute('href').slice(1)); + const target = id && document.getElementById(id); + if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id); + } + } + return { duplicates, stray: stray.sort() }; +})()`; + +test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => { + const { b, wait } = await open(t); + await wait(`${title('b')}.includes("Forge")`); + const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); + await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); + const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] }); + const set = agents => ({ state: 'ok', agents, excluded: [] }); + const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [ + { id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] }, + projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } }, + ] }; + await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`); + await b.evaluate('window.MosaicPanes.refresh()'); + await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); + const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`; + const pages = [ + ['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-system-types-heading]').length === 2`], + ['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`], + [`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], + [`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`], + [`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], + [`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`], + [`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], + ]; + const found = {}, drawn = {}; + for (const [route, ready] of pages) { + await b.evaluate(both(route)); + await wait(ready); + await settled(b); + const page = route.replace(P, 'P'); + found[page] = await b.evaluate(idAudit); + drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`); + } + assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }]))); + // The audit is not vacuous: the dashboard draws its system types heading and the requirements page + // its request select, north star and PRD headings, each with the pane's prefix. + assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why', 'pane-a-ws-types-h']); + assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']); +}); diff --git a/packages/webui/tests/tasks-board-browser.test.mjs b/packages/webui/tests/tasks-board-browser.test.mjs index c8404f969..95be63d1a 100644 --- a/packages/webui/tests/tasks-board-browser.test.mjs +++ b/packages/webui/tests/tasks-board-browser.test.mjs @@ -128,11 +128,14 @@ test('org and project filters narrow the board', async t => { }); test('the layout toggle is remembered across a reload', async t => { - const { b, wait } = await open(t); + const { b, wait, url } = await open(t); await b.evaluate('document.querySelector("[data-tb-layout=list]").click()'); await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")'); assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true'); - await b.evaluate('location.reload()'); + // Load the page again through navigate(), which waits for the load. A location.reload() from evaluate + // returns before the reload commits, so the next wait ran on the old page and the reload killed it + // (as instances-browser found). The same URL with only a hash would not load, so add a query. + await b.navigate(url.replace('/#', '/?reload=1#')); await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")'); assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list'); assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5); @@ -221,6 +224,107 @@ test('an edit dialog left open while another writer changes the task saves again assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false); }); +// Polish 1 (#1873): after a conflict the dialog reads the current task before it reopens. This holds +// that read: the first workspace read after the write's answer waits until window.__release(). +const HOLD_CONFLICT_READ = `(() => { + const real = window.fetch; + window.__armed = false; window.__held = false; + window.fetch = (u, o) => { + const url = String(u); + if (url === '/api/workspace/tasks') return real(u, o).then(r => { window.__armed = true; return r; }); + if (url.startsWith('/api/workspace?') && window.__armed && !window.__held) { + window.__held = true; + return new Promise(go => { window.__release = () => real(u, o).then(go); }); + } + return real(u, o); + }; +})()`; +const SAVE_AS = title => `(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = ${JSON.stringify(title)}; f.requestSubmit(); })()`; +const STATUS = 'document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent'; + +test('after a conflict the dialog shows the refusal only with the current task, and takes no save while it reads it', async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); + const t42 = orgs.store.find(s => s.n === 42); + Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 }); + await b.evaluate(HOLD_CONFLICT_READ); + await b.evaluate(SAVE_AS('Invoice export, CSV')); + await wait('window.__held'); + // The write was refused and the current task is still being read: the form still holds the old task, + // so it does not show the refusal and its Save is off. + assert.equal(await b.evaluate(STATUS), 'Reading the current task…'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export, CSV'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true); + // A save in this window, even one sent past the disabled button, goes nowhere. + await b.evaluate(SAVE_AS('Invoice export, CSV again')); + await b.evaluate('new Promise(r => setTimeout(r, 200))'); + assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]); + // Once the read answers, the refusal and the current task appear together. + await b.evaluate('window.__release()'); + await wait('/changed after the board read it/.test(' + STATUS + ')'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), false); + await b.evaluate(SAVE_AS('Invoice export, CSV')); + await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)'); + assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]); + assert.equal(orgs.writes.length, 2); +}); + +test('after a conflict a task that can no longer be read leaves the dialog unable to save', async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); + const t42 = orgs.store.find(s => s.n === 42); + t42.rev++; + await b.evaluate(HOLD_CONFLICT_READ); + await b.evaluate(SAVE_AS('Invoice export, CSV')); + await wait('window.__held'); + // The task leaves the store before the dialog's read answers, so neither read finds it. + orgs.store.splice(orgs.store.indexOf(t42), 1); + await b.evaluate('window.__release()'); + await wait('/could not be read/.test(' + STATUS + ')'); + assert.match(await b.evaluate(STATUS), /^The task changed after the board read it\. Nothing was written, and the current task could not be read; close this form and open the task again\.$/); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog button")].map(x => [x.textContent, x.disabled])'), [['Archive', true], ['Cancel', false], ['Save', true]]); + await b.evaluate(SAVE_AS('Invoice export, CSV again')); + await b.evaluate('new Promise(r => setTimeout(r, 200))'); + assert.equal(orgs.writes.length, 1); + await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()'); + await wait('!document.querySelector("#tb-dialog").open'); +}); + +test('a dialog closed just before the conflict read answers stays closed', async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); + orgs.store.find(s => s.n === 42).rev++; + // Hold the read's body, not the request, so the close and the read's answer land in one task: + // close() clears `open` at once, but the event that empties the dialog runs after the read resumes. + await b.evaluate(`(() => { + const real = window.fetch; + let armed = false, held = false; + window.fetch = (u, o) => { + if (String(u) === '/api/workspace/tasks') return real(u, o).then(r => { armed = true; return r; }); + if (String(u).startsWith('/api/workspace?') && armed && !held) { + held = true; + return real(u, o).then(async r => { + const body = await r.json(); + r.json = () => new Promise(go => { window.__releaseJson = () => go(structuredClone(body)); }); + return r; + }); + } + return real(u, o); + }; + })()`); + await b.evaluate(SAVE_AS('Invoice export, CSV')); + await wait('typeof window.__releaseJson === "function"'); + await b.evaluate('document.querySelector("#tb-dialog").close(); window.__releaseJson()'); + await b.evaluate('new Promise(r => setTimeout(r, 250))'); + assert.deepEqual(await b.evaluate('[document.querySelector("#tb-dialog").open, document.querySelector("#tb-dialog").innerHTML]'), [false, '']); + assert.equal(orgs.writes.length, 1); +}); + test('a poll that answers after focus has moved leaves focus where the person put it', async t => { const { b, wait } = await open(t); await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');