@bongos/core 1.21.26 → 1.21.28
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.
- package/.bongos-core.json +76 -61
- package/docs/copy-inventory.md +21 -13
- package/docs/copy-registry.json +88 -16
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +68 -0
- package/docs/page-readings.json +3504 -3354
- package/modules/hall-ui/public/atlas.html +3 -1
- package/modules/hall-ui/public/blockers.html +3 -1
- package/modules/hall-ui/public/board-room.html +3 -1
- package/modules/hall-ui/public/city.html +3 -1
- package/modules/hall-ui/public/collab.html +3 -1
- package/modules/hall-ui/public/commands.html +3 -1
- package/modules/hall-ui/public/copy-desk.html +3 -1
- package/modules/hall-ui/public/craft-mode.js +297 -0
- package/modules/hall-ui/public/deploy.html +3 -1
- package/modules/hall-ui/public/diagrams.html +3 -1
- package/modules/hall-ui/public/drachmae.html +3 -1
- package/modules/hall-ui/public/fleet.html +3 -1
- package/modules/hall-ui/public/gate.html +3 -1
- package/modules/hall-ui/public/goal-map.html +3 -1
- package/modules/hall-ui/public/goals.html +3 -1
- package/modules/hall-ui/public/government.html +3 -1
- package/modules/hall-ui/public/idea.html +3 -1
- package/modules/hall-ui/public/ideas.html +3 -1
- package/modules/hall-ui/public/index.html +3 -1
- package/modules/hall-ui/public/modules.html +3 -1
- package/modules/hall-ui/public/primer.html +3 -1
- package/modules/hall-ui/public/profile.html +3 -1
- package/modules/hall-ui/public/project-settings.html +3 -1
- package/modules/hall-ui/public/ranks.html +3 -1
- package/modules/hall-ui/public/roadmap.html +3 -1
- package/modules/hall-ui/public/roster.html +3 -1
- package/modules/hall-ui/public/sessions.html +3 -1
- package/modules/hall-ui/public/settings.html +3 -1
- package/modules/hall-ui/public/shell-trail.js +186 -0
- package/modules/hall-ui/public/shell.js +23 -141
- package/modules/hall-ui/public/street.html +3 -1
- package/modules/hall-ui/public/studio.html +3 -1
- package/modules/hall-ui/public/style.css +274 -0
- package/modules/hall-ui/public/task.html +3 -1
- package/modules/hall-ui/public/thinking.html +3 -1
- package/modules/hall-ui/public/tweak-editor.html +3 -1
- package/modules/hall-ui/public/watch.html +3 -1
- package/modules/hall-ui/public/work.html +3 -1
- package/modules/hall-ui/records/craft-mode.md +22 -0
- package/modules/hall-ui/records/hall-navigation.md +8 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/craft_mode.mjs +267 -112
- package/tests/goal_map_page.mjs +2 -2
- package/tests/hall_city.mjs +1 -1
- package/tests/hall_fullscreen.mjs +125 -5
- package/tests/hall_street.mjs +1 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
package/tests/craft_mode.mjs
CHANGED
|
@@ -1,141 +1,296 @@
|
|
|
1
|
-
// tests/craft_mode.mjs — the
|
|
2
|
-
// printed (task 1002992).
|
|
1
|
+
// tests/craft_mode.mjs — the hall's craft mode picker (task 1004492, goal 1000095 WA6).
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
3
|
+
// On entering the hall a picker asks once per session "What are you doing
|
|
4
|
+
// now?" (Building, Artistry, Ideating, Governing, or Legacy mode); the answer is
|
|
5
|
+
// html[data-craft], and a switch in the top bar changes it without a reload.
|
|
6
|
+
// These cases boot the REAL shell-trail.js + craft-mode.js + shell.js in a DOM
|
|
7
|
+
// stub, in the order every hall page loads them, and drive them the way a
|
|
8
|
+
// reader does: sign in, pick a door, reload, start a new session, press the
|
|
9
|
+
// switch. The criterion's own disqualifier comes first: a hall with no mode
|
|
10
|
+
// chosen must render exactly as the hall did before craft modes existed.
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
14
13
|
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
14
|
import path from 'node:path';
|
|
15
|
+
import vm from 'node:vm';
|
|
17
16
|
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { makeRunner } from './helpers.mjs';
|
|
19
17
|
|
|
20
|
-
const require = createRequire(import.meta.url);
|
|
21
18
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
19
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
20
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
21
|
+
const SHELL = read('shell.js');
|
|
22
|
+
const TRAIL = read('shell-trail.js');
|
|
23
|
+
const CRAFT = read('craft-mode.js');
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// A tiny element: attributes, a dataset, children by markup. An element built
|
|
26
|
+
// from markup answers the selectors the craft code asks with a regex over that
|
|
27
|
+
// markup, so a door or a checkbox the code forgot to render cannot be found.
|
|
28
|
+
function makeEl(tag, html = '') {
|
|
29
|
+
const attrs = {}, listeners = {};
|
|
30
|
+
const el = {
|
|
31
|
+
tagName: tag, id: '', className: '', dataset: {}, style: {}, textContent: '', hidden: false, title: '',
|
|
32
|
+
_html: html, children: [], parentNode: null,
|
|
33
|
+
classList: { _s: new Set(), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle(c, on) { if (on === undefined) on = !this._s.has(c); if (on) this._s.add(c); else this._s.delete(c); return on; }, contains(c) { return this._s.has(c); } },
|
|
34
|
+
setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
35
|
+
hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
|
|
36
|
+
addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
|
|
37
|
+
fire(t, ev) { for (const fn of listeners[t] || []) fn(ev); },
|
|
38
|
+
appendChild(c) { c.parentNode = el; el.children.push(c); return c; },
|
|
39
|
+
removeChild(c) { el.children = el.children.filter((x) => x !== c); c.parentNode = null; },
|
|
40
|
+
insertBefore(c) { return el.appendChild(c); },
|
|
41
|
+
focus() { focused.el = el; },
|
|
42
|
+
closest() { return null; },
|
|
43
|
+
querySelector(sel) { return el.querySelectorAll(sel)[0] || null; },
|
|
44
|
+
querySelectorAll(sel) { return fromMarkup(el, sel); },
|
|
45
|
+
set innerHTML(v) { el._html = String(v); el._kids = null; }, get innerHTML() { return el._html; },
|
|
46
|
+
};
|
|
47
|
+
return el;
|
|
48
|
+
}
|
|
49
|
+
const focused = { el: null };
|
|
50
|
+
// Buttons and inputs parsed out of an element's markup, built once so a state
|
|
51
|
+
// written to one (aria-pressed, checked) is the state read back later.
|
|
52
|
+
function kids(el) {
|
|
53
|
+
if (el._kids) return el._kids;
|
|
54
|
+
el._kids = [...el._html.matchAll(/<(button|input)\b([^>]*)>/g)].map((m) => {
|
|
55
|
+
const k = makeEl(m[1].toUpperCase());
|
|
56
|
+
for (const a of m[2].matchAll(/([a-z-]+)(?:="([^"]*)")?/g)) {
|
|
57
|
+
if (a[1] === 'class') k.className = a[2]; else k.setAttribute(a[1], a[2] == null ? '' : a[2]);
|
|
58
|
+
}
|
|
59
|
+
k.checked = k.hasAttribute('checked');
|
|
60
|
+
k.closest = () => k;
|
|
61
|
+
return k;
|
|
62
|
+
});
|
|
63
|
+
return el._kids;
|
|
64
|
+
}
|
|
65
|
+
function fromMarkup(el, sel) {
|
|
66
|
+
const all = kids(el);
|
|
67
|
+
let m;
|
|
68
|
+
if ((m = /^\[([a-z-]+)\]$/.exec(sel))) return all.filter((k) => k.hasAttribute(m[1]));
|
|
69
|
+
if ((m = /^#craft-switch \[([a-z-]+)\]$/.exec(sel))) return all.filter((k) => k.hasAttribute(m[1]));
|
|
70
|
+
if ((m = /^\.([a-z-]+)$/.exec(sel))) return all.filter((k) => k.className.split(/\s+/).includes(m[1]));
|
|
71
|
+
if (sel === 'button, input') return all;
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
28
74
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
})
|
|
75
|
+
// One page load. `session` is the tab's sessionStorage and `store` the
|
|
76
|
+
// browser's localStorage: pass the same objects to a second boot and it is a
|
|
77
|
+
// reload; a fresh `session` with the same `store` is a new browser session.
|
|
78
|
+
function boot({ craft = true, session = {}, store = {}, throws = false, optOut = false, narrow = false } = {}) {
|
|
79
|
+
const events = [];
|
|
80
|
+
const root = makeEl('HTML');
|
|
81
|
+
const topbar = makeEl('DIV');
|
|
82
|
+
const sidebar = makeEl('NAV');
|
|
83
|
+
const body = makeEl('BODY');
|
|
84
|
+
body.dataset.page = 'work';
|
|
85
|
+
if (optOut) body.setAttribute('data-craft-mode', 'off');
|
|
86
|
+
const els = { 'app-topbar': topbar, 'app-sidebar': sidebar };
|
|
87
|
+
const docListeners = {};
|
|
88
|
+
const documentObj = {
|
|
89
|
+
body, referrer: '', documentElement: root, activeElement: null,
|
|
90
|
+
getElementById(id) {
|
|
91
|
+
if (els[id]) return els[id];
|
|
92
|
+
if (id === 'craft-switch') {
|
|
93
|
+
if (!topbar._html.includes('id="craft-switch"')) return null;
|
|
94
|
+
const sw = els[id] = makeEl('DIV', topbar._html.match(/<div class="craft-switch[\s\S]*?<\/div>/)[0]);
|
|
95
|
+
return sw;
|
|
96
|
+
}
|
|
97
|
+
if (/^(fullscreen-toggle|theme-toggle|zoom-toggle|topbar-menu|topbar-title|topbar-jump|topbar-back|founding-band|fullscreen-hint|skip-link|app-foot|app-scrim|shell-signin|shell-user|logout-link|craft-picker)$/.test(id)) {
|
|
98
|
+
if (id === 'craft-picker') return body.children.find((c) => c.id === 'craft-picker') || null;
|
|
99
|
+
return topbar._html.includes(`id="${id}"`) ? (els[id] = els[id] || makeEl('X')) : null;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
},
|
|
103
|
+
querySelector: () => null,
|
|
104
|
+
querySelectorAll: (sel) => (sel.startsWith('#craft-switch') && els['craft-switch'] ? els['craft-switch'].querySelectorAll(sel) : []),
|
|
105
|
+
createElement: (t) => makeEl(t.toUpperCase()),
|
|
106
|
+
addEventListener(t, fn) { (docListeners[t] ||= []).push(fn); },
|
|
107
|
+
removeEventListener(t, fn) { docListeners[t] = (docListeners[t] || []).filter((f) => f !== fn); },
|
|
108
|
+
dispatchEvent(ev) { events.push(ev); return true; },
|
|
109
|
+
get cookie() { return ''; }, set cookie(_v) {},
|
|
110
|
+
};
|
|
111
|
+
const location = { hostname: 'h', origin: 'https://h', href: 'https://h/work', pathname: '/work', hash: '', search: '', reloads: 0, assign() { this.reloads++; }, replace() { this.reloads++; }, reload() { this.reloads++; } };
|
|
112
|
+
const mk = (obj) => ({
|
|
113
|
+
getItem: (k) => { if (throws) throw new Error('SecurityError'); return k in obj ? obj[k] : null; },
|
|
114
|
+
setItem: (k, v) => { if (throws) throw new Error('SecurityError'); obj[k] = String(v); },
|
|
115
|
+
removeItem: (k) => { if (throws) throw new Error('SecurityError'); delete obj[k]; },
|
|
116
|
+
});
|
|
117
|
+
const windowObj = {
|
|
118
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
119
|
+
BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
|
|
120
|
+
matchMedia: (q) => ({ matches: narrow && /max-width: 640px/.test(q), addEventListener() {} }),
|
|
121
|
+
localStorage: mk(store), sessionStorage: mk(session), location,
|
|
122
|
+
clearTimeout() {}, setTimeout() { return 0; }, requestAnimationFrame() {}, scrollTo() {},
|
|
123
|
+
addEventListener() {}, dispatchEvent() { return true; },
|
|
124
|
+
};
|
|
125
|
+
const sandbox = {
|
|
126
|
+
window: windowObj, document: documentObj, location, console, URL, URLSearchParams, queueMicrotask,
|
|
127
|
+
setTimeout() { return 0; }, clearTimeout() {},
|
|
128
|
+
Event: class { constructor(t) { this.type = t; } },
|
|
129
|
+
CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } },
|
|
130
|
+
};
|
|
131
|
+
sandbox.globalThis = sandbox;
|
|
132
|
+
vm.createContext(sandbox);
|
|
133
|
+
vm.runInContext(TRAIL, sandbox, { filename: 'shell-trail.js' });
|
|
134
|
+
if (craft) vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
|
|
135
|
+
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
136
|
+
const shell = windowObj.OTBShell;
|
|
137
|
+
const picker = () => body.children.find((c) => c.id === 'craft-picker') || null;
|
|
138
|
+
const sw = () => documentObj.getElementById('craft-switch');
|
|
139
|
+
return {
|
|
140
|
+
root, topbar, sidebar, body, session, store, location, events,
|
|
141
|
+
mode: () => root.dataset.craft,
|
|
142
|
+
picker,
|
|
143
|
+
signIn: (builder = { id: 7, rank: 'thetes' }) => shell.applyAccess({ authed: true, rank: builder.rank, builder }),
|
|
144
|
+
signOut: () => shell.applyAccess({ authed: false }),
|
|
145
|
+
// Press a picker button: a door, Legacy, or the close button.
|
|
146
|
+
pick(attr, value, { askAgain = true } = {}) {
|
|
147
|
+
const p = picker();
|
|
148
|
+
const box = p.querySelector('[data-craft-again]');
|
|
149
|
+
if (box) box.checked = askAgain;
|
|
150
|
+
const btn = p.querySelectorAll(`[${attr}]`).find((b) => value == null || b.getAttribute(attr) === value);
|
|
151
|
+
assert.ok(btn, `the picker renders a [${attr}${value ? '=' + value : ''}] button`);
|
|
152
|
+
p.fire('click', { target: btn });
|
|
153
|
+
},
|
|
154
|
+
press(mode) {
|
|
155
|
+
const btn = sw().querySelectorAll('[data-craft-opt]').find((b) => b.getAttribute('data-craft-opt') === mode);
|
|
156
|
+
assert.ok(btn, `the switch renders a ${mode} option`);
|
|
157
|
+
sw().fire('click', { target: btn });
|
|
158
|
+
},
|
|
159
|
+
pressed: () => sw().querySelectorAll('[data-craft-opt]').filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.getAttribute('data-craft-opt')),
|
|
160
|
+
escape() { for (const fn of docListeners.keydown || []) fn({ key: 'Escape', preventDefault() {} }); },
|
|
161
|
+
};
|
|
162
|
+
}
|
|
33
163
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
assert.ok(names.length >= 3, 'the registry was read at all');
|
|
37
|
-
for (const d of names) {
|
|
38
|
-
const m = craft.describeCraftMode(d, MODES);
|
|
39
|
-
assert.ok(m && m.inject, `${d} injects`);
|
|
40
|
-
assert.equal(m.discipline, d);
|
|
41
|
-
const pointer = MODES[d].pack || `/${MODES[d].skill}`;
|
|
42
|
-
assert.ok(m.contract.includes(pointer), `${d} names ${pointer}`);
|
|
43
|
-
assert.ok(m.contract.includes(MODES[d].directive.slice(0, 40)), `${d} carries its directive`);
|
|
44
|
-
}
|
|
45
|
-
assert.match(craft.describeCraftMode('engineer', MODES).contract, /read docs\/packs\/engineer\.md/);
|
|
46
|
-
assert.match(craft.describeCraftMode('ui', MODES).contract, /— \/design/, 'a skill is a command to invoke, not a file to read');
|
|
47
|
-
});
|
|
164
|
+
const MODES = ['building', 'artistry', 'ideating', 'governing', 'legacy'];
|
|
165
|
+
const noSwitch = (html) => html.replace(/<div class="craft-switch[\s\S]*?<\/div>/, '');
|
|
48
166
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
assert.equal(
|
|
53
|
-
assert.deepEqual(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert.
|
|
167
|
+
test('NO MODE IS THE HALL AS TODAY: with no mode chosen, nothing but the switch differs from a hall without craft modes', () => {
|
|
168
|
+
const before = boot({ craft: false });
|
|
169
|
+
const after = boot({ craft: true });
|
|
170
|
+
assert.equal(after.mode(), undefined, 'no html[data-craft]');
|
|
171
|
+
assert.deepEqual({ ...after.root.dataset }, { ...before.root.dataset }, 'the root carries exactly the attributes it did');
|
|
172
|
+
assert.equal(after.sidebar.innerHTML, before.sidebar.innerHTML, 'the sidebar is byte-identical');
|
|
173
|
+
assert.equal(noSwitch(after.topbar.innerHTML), before.topbar.innerHTML, 'the top bar differs by the switch alone');
|
|
174
|
+
assert.ok(after.topbar.innerHTML.includes('id="craft-switch"'), 'and the switch is there to choose one');
|
|
175
|
+
assert.equal(after.picker(), null, 'nobody is asked before a signed-in builder is known');
|
|
57
176
|
});
|
|
58
177
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
assert.
|
|
178
|
+
test('a signed-in builder is asked ONCE per session, and a reload does not ask again', () => {
|
|
179
|
+
const session = {};
|
|
180
|
+
const p = boot({ session });
|
|
181
|
+
p.signIn();
|
|
182
|
+
assert.ok(p.picker(), 'the picker opens');
|
|
183
|
+
const html = p.picker().innerHTML;
|
|
184
|
+
assert.match(html, /role="dialog" aria-modal="true"/);
|
|
185
|
+
assert.match(html, /What are you doing now\?/);
|
|
186
|
+
for (const m of ['building', 'artistry', 'ideating', 'governing']) assert.match(html, new RegExp(`data-craft-pick="${m}"`), `a ${m} door`);
|
|
187
|
+
assert.match(html, /data-craft-pick="legacy"[^>]*>Legacy mode/, 'Legacy mode is the fifth choice (owner, 2026-10-01)');
|
|
188
|
+
p.escape();
|
|
189
|
+
assert.equal(p.picker(), null, 'Escape closes it without choosing');
|
|
190
|
+
assert.equal(p.mode(), undefined, 'and leaves the hall with no mode');
|
|
191
|
+
const reload = boot({ session });
|
|
192
|
+
reload.signIn();
|
|
193
|
+
assert.equal(reload.picker(), null, 'the same session is not asked twice');
|
|
67
194
|
});
|
|
68
195
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert.
|
|
73
|
-
assert.match(me, /craft_mode: describeCraftMode\(c\.discipline, craftModes\)/);
|
|
74
|
-
assert.doesNotMatch(me, /"modes"\s*:/, 'no second copy of the registry in the route');
|
|
196
|
+
test('a visitor who is not signed in is never asked', () => {
|
|
197
|
+
const p = boot();
|
|
198
|
+
p.signOut();
|
|
199
|
+
assert.equal(p.picker(), null);
|
|
75
200
|
});
|
|
76
201
|
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
202
|
+
test('choosing a door sets html[data-craft] for every page of the session, and says so on document', () => {
|
|
203
|
+
const session = {}, store = {};
|
|
204
|
+
const p = boot({ session, store });
|
|
205
|
+
p.signIn();
|
|
206
|
+
p.pick('data-craft-pick', 'ideating');
|
|
207
|
+
assert.equal(p.picker(), null, 'the picker closes');
|
|
208
|
+
assert.equal(p.mode(), 'ideating');
|
|
209
|
+
assert.ok(p.events.some((e) => e.type === 'otb:craft' && e.detail.mode === 'ideating'), 'otb:craft fires with the mode');
|
|
210
|
+
assert.deepEqual(p.pressed(), ['ideating'], 'the switch lights the chosen mode');
|
|
211
|
+
const next = boot({ session, store });
|
|
212
|
+
assert.equal(next.mode(), 'ideating', 'the next page of the session reads it before /me answers');
|
|
213
|
+
assert.equal(store['hall-craft-keep'], undefined, '"Ask me again next session" was ticked, so nothing outlives the session');
|
|
214
|
+
const tomorrow = boot({ store });
|
|
215
|
+
tomorrow.signIn();
|
|
216
|
+
assert.equal(tomorrow.mode(), undefined, 'a new session starts with no mode');
|
|
217
|
+
assert.ok(tomorrow.picker(), 'and asks again');
|
|
218
|
+
});
|
|
80
219
|
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
220
|
+
test('unticking "Ask me again next session" keeps the mode for later sessions', () => {
|
|
221
|
+
const store = {};
|
|
222
|
+
const p = boot({ store });
|
|
223
|
+
p.signIn();
|
|
224
|
+
p.pick('data-craft-pick', 'governing', { askAgain: false });
|
|
225
|
+
assert.equal(store['hall-craft-keep'], 'governing');
|
|
226
|
+
const tomorrow = boot({ store });
|
|
227
|
+
tomorrow.signIn();
|
|
228
|
+
assert.equal(tomorrow.mode(), 'governing', 'the kept mode applies');
|
|
229
|
+
assert.equal(tomorrow.picker(), null, 'and nobody is asked');
|
|
85
230
|
});
|
|
86
231
|
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
assert.deepEqual(
|
|
91
|
-
|
|
92
|
-
assert.
|
|
93
|
-
|
|
232
|
+
test('THE HEADER SWITCH changes the mode without a reload, and offers all five', () => {
|
|
233
|
+
const p = boot();
|
|
234
|
+
const opts = [...p.topbar.innerHTML.matchAll(/data-craft-opt="([a-z]+)"/g)].map((m) => m[1]);
|
|
235
|
+
assert.deepEqual(opts, MODES, 'the four crafts, then Legacy');
|
|
236
|
+
p.press('artistry');
|
|
237
|
+
assert.equal(p.mode(), 'artistry');
|
|
238
|
+
p.press('legacy');
|
|
239
|
+
assert.equal(p.mode(), 'legacy', 'anyone can go back to the old view at any time');
|
|
240
|
+
assert.deepEqual(p.pressed(), ['legacy']);
|
|
241
|
+
assert.equal(p.location.reloads, 0, 'no navigation happened');
|
|
242
|
+
assert.equal(p.session['hall-craft'], 'legacy', 'and the session remembers it');
|
|
94
243
|
});
|
|
95
244
|
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
245
|
+
test('on a phone the switch shows only the current mode, and tapping it opens the picker', () => {
|
|
246
|
+
const session = { 'hall-craft': 'building' };
|
|
247
|
+
const p = boot({ session, narrow: true });
|
|
248
|
+
p.press('building');
|
|
249
|
+
assert.ok(p.picker(), 'the current mode opens the picker');
|
|
250
|
+
assert.equal(p.mode(), 'building', 'without changing the mode');
|
|
251
|
+
});
|
|
99
252
|
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
253
|
+
test('artists start on Artistry: the door is pre-selected and the picker says why', () => {
|
|
254
|
+
const p = boot();
|
|
255
|
+
p.signIn({ id: 3, rank: 'thetes', main_discipline: 'artist', preferred_disciplines: ['engineer', 'artist'] });
|
|
256
|
+
const html = p.picker().innerHTML;
|
|
257
|
+
assert.match(html, /craft-door--artistry craft-door--pre"/);
|
|
258
|
+
assert.match(html, /Artists start on Artistry\./);
|
|
259
|
+
const eng = boot();
|
|
260
|
+
eng.signIn({ id: 4, rank: 'thetes', main_discipline: 'engineer', preferred_disciplines: ['artist'] });
|
|
261
|
+
assert.doesNotMatch(eng.picker().innerHTML, /craft-door--pre/, 'a main role that is not artist pre-selects nothing');
|
|
262
|
+
const unset = boot();
|
|
263
|
+
unset.signIn({ id: 5, rank: 'thetes', main_discipline: null, preferred_disciplines: ['artist'] });
|
|
264
|
+
assert.match(unset.picker().innerHTML, /craft-door--artistry craft-door--pre"/, 'with no main role, the first craft decides');
|
|
265
|
+
});
|
|
107
266
|
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
assert.
|
|
113
|
-
assert.doesNotMatch(src, /Your pack:/);
|
|
267
|
+
test('storage that throws is never asked and never wears a mode', () => {
|
|
268
|
+
const p = boot({ throws: true });
|
|
269
|
+
p.signIn();
|
|
270
|
+
assert.equal(p.picker(), null, 'it could not remember being asked, so it would ask on every page');
|
|
271
|
+
assert.equal(p.mode(), undefined);
|
|
114
272
|
});
|
|
115
273
|
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const lines = capture(() => claimCli.printRoleLine({ discipline: 'ui' }));
|
|
123
|
-
assert.equal(calls.length, 1, 'the ui claim landed its skills');
|
|
124
|
-
assert.equal(lines[0], '▶ UI design session — /design');
|
|
125
|
-
assert.match(lines[1], /2 module-owned skill\(s\)/);
|
|
126
|
-
calls.length = 0;
|
|
127
|
-
capture(() => claimCli.printRoleLine({ discipline: 'engineer' }));
|
|
128
|
-
assert.equal(calls.length, 0, 'a craft that did not opt in lands nothing');
|
|
129
|
-
} finally {
|
|
130
|
-
materialize.materializeModuleSkillsIntoCore = saved.into;
|
|
131
|
-
materialize.isCoreCheckoutRoot = saved.root;
|
|
132
|
-
}
|
|
274
|
+
test('a page can opt out with <body data-craft-mode="off">', () => {
|
|
275
|
+
const p = boot({ optOut: true, session: { 'hall-craft': 'artistry' } });
|
|
276
|
+
p.signIn();
|
|
277
|
+
assert.doesNotMatch(p.topbar.innerHTML, /craft-switch/, 'no switch');
|
|
278
|
+
assert.equal(p.picker(), null, 'no picker');
|
|
279
|
+
assert.equal(p.mode(), undefined, 'no mode');
|
|
133
280
|
});
|
|
134
281
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
assert.
|
|
138
|
-
assert.deepEqual(capture(() => claimCli.printRoleLine(null)), []);
|
|
282
|
+
test('a stored value that is not a mode is ignored, not trusted', () => {
|
|
283
|
+
const p = boot({ session: { 'hall-craft': '"><img src=x>' }, store: { 'hall-craft-keep': 'admin' } });
|
|
284
|
+
assert.equal(p.mode(), undefined);
|
|
139
285
|
});
|
|
140
286
|
|
|
141
|
-
|
|
287
|
+
test('every hall page loads shell-trail.js and craft-mode.js right before shell.js, deferred alike', () => {
|
|
288
|
+
const pages = fs.readdirSync(HALL).filter((f) => f.endsWith('.html') && read(f).includes('/builders/shell.js'));
|
|
289
|
+
assert.ok(pages.length >= 30, `found ${pages.length} hall pages`);
|
|
290
|
+
for (const f of pages) {
|
|
291
|
+
const tags = [...read(f).matchAll(/<script src="\/builders\/([a-z-]+)\.js[^"]*"( defer)?><\/script>/g)].map((m) => [m[1], !!m[2]]);
|
|
292
|
+
const at = tags.findIndex((t) => t[0] === 'shell');
|
|
293
|
+
assert.deepEqual(tags.slice(at - 2, at + 1).map((t) => t[0]), ['shell-trail', 'craft-mode', 'shell'], f);
|
|
294
|
+
assert.equal(new Set(tags.slice(at - 2, at + 1).map((t) => t[1])).size, 1, `${f}: all three load the same way`);
|
|
295
|
+
}
|
|
296
|
+
});
|
package/tests/goal_map_page.mjs
CHANGED
|
@@ -431,9 +431,9 @@ await test('the page is the sky\'s own surface — the same sheet and the same s
|
|
|
431
431
|
assert.match(html, /<main id="hall-main" class="sky-stage">/, 'the sky is drawn in the stage, the skip link\'s target');
|
|
432
432
|
const scripts = (h) => [...h.matchAll(/<script src="\/builders\/([^"?]+)\?v=([^"]+)"/g)].map((m) => [m[1], m[2]]);
|
|
433
433
|
// criterion-uat-ui.js (task 1004402): the rings' UAT state words, shared with the goal page.
|
|
434
|
-
assert.deepEqual(scripts(html).filter((s) =>
|
|
434
|
+
assert.deepEqual(scripts(html).filter((s) => !['shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0])).map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
|
|
435
435
|
// Both doors load the shell (tasks 1004327, 1004430); the SKY's files are the same.
|
|
436
|
-
const skyScripts = (h) => scripts(h).filter((s) =>
|
|
436
|
+
const skyScripts = (h) => scripts(h).filter((s) => !['shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0]));
|
|
437
437
|
assert.deepEqual(skyScripts(html), skyScripts(thinking), 'the two doors load the same sky files at the same stamp');
|
|
438
438
|
assert.equal(html.match(/thinking\.css\?v=([^"]+)/)[1], thinking.match(/thinking\.css\?v=([^"]+)/)[1]);
|
|
439
439
|
assert.match(read('thinking.css'), /body\[data-page\] \.sky-stage \{/, 'the sheet dresses the one stage both doors draw in inside the hall');
|
package/tests/hall_city.mjs
CHANGED
|
@@ -253,7 +253,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
|
|
|
253
253
|
for (const id of ['app-sidebar', 'app-scrim', 'app-topbar']) assert.match(html, new RegExp(`id="${id}"`), id);
|
|
254
254
|
assert.match(html, /<main id="hall-main" class="city-main">\s*<div class="city-top">/, 'the stage is the one <main>, the strip inside it');
|
|
255
255
|
const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
|
|
256
|
-
assert.deepEqual(scripts, ['shell', 'palette', 'city-draw', 'city'], 'the shell first, as on every hall page');
|
|
256
|
+
assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'city'], 'the shell first, as on every hall page');
|
|
257
257
|
const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
258
258
|
assert.match(css, /\.city-main \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
|
|
259
259
|
assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
|
|
@@ -22,6 +22,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
22
22
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
23
|
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
24
24
|
const SHELL = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
|
|
25
|
+
// task 1004492: the way back lives in shell-trail.js, which every page loads first.
|
|
26
|
+
const TRAIL = fs.readFileSync(path.join(HALL, 'shell-trail.js'), 'utf8');
|
|
25
27
|
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
26
28
|
|
|
27
29
|
// Boot the shell once — one page load. `store` is the browser's localStorage and
|
|
@@ -33,7 +35,8 @@ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
|
33
35
|
// <body data-back-fallback> and `exits` how many [data-shell-exit] links the
|
|
34
36
|
// page placed for the shell to fill (task 1004477).
|
|
35
37
|
function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = false,
|
|
36
|
-
referrer = '', search = '', session = {}, fallback = null, exits = 0, path = null, meId = null
|
|
38
|
+
referrer = '', search = '', session = {}, fallback = null, exits = 0, path = null, meId = null,
|
|
39
|
+
hash = '', sessionThrows = false } = {}) {
|
|
37
40
|
const els = new Map();
|
|
38
41
|
const windowListeners = {};
|
|
39
42
|
const timers = [];
|
|
@@ -87,11 +90,13 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
|
|
|
87
90
|
addEventListener() {}, dispatchEvent() {},
|
|
88
91
|
get cookie() { return ''; }, set cookie(_v) {},
|
|
89
92
|
};
|
|
90
|
-
const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash
|
|
93
|
+
const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash, search, assign() {}, replace() {} };
|
|
94
|
+
// `session` is the TAB's sessionStorage: pass one object to several boots and
|
|
95
|
+
// they are page loads in one tab (the trail, task 1004476).
|
|
91
96
|
const sessionStorage = {
|
|
92
|
-
getItem: (k) => (k in session ? session[k] : null
|
|
93
|
-
setItem: (k, v) => { session[k] = String(v); },
|
|
94
|
-
removeItem: (k) => { delete session[k]; },
|
|
97
|
+
getItem: (k) => { if (sessionThrows) throw new Error('SecurityError'); return k in session ? session[k] : null; },
|
|
98
|
+
setItem: (k, v) => { if (sessionThrows) throw new Error('SecurityError'); session[k] = String(v); },
|
|
99
|
+
removeItem: (k) => { if (sessionThrows) throw new Error('SecurityError'); delete session[k]; },
|
|
95
100
|
};
|
|
96
101
|
const localStorage = {
|
|
97
102
|
getItem: (k) => { if (storageThrows) throw new Error('SecurityError'); return k in store ? store[k] : null; },
|
|
@@ -118,6 +123,7 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
|
|
|
118
123
|
};
|
|
119
124
|
sandbox.globalThis = sandbox;
|
|
120
125
|
vm.createContext(sandbox);
|
|
126
|
+
vm.runInContext(TRAIL, sandbox, { filename: 'shell-trail.js' });
|
|
121
127
|
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
122
128
|
const root = documentObj.documentElement;
|
|
123
129
|
return {
|
|
@@ -131,6 +137,11 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
|
|
|
131
137
|
shell: () => windowObj.OTBShell,
|
|
132
138
|
session,
|
|
133
139
|
resizes: () => resizes,
|
|
140
|
+
// A hash route moving without a page load (a goal opened on /goals).
|
|
141
|
+
go(h) { location.hash = h; for (const fn of windowListeners.hashchange || []) fn({ type: 'hashchange' }); },
|
|
142
|
+
trail: () => JSON.parse(session['hall-nav-trail'] || '[]').map((c) => c.label),
|
|
143
|
+
crumbs: () => [...topbar._html.matchAll(/class="topbar-crumb" href="([^"]*)">([^<]*)</g)].map((m) => `${m[2]} ${m[1]}`),
|
|
144
|
+
back: () => { const m = /id="topbar-back" href="([^"]*)"[^>]*>(?:<svg[\s\S]*?<\/svg>)?<span class="topbar-back__label">([^<]*)</.exec(topbar._html); return m ? `${m[2]} ${m[1]}` : null; },
|
|
134
145
|
// Press a key the way the browser dispatches it: every window listener,
|
|
135
146
|
// then the page may mark the press used, then the queued timers run.
|
|
136
147
|
press(key, { target = { tagName: 'CANVAS' }, pageUses = false } = {}) {
|
|
@@ -277,6 +288,115 @@ test('Back: an origin\'s own ?from= is dropped, so one page\'s way back never ch
|
|
|
277
288
|
assert.match(p.topbar(), /href="\/roster\?q=a"/);
|
|
278
289
|
});
|
|
279
290
|
|
|
291
|
+
// THE TRAIL (task 1004476). The owner's case: Goals -> goal 12 -> task 340 ->
|
|
292
|
+
// an idea shows "Goals › goal 12 › task 340", each a link, and Back is the last.
|
|
293
|
+
// One `session` object across boots is one browser tab.
|
|
294
|
+
function walkToIdea() {
|
|
295
|
+
const tab = {};
|
|
296
|
+
const goals = boot({ page: 'goals', optIn: false, path: '/goals', session: Object.assign(tab, { 'hall-nav-hop': '/goals' }) });
|
|
297
|
+
goals.go('#/goal/12');
|
|
298
|
+
boot({ page: 'task', optIn: false, path: '/task/340', search: '?from=' + encodeURIComponent('/goals#/goal/12'), fallback: '/work', session: tab });
|
|
299
|
+
const idea = boot({ page: 'idea', optIn: false, path: '/idea/4', referrer: 'https://h/task/340', fallback: '/thinking', session: tab });
|
|
300
|
+
return { tab, idea };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
test('trail: a drill-down two layers deep shows every step, each a link, and Back is the last', () => {
|
|
304
|
+
const { tab, idea } = walkToIdea();
|
|
305
|
+
assert.deepEqual(idea.crumbs(), ['Goals /goals', 'goal 12 /goals#/goal/12']);
|
|
306
|
+
assert.equal(idea.back(), 'task 340 /task/340', 'the step before this page is still the Back link');
|
|
307
|
+
assert.match(idea.topbar(), /<nav class="topbar-trail topbar-trail--long" aria-label="Breadcrumb">/);
|
|
308
|
+
assert.deepEqual(JSON.parse(tab['hall-nav-trail']).map((c) => c.label), ['Goals', 'goal 12', 'task 340', 'idea 4']);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('trail: one step back is exactly the Back link it always was', () => {
|
|
312
|
+
const tab = {};
|
|
313
|
+
boot({ page: 'roster', optIn: false, path: '/roster', session: Object.assign(tab, { 'hall-nav-hop': '/roster' }) });
|
|
314
|
+
const p = boot({ page: 'profile', optIn: false, search: '?id=7', referrer: 'https://h/roster', session: tab });
|
|
315
|
+
assert.doesNotMatch(p.topbar(), /topbar-trail|topbar-crumb/);
|
|
316
|
+
assert.match(p.topbar(), /<a class="topbar-back" id="topbar-back" href="\/roster" title="Back to Builders"><svg/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('trail: returning to a crumb trims everything after it, and a reload keeps it', () => {
|
|
320
|
+
const { tab } = walkToIdea();
|
|
321
|
+
const goal = boot({ page: 'goals', optIn: false, path: '/goals', hash: '#/goal/12', referrer: 'https://h/idea/4', session: tab });
|
|
322
|
+
assert.deepEqual(goal.crumbs(), [], 'one step left, so just Back');
|
|
323
|
+
assert.equal(goal.back(), 'Goals /goals');
|
|
324
|
+
assert.deepEqual(goal.trail(), ['Goals', 'goal 12']);
|
|
325
|
+
// A reload keeps its referrer: the task opened from the goal again.
|
|
326
|
+
const task = boot({ page: 'task', optIn: false, path: '/task/340', referrer: 'https://h/goals', fallback: '/work', session: tab });
|
|
327
|
+
const again = boot({ page: 'task', optIn: false, path: '/task/340', referrer: 'https://h/goals', fallback: '/work', session: tab });
|
|
328
|
+
for (const p of [task, again]) {
|
|
329
|
+
assert.deepEqual(p.crumbs(), ['Goals /goals'], 'a referrer has no #hash, and still finds goal 12');
|
|
330
|
+
assert.equal(p.back(), 'goal 12 /goals#/goal/12');
|
|
331
|
+
}
|
|
332
|
+
assert.deepEqual(again.trail(), ['Goals', 'goal 12', 'task 340']);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('trail: a hash route back to a crumb trims, and a view switch only renames the page', () => {
|
|
336
|
+
const tab = {};
|
|
337
|
+
const goals = boot({ page: 'goals', optIn: false, path: '/goals', session: Object.assign(tab, { 'hall-nav-hop': '/goals' }) });
|
|
338
|
+
goals.go('#/goal/12');
|
|
339
|
+
assert.deepEqual(goals.trail(), ['Goals', 'goal 12']);
|
|
340
|
+
goals.go('');
|
|
341
|
+
assert.deepEqual(goals.trail(), ['Goals'], 'back to the list is a return, not a step');
|
|
342
|
+
goals.go('#archived');
|
|
343
|
+
assert.deepEqual(goals.trail(), ['Goals'], 'a view of the same page is not a drill-down');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('trail: a sidebar hop starts over', () => {
|
|
347
|
+
const { tab } = walkToIdea();
|
|
348
|
+
const work = boot({ page: 'work', optIn: false, path: '/work', referrer: 'https://h/idea/4', session: Object.assign(tab, { 'hall-nav-hop': '/work' }) });
|
|
349
|
+
assert.doesNotMatch(work.topbar(), /topbar-back/);
|
|
350
|
+
assert.deepEqual(work.trail(), ['Tasks']);
|
|
351
|
+
const task = boot({ page: 'task', optIn: false, path: '/task/9', referrer: 'https://h/work', fallback: '/work', session: tab });
|
|
352
|
+
assert.deepEqual(task.crumbs(), []);
|
|
353
|
+
assert.equal(task.back(), 'Tasks /work');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('trail: an origin the trail does not know starts a fresh one by the ?from= rules', () => {
|
|
357
|
+
const { tab } = walkToIdea();
|
|
358
|
+
const p = boot({ page: 'profile', optIn: false, search: '?id=7&from=%2Froster', referrer: 'https://h/idea/4', session: tab });
|
|
359
|
+
assert.deepEqual(p.crumbs(), [], 'the old trail is not this page\'s way here');
|
|
360
|
+
assert.equal(p.back(), 'Builders /roster');
|
|
361
|
+
assert.deepEqual(p.trail(), ['Builders', 'Profile']);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('trail: capped at four crumbs, and on a phone the middle collapses', () => {
|
|
365
|
+
const tab = {};
|
|
366
|
+
boot({ page: 'goals', optIn: false, path: '/goals', session: Object.assign(tab, { 'hall-nav-hop': '/goals' }) });
|
|
367
|
+
let prev = '/goals';
|
|
368
|
+
let last;
|
|
369
|
+
for (const id of [1, 2, 3, 4, 5]) {
|
|
370
|
+
last = boot({ page: 'task', optIn: false, path: `/task/${id}`, referrer: `https://h${prev}`, fallback: '/work', session: tab });
|
|
371
|
+
prev = `/task/${id}`;
|
|
372
|
+
}
|
|
373
|
+
assert.deepEqual(last.crumbs(), ['task 1 /task/1', 'task 2 /task/2', 'task 3 /task/3'], 'Goals fell off the front');
|
|
374
|
+
assert.equal(last.back(), 'task 4 /task/4');
|
|
375
|
+
assert.equal(last.trail().length, 5, 'four crumbs plus this page');
|
|
376
|
+
const html = last.topbar();
|
|
377
|
+
assert.equal((html.match(/topbar-trail__step--mid/g) || []).length, 2, 'every crumb between the first and Back is the middle');
|
|
378
|
+
assert.match(html, /<nav class="topbar-trail topbar-trail--long"/);
|
|
379
|
+
assert.match(html, /<span class="topbar-trail__more"><a class="topbar-crumb topbar-crumb--more" href="\/task\/1" title="task 1 › task 2 › task 3" aria-label="task 1">…<\/a>/,
|
|
380
|
+
'the fold is a link to the start of the trail, naming what it hides');
|
|
381
|
+
const css = read('style.css');
|
|
382
|
+
assert.match(css, /@media \(max-width: 640px\) \{\s*\.topbar-trail__step--mid \{ display: none; \}\s*\.topbar-trail--long \.topbar-trail__more \{ display: inline-flex; \}/,
|
|
383
|
+
'source: on a phone the middle hides and "…" stands in');
|
|
384
|
+
assert.match(css, /@media \(max-width: 480px\) \{\s*\.topbar-trail__step--first \{ display: none; \}/, 'source: narrower, the first crumb folds too');
|
|
385
|
+
assert.match(css, /@media \(max-width: 360px\) \{\s*\.topbar-trail__mark, \.topbar-trail \.topbar-trail__more, \.topbar-trail \.topbar-back__label \{ display: none; \}/,
|
|
386
|
+
'source: narrowest, only the Back arrow is left');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('trail: a hostile stored crumb is dropped, and storage that throws keeps the one-step Back', () => {
|
|
390
|
+
const tab = { 'hall-nav-trail': JSON.stringify([{ href: '//evil.example', label: 'x' }, { href: '/api/gds/x', label: 'y' },
|
|
391
|
+
{ href: 'javascript:alert(1)', label: 'z' }, { href: '/goals', label: '<b>Goals</b>' }]) };
|
|
392
|
+
const p = boot({ page: 'task', optIn: false, path: '/task/9', referrer: 'https://h/goals', fallback: '/work', session: tab });
|
|
393
|
+
assert.equal(p.back(), '<b>Goals</b> /goals', 'a stored label is escaped');
|
|
394
|
+
assert.deepEqual(JSON.parse(tab['hall-nav-trail']).map((c) => c.href), ['/goals', '/task/9']);
|
|
395
|
+
const locked = boot({ page: 'task', optIn: false, path: '/task/9', referrer: 'https://h/goals', fallback: '/work', sessionThrows: true });
|
|
396
|
+
assert.equal(locked.back(), 'Goals /goals');
|
|
397
|
+
assert.deepEqual(locked.crumbs(), []);
|
|
398
|
+
});
|
|
399
|
+
|
|
280
400
|
test('an opted-in page renders ONE switch in the top bar, off by default', () => {
|
|
281
401
|
const p = boot();
|
|
282
402
|
const btn = p.button();
|