fix(webui): task board conflicts reopen on the current task and say why (#1873) #1921

Merged
jason.woltje merged 3 commits from org-polish2 into refactor 2026-11-11 05:41:36 +00:00
4 changed files with 197 additions and 76 deletions

No files matched your search

+29 -24
View File
@@ -294,7 +294,7 @@
d = document.createElement('dialog');
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
document.body.append(d);
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
}
return d;
}
@@ -322,7 +322,7 @@
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Create task</button></div>
</form>`;
}
function editForm(t) {
function editForm(t, refusal = null) {
const o = orgOf(t.org), w = taskWritable(t);
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
@@ -331,6 +331,7 @@
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
<dl class="tb-dl"><dt>State</dt><dd>${esc(STATE_LABEL[t.state] || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
<p class="tb-field-note">${esc(why)}</p>
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
}
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
@@ -345,6 +346,7 @@
function openDialog(html, back, task = null) {
const d = dialogEl();
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby');
if (!d.open) d.showModal();
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
}
@@ -371,6 +373,25 @@
return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null;
} catch { return null; }
}
// A conflict replaces the stale form only once the current task has been read.
async function reopenConflict(form, t, message) {
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, message), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, 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();
}
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.
@@ -390,28 +411,7 @@
}
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;
}
if (!r.ok && r.error === 'task-conflict' && t) { await reopenConflict(form, t, r.message); return; }
btns.forEach(b => { b.disabled = false; });
if (!r.ok) {
formStatus(form, r.message);
@@ -425,6 +425,7 @@
paint();
}
async function archive(form) {
if (form.querySelector('[data-tb-archive]')?.disabled) return;
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
if (!t) return;
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
@@ -432,7 +433,11 @@
form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm';
return;
}
const btns = form.querySelectorAll('button');
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
if (!r.ok && r.error === 'task-conflict') { await reopenConflict(form, t, r.message); return; }
btns.forEach(b => { b.disabled = false; });
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
dialogEl().close();
@@ -28,6 +28,32 @@ const state = b => b.evaluate('JSON.parse(JSON.stringify(window.MosaicAppearance
const stored = (b, key) => b.evaluate(`localStorage.getItem(${JSON.stringify(key)})`);
const inline = b => b.evaluate('document.documentElement.getAttribute("style")');
test('an organization browser override draws system and explicit light from the light colours, including after reload', { timeout: 90000 }, async t => {
const { b, f } = await open(t, { saved: { theme: 'rosewood', mode: 'dark' } });
const rgb = hex => `rgb(${hex.slice(1).match(/../g).map(n => parseInt(n, 16)).join(', ')})`;
const theme = THEMES.find(t => t.id === 'rosewood');
for (const scheme of ['light', 'dark']) {
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] });
for (const mode of ['system', 'light']) {
await b.evaluate(`window.MosaicAppearance.set({ mode: ${JSON.stringify(mode)} })`);
// Read the actual scoped storage on a new document, not only the in-memory choice.
await b.navigate(`${f.base}/?appearance=${scheme}-${mode}`);
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
const expected = mode === 'system' ? scheme : mode;
const drawn = await b.evaluate(`({
palette: document.documentElement.dataset.palette,
mode: document.documentElement.getAttribute('data-mode'),
canvas: getComputedStyle(document.body).backgroundColor,
source: window.MosaicAppearance.state().modeSource,
override: JSON.parse(localStorage.getItem('mosaic-console-appearance:acme')),
})`);
assert.deepEqual(drawn, { palette: 'rosewood', mode: mode === 'system' ? null : mode,
canvas: rgb(theme.tokens[expected]['--canvas']), source: 'browser', override: { mode } }, `${scheme}/${mode}`);
t.diagnostic(JSON.stringify({ scheme, requested: mode, ...drawn }));
}
}
});
test('the built-in default is drawn by tokens.css alone: Harbor, dark, no inline colour, no warning, no cue, nothing stored', { timeout: 90000 }, async t => {
const { b } = await open(t, { org: null });
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
@@ -53,6 +53,31 @@ const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.t
const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()';
const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
test('side by side stays dark by default under light media, and applying system or light really draws light', async t => {
const { b, wait, base } = await open(t);
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
const drawn = () => b.evaluate(`({ mode: document.documentElement.getAttribute('data-mode'),
palette: document.documentElement.dataset.palette, canvas: getComputedStyle(document.body).backgroundColor,
source: window.MosaicAppearance.state().modeSource })`);
assert.deepEqual(await drawn(), { mode: 'dark', palette: 'rosewood', canvas: 'rgb(26, 20, 23)', source: 'default' });
for (const mode of ['system', 'light']) {
// U5's corrected shot recipe seeds the per-org key, then loads a fresh document.
const before = await drawn();
await b.evaluate(`localStorage.setItem('mosaic-console-appearance:ddigital', JSON.stringify({ mode: ${JSON.stringify(mode)} }))`);
assert.deepEqual(await drawn(), before, 'writing storage alone does not apply an override to this document');
await b.navigate('about:blank');
await b.navigate(`${base}?appearance=${mode}#/orgs/ddigital/side-by-side`);
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
const result = await drawn();
assert.deepEqual(result, { mode: mode === 'system' ? null : 'light', palette: 'rosewood',
canvas: 'rgb(246, 243, 245)', source: 'browser' });
t.diagnostic(JSON.stringify({ scheme: 'light', requested: mode, ...result }));
}
});
test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => {
const state = { flip: false, seen: [] };
const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state });
+117 -52
View File
@@ -34,7 +34,7 @@ function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
// A task's digest changes with every write to it, as the store's does.
const digest = t => D((t.n + t.rev) % 10);
const TASKS = {
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1',
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: t.readOnly ? undefined : digest(t), requirement: 'REQ-API-1',
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
ddigital: () => [
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
@@ -242,6 +242,67 @@ const HOLD_CONFLICT_READ = `(() => {
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('an Archive conflict reopens on the current task and never sends the old digest again', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
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('document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()');
await wait('window.__held');
assert.equal(await b.evaluate(STATUS), 'Reading the current task…');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").disabled'), true);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true);
await b.evaluate(SAVE_AS('A stale Save while Archive refreshes'));
// Even a synthetic click past the disabled control cannot send another stale write.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").dispatchEvent(new MouseEvent("click", { bubbles: true }))');
assert.deepEqual(orgs.writes, [['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }]]);
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-archive]").textContent'), 'Archive');
// The refreshed task must be confirmed anew; its archive uses only the current version.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()');
assert.equal(orgs.writes.length, 1);
await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()');
await wait('/#42 archived in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [
['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }],
['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(3) }],
]);
});
for (const operation of ['Save', 'Archive']) {
test(`${operation}: a conflict onto a read-only task explains the refusal only until the dialog closes`, async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (now read only)', readOnly: true, rev: t42.rev + 1 });
await b.evaluate(operation === 'Save' ? SAVE_AS('Invoice export, CSV')
: 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form")'), null);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog h2").textContent'), 'Invoice export (now read only)');
const status = await b.evaluate(`(() => { const s = document.querySelector('#tb-dialog [data-tb-form-status]');
return s && { role: s.getAttribute('role'), text: s.textContent, styled: s.classList.contains('tb-form-status'), visible: s.getClientRects().length > 0 }; })()`);
assert.ok(status, 'a refused write needs a status in the read-only view');
assert.equal(status.role, 'status');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), 'tb-dialog-status');
assert.equal(await b.evaluate('document.getElementById("tb-dialog-status").textContent'), status.text);
assert.equal(status.styled, true); assert.equal(status.visible, true);
assert.equal(status.text, 'The task changed after the board read it. Nothing was written; the board now shows the current task.');
assert.equal(orgs.writes.length, 1);
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('!document.querySelector("#tb-dialog").open && !document.querySelector("#tb-dialog").innerHTML');
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]")'), null, 'the next ordinary open has no refusal line');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), null);
});
}
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()');
@@ -272,58 +333,62 @@ test('after a conflict the dialog shows the refusal only with the current task,
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');
});
for (const operation of ['Save', 'Archive']) {
const send = operation === 'Save' ? SAVE_AS('Invoice export, CSV') : 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()';
test(`${operation}: after a conflict a task that can no longer be read leaves the dialog unable to write`, 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(send);
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])'), [[operation === 'Archive' ? 'Archive: press again to confirm' : '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(`${operation}: 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(send);
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);