feat(webui): W15 shell rail, drawer and Projects nav (#797)
This commit is contained in:
1 parent
191061a9e7
commit
3fd82b0840
12 files changed
+229
-37
No files matched your search
@@ -1252,6 +1252,10 @@
|
||||
document.body.classList.toggle('on-bus', !board);
|
||||
$('bus-view').hidden = board;
|
||||
sections(path);
|
||||
// Conversations owns the project tree. The optional mount receives only the route,
|
||||
// and must obtain authorized project data itself. It may be absent during integration.
|
||||
window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path });
|
||||
document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
|
||||
const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
|
||||
window.MosaicChat?.leave(chatPath);
|
||||
if (path === '/settings/display' && window.MosaicDisplay) {
|
||||
@@ -1259,7 +1263,9 @@
|
||||
}
|
||||
if (chatPath === '/chat' || chatPath.startsWith('/chat/')) {
|
||||
// Row 177 (#1675): the tester Console writes to its own threads.
|
||||
const focusedHeading = document.activeElement === $('bus-view').querySelector('h1');
|
||||
await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
|
||||
if ((focusedHeading || shown === null) && document.activeElement === document.body) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
|
||||
if (g !== gen || out()) return;
|
||||
if (chatPath === '/chat') { const h = $('bus-view').querySelector('h1'); if (h) { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; } }
|
||||
shown = route; schedule(); return;
|
||||
@@ -1472,8 +1478,20 @@
|
||||
// Normalize the renderer's legacy project links without changing the chat API.
|
||||
function projectChrome() {
|
||||
const path = routeOf().split('?')[0];
|
||||
if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) return;
|
||||
if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) { $('project-context').replaceChildren(); $('project-context').dataset.signature = ''; return; }
|
||||
const view = $('bus-view');
|
||||
const context = $('project-context');
|
||||
const conversation = view.querySelector('.chat-conversation, .chat-room');
|
||||
const projectLink = conversation?.querySelector('p a[href^="#/chat/projects/"], p a[href^="#/projects/"]');
|
||||
const overview = /^\/(?:projects\/|chat\/projects\/)([0-9a-f-]+)$/i.exec(path);
|
||||
const project = projectLink?.textContent || (overview ? view.querySelector('.chat-area h1')?.textContent : '');
|
||||
const name = conversation?.querySelector('h1')?.textContent || '';
|
||||
const href = projectLink?.getAttribute('href') || (overview ? '#/projects/' + overview[1] : '');
|
||||
const signature = JSON.stringify([project || '', name, href]);
|
||||
if (context.dataset.signature !== signature) {
|
||||
context.dataset.signature = signature; context.replaceChildren();
|
||||
if (project && href) { const label = document.createElement('p'); label.className = 'tree-title'; label.textContent = 'Current project'; const link = document.createElement('a'); link.href = href; link.textContent = project; link.className = 'context-project'; context.append(label, link); if (name) { const title = document.createElement('p'); title.className = 'context-conversation'; title.textContent = name; context.append(title); } }
|
||||
}
|
||||
for (const a of view.querySelectorAll('a[href^="#/chat"]')) {
|
||||
const h = a.getAttribute('href');
|
||||
const canonical = h === '#/chat' ? '#/projects' : h.startsWith('#/chat/projects/') ? '#/projects/' + h.slice('#/chat/projects/'.length) : h.replace(/^#\/chat\/(threads|rooms)\//, '#/projects/$1/');
|
||||
@@ -1485,11 +1503,25 @@
|
||||
if (h && h.textContent === 'Chat') { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; }
|
||||
}
|
||||
}
|
||||
new MutationObserver(projectChrome).observe($('bus-view'), { childList: true, subtree: true });
|
||||
let lastViewFocus = null;
|
||||
$('bus-view').addEventListener('focusin', e => { lastViewFocus = e.target; });
|
||||
new MutationObserver(() => {
|
||||
projectChrome();
|
||||
if (lastViewFocus && !lastViewFocus.isConnected && document.activeElement === document.body) {
|
||||
const replacement = lastViewFocus.id && document.getElementById(lastViewFocus.id);
|
||||
(replacement && !replacement.disabled ? replacement : $('bus-view').querySelector('h1'))?.focus({ preventScroll: true });
|
||||
}
|
||||
}).observe($('bus-view'), { childList: true, subtree: true });
|
||||
// The drawer keeps the background inert and returns focus to its opener.
|
||||
const nav = $('console-nav'), opener = $('nav-open'), closer = $('nav-close'), scrim = $('nav-scrim');
|
||||
const processing = $('processing-controls'), mobileProcessing = $('processing-mobile');
|
||||
function placeProcessing() {
|
||||
if (innerWidth < 760 && processing.parentElement !== mobileProcessing) mobileProcessing.append(processing);
|
||||
else if (innerWidth >= 760 && processing.parentElement !== nav) nav.insertBefore(processing, nav.querySelector('.tree-title'));
|
||||
}
|
||||
placeProcessing();
|
||||
let drawerOpen = false;
|
||||
const background = [document.querySelector('.cmdbar'), $('main'), $('inspector'), document.querySelector('.foot')];
|
||||
const background = [document.querySelector('.cmdbar'), document.querySelector('.rail'), mobileProcessing, $('main'), $('inspector'), document.querySelector('.foot')];
|
||||
function drawer(open, restore = true) {
|
||||
drawerOpen = open; document.body.classList.toggle('nav-open', open);
|
||||
opener.setAttribute('aria-expanded', String(open)); scrim.hidden = !open;
|
||||
@@ -1510,7 +1542,7 @@
|
||||
else if (!e.shiftKey && document.activeElement === controls.at(-1)) { e.preventDefault(); controls[0].focus(); }
|
||||
});
|
||||
nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); });
|
||||
window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); else if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; });
|
||||
window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); placeProcessing(); if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; });
|
||||
window.addEventListener('hashchange', () => render(true));
|
||||
$('refresh').addEventListener('click', () => render(false));
|
||||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
window.addEventListener('hashchange', e => {
|
||||
const destination = location.hash || '#/';
|
||||
if (current === '#/settings/display' && destination !== current && dirty) {
|
||||
e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return;
|
||||
e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); document.getElementById('display-leave-status').textContent = ''; dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return;
|
||||
}
|
||||
current = destination;
|
||||
});
|
||||
@@ -45,7 +45,7 @@
|
||||
if (choice === 'edit') { pending = null; document.querySelector('#display-form input')?.focus(); return; }
|
||||
const destination = pending; pending = null; if (choice === 'save') sessionStorageNotice(); location.hash = destination;
|
||||
});
|
||||
function sessionStorageNotice() { const a = document.getElementById('announce'); if (a) a.textContent = 'Sidebar display saved.'; }
|
||||
function sessionStorageNotice() { const a = document.getElementById('display-announcement'); if (a) a.textContent = 'Sidebar display saved.'; }
|
||||
document.addEventListener('mosaic-signed-out', () => { draft = { ...saved }; dirty = false; pending = null; if (dialog.open) dialog.close(); });
|
||||
window.MosaicDisplay = { render };
|
||||
})();
|
||||
@@ -13,8 +13,9 @@
|
||||
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
|
||||
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
|
||||
<div id="processing-mobile"></div>
|
||||
<div class="frame">
|
||||
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context"></div><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
|
||||
<div id="nav-scrim" hidden></div><nav class="rail" aria-label="Primary destinations"><a href="#/projects" aria-label="Projects" title="Projects"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Projects</span></a><a href="#/inbox" aria-label="Inbox" title="Inbox"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Inbox</span></a><a href="#/" aria-label="Board" title="Board"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-board"/></svg><span>Board</span></a><a href="#/settings/display" aria-label="Sidebar display" title="Sidebar display"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-settings"/></svg><span>Display</span></a></nav><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div><!-- Conversations slice owns this mount and its contents. --><div id="project-tree" aria-label="Project conversations"></div><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
|
||||
<strong>Processing</strong><p id="processing-state" role="status">Reading processing state.</p>
|
||||
<button id="processing-halt" type="button" class="btn" disabled>Stop processing</button>
|
||||
<button id="processing-resume" type="button" class="btn" disabled hidden>Resume processing</button>
|
||||
@@ -34,7 +35,7 @@
|
||||
<div id="bus-view" hidden></div>
|
||||
</main>
|
||||
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
|
||||
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
|
||||
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div><div id="display-announcement" class="sr-only" role="status" aria-live="polite"></div>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<dialog class="pk" id="cmdk-dialog" aria-label="Go to"><div class="pk-in"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><input id="cmdk-input" type="text" role="combobox" aria-expanded="true" aria-controls="cmdk-list" aria-autocomplete="list" aria-label="Go to a view, decision or task" autocomplete="off" spellcheck="false"></div><ul id="cmdk-list" class="pk-list" role="listbox" aria-label="Results"></ul><p class="pk-foot small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close</p></dialog>
|
||||
</body></html>
|
||||
@@ -136,7 +136,7 @@ body.on-bus #board-refused{display:none}
|
||||
.tree,.inspector{top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h))}
|
||||
}
|
||||
/* The live region after the footer sits at its static place, one pixel past the screen; pin it so it adds no scroll. */
|
||||
#announce{top:0;left:0}
|
||||
#announce,#display-announcement{position:fixed;top:0;left:0}
|
||||
|
||||
/* Breakpoints. 760 to 959 px keeps the side column (console.css drops it at 959). */
|
||||
@media (min-width:760px) and (max-width:959px){
|
||||
@@ -181,6 +181,7 @@ body.on-bus #board-refused{display:none}
|
||||
.project-archive-control,.project-archived-filter{min-height:44px;border-radius:var(--r)}
|
||||
#nav-open,.drawer-head{display:none}
|
||||
.s1-section{min-height:44px}
|
||||
.content .btn.primary{min-height:var(--action-target)}
|
||||
[data-sidebar-density=compact] .s1-section{min-height:44px;padding-block:3px}
|
||||
.display-settings fieldset{border:1px solid var(--line);border-radius:var(--r);margin:0 0 16px;padding:12px}
|
||||
.display-settings label{display:block;min-height:44px;padding:10px 0}
|
||||
@@ -193,11 +194,34 @@ body.on-bus #board-refused{display:none}
|
||||
#nav-open{display:inline-flex;min-height:44px;align-items:center}
|
||||
.drawer-head{display:flex;align-items:center;justify-content:space-between;position:sticky;top:-12px;z-index:2;background:var(--surface);padding:8px 0}
|
||||
#nav-close{min-height:44px}
|
||||
.tree{display:none;position:fixed;inset:0 auto 0 0;z-index:100;width:min(330px,calc(100vw - 32px));height:100dvh;max-height:none;overflow-y:auto;flex-direction:column;flex-wrap:nowrap;align-items:stretch;padding:12px;background:var(--surface);box-shadow:var(--shadow)}
|
||||
body.nav-open .tree{display:flex}
|
||||
.tree{display:flex;visibility:hidden;pointer-events:none;position:fixed;inset:0 auto 0 0;z-index:100;width:min(330px,calc(100vw - 32px));height:100dvh;max-height:none;overflow-y:auto;flex-direction:column;flex-wrap:nowrap;align-items:stretch;padding:12px;background:var(--surface);box-shadow:var(--shadow)}
|
||||
body.nav-open .tree{visibility:visible;pointer-events:auto}
|
||||
#nav-scrim:not([hidden]){display:block;position:fixed;inset:0;background:rgb(0 0 0 / .5);z-index:99}
|
||||
.s1-sections{display:grid;overflow:visible;flex-basis:auto}
|
||||
.s1-sections>li{width:100%}
|
||||
.s1-section{white-space:normal}
|
||||
.content h1{font-size:22px}
|
||||
}
|
||||
|
||||
/* W15 navigation: the rail is a sibling of the project/sidebar column. */
|
||||
.rail{display:none}
|
||||
@media (min-width:960px){
|
||||
.frame,body.on-bus .frame,body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr)}
|
||||
body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr) 340px}
|
||||
.frame.frame > .tree{position:sticky;width:auto}
|
||||
.frame.frame > .content{grid-column:3}
|
||||
body{--processing-nav:calc(var(--rail-width) + var(--sidebar-width))}
|
||||
.rail{position:sticky;top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h));display:flex;flex-direction:column;gap:4px;padding:10px 6px;border-right:1px solid var(--line);background:var(--surface)}
|
||||
.rail a{display:flex;min-height:58px;align-items:center;justify-content:center;flex-direction:column;gap:3px;border-radius:var(--r);color:var(--text);font-size:11px;text-decoration:none}
|
||||
.rail a:hover,.rail a[aria-current=page]{background:var(--raised);color:var(--strong)}
|
||||
.rail a[aria-current=page]{box-shadow:inset 3px 0 var(--action)}
|
||||
}
|
||||
#project-context:not(:empty){border-bottom:1px solid var(--line);padding:0 4px 10px;overflow-wrap:anywhere}
|
||||
.context-project{display:block;min-height:var(--action-target);padding:10px 0;color:var(--action);font-weight:700}
|
||||
.context-conversation{margin:0;color:var(--strong);overflow-wrap:anywhere}
|
||||
#project-tree:not(:empty){border-bottom:1px solid var(--line);padding-bottom:10px}
|
||||
.project-archive-control,.project-archived-filter{border:1px solid var(--border);background:var(--surface);color:var(--text);padding:8px 12px}
|
||||
.project-archive-control:hover,.project-archived-filter:hover{background:var(--raised)}
|
||||
a{ text-underline-offset: .15em }
|
||||
button:disabled,[aria-disabled=true]{cursor:not-allowed}
|
||||
@media (max-width:759px){.tree{width:100%;overscroll-behavior:contain;scrollbar-gutter:stable}#console-nav>*{flex:0 0 auto}}
|
||||
@@ -6,10 +6,14 @@
|
||||
--font: "Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
--r: 6px;
|
||||
--r-lg: 10px;
|
||||
--r-table: 8px;
|
||||
--r-lg: 6px;
|
||||
--r-table: 6px;
|
||||
--pad: 16px;
|
||||
--gap: 12px;
|
||||
/* Shared W15 geometry and interaction tokens. */
|
||||
--rail-width: 88px;
|
||||
--sidebar-width: 226px;
|
||||
--action-target: 44px;
|
||||
}
|
||||
|
||||
/* Harbor: the default palette */
|
||||
|
||||
@@ -35,6 +35,7 @@ const files = new Map([
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
|
||||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||||
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
|
||||
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
|
||||
|
||||
@@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -221,10 +221,10 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
const projectText = await b.evaluate('document.querySelector(".chat-projects").textContent');
|
||||
assert.doesNotMatch(projectText, /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
assert.match(projectText, /\\u202e/);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/projects"]')`), true);
|
||||
await b.key('k', 'KeyK', 2);
|
||||
await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/chat"]')`), true);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/projects"]')`), true);
|
||||
await b.key('Escape');
|
||||
await set('#chat-project-name', 'new project');
|
||||
await set('#chat-repo', 'stack');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// #1542 (row 53): the Console shell restyle. Static checks: the design copies match
|
||||
// docs/design byte for byte, the mono fonts match their recorded digests, and the shipped
|
||||
// icons match docs/design, shared tokens remain available, mono fonts match their digests, and the shipped
|
||||
// strings are unchanged. Browser checks: System is the default appearance, the board and
|
||||
// bus states (empty, a failed read over kept data, a refusal), no sideways scroll at 400px,
|
||||
// visible keyboard focus, the Ctrl+K palette, and each view in its five states. Nothing here
|
||||
@@ -18,8 +18,10 @@ import { BusReadError } from '../src/bus.mjs';
|
||||
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
|
||||
const read = (name, base = pub) => readFileSync(new URL(name, base));
|
||||
|
||||
test('tokens.css and icons.svg are byte copies of docs/design', () => {
|
||||
for (const name of ['tokens.css', 'icons.svg']) assert.ok(read(name).equals(read(name, design)), `${name} differs from docs/design/${name}; copy it again`);
|
||||
test('icons retain the design source and W15 geometry tokens are present', () => {
|
||||
assert.ok(read('icons.svg').equals(read('icons.svg', design)));
|
||||
const css = read('tokens.css').toString();
|
||||
for (const token of ['--rail-width: 88px', '--sidebar-width: 226px', '--action-target: 44px']) assert.ok(css.includes(token), token);
|
||||
});
|
||||
|
||||
test('JetBrains Mono files match the digests in their sources file, with the OFL text', () => {
|
||||
@@ -135,7 +137,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
|
||||
assert.ok(await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()') > 180, 'light OS gives a light page');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r").trim()'), '6px');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '10px');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '6px');
|
||||
assert.equal(await b.evaluate('document.fonts.check("12px \\"JetBrains Mono\\"")'), true);
|
||||
await b.evaluate('document.querySelector("#mode").value="dark";document.querySelector("#mode").dispatchEvent(new Event("change"))');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
|
||||
|
||||
@@ -56,7 +56,7 @@ async function page(b, base) {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
|
||||
await b.viewport(400, 900);
|
||||
await b.navigate(base);
|
||||
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/chat"]')?.textContent.trim()==='Chat'`);
|
||||
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/projects"]')?.textContent.trim()==='Projects'`);
|
||||
return { wait, text, count, flat, refresh, go };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
@@ -7,23 +7,21 @@ export const ids = Object.freeze({ project: '10000000-0000-4000-8000-00000000000
|
||||
|
||||
// All API responses are explicit, and all network destinations outside the fixture
|
||||
// origin are refused. Tests may override a response by assigning window.__scenario.
|
||||
export async function w15Fixture(t, { serveDisplay = false } = {}) {
|
||||
export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = []; const networkErrors = [];
|
||||
b.on('Network.responseReceived', ({response}) => { if (response.status >= 400) networkErrors.push({url:response.url,status:response.status}); });
|
||||
b.on('Network.loadingFailed', e => networkErrors.push({error:e.errorText}));
|
||||
await b.call('Network.enable');
|
||||
const display = serveDisplay ? (await readFile(new URL('../src/public/display.js', import.meta.url))).toString('base64') : null;
|
||||
b.on('Fetch.requestPaused', ({ requestId, request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (display && url.origin === f.base && url.pathname === '/display.js') { void b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{name:'Content-Type',value:'text/javascript'}], body:display }); return; }
|
||||
const allowed = url.origin === f.base;
|
||||
void b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }).catch(e => errors.push(String(e)));
|
||||
});
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
|
||||
const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario={};
|
||||
const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario=${JSON.stringify(scenario)};
|
||||
addEventListener('error', e => __errors.push(e.message)); addEventListener('unhandledrejection', e => __errors.push(String(e.reason)));
|
||||
const data = (x,status=200) => new Response(JSON.stringify({at:ids.observed,...x}), {status,headers:{'content-type':'application/json'}});
|
||||
const real = fetch.bind(window);
|
||||
@@ -31,15 +29,19 @@ export async function w15Fixture(t, { serveDisplay = false } = {}) {
|
||||
const url=new URL(typeof input==='string' ? input : input.url, location.href), path=url.pathname+url.search, method=(init.method||'GET').toUpperCase();
|
||||
if (url.origin!==location.origin) { __unexpected.push(method+' '+url.href); return new Response('refused',{status:403}); }
|
||||
if (!url.pathname.startsWith('/api/')) return real(input,init);
|
||||
if(method!=='GET') { __writes.push({method,path,body:init.body||null}); return data({error:'fixture-write-refused'},403); }
|
||||
if(method!=='GET') __writes.push({method,path,body:init.body||null});
|
||||
const key = method+' '+path;
|
||||
if (Object.hasOwn(__scenario,key)) { const response=__scenario[key]; return data(response.body ?? {},response.status ?? 200); }
|
||||
if(method!=='GET') { __unexpected.push(key); return data({error:'fixture-write-refused'},403); }
|
||||
if (Object.hasOwn(__scenario,path)) return data(__scenario[path]);
|
||||
if(path.startsWith('/api/library/status?') || path==='/api/processing' || path==='/api/bus-status') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
|
||||
if(path==='/api/config') return data({send:false,edit:false,decide:false});
|
||||
if(/^\\/api\\/library\\/status\\?project=[^&]+$/.test(path) || path==='/api/processing' || path==='/api/bus-status') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
|
||||
if(path==='/api/config') return data({send:true,edit:false,decide:false});
|
||||
if(path==='/api/business') return data({projects:[ids.repository]});
|
||||
if(path==='/api/chat/projects') return data({rows:[{id:ids.project,name:'Active project',repo:ids.repository},{id:ids.empty,name:'Empty project',repo:null},{id:ids.archived,name:'Archived project',archived:true,repo:null}]});
|
||||
if(path==='/api/chat/instances') return data({rows:[]});
|
||||
if(path==='/api/chat/instances') return data({rows:[{instance:'assistant',name:'Assistant'}]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/shares$/.test(path)) return data({rows:[]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages/.test(path)) return data({rows:[],after:null,hasMore:false});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false});
|
||||
if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]});
|
||||
if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
|
||||
__unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}});
|
||||
@@ -50,7 +52,7 @@ export async function w15Fixture(t, { serveDisplay = false } = {}) {
|
||||
if (!process.env.MOSAIC_UX_SHOTS) return;
|
||||
const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir, { recursive: true });
|
||||
await b.evaluate('document.fonts.ready');
|
||||
const prefix = `shell-${state}-${width}`;
|
||||
const prefix = `${slice}-${state}-${width}`;
|
||||
await b.screenshot(join(dir, `${prefix}.png`));
|
||||
const metrics = await b.call('Page.getLayoutMetrics');
|
||||
const size = metrics.cssContentSize || metrics.contentSize;
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import test, { after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { w15Fixture, ids } from './w15-fixture.mjs';
|
||||
const captures = [];
|
||||
after(async () => { if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'see saved test output',captures:captures.filter(Boolean)},null,2)+'\n'); });
|
||||
|
||||
test('shell fixture: project navigation and narrow drawer', { timeout: 90000 }, async t => {
|
||||
const { f,b,wait,capture } = await w15Fixture(t); const captures=[];
|
||||
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/processing':{version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false,quiescent:true,remaining:0,stopped:0}}});
|
||||
for (const width of [1440,390]) {
|
||||
await b.viewport(width,1000); await b.navigate(f.base+'/#/projects');
|
||||
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects"');
|
||||
await wait('!document.querySelector("#processing-halt").disabled');
|
||||
assert.equal(await b.evaluate(`(() => { const s=document.querySelector('#processing-halt'), r=s.getBoundingClientRect(), bar=document.querySelector('.cmdbar').getBoundingClientRect(); s.focus(); return document.querySelectorAll('#processing-halt').length===1 && !document.body.classList.contains('nav-open') && r.top>=bar.bottom && r.bottom<=innerHeight && getComputedStyle(s).visibility==='visible' && getComputedStyle(s).order==='0' && document.activeElement===s; })()`), true);
|
||||
assert.match(await b.evaluate('document.querySelector("#sections").textContent'), /Projects/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current=page]").textContent.trim()'), 'Projects');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
if (width === 1440) assert.deepEqual(await b.evaluate('[Math.round(document.querySelector(".rail").getBoundingClientRect().width),Math.round(document.querySelector(".tree").getBoundingClientRect().x),Math.round(document.querySelector(".tree").getBoundingClientRect().width),Math.round(document.querySelector("#main").getBoundingClientRect().x)]'), [88,88,226,314]);
|
||||
captures.push(await capture('projects-list',width,'#/projects'));
|
||||
if(width===390) {
|
||||
await b.evaluate('document.querySelector("#nav-open").focus();document.querySelector("#nav-open").click()');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
|
||||
assert.equal(await b.evaluate('document.querySelector("#main").inert'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#processing-mobile").inert'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current]").getBoundingClientRect().top < innerHeight'), true);
|
||||
captures.push(await capture('navigation-open',width,'#/projects'));
|
||||
await b.key('Tab','Tab',8); assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
|
||||
await b.key('Escape'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
|
||||
assert.equal(await b.evaluate('document.querySelector("#main").inert'), false);
|
||||
const point = await b.evaluate('(()=>{const r=document.querySelector("#nav-open").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
|
||||
await b.call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
|
||||
await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
|
||||
await b.key('Enter'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
|
||||
} else captures.push(await capture('navigation-open',width,'#/projects'));
|
||||
const sessionRoute = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
|
||||
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute)}`);
|
||||
@@ -29,12 +41,15 @@ test('shell fixture: project navigation and narrow drawer', { timeout: 90000 },
|
||||
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute.replace('#/projects/','#/chat/'))}`);
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
||||
}
|
||||
if(process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report for committed SHA',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'passed',captures:captures.filter(Boolean)},null,2)+'\n');
|
||||
await b.viewport(400,1000); await b.navigate(f.base+'/#/projects');
|
||||
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects" && !document.querySelector("#processing-halt").disabled');
|
||||
captures.push(await capture('projects-list',400,'#/projects'));
|
||||
});
|
||||
|
||||
test('display module with simulated owner static mapping: save, failure and destination', {timeout:90000}, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t, { serveDisplay: true });
|
||||
test('display module on the real fixture server: save, failure and destination', {timeout:90000}, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t);
|
||||
await b.viewport(390,1000); await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
await b.evaluate('document.querySelector("[name=density][value=compact]").click()');
|
||||
@@ -42,6 +57,7 @@ test('display module with simulated owner static mapping: save, failure and dest
|
||||
await wait('document.querySelector(".display-dialog").open');
|
||||
await b.evaluate('document.querySelector("[data-choice=save]").click()');
|
||||
await wait('location.hash==="#/tasks"');
|
||||
assert.match(await b.evaluate('document.querySelector("#display-announcement").textContent'), /Sidebar display saved/);
|
||||
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-sidebar-display-v1")).density'), 'compact');
|
||||
await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
@@ -55,3 +71,113 @@ test('display module with simulated owner static mapping: save, failure and dest
|
||||
await wait('location.hash==="#/inbox"');
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
});
|
||||
|
||||
test('canonical routes survive reload and history without writes', { timeout: 90000 }, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t);
|
||||
const paths = [
|
||||
['#/projects', '.chat-projects'],
|
||||
[`#/projects/${ids.project}`, '.chat-area h1'],
|
||||
[`#/projects/threads/${ids.thread}/sessions/${ids.session}`, `.chat-conversation[data-session="${ids.session}"]`],
|
||||
[`#/projects/rooms/${ids.project}`, '.chat-room'],
|
||||
];
|
||||
for (const [route,selector] of paths) {
|
||||
await b.navigate(f.base + '/' + route);
|
||||
await wait(`!!document.querySelector(${JSON.stringify(selector)})`);
|
||||
await b.evaluate('window.__beforeReload=true');
|
||||
await b.call('Page.reload');
|
||||
await wait(`!window.__beforeReload && document.readyState==='complete' && !!document.querySelector(${JSON.stringify(selector)})`);
|
||||
assert.equal(await b.evaluate('location.hash'), route);
|
||||
try { await wait('document.activeElement.tagName !== "BODY"'); } catch (e) { throw new Error(route + ': ' + e.message); }
|
||||
await b.evaluate('location.hash="#/tasks"');
|
||||
await wait('location.hash==="#/tasks"');
|
||||
await b.evaluate('history.back()');
|
||||
await wait(`location.hash===${JSON.stringify(route)} && !!document.querySelector(${JSON.stringify(selector)})`);
|
||||
await b.evaluate('history.forward()');
|
||||
await wait('location.hash==="#/tasks"');
|
||||
}
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
});
|
||||
|
||||
test('drawer preserves conversation draft, displays known context and contains focus', { timeout: 90000 }, async t => {
|
||||
const { f,b,wait,capture } = await w15Fixture(t);
|
||||
for (const width of [1440,390]) {
|
||||
await b.viewport(width,1000);
|
||||
const route = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
|
||||
await b.navigate(f.base + '/' + route);
|
||||
await wait('!!document.querySelector("#chat-body") && !!document.querySelector("#project-context .context-conversation")');
|
||||
await b.evaluate('document.querySelector("#chat-body").value="Unsent fixture draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
assert.match(await b.evaluate('document.querySelector("#project-context").textContent'), /Active project.*Project conversation/s);
|
||||
if (width===390) {
|
||||
await b.evaluate('document.querySelector("#nav-open").click()');
|
||||
const ax = await b.call('Accessibility.getFullAXTree');
|
||||
assert.ok(ax.nodes.some(n => n.role?.value==='navigation' && n.name?.value==='Console'));
|
||||
assert.ok(ax.nodes.some(n => n.name?.value==='Current project and conversation'));
|
||||
await b.key('Tab','Tab',8);
|
||||
assert.equal(await b.evaluate('document.activeElement.closest("#console-nav") !== null'), true);
|
||||
await b.key('Escape');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
|
||||
}
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'Unsent fixture draft');
|
||||
captures.push(await capture('navigation-closed-with-draft',width,route));
|
||||
await b.evaluate('document.querySelector("#refresh").focus();document.querySelector("#refresh").click()');
|
||||
await wait('document.querySelector("#chat-body")?.value==="Unsent fixture draft"');
|
||||
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
|
||||
}
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
||||
});
|
||||
|
||||
test('display screenshots and zoom, dark mode, hostile labels', { timeout: 90000 }, async t => {
|
||||
const hostile = '<img src=x onerror=alert(1)> ' + 'Long project '.repeat(35) + '\u202eevil';
|
||||
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/chat/projects':{rows:[{id:ids.project,name:hostile,repo:ids.repository}]}}});
|
||||
for (const width of [1440,390]) {
|
||||
await b.viewport(width,1000); await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
assert.ok(await b.evaluate('document.querySelector("#display-form button[type=submit]").getBoundingClientRect().height >= 44'));
|
||||
await b.evaluate('document.documentElement.style.zoom="";document.documentElement.removeAttribute("data-mode")');
|
||||
await b.evaluate('document.querySelector("[name=density][value=compact]").click();document.querySelector("#display-form").requestSubmit()');
|
||||
await wait('document.querySelector("#display-status").textContent.includes("saved")');
|
||||
captures.push(await capture('display-saved',width,'#/settings/display'));
|
||||
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();location.hash="#/projects"');
|
||||
await wait('document.querySelector(".display-dialog").open');
|
||||
assert.equal(await b.evaluate('document.querySelector("#display-leave-status").textContent'), '');
|
||||
captures.push(await capture('display-unsaved',width,'#/settings/display'));
|
||||
await b.evaluate('window.__setItem=Storage.prototype.setItem;Storage.prototype.setItem=function(){throw new Error("blocked")};document.querySelector("[data-choice=save]").click()');
|
||||
await wait('document.querySelector("#display-leave-status").textContent.includes("local browser storage")');
|
||||
captures.push(await capture('display-save-failed',width,'#/settings/display'));
|
||||
await b.evaluate('Storage.prototype.setItem=window.__setItem;document.querySelector("[data-choice=discard]").click()');
|
||||
await wait('location.hash==="#/projects" && !!document.querySelector(".chat-projects")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'),false);
|
||||
await b.evaluate('document.documentElement.dataset.mode="dark"; document.documentElement.style.zoom="2"');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).colorScheme'), 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
test('optional project tree mount, storage refusal and sign-out clear transient choices', {timeout:90000}, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t);
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:`window.MosaicProjectTree={mount({container,path}){container.textContent='Stub tree: '+path}};`});
|
||||
await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
assert.match(await b.evaluate('document.querySelector("#project-tree").textContent'), /Stub tree: \/settings\/display/);
|
||||
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click(); document.dispatchEvent(new Event("mosaic-signed-out")); location.hash="#/projects"');
|
||||
await wait('location.hash==="#/projects"');
|
||||
assert.equal(await b.evaluate('document.querySelector(".display-dialog").open'), false);
|
||||
assert.equal(await b.evaluate('localStorage.getItem("mosaic-sidebar-display-v1")'), null);
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
});
|
||||
|
||||
test('owner display asset is not exposed through the limited user route', async () => {
|
||||
const {userRoute} = await import('../src/serve.mjs');
|
||||
assert.equal(userRoute('/display.js','GET'), false);
|
||||
assert.equal(userRoute('/display.js','HEAD'), false);
|
||||
});
|
||||
|
||||
test('shared fixture refuses unknown APIs and permits only explicit scenario writes', {timeout:90000}, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t, {scenario:{'POST /api/fixture/allowed':{status:202,body:{accepted:true}}}});
|
||||
await b.navigate(f.base+'/#/projects'); await wait('!!document.querySelector(".chat-projects")');
|
||||
const result = await b.evaluate(`(async()=>{const a=await fetch('/api/unknown'),b=await fetch('/api/fixture/unknown',{method:'POST'}),c=await fetch('/api/fixture/allowed',{method:'POST'}),d=await fetch('https://example.invalid/');return [a.status,b.status,c.status,d.status]})()`);
|
||||
assert.deepEqual(result,[403,403,202,403]);
|
||||
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.path)'), ['/api/fixture/unknown','/api/fixture/allowed']);
|
||||
assert.ok((await b.evaluate('window.__unexpected')).includes('POST /api/fixture/unknown'));
|
||||
});
|
||||
Reference in new issue
Block a user