Re-check finding 1 (P2): the wording for refusals that can come after the decision is consumed told the user to look for the proposal "as answered" in the inbox and otherwise read "nothing was applied". The bus inbox lists no closed decision, so a consumed one drops out of it and the user would read the false claim. Those codes now say only that part of it may already be applied and to check the Agents page. decision-http tests assert no "nothing was applied" in any case and no inbox pointer for those codes. Re-check P3-b: the autonomy controls (Save, Discard, notices, review summary and buttons) carry data-focus-key, so a focused button in a clean form gets its focus back after the poll repaints the section. Re-check P3-c: a failed autonomy read keeps the last read only while the page holds the form (dirty, or focus inside it). A clean form shows the read error. org-live-edit-browser test 4 covers both. Re-check P3-a (harness-off after the consume through a corrupt prior settings file) is deferred: wording it exactly needs the bus to mark the phase on the refusal.
165 lines
14 KiB
JavaScript
165 lines
14 KiB
JavaScript
// E2E6 P1-1, P2-2 (#1873): an editor in use on an organization's page survives the page's refresh. The
|
|
// workflow editor's node, typed draft and focus, the autonomy form's unsaved choice and a pack's picked
|
|
// project are kept when the page reads every section again. An apply refreshes the other sections.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { createDevOwner } from '../src/dev-owner.mjs';
|
|
import { browser } from './browser.mjs';
|
|
import { close } from './fixture.mjs';
|
|
|
|
const waitFor = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,2000))})()`);
|
|
const REV = n => String(n).repeat(64);
|
|
|
|
async function fixture(t) {
|
|
const counts = { workflows: 0, autonomy: 0, packs: 0 };
|
|
// ctl lets a test fail the next reads or change what the packs read returns.
|
|
const ctl = { workflowsFail: false, autonomyFail: false, packLabel: 'Web project' };
|
|
const flows = {
|
|
revision: REV('a'),
|
|
document: {
|
|
workflows: { intake: { title: 'Intake', steps: [{ title: 'Sort', state: 'todo', roles: ['human'] }] } },
|
|
projects: { site: { workflows: {} }, docs: { workflows: {} } },
|
|
definitions: {},
|
|
},
|
|
templates: ['coder'],
|
|
};
|
|
const read = async verb => {
|
|
if (verb === 'packs.console') { counts.packs++; return { packs: [{ id: 'web-project', origin: 'installed', pack: { label: ctl.packLabel, description: 'A board.', version: '0.2.0', scope: 'project' } }] }; }
|
|
if (verb === 'pack.preview.console') return { pack: { id: 'web-project', version: '0.2.0', label: 'Web project', origin: 'installed', scope: 'project' }, project: 'docs', revision: REV('c'), items: [{ item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }], hires: [], skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d') };
|
|
if (verb === 'autonomy.get.console') { counts.autonomy++; if (ctl.autonomyFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return { revision: REV('e'), autonomy: { merge: 'out of the loop' }, levels: { 'cross-role': 'out of the loop', merge: 'out of the loop', policy: 'out of the loop', authority: 'in the loop' } }; }
|
|
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
|
|
};
|
|
const write = async (verb, args) => {
|
|
if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '0.2.0' } };
|
|
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
|
|
};
|
|
const server = await startServer({
|
|
port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {},
|
|
organizations: {
|
|
list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }],
|
|
resolve: async () => ({
|
|
busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: write,
|
|
workflowsRead: async () => { counts.workflows++; if (ctl.workflowsFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return structuredClone(flows); }, send: async () => ({}),
|
|
reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
|
|
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
|
|
chat: { projects: async () => [], threads: async () => [] },
|
|
}),
|
|
},
|
|
});
|
|
t.after(() => close(server));
|
|
const b = await browser({ machine: false, args: ['--disable-extensions'] });
|
|
t.after(() => b.close());
|
|
await b.viewport(1440, 900);
|
|
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`);
|
|
// poll: wait for the page's own 10-second poll. Otherwise send the event a write on the page sends,
|
|
// which repaints /organization the same way.
|
|
const refresh = async ({ poll = false } = {}) => {
|
|
const n = counts.workflows;
|
|
if (!poll) await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org:'acme'}}));true`);
|
|
for (let i = 0; i < 500 && counts.workflows === n; i++) await new Promise(r => setTimeout(r, 30));
|
|
assert.ok(counts.workflows > n, 'the refresh read the workflows again');
|
|
await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),200))`);
|
|
};
|
|
return { b, counts, ctl, refresh };
|
|
}
|
|
|
|
test('E2E6 P1-1: an open workflow editor keeps its node, draft and focus across a refresh', { timeout: 60000 }, async t => {
|
|
const { b, refresh } = await fixture(t);
|
|
await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')`, 'workflow list');
|
|
await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`);
|
|
await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor');
|
|
await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Intake, revised';i.focus();i.setSelectionRange(3,3);f.querySelector('[data-wf-edit-step="0"] [name=roles]').value='human, coder'}true`);
|
|
await refresh({ poll: true });
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node');
|
|
assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf-editor]').length`), 1);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] [data-wf-edit-step="0"] [name=roles]').value`), 'human, coder');
|
|
assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field');
|
|
assert.equal(await b.evaluate(`document.activeElement.selectionStart`), 3, 'the caret stays put');
|
|
// A second refresh with focus elsewhere still keeps the open editor: it holds data-live-hold.
|
|
await b.evaluate(`document.activeElement.blur();true`);
|
|
await refresh();
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised');
|
|
// Cancel closes it; the next refresh paints the list fresh.
|
|
await b.evaluate(`document.querySelector('[data-wf-cancel]').click();true`);
|
|
await waitFor(b, `!document.querySelector('[data-wf-editor]')`, 'cancel');
|
|
await refresh();
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')`), null);
|
|
});
|
|
|
|
test('E2E6 P1-1, P2-14: an unsaved autonomy choice, a picked pack project and a focused link survive the poll', { timeout: 60000 }, async t => {
|
|
const { b, refresh } = await fixture(t);
|
|
await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-project]')`, 'autonomy and packs');
|
|
await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`);
|
|
await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}));s.blur()}true`);
|
|
// P2-14: a visible Open link outside any editor keeps focus through restore(). (The tester's "Open
|
|
// Inbox →" is the legacy dashboard card outside #bus-view, hidden on this page, so it never took focus.)
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-focus-key="set:Identity"]').focus();document.activeElement.dataset.focusKey`), 'set:Identity');
|
|
await refresh({ poll: true });
|
|
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'set:Identity', 'focus stays on the link');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty form is kept');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'in the loop');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-pack-project]').value`), 'docs', 'the picked project is kept');
|
|
});
|
|
|
|
test('E2E6 P2-2: a pack apply re-reads the other sections and keeps its receipt', { timeout: 60000 }, async t => {
|
|
const { b, counts } = await fixture(t);
|
|
await waitFor(b, `document.querySelector('[data-pack="web-project"] [data-pack-preview-button]')`, 'pack list');
|
|
await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}))}true`);
|
|
await b.evaluate(`document.querySelector('[data-pack-preview-button]').click();true`);
|
|
await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview');
|
|
const before = { ...counts };
|
|
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
|
|
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply');
|
|
for (let i = 0; i < 300 && counts.workflows === before.workflows; i++) await new Promise(r => setTimeout(r, 30));
|
|
assert.ok(counts.workflows > before.workflows, 'workflows read again after the apply');
|
|
assert.ok(counts.autonomy > before.autonomy, 'autonomy read again after the apply');
|
|
await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),300))`);
|
|
assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read');
|
|
});
|
|
|
|
test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, a focused button does not hold its section, and a clean form shows a failed read', { timeout: 60000 }, async t => {
|
|
const { b, ctl, refresh } = await fixture(t);
|
|
await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')&&document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-preview-button]')`, 'sections');
|
|
// A focused Preview button alone no longer holds the packs section: the next poll shows a changed
|
|
// pack and focus comes back to the rebuilt button through its data-focus-key.
|
|
await b.evaluate(`document.querySelector('[data-pack-preview-button]').focus();true`);
|
|
ctl.packLabel = 'Web project, renamed';
|
|
await refresh({ poll: true });
|
|
await waitFor(b, `/Web project, renamed/.test(document.querySelector('[data-packs-org]').textContent)`, 'the renamed pack');
|
|
assert.match(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), /^pack-preview:acme:web-project$/, 'focus returns to the rebuilt button');
|
|
// One failed workflows read while the editor is open keeps the same editor node, draft and focus.
|
|
await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`);
|
|
await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor');
|
|
await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Typed during an outage';i.focus()}true`);
|
|
// A dirty autonomy form survives a failed autonomy read the same way.
|
|
await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`);
|
|
await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').focus();true`);
|
|
ctl.workflowsFail = true; ctl.autonomyFail = true;
|
|
await refresh({ poll: true });
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node after a failed read');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Typed during an outage');
|
|
assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty autonomy form is kept after a failed read');
|
|
// Discard draws the last read in place of the held form.
|
|
ctl.workflowsFail = false; ctl.autonomyFail = false;
|
|
await b.evaluate(`document.querySelector('[data-autonomy-discard]').click();true`);
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')!==window.__auto`), true, 'a fresh form');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'out of the loop', 'the stored value is back');
|
|
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]').hasAttribute('data-live-dirty')`), false);
|
|
assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-autonomy-discard')`), true, 'focus stays on Discard');
|
|
// Re-check P3: a focused button in a clean form gets its focus back after the poll repaints it.
|
|
await refresh({ poll: true });
|
|
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'autonomy-discard:acme', 'focus returns to Discard after a poll');
|
|
// Re-check P3: a clean form the page does not hold shows a failed read instead of hiding it.
|
|
await b.evaluate(`document.activeElement.blur();true`);
|
|
ctl.autonomyFail = true;
|
|
await refresh({ poll: true });
|
|
await waitFor(b, `!document.querySelector('[data-autonomy-form]')&&/bus-unreachable|could not|refused/i.test(document.querySelector('[data-autonomy]')?.textContent??'')`, 'the autonomy read error');
|
|
ctl.autonomyFail = false;
|
|
await refresh({ poll: true });
|
|
await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')`, 'the form after a good read');
|
|
});
|