@bongos/core 1.21.51 → 1.21.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,9 @@
18
18
  // header of its own — the same markup as projects.html, cosmos.css's rules,
19
19
  // no map search, links into /projects, Account as the current place — and
20
20
  // the bands EXECUTED: the chip from GET /me, open/close, Escape, the arrows,
21
- // a click outside, and sign out as the POST.
21
+ // a click outside, and sign out as the POST. The bands script is the shared
22
+ // assets/hub-bar.js since task 1004489 (the three footer doors load it too),
23
+ // so the page's own script and that file run together here, as in a browser.
22
24
  //
23
25
  // DB-free. Run: node --test tests/hub_account_page.mjs
24
26
 
@@ -37,6 +39,7 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
37
39
  const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
38
40
  const PAGE = fs.readFileSync(path.join(LANDING, 'account.html'), 'utf8').replace(/\r\n/g, '\n');
39
41
  const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
42
+ const BAR = fs.readFileSync(path.join(LANDING, 'assets', 'hub-bar.js'), 'utf8');
40
43
  const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
41
44
  const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
42
45
 
@@ -62,7 +65,9 @@ test('portable and dark only: stamped title, balanced sentinels, no :root, no ba
62
65
  });
63
66
 
64
67
  test('the script builds no markup from strings, and the controls are real markup', () => {
65
- assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML/, 'live DOM is textContent / createElement only');
68
+ for (const [name, src] of [['the page', SCRIPT], ['assets/hub-bar.js', BAR]]) {
69
+ assert.doesNotMatch(src, /innerHTML|insertAdjacentHTML|outerHTML/, `${name}: live DOM is textContent / createElement only`);
70
+ }
66
71
  for (const id of ['fName', 'fBio', 'fHandle', 'fLinkWebsite', 'fLinkX', 'fLinkLinkedin', 'fLinkDiscord', 'profileSave', 'handleSave', 'privacySave', 'recordSave']) {
67
72
  assert.match(PAGE, new RegExp(`id="${id}"`), `#${id} ships in the markup`);
68
73
  }
@@ -76,8 +81,17 @@ test('the script builds no markup from strings, and the controls are real markup
76
81
  test('the door returns here, and the page calls only its own-scoped routes', () => {
77
82
  assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
78
83
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
79
- assert.deepEqual([...new Set(calls)].sort(), ['GET /me', 'GET /me/cross-project','PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
80
- assert.match(SCRIPT, /fetch\(API \+ '\/auth\/logout', \{ method: 'POST'/, 'sign out is the hub logout POST');
84
+ assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
85
+ // the bar's two calls live in the shared asset: the identity read and sign out
86
+ const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
87
+ assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
88
+ assert.match(BAR, /var API = '\/api\/bongos';/);
89
+ });
90
+
91
+ test('the bar’s script is the shared asset, loaded before the page’s own (task 1004489)', () => {
92
+ assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>\n<script>\n\(function \(\) \{/, 'hub-bar.js runs first, so window.HubBar exists when the page script asks for it');
93
+ assert.doesNotMatch(SCRIPT, /function (openBand|closeMenus|openMenu|paintBar)\b|\/auth\/logout/, 'no second copy of the bands, the chip or sign out in the page');
94
+ assert.match(SCRIPT, /var bar = window\.HubBar \|\| \{ paint: function \(\) \{\}, ready: Promise\.resolve\(null\) \};/, 'and the page survives the asset failing to load');
81
95
  });
82
96
 
83
97
  // ── 1b. the bar is the hub's (task 1004455) ──────────────────────────────────
@@ -259,7 +273,10 @@ function boot(answer) {
259
273
  return Promise.resolve({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve(data) });
260
274
  };
261
275
  const location = { hostname: 'cloudbongos.com', pathname: '/account' };
262
- vm.runInNewContext(SCRIPT, { document, fetch, location, window: { location: { assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
276
+ // one global, as in the browser: hub-bar.js sets window.HubBar, then the page reads it
277
+ const ctx = vm.createContext({ document, fetch, location, window: { location: { assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
278
+ vm.runInContext(BAR, ctx);
279
+ vm.runInContext(SCRIPT, ctx);
263
280
  return dom;
264
281
  }
265
282
  const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r)); };
@@ -7,6 +7,10 @@
7
7
  // the builders/status hosts), they are PORTABLE (server-stamped, no baked apex or
8
8
  // repo, apex-only claims removed off the hub), and BOTH footers link them.
9
9
  //
10
+ // They also wear the hub's bar (task 1004489): the same markup as projects.html
11
+ // and account.html, and assets/hub-bar.js EXECUTED against each page's served
12
+ // shape on the hub and on a child instance.
13
+ //
10
14
  // DB-free: static HTML through the same transforms landingPage() applies, plus
11
15
  // the platform app over an ephemeral listener (the public_landing_projects shape).
12
16
  //
@@ -17,6 +21,7 @@ import { test, before, after } from 'node:test';
17
21
  import http from 'node:http';
18
22
  import fs from 'node:fs';
19
23
  import path from 'node:path';
24
+ import vm from 'node:vm';
20
25
  import { fileURLToPath } from 'node:url';
21
26
  import { createRequire } from 'node:module';
22
27
 
@@ -126,6 +131,276 @@ test('cosmos.css carries the footer-nav rule beside .foot a', () => {
126
131
  assert.match(css, /\.foot \.footNav\{/);
127
132
  });
128
133
 
134
+ // ── the hub's bar (task 1004489) ───────────────────────────────────────────
135
+ //
136
+ // /projects and /account wear the hub's bar and its two bands; the doors used to
137
+ // draw their own simple header, so a footer link swapped the whole chrome. Now
138
+ // they wear the same bar: the hub's markup, cosmos.css's rules, no map search,
139
+ // band links naming /projects in full, no item the current place, and Account
140
+ // apex-only (these pages serve on EVERY instance; /account only on the hub).
141
+ // The behaviour is assets/hub-bar.js, shared with account.html, EXECUTED below
142
+ // against each page's own markup in both shapes.
143
+
144
+ const HUB = read('projects.html');
145
+ const ACCOUNT = read('account.html');
146
+ const BAR = read('assets/hub-bar.js');
147
+ const bandOf = (html, id) => {
148
+ const m = html.match(new RegExp(`<div class="menuBand" id="${id}"[\\s\\S]*?\\n</div>`));
149
+ assert.ok(m, `#${id} is a whole band`);
150
+ return m[0];
151
+ };
152
+ const bandLinks = (band) => [...band.matchAll(/<a class="bandT[^"]*" href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => [m[2], m[1]]);
153
+ // the bar and both bands, from the skip link to the end of the profile band
154
+ const chrome = (html) => {
155
+ const m = html.match(/<a class="skip vh"[\s\S]*?<div class="menuBand" id="menuProfile"[\s\S]*?\n<\/div>\n/);
156
+ assert.ok(m, 'the skip link, the bar and both bands are one block');
157
+ return m[0];
158
+ };
159
+
160
+ for (const p of PAGES) {
161
+ test(`${p.file}: the bar is the hub's own — its markup, no simple header, no map search`, () => {
162
+ const html = read(p.file);
163
+ assert.doesNotMatch(html, /<header class="top">|class="btn-space"|class="mark"/, 'the simple header is gone');
164
+ assert.doesNotMatch(html, /id="(mapQ|qWrap|qToggle|onlyMine)"/, 'the search only filters the map, so it stays on the map');
165
+ for (const piece of [
166
+ '<header class="shell bar">',
167
+ '<a class="barMark" id="barMark" href="/">',
168
+ '<ellipse cx="8.4" cy="5.6" rx="6.6" ry="2.9"/>',
169
+ '<button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>',
170
+ '<button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden>',
171
+ '<div class="menuBand" id="menuProjects" data-menu="projects" hidden>',
172
+ '<div class="menuBand" id="menuProfile" data-menu="profile" hidden>',
173
+ '<div class="bandIn bandProfile">',
174
+ '<!--apex-only:start--><li class="bandCell"><a class="bandT" href="/account">Account</a></li><!--apex-only:end-->',
175
+ '<li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>',
176
+ ]) {
177
+ assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
178
+ assert.ok(html.includes(piece), `${p.file} does not carry the hub's: ${piece}`);
179
+ }
180
+ assert.ok(html.includes(`<a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=${p.route}&amp;repos=1">Sign in</a>`),
181
+ 'signed out, the door in the chip’s slot returns to this page, asking for repo access like every hub door (task 1004549)');
182
+ assert.match(html, /<a class="skip vh" href="#legal">Skip to content<\/a>\n/);
183
+ assert.match(html, /<main class="legal" id="legal">/, 'the skip link has its landmark');
184
+ });
185
+
186
+ test(`${p.file}: the bands say what the hub's say and lead into /projects; nothing here is the current place`, () => {
187
+ const html = read(p.file);
188
+ const clauses = (band) => [...band.matchAll(/>([^<]+)<\/a><span class="bandC">([^<]+)<\/span>/g)].map((m) => [m[1], m[2]]);
189
+ assert.deepEqual(clauses(bandOf(html, 'menuProjects')), clauses(bandOf(HUB, 'menuProjects')), 'the Projects band’s items and clauses are the hub’s');
190
+ assert.deepEqual(bandLinks(bandOf(html, 'menuProjects')), [
191
+ ['Sky', '/projects?view=map'], ['Table', '/projects?view=map&amp;sub=table'],
192
+ ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
193
+ ], 'this page has no views of its own, so every item names /projects in full');
194
+ assert.deepEqual(bandLinks(bandOf(html, 'menuProfile')), [
195
+ ['My projects', '/projects?view=mine'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
196
+ ]);
197
+ assert.doesNotMatch(chrome(html), /aria-current/, 'no band item is this page');
198
+ const child = stripApexOnly(html, false);
199
+ assert.deepEqual(bandLinks(bandOf(child, 'menuProfile')), [
200
+ ['My projects', '/projects?view=mine'], ['Sign out', '/api/bongos/auth/logout'],
201
+ ], 'a child instance has no account page, so it loses the item whole');
202
+ });
203
+
204
+ test(`${p.file}: the bar is not restyled, and its script is the shared asset`, () => {
205
+ const html = read(p.file);
206
+ const styles = [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
207
+ assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
208
+ assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<\/body>/, 'one shared script, after the markup it drives');
209
+ assert.doesNotMatch(html, /<script>/, 'and no inline copy of the bands');
210
+ assert.match(html, /<a data-link-hall href="\{\{buildersOrigin\}\}">Builders' hall<\/a><\/nav><\/footer>/, 'the hall keeps a door in the footer row now the bar has no Hall pill, as on /account');
211
+ });
212
+ }
213
+
214
+ test('the three doors and the account page carry one bar: identical but for the sign-in return and the current place', () => {
215
+ const norm = (html, route) => chrome(html)
216
+ .replace(`return=${route}&amp;`, 'return=ROUTE&amp;')
217
+ .replace(/href="#\w+">Skip to content/, 'href="#MAIN">Skip to content')
218
+ .replace(/<!--[\s\S]*?-->/g, '')
219
+ .replace(/^[ \t]*\n/gm, '')
220
+ .replace(' aria-current="page"', '');
221
+ const doors = PAGES.map((p) => norm(read(p.file), p.route));
222
+ assert.equal(doors[1], doors[0], 'terms.html and privacy.html');
223
+ assert.equal(doors[2], doors[0], 'contact.html and privacy.html');
224
+ assert.equal(norm(ACCOUNT, '/account'), doors[0], 'account.html and the doors: one bar, so a change to it is a change to all four');
225
+ });
226
+
227
+ test('cosmos.css still carries the bar and the bands the doors lean on', () => {
228
+ const css = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
229
+ for (const rule of ['.bar{', '.barMark{', '.trig{', '.signinDoor{', '.menuBand{']) assert.ok(css.includes(rule), `cosmos.css still carries ${rule}`);
230
+ });
231
+
232
+ test('assets/hub-bar.js builds no markup from strings and makes only the bar’s two calls', () => {
233
+ assert.doesNotMatch(BAR, /innerHTML|insertAdjacentHTML|outerHTML/);
234
+ const calls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
235
+ assert.deepEqual(calls.sort(), ['GET /me', 'POST /auth/logout']);
236
+ assert.doesNotMatch(BAR, /gtag|plausible|googletagmanager/, 'the privacy page promises no trackers');
237
+ });
238
+
239
+ // A DOM stub just wide enough for the bar: every id in the markup is an element
240
+ // (hidden where the markup says so), each band's links are items in markup order,
241
+ // and fetch answers from a handler. Booted from the SERVED shape of the page —
242
+ // hub or child — so the child's missing Account item is what the script meets.
243
+ function bootBar(html, answer, pathname) {
244
+ const els = new Map();
245
+ const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
246
+ const el = (id, hidden, attrs) => listeners({
247
+ id, hidden, attrs, textContent: '',
248
+ getAttribute(n) { return n in attrs ? attrs[n] : null; },
249
+ setAttribute(n, v) { attrs[n] = v; },
250
+ closest(sel) { return sel === '.trig' && /\btrig\b/.test(attrs.class || '') ? this : null; },
251
+ focus() { document.activeElement = this; },
252
+ });
253
+ for (const m of html.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
254
+ const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
255
+ els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4]), attrs));
256
+ }
257
+ const trigs = [...els.values()].filter((e) => /\btrig\b/.test(e.attrs.class || ''));
258
+ const bands = [...html.matchAll(/<div class="menuBand" id="(\w+)"[\s\S]*?\n<\/div>/g)].map((m) => {
259
+ const band = els.get(m[1]);
260
+ band.items = [...m[0].matchAll(/<li class="bandCell"(?: id="(\w+)")?><a class="([^"]+)" href="([^"]+)"([^>]*)>([^<]+)<\/a>/g)].map((a) => ({
261
+ cls: a[2], href: a[3], text: a[5], cell: els.get(a[1]) || { hidden: false },
262
+ focus() { document.activeElement = this; },
263
+ closest(sel) {
264
+ if (sel === 'a') return this;
265
+ if (sel === '[aria-current="page"]') return a[4].includes(' aria-current="page"') ? this : null;
266
+ if (sel === 'a.signout') return /\bsignout\b/.test(this.cls) ? this : null;
267
+ if (sel === '.menuBand') return band;
268
+ if (sel === '[hidden]') return this.cell.hidden ? this.cell : null;
269
+ return null;
270
+ },
271
+ }));
272
+ band.contains = (x) => band.items.includes(x);
273
+ band.querySelector = () => band.items.find((i) => !i.cell.hidden) || null;
274
+ band.querySelectorAll = () => band.items;
275
+ return band;
276
+ });
277
+ const docOn = {};
278
+ const event = (extra) => ({ prevented: false, preventDefault() { this.prevented = true; }, ...extra });
279
+ const calls = [];
280
+ const dom = {
281
+ els, calls, assigned: null,
282
+ active: () => document.activeElement,
283
+ band: (id) => els.get(id),
284
+ item: (id, text) => els.get(id).items.find((i) => i.text === text),
285
+ click(target, detail = 1) {
286
+ const band = bands.find((b) => b.items.includes(target));
287
+ const ev = event({ target, detail });
288
+ for (const f of [...(target._on ? target._on.click || [] : []), ...(band ? band._on.click || [] : []), ...(docOn.click || [])]) f(ev);
289
+ return ev;
290
+ },
291
+ key(key) { const ev = event({ key }); for (const f of docOn.keydown || []) f(ev); return ev; },
292
+ focusout(band, relatedTarget) { for (const f of band._on.focusout || []) f({ relatedTarget }); },
293
+ };
294
+ const document = {
295
+ cookie: '', activeElement: null,
296
+ addEventListener: (t, f) => { (docOn[t] ||= []).push(f); },
297
+ getElementById: (id) => els.get(id) || null,
298
+ querySelector(sel) {
299
+ if (sel === '.menuBand:not([hidden])') return bands.find((b) => !b.hidden) || null;
300
+ if (sel === '.trig[aria-expanded="true"]') return trigs.find((t) => t.attrs['aria-expanded'] === 'true') || null;
301
+ throw new Error(`unstubbed selector: ${sel}`);
302
+ },
303
+ querySelectorAll(sel) {
304
+ if (sel === '.trig') return trigs;
305
+ if (sel === '.menuBand') return bands;
306
+ throw new Error(`unstubbed selector: ${sel}`);
307
+ },
308
+ };
309
+ const fetch = (url, init = {}) => {
310
+ const p = url.replace(/^\/api\/bongos/, '');
311
+ calls.push({ method: init.method || 'GET', path: p });
312
+ const { status, data } = answer(init.method || 'GET', p);
313
+ return Promise.resolve({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve(data) });
314
+ };
315
+ const window = { location: { assign(to) { dom.assigned = to; } } };
316
+ vm.runInNewContext(BAR, { document, fetch, window, location: { hostname: 'cloudbongos.com', pathname }, Array, Promise });
317
+ dom.window = window;
318
+ return dom;
319
+ }
320
+ const settle = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r)); };
321
+ const ME = { builder: { id: '1', github_login: 'octocat', avatar_url: 'https://avatars.example/u/1' } };
322
+ const signedIn = (m, p) => (p === '/me' ? { status: 200, data: ME } : { status: 204, data: null });
323
+
324
+ for (const p of PAGES) {
325
+ for (const hub of [true, false]) {
326
+ const shape = hub ? 'the hub' : 'a child instance';
327
+ test(`${p.file} on ${shape}, executed: signed out the door takes the chip's slot; signed in the chip names the login`, async () => {
328
+ const served = stripApexOnly(read(p.file), hub);
329
+ const out = bootBar(served, () => ({ status: 401, data: { error: { code: 'unauthenticated' } } }), p.route);
330
+ await settle();
331
+ assert.deepEqual(out.calls, [{ method: 'GET', path: '/me' }], 'one identity read, nothing else');
332
+ assert.equal(out.els.get('who').hidden, true, 'no chip without a session');
333
+ assert.equal(out.els.get('signinTop').hidden, false);
334
+ assert.equal(out.els.get('mineCell').hidden, true, 'and the Projects band offers no Your projects');
335
+ assert.equal(await out.window.HubBar.ready, null);
336
+
337
+ const dom = bootBar(served, signedIn, p.route);
338
+ await settle();
339
+ assert.equal(dom.els.get('who').hidden, false);
340
+ assert.equal(dom.els.get('signinTop').hidden, true);
341
+ assert.equal(dom.els.get('whoLogin').textContent, 'octocat');
342
+ assert.equal(dom.els.get('bandWhoLogin').textContent, 'octocat');
343
+ assert.equal(dom.els.get('who').getAttribute('aria-label'), 'Account, octocat');
344
+ for (const id of ['whoImg', 'bandWhoImg']) assert.equal(dom.els.get(id).src, 'https://avatars.example/u/1');
345
+ assert.equal(dom.els.get('mineCell').hidden, false);
346
+ assert.deepEqual(dom.band('menuProfile').items.map((i) => i.text), hub ? ['My projects', 'Account', 'Sign out'] : ['My projects', 'Sign out']);
347
+ });
348
+ }
349
+
350
+ test(`${p.file}, executed: the bands open and close the way the hub's do — toggle, one at a time, outside, Tab out, keys`, async () => {
351
+ const dom = bootBar(stripApexOnly(read(p.file), true), signedIn, p.route);
352
+ await settle();
353
+ const [projTrig, who] = [dom.els.get('projTrig'), dom.els.get('who')];
354
+ const [projects, profile] = [dom.band('menuProjects'), dom.band('menuProfile')];
355
+ const expanded = (t) => t.getAttribute('aria-expanded');
356
+
357
+ dom.click(projTrig);
358
+ assert.deepEqual([projects.hidden, expanded(projTrig)], [false, 'true']);
359
+ dom.click(projTrig);
360
+ assert.deepEqual([projects.hidden, expanded(projTrig)], [true, 'false'], 'the trigger toggles');
361
+ dom.click(projTrig);
362
+ dom.click(who);
363
+ assert.deepEqual([projects.hidden, profile.hidden, expanded(who)], [true, false, 'true'], 'opening one closes the other');
364
+ dom.click({ closest: () => null });
365
+ assert.deepEqual([profile.hidden, expanded(who)], [true, 'false'], 'a click outside closes');
366
+
367
+ dom.click(who);
368
+ const acct = dom.click(dom.item('menuProfile', 'Account'));
369
+ assert.equal(acct.prevented, false, 'Account is a real navigation from here — this page is not it');
370
+ assert.equal(profile.hidden, true, 'an item activated closes its band');
371
+
372
+ dom.click(projTrig);
373
+ dom.focusout(projects, null);
374
+ assert.equal(projects.hidden, false, 'a null relatedTarget is left to the document click');
375
+ dom.focusout(projects, { closest: () => null });
376
+ assert.equal(projects.hidden, true, 'Tab out of the band closes it');
377
+
378
+ assert.equal(dom.key('ArrowDown').prevented, false, 'with nothing open the keys are the page’s');
379
+ dom.click(projTrig, 0);
380
+ assert.equal(dom.active().text, 'Sky', 'a keyboard activation lands on the first item');
381
+ dom.key('ArrowUp');
382
+ assert.equal(dom.active().text, 'New project', 'ArrowUp wraps past the first item');
383
+ dom.key('ArrowDown');
384
+ dom.key('ArrowDown');
385
+ assert.equal(dom.active().text, 'Table');
386
+ dom.key('Escape');
387
+ assert.equal(projects.hidden, true);
388
+ assert.equal(dom.active(), projTrig, 'Escape hands focus back to the trigger');
389
+ });
390
+
391
+ test(`${p.file}, executed: sign out is the profile band's anchor upgraded to the POST, then this page again`, async () => {
392
+ const dom = bootBar(stripApexOnly(read(p.file), false), signedIn, p.route);
393
+ await settle();
394
+ assert.equal(dom.click(dom.item('menuProfile', 'My projects')).prevented, false, 'every other item is a real navigation');
395
+ dom.click(dom.els.get('who'));
396
+ const ev = dom.click(dom.item('menuProfile', 'Sign out'));
397
+ assert.equal(ev.prevented, true, 'the GET the href names would leave the session live');
398
+ await settle();
399
+ assert.deepEqual(dom.calls.at(-1), { method: 'POST', path: '/auth/logout' });
400
+ assert.equal(dom.assigned, p.route);
401
+ });
402
+ }
403
+
129
404
  // ── served on the apex, under the instance's identity, never elsewhere ─────
130
405
 
131
406
  let server;