@bongos/core 1.21.108 → 1.21.110

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.
@@ -0,0 +1,307 @@
1
+ // tests/craft_review_mode.mjs — artist review mode (task 1004686, goal 1000095).
2
+ //
3
+ // Owner ask, 2026-10-09: hovering the Review button in Artistry opens a menu of
4
+ // the four experiences (Ideator, Governing, Building, Artistry); picking one
5
+ // puts the artist into review mode on that mode's view, and graffiti is painted
6
+ // across the top bar's empty space while it lasts. These cases run the REAL
7
+ // craft-mode.js (and, when it is loaded, the real craft-review.js) over a small
8
+ // DOM, and read the real craft-review.css for the motion rules.
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import vm from 'node:vm';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
18
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
19
+ const CRAFT = read('craft-mode.js');
20
+ const REVIEW = read('craft-review.js');
21
+ const CSS = read('craft-review.css');
22
+ const STYLE = read('style.css');
23
+
24
+ function node(tag, id, cls) {
25
+ const attrs = {};
26
+ const listeners = {};
27
+ const classes = new Set(String(cls || '').split(/\s+/).filter(Boolean));
28
+ const el = {
29
+ tagName: tag, id: id || '', dataset: {}, children: [], parentNode: null, innerHTML: '', title: '', type: '', src: '', textContent: '',
30
+ get className() { return [...classes].join(' '); }, set className(v) { classes.clear(); String(v).split(/\s+/).filter(Boolean).forEach((c) => classes.add(c)); },
31
+ classList: {
32
+ add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c),
33
+ toggle: (c, on) => { if (on === undefined) on = !classes.has(c); if (on) classes.add(c); else classes.delete(c); return on; },
34
+ },
35
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
36
+ hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
37
+ get nextSibling() { const s = this.parentNode && this.parentNode.children; return s ? s[s.indexOf(this) + 1] || null : null; },
38
+ appendChild(c) { c.parentNode = this; this.children.push(c); if (this.onAppend) this.onAppend(c); return c; },
39
+ insertBefore(c, ref) { c.parentNode = this; const at = ref ? this.children.indexOf(ref) : -1; if (at === -1) this.children.push(c); else this.children.splice(at, 0, c); return c; },
40
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; },
41
+ contains(n) { for (let x = n; x; x = x.parentNode) if (x === el) return true; return false; },
42
+ querySelector() { return null; },
43
+ querySelectorAll() { return []; },
44
+ addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
45
+ fire(t, ev = {}) { (listeners[t] || []).forEach((fn) => fn({ target: el, preventDefault() {}, ...ev })); },
46
+ click(ev) { el.fire('click', ev); },
47
+ focus() {},
48
+ };
49
+ return el;
50
+ }
51
+ function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
52
+ const flush = () => new Promise((r) => setTimeout(r, 0));
53
+
54
+ // One page load. Pass the same `session` to a second boot and it is a reload.
55
+ function boot({ session = {}, store = {}, narrow = false } = {}) {
56
+ const sidebar = node('NAV', 'app-sidebar');
57
+ const topbar = node('DIV', 'app-topbar');
58
+ const title = topbar.appendChild(node('H2', 'topbar-title'));
59
+ const side = topbar.appendChild(node('DIV', '', 'topbar-side'));
60
+ const sw = side.appendChild(node('DIV', 'craft-switch'));
61
+ const head = node('HEAD');
62
+ const root = node('HTML');
63
+ const loaded = [];
64
+ const opened = [];
65
+ const docListeners = {};
66
+ const everything = () => [sidebar, topbar, ...all(sidebar), ...all(topbar)];
67
+ const document = {
68
+ documentElement: root, body: node('BODY'), head, activeElement: null,
69
+ currentScript: { src: 'https://h/builders/craft-mode.js?v=2026-10-09-test' },
70
+ getElementById: (id) => everything().find((n) => n.id === id) || null,
71
+ querySelector: (sel) => (sel === '#app-topbar > .topbar-side' ? side : null),
72
+ querySelectorAll: (sel) => (sel === '[data-craft-tool]' ? everything().filter((n) => n.hasAttribute('data-craft-tool')) : []),
73
+ createElement: (t) => node(t.toUpperCase()),
74
+ addEventListener(t, fn) { (docListeners[t] ||= new Set()).add(fn); },
75
+ removeEventListener(t, fn) { if (docListeners[t]) docListeners[t].delete(fn); },
76
+ dispatchEvent() { return true; },
77
+ };
78
+ const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
79
+ const window = { localStorage: mk(store), sessionStorage: mk(session), matchMedia: (q) => ({ matches: narrow && /max-width: 640px/.test(q) }), location: { search: '' } };
80
+ const sandbox = { window, document, Promise, setTimeout, clearTimeout, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
81
+ vm.createContext(sandbox);
82
+ // "Loading" a script: the wall runs the REAL craft-review.js; the palette is a fake panel.
83
+ head.onAppend = (tag) => {
84
+ loaded.push(tag.src);
85
+ if (/craft-review\.js/.test(tag.src)) vm.runInContext(REVIEW, sandbox, { filename: 'craft-review.js' });
86
+ else window.OTBPageNotes = { open: () => opened.push('open'), openNote: () => {} };
87
+ tag.onload();
88
+ };
89
+ vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
90
+ side.innerHTML = window.OTBCraftMode.switchHtml();
91
+ window.OTBCraftMode.mount();
92
+ const byId = (id) => document.getElementById(id);
93
+ return {
94
+ craft: window.OTBCraftMode, root, topbar, side, sw, title, loaded, opened, session, byId, docListeners,
95
+ tools: () => all(side).filter((n) => n.hasAttribute('data-craft-tool')).map((n) => n.id.replace('craft-tool-', '')),
96
+ menu: () => byId('craft-review-menu'),
97
+ menuOpen: () => !!byId('craft-review-menu') && !byId('craft-review-menu').hasAttribute('hidden'),
98
+ // A click on a menu item: the menu's own listener reads the button off the target.
99
+ pick: (attrs) => byId('craft-review-menu').fire('click', { target: { closest: () => ({ getAttribute: (k) => (k in attrs ? attrs[k] : null) }) } }),
100
+ };
101
+ }
102
+ const artist = () => ({ 'hall-craft': 'artistry', 'hall-craft-asked': '1' });
103
+
104
+ test('THE SPLIT PILL: in Artistry, Review rides a group with a review-mode chevron and a closed menu', () => {
105
+ const p = boot({ session: artist() });
106
+ const pill = p.byId('craft-review');
107
+ assert.ok(pill, 'the group exists');
108
+ assert.equal(p.byId('craft-tool-palette').parentNode, pill, 'Review sits in it');
109
+ const more = p.byId('craft-review-more');
110
+ assert.equal(more.parentNode, pill);
111
+ assert.equal(more.getAttribute('aria-haspopup'), 'menu');
112
+ assert.equal(more.getAttribute('aria-expanded'), 'false');
113
+ assert.equal(more.getAttribute('aria-controls'), 'craft-review-menu');
114
+ assert.equal(p.menu().getAttribute('role'), 'menu');
115
+ assert.ok(p.menu().hasAttribute('hidden'), 'the menu starts closed');
116
+ assert.equal(p.byId('craft-graffiti'), null, 'no wall until a review starts');
117
+ assert.equal(p.root.getAttribute('data-craft-review'), null);
118
+ for (const m of ['building', 'ideating', 'governing']) {
119
+ p.craft.choose(m);
120
+ assert.equal(p.byId('craft-review'), null, `no Review pill in ${m}`);
121
+ }
122
+ });
123
+
124
+ test('THE MENU: write feedback first, then the four experiences in the owner\'s order and words', () => {
125
+ const p = boot({ session: artist() });
126
+ p.byId('craft-review-more').click({ detail: 1 });
127
+ assert.ok(p.menuOpen(), 'the chevron opens it');
128
+ assert.equal(p.byId('craft-review-more').getAttribute('aria-expanded'), 'true');
129
+ const html = p.menu().innerHTML;
130
+ const labels = [...html.matchAll(/<span>([^<]+)<\/span>/g)].map((m) => m[1]);
131
+ assert.deepEqual(labels, ['Write feedback on this page', 'Ideator experience', 'Governing experience', 'Building experience', 'Artistry experience']);
132
+ assert.deepEqual([...html.matchAll(/data-review-pick="([a-z]+)"/g)].map((m) => m[1]), ['ideating', 'governing', 'building', 'artistry']);
133
+ assert.equal((html.match(/role="menuitemradio" aria-checked="true"/g) || []).length, 0, 'nothing is checked before a review');
134
+ assert.doesNotMatch(html, /Stop reviewing/, 'no way out of a review that has not started');
135
+ p.byId('craft-review-more').click({ detail: 1 });
136
+ assert.ok(!p.menuOpen(), 'the chevron closes it again');
137
+ });
138
+
139
+ test('HOVER opens it for a mouse, never for a touch, and leaving closes it after a beat', async () => {
140
+ const p = boot({ session: artist() });
141
+ const pill = p.byId('craft-review');
142
+ pill.fire('pointerenter', { pointerType: 'touch' });
143
+ assert.ok(!p.menuOpen(), 'a touch is not a hover');
144
+ pill.fire('pointerenter', { pointerType: 'mouse' });
145
+ assert.ok(p.menuOpen(), 'a mouse over the pill opens the menu');
146
+ pill.fire('pointerleave', { pointerType: 'mouse' });
147
+ assert.ok(p.menuOpen(), 'it stays a beat, so the pointer can reach the menu');
148
+ pill.fire('pointerenter', { pointerType: 'mouse' });
149
+ await new Promise((r) => setTimeout(r, 260));
150
+ assert.ok(p.menuOpen(), 'coming back in cancels the close');
151
+ pill.fire('pointerleave', { pointerType: 'mouse' });
152
+ await new Promise((r) => setTimeout(r, 260));
153
+ assert.ok(!p.menuOpen(), 'and leaving for good closes it');
154
+ });
155
+
156
+ test('CLICKING Review still opens the feedback panel; on a phone a tap opens the menu instead', async () => {
157
+ const p = boot({ session: artist() });
158
+ p.byId('craft-tool-palette').click({ detail: 1 });
159
+ await flush();
160
+ assert.deepEqual(p.opened, ['open'], 'the feedback doc opens, as before');
161
+ assert.ok(!p.menuOpen());
162
+ const phone = boot({ session: artist(), narrow: true });
163
+ phone.byId('craft-tool-palette').click({ detail: 1 });
164
+ await flush();
165
+ assert.deepEqual(phone.opened, [], 'no panel on the tap');
166
+ assert.ok(phone.menuOpen(), 'the menu instead (a phone has no hover)');
167
+ phone.pick({ 'data-review-act': 'write' });
168
+ await flush();
169
+ assert.deepEqual(phone.opened, ['open'], 'and its first line opens the feedback doc');
170
+ });
171
+
172
+ test('PICKING an experience: the hall wears that mode, the artist keeps the Review tools, and the wall goes up', async () => {
173
+ const p = boot({ session: artist() });
174
+ p.byId('craft-review-more').click({ detail: 1 });
175
+ p.pick({ 'data-review-pick': 'governing' });
176
+ assert.ok(!p.menuOpen(), 'the menu closes on a pick');
177
+ assert.equal(p.root.dataset.craft, 'governing', 'the hall wears Governing: its tabs, look and tallies follow');
178
+ assert.equal(p.root.getAttribute('data-craft-review'), 'governing');
179
+ assert.equal(p.craft.current(), 'governing');
180
+ assert.equal(p.craft.reviewing(), 'governing');
181
+ assert.equal(p.session['hall-craft'], 'artistry', 'the artist\'s own mode stays Artistry');
182
+ assert.equal(p.session['hall-craft-review'], 'governing', 'the review lasts the session');
183
+ assert.deepEqual(p.tools(), ['palette', 'paintbrush'], 'Review and Edit words stay in the bar');
184
+ assert.ok(p.byId('craft-tool-palette').classList.contains('craft-tool--reviewing'));
185
+ const wall = p.byId('craft-graffiti');
186
+ assert.ok(wall, 'the graffiti wall is up');
187
+ assert.equal(wall.parentNode, p.topbar, 'in the top bar');
188
+ assert.equal(wall.nextSibling, p.side, 'in the empty space before the controls');
189
+ assert.equal(wall.getAttribute('aria-hidden'), 'true', 'decorative');
190
+ await flush();
191
+ assert.deepEqual(p.loaded, ['/builders/craft-review.js?v=2026-10-09-test'], 'the wall\'s script loads on demand, at craft-mode.js\'s version');
192
+ assert.equal((wall.innerHTML.match(/class="gx-tag /g) || []).length, 6, 'six doodles');
193
+ assert.doesNotMatch(wall.innerHTML, /<text/, 'scribbles, not words (owner, 2026-10-09)');
194
+ p.byId('craft-review-more').click({ detail: 1 });
195
+ assert.match(p.menu().innerHTML, /aria-checked="true" data-review-pick="governing"/, 'the menu checks the live experience');
196
+ assert.match(p.menu().innerHTML, /data-review-act="stop"/, 'and offers a way out');
197
+ });
198
+
199
+ test('IT LASTS THE SESSION: a reload keeps the review; a new session starts without one', () => {
200
+ const session = { ...artist(), 'hall-craft-review': 'ideating' };
201
+ const p = boot({ session });
202
+ assert.equal(p.root.dataset.craft, 'ideating', 'a reload wears the experience under review');
203
+ assert.match(p.side.innerHTML, /data-craft-opt="ideating" aria-pressed="true"/, 'the switch shows the experience being worn');
204
+ assert.ok(p.byId('craft-graffiti'), 'the wall goes straight back up');
205
+ const fresh = boot({ session: {}, store: { 'hall-craft-keep': 'artistry' } });
206
+ assert.equal(fresh.root.dataset.craft, 'artistry', 'a new session is the artist\'s own Artistry');
207
+ assert.equal(fresh.byId('craft-graffiti'), null);
208
+ const notArtist = boot({ session: { 'hall-craft': 'building', 'hall-craft-asked': '1', 'hall-craft-review': 'governing' } });
209
+ assert.equal(notArtist.root.dataset.craft, 'building', 'a stored review outside Artistry is ignored: only an artist reviews');
210
+ assert.equal(notArtist.craft.reviewing(), null);
211
+ const junk = boot({ session: { ...artist(), 'hall-craft-review': 'legacy' } });
212
+ assert.equal(junk.root.dataset.craft, 'artistry', 'a review that is not one of the four reads as none');
213
+ });
214
+
215
+ test('REVIEWING Ideating shows its own tool too, marked a guest so a tiny phone can fold it away', () => {
216
+ const p = boot({ session: artist() });
217
+ p.craft.review('ideating');
218
+ assert.deepEqual(p.tools(), ['palette', 'paintbrush', 'lightbulb'], 'the artist\'s tools, then the experience\'s own, in order');
219
+ assert.ok(p.byId('craft-tool-lightbulb').classList.contains('craft-tool--guest'));
220
+ assert.ok(!p.byId('craft-tool-paintbrush').classList.contains('craft-tool--guest'));
221
+ p.craft.choose('ideating');
222
+ assert.deepEqual(p.tools(), ['lightbulb'], 'choosing Ideating for real drops the artist\'s tools');
223
+ assert.ok(!p.byId('craft-tool-lightbulb').classList.contains('craft-tool--guest'), 'and the lightbulb is no guest any more');
224
+ });
225
+
226
+ test('ENDING it: Stop reviewing, or any mode from the switch, puts the artist back and takes the wall down', () => {
227
+ const p = boot({ session: artist() });
228
+ p.craft.review('building');
229
+ assert.equal(p.root.dataset.craft, 'building');
230
+ p.byId('craft-review-more').click({ detail: 1 });
231
+ p.pick({ 'data-review-act': 'stop' });
232
+ assert.equal(p.root.dataset.craft, 'artistry', 'Stop reviewing is back in Artistry');
233
+ assert.equal(p.root.getAttribute('data-craft-review'), null);
234
+ assert.equal(p.byId('craft-graffiti'), null, 'the wall comes down');
235
+ assert.equal(p.session['hall-craft-review'], undefined, 'and the session forgets it');
236
+ assert.ok(!p.byId('craft-tool-palette').classList.contains('craft-tool--reviewing'));
237
+ p.craft.review('governing');
238
+ p.craft.choose('artistry');
239
+ assert.equal(p.craft.reviewing(), null, 'choosing a mode from the switch, even Artistry, ends a review');
240
+ assert.equal(p.byId('craft-graffiti'), null);
241
+ p.craft.review('governing');
242
+ p.craft.choose('governing');
243
+ assert.equal(p.root.dataset.craft, 'governing');
244
+ assert.equal(p.root.getAttribute('data-craft-review'), null, 'Governing chosen for real is not a review');
245
+ assert.equal(p.byId('craft-review'), null, 'and carries no Review pill');
246
+ });
247
+
248
+ test('THE KEYS: arrows move through the menu, Escape closes it, an outside click closes it', () => {
249
+ const p = boot({ session: artist() });
250
+ p.byId('craft-review-more').click({ detail: 1 });
251
+ assert.equal(p.docListeners.click && p.docListeners.click.size, 1, 'an open menu listens for a click outside');
252
+ p.menu().fire('keydown', { key: 'Escape' });
253
+ assert.ok(!p.menuOpen(), 'Escape closes it');
254
+ assert.equal(p.docListeners.click.size, 0, 'and stops listening');
255
+ p.byId('craft-review-more').fire('keydown', { key: 'ArrowDown' });
256
+ assert.ok(p.menuOpen(), 'ArrowDown on the chevron opens it');
257
+ [...p.docListeners.click][0]({ target: p.title });
258
+ assert.ok(!p.menuOpen(), 'a click elsewhere closes it');
259
+ p.byId('craft-tool-palette').fire('keydown', { key: 'ArrowDown' });
260
+ assert.ok(p.menuOpen(), 'ArrowDown on Review opens it too');
261
+ assert.match(CRAFT, /e\.key === 'ArrowUp'/);
262
+ assert.match(CRAFT, /e\.key === 'Home'/);
263
+ assert.match(CRAFT, /e\.key === 'End'/);
264
+ });
265
+
266
+ test('THE WALL is scribbled in CSS only, in ink alone, and stands still under reduced motion', () => {
267
+ assert.match(STYLE, /@import url\("craft-review\.css"\);/, 'style.css imports the sheet');
268
+ assert.match(STYLE, /@media \(prefers-reduced-motion: reduce\) \{\s*\*, \*::before, \*::after \{\s*animation: none !important;/, 'the hall\'s kill switch covers it');
269
+ assert.match(CSS, /\.gx-tag:not\(\.gx-tag--1\):not\(\.gx-tag--2\):not\(\.gx-tag--4\) \.gx-win \{ opacity: 0; \}/, 'the rest frame is three doodles, finished');
270
+ assert.match(CSS, /\.gx-track \{ animation-name: gx-track; opacity: 0; \}/, 'and no pen');
271
+ // Each @keyframes body, read to its matching brace.
272
+ const frames = [...CSS.matchAll(/@keyframes [\w-]+ \{/g)].map((m) => {
273
+ let depth = 1, i = m.index + m[0].length;
274
+ for (; i < CSS.length && depth; i++) depth += CSS[i] === '{' ? 1 : CSS[i] === '}' ? -1 : 0;
275
+ return CSS.slice(m.index + m[0].length, i - 1);
276
+ });
277
+ assert.equal(frames.length, 4, 'win, ink, track, scribble');
278
+ for (const f of frames) {
279
+ const props = [...f.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
280
+ for (const prop of props) assert.ok(['transform', 'opacity'].includes(prop), `a keyframe animates ${prop}`);
281
+ }
282
+ assert.doesNotMatch(CSS, /#[0-9a-fA-F]{3,8}\b|rgba?\(/, 'every colour is a token');
283
+ // Black and white (owner, 2026-10-09): the wall paints with the ink token alone.
284
+ const wall = CSS.slice(CSS.indexOf('/* ---------- the scribble wall'));
285
+ assert.ok(wall.length > 200, 'the wall section is found');
286
+ const paints = new Set([...wall.matchAll(/var\((--[a-z-]+)/g)].map((m) => m[1]).filter((v) => !v.startsWith('--gx-')));
287
+ assert.deepEqual([...paints], ['--ink'], 'every mark is ink: black on the light bar, white on the dark');
288
+ assert.doesNotMatch(REVIEW, /requestAnimationFrame|setInterval|\.animate\(/, 'no motion in JavaScript');
289
+ assert.match(CSS, /@media \(max-width: 359px\) \{ \.craft-tool\.craft-tool--guest \{ display: none; \} \}/, 'two classes, so style.css\'s later .craft-tool cannot win');
290
+ assert.match(CSS, /@media \(max-width: 640px\) \{ \.craft-graffiti \{ display: none; \} \}/, 'no wall on a phone');
291
+ });
292
+
293
+ test('craft-review.js draws six small sky doodles, each with its pen, the wide ones foldable', () => {
294
+ const sandbox = { window: {} };
295
+ vm.createContext(sandbox);
296
+ vm.runInContext(REVIEW, sandbox);
297
+ const api = sandbox.window.OTBCraftReview;
298
+ const html = api.wallHtml();
299
+ assert.equal(api.COUNT, 6);
300
+ assert.equal((html.match(/class="gx-nib"/g) || []).length, 6, 'a pen for every doodle');
301
+ assert.equal((html.match(/gx-tag--wide/g) || []).length, 2, 'the constellation and the shooting star fold away on a tight wall');
302
+ const widths = [...html.matchAll(/--gx-w:(\d+)px/g)].map((m) => Number(m[1]));
303
+ assert.equal(widths.length, 6);
304
+ assert.ok(widths.every((w) => w <= 56), 'small: no doodle is wider than 56px');
305
+ assert.equal((html.match(/height="24"/g) || []).length, 6, 'and none taller than 24px');
306
+ assert.doesNotMatch(html, /<text|style="[^"]*(fill|stroke|color)/, 'no words, and no colour written inline');
307
+ });
@@ -0,0 +1,81 @@
1
+ // tests/hall_preview_encoded_paths.mjs — the hall preview decodes the API path
2
+ // before matching fixtures and canned writes (task 1004700).
3
+ //
4
+ // The paintbrush sends its page id through encodeURIComponent, so builders:studio
5
+ // arrives as builders%3Astudio. Matched raw, every copy-desk read and canned write
6
+ // 404'd and the bar said "That page could not be taken just now". This boots the
7
+ // real harness and asks for both spellings.
8
+ //
9
+ // Run: node --test tests/hall_preview_encoded_paths.mjs
10
+
11
+ import { test, before, after } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { spawn } from 'node:child_process';
14
+ import net from 'node:net';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ let child;
20
+ let base;
21
+
22
+ function freePort() {
23
+ return new Promise((resolve, reject) => {
24
+ const s = net.createServer();
25
+ s.once('error', reject);
26
+ s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
27
+ });
28
+ }
29
+
30
+ before(async () => {
31
+ const port = await freePort();
32
+ base = `http://127.0.0.1:${port}/api/bongos/copy-desk/pages`;
33
+ child = spawn(process.execPath, ['scripts/hall-preview/server.js', '--port', String(port), '--fixture-me'], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
34
+ await new Promise((resolve, reject) => {
35
+ const t = setTimeout(() => reject(new Error('hall-preview did not start')), 60000);
36
+ child.stdout.on('data', (b) => { if (String(b).includes('hall-preview:')) { clearTimeout(t); resolve(); } });
37
+ child.once('exit', (code) => { clearTimeout(t); reject(new Error(`hall-preview exited ${code}`)); });
38
+ });
39
+ });
40
+
41
+ after(() => { if (child) child.kill(); });
42
+
43
+ async function call(method, id, tail) {
44
+ const r = await fetch(`${base}/${id}/${tail}`, { method, headers: { 'content-type': 'application/json' }, body: method === 'GET' ? undefined : '{}' });
45
+ return { status: r.status, body: await r.json() };
46
+ }
47
+
48
+ test('GET draft: an encoded page id gets the same fixture as the plain one', async () => {
49
+ const plain = await call('GET', 'builders:studio', 'draft');
50
+ const encoded = await call('GET', encodeURIComponent('builders:studio'), 'draft');
51
+ assert.equal(plain.status, 200);
52
+ assert.equal(encoded.status, 200);
53
+ assert.deepEqual(encoded.body, plain.body);
54
+ // and the catch-all, for a page with no fixture of its own
55
+ const other = await call('GET', encodeURIComponent('builders:work'), 'draft');
56
+ assert.equal(other.status, 200);
57
+ assert.deepEqual(other.body, (await call('GET', 'builders:work', 'draft')).body);
58
+ });
59
+
60
+ test('canned PUT draft: an encoded page id gets the same canned reply as the plain one', async () => {
61
+ const plain = await call('PUT', 'builders:studio', 'draft');
62
+ const encoded = await call('PUT', encodeURIComponent('builders:studio'), 'draft');
63
+ assert.equal(plain.status, 200);
64
+ assert.equal(encoded.status, 200);
65
+ assert.deepEqual(encoded.body, plain.body);
66
+ });
67
+
68
+ test('a decoded backslash or .. segment cannot walk out of the fixtures dir', async () => {
69
+ for (const id of ['..%5c..%5c..%5cpackage', '..%2F..%2Fpackage', 'a%00b']) {
70
+ const r = await call('GET', id, 'draft');
71
+ assert.equal(r.status, 400, id);
72
+ assert.equal(r.body.error.code, 'bad_path', id);
73
+ }
74
+ // the PUT path is checked before CANNED_WRITES too
75
+ assert.equal((await call('PUT', '..%5cx', 'draft')).status, 400);
76
+ });
77
+
78
+ test('a malformed escape does not crash the harness', async () => {
79
+ const r = await call('GET', 'builders%E0%A4%A', 'draft');
80
+ assert.equal(r.status, 404);
81
+ });
@@ -77,7 +77,10 @@ const flush = () => new Promise((r) => setTimeout(r, 0));
77
77
  test('THE PALETTE: in the top bar in Artistry alone, right before the switch, and gone when the mode changes', () => {
78
78
  const p = boot({ session: { 'hall-craft': 'artistry' } });
79
79
  assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette', 'craft-tool-paintbrush'], 'the palette, then the paintbrush (task 1004497)');
80
- assert.equal(p.tools()[0].nextSibling, p.tools()[1], 'the palette comes first');
80
+ // Task 1004686: the palette sits in its split pill (Review + the review-mode chevron).
81
+ const pill = p.tools()[0].parentNode;
82
+ assert.equal(pill.id, 'craft-review', 'the palette rides the Review split pill');
83
+ assert.equal(pill.nextSibling, p.tools()[1], 'the palette comes first');
81
84
  assert.equal(p.tools()[1].nextSibling, p.sw, 'and the pair sits right before the mode switch');
82
85
  assert.equal(p.tools()[0].getAttribute('aria-label'), 'Artist review: write feedback on this page', 'one name with the public site\'s button (task 1004499)');
83
86
  for (const m of ['building', 'governing']) {
@@ -83,12 +83,70 @@ test('the mechanics-map step names the subagent mechanism and the retro preceden
83
83
  assert.ok(section.includes('task 1003075'));
84
84
  });
85
85
 
86
- test('the step is scoped to process-change sessions, not every planning session', () => {
86
+ // task 1004675 (idea 1001074): the step used to read as an opt-in for
87
+ // process-change sessions only, so the sessions with a confident owner model
88
+ // skipped it. It is now the default; skipping is what needs a stated reason.
89
+ function phase15() {
87
90
  const start = NORM.indexOf('Phase 1.5 — Map the mechanism');
88
91
  const end = NORM.indexOf('Phase 2 — Criteria', start);
89
- const section = NORM.slice(start, end === -1 ? undefined : end);
90
- assert.ok(/process-change/i.test(section));
91
- assert.ok(/skip/i.test(section), 'says explicitly when to skip it');
92
+ return NORM.slice(start, end === -1 ? undefined : end);
93
+ }
94
+
95
+ test('the mapping step is the default for any existing surface, and skipping it needs a stated reason', () => {
96
+ const section = phase15();
97
+ assert.ok(!/process-change sessions only/i.test(section), 'no longer framed as opt-in for process-change sessions');
98
+ assert.match(section, /by default for any session that touches an existing surface/);
99
+ assert.match(section, /Skipping it is the thing that needs a stated reason/);
100
+ assert.match(section, /confident model/, 'names the confident-owner case as the one most likely to skip');
101
+ });
102
+
103
+ test('the mapping subagent is always asked the computable-quantity question', () => {
104
+ assert.match(phase15(), /Name any quantity this design assumes is computable, and say whether it actually is/);
105
+ });
106
+
107
+ // --- task 1004675: tie criteria to the agreed design --------------------------
108
+
109
+ function criteriaPhase() {
110
+ const start = NORM.indexOf('### Phase 2 — Criteria');
111
+ const end = NORM.indexOf('### Phase 2.5', start);
112
+ return NORM.slice(start, end);
113
+ }
114
+
115
+ test('Phase 2 says the grader reads the description and diff, not the spec', () => {
116
+ const s = criteriaPhase();
117
+ assert.match(s, /grader reads the \*\*task description and the diff\*\*/);
118
+ assert.match(s, /does \*\*not\*\* read `docs\/specs\/`/);
119
+ });
120
+
121
+ test('Phase 2 names the three bindings: model vs rendering, a proof a wrong build fails, cite the design of record', () => {
122
+ const s = criteriaPhase();
123
+ assert.match(s, /Split the settled design into MODEL and RENDERING/);
124
+ assert.match(s, /goes \*\*into the criterion text\*\*/, 'the model goes in the criterion');
125
+ assert.match(s, /stays in the spec, free to change/, 'the rendering stays in the spec');
126
+ assert.match(s, /proof whose scenario a wrong build fails/);
127
+ assert.match(s, /Cite the design of record in every task description, and make sure the builder can open it/);
128
+ assert.match(s, /private artifact link/, 'warns that an unopenable design of record is decorative');
129
+ });
130
+
131
+ test('Phase 3 carries the binding into the proof task and every task description', () => {
132
+ const start = NORM.indexOf('### Phase 3');
133
+ const s = NORM.slice(start, NORM.indexOf('### Phase 4', start));
134
+ assert.match(s, /The proof's scenario must be one a wrong build fails/);
135
+ assert.match(s, /every description cites the design of record/);
136
+ });
137
+
138
+ test('Phase 4 lays naming options side by side before recommending one', () => {
139
+ const start = NORM.indexOf('### Phase 4');
140
+ const s = NORM.slice(start, NORM.indexOf('### Phase 5', start));
141
+ assert.match(s, /show the options side by side before recommending one/);
142
+ assert.match(s, /never one name with "or something else\?"/);
143
+ });
144
+
145
+ test('big design-driven asks are routed to /planning-session before seeding, from both playbooks', () => {
146
+ const when = NORM.slice(NORM.indexOf('## When to invoke'), NORM.indexOf('## When NOT to invoke'));
147
+ assert.match(when, /big design-driven ask arrives — route it here BEFORE any task is seeded/);
148
+ const goal = fs.readFileSync(path.join(ROOT, '.claude/skills/goal-create/SKILL.md'), 'utf8').replace(/\s+/g, ' ');
149
+ assert.match(goal, /big design-driven ask\*\* .* route it to `\/planning-session` before seeding anything/);
92
150
  });
93
151
 
94
152
  // --- task 1001438: the Phase 5 promote must be scoped to the seeded set -----
@@ -283,4 +283,20 @@ await test('--batch refuses a task that is not a page tweak', async () => {
283
283
  assert.deepEqual(h.writes, []);
284
284
  });
285
285
 
286
+ // task 1004678 (ADR 0371 D8): the unattended worker names its page with --page.
287
+ await test('--page: a round for a DIFFERENT page is refused with exit 5 before a file is written', async () => {
288
+ const h = harness({ batch: BATCH() });
289
+ const code = await apply.batchMain({ taskId: '5001', pageId: 'landing:other' }, h.deps);
290
+ assert.equal(code, 5, 'exit 5 is the wrong-page halt tweak-halt.js maps');
291
+ assert.deepEqual(h.writes, [], 'nothing written');
292
+ assert.equal(h.calls.filter((c) => c.method === 'PATCH').length, 0, 'no applied block recorded');
293
+ });
294
+
295
+ await test("--page: the round's own page applies as usual", async () => {
296
+ const h = harness({ batch: BATCH() });
297
+ const code = await apply.batchMain({ taskId: '5001', pageId: 'landing:fixture', now: () => 't' }, h.deps);
298
+ assert.equal(code, 0);
299
+ assert.ok(h.writes.length > 0);
300
+ });
301
+
286
302
  summary();