@bongos/core 1.21.109 → 1.21.111
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 +68 -28
- package/docs/copy-inventory.md +14 -10
- package/docs/copy-registry.json +48 -12
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +195 -195
- package/modules/copy-desk/skills/tweak/SKILL.md +28 -0
- package/modules/discord/bug-attachments.js +35 -8
- package/modules/hall-ui/public/craft-mode.js +276 -21
- package/modules/hall-ui/public/craft-review.css +227 -0
- package/modules/hall-ui/public/craft-review.js +64 -0
- package/modules/hall-ui/public/style.css +1 -0
- package/modules/hall-ui/records/craft-mode.md +8 -0
- package/modules/lifecycle/autopickup-files.js +140 -0
- package/modules/lifecycle/db-ship.js +2 -1
- package/modules/lifecycle/routes/tasks.js +4 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/bug-attachment-audit.js +13 -7
- package/scripts/gds/bug-attachment-recover.js +198 -0
- package/scripts/gds/copy-apply.js +17 -2
- package/scripts/gds/tweak-halt.js +69 -0
- package/src/module-api.js +1 -1
- package/tests/bug_attachment_audit.mjs +4 -3
- package/tests/bug_attachment_recover.mjs +128 -0
- package/tests/bug_attachments.mjs +30 -0
- package/tests/craft_review_mode.mjs +307 -0
- package/tests/page_notes_panel.mjs +4 -1
- package/tests/tweak_batch_apply.mjs +16 -0
- package/tests/tweak_unattended.mjs +274 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
-
|
|
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']) {
|
|
@@ -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();
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// tests/tweak_unattended.mjs — /tweak --page <id> --unattended halts by releasing,
|
|
2
|
+
// and the server, not the worker, holds its rules (task 1004678 / BV2.TW19b,
|
|
3
|
+
// ADR 0371 D8).
|
|
4
|
+
//
|
|
5
|
+
// 1. tweak-halt.js: every halt releases the round with fixed wording, and
|
|
6
|
+
// copy-apply's exits map onto those halts;
|
|
7
|
+
// 2. NO LAND WITHOUT THE ARTIST: every statement in the lifecycle that writes
|
|
8
|
+
// status 'confirmed' is enumerated from the source, and each one is either
|
|
9
|
+
// the artist's approval, the owner's override, or refuses a page tweak.
|
|
10
|
+
// confirmTask (the --skip-grade / grader-bypass path) is driven for real;
|
|
11
|
+
// 3. NO FILE OUTSIDE THE PAGE: autopickup-files.js over the server's own read of
|
|
12
|
+
// the pull request's files, and the real publish route refusing (409, PR
|
|
13
|
+
// closed, never armed) when an auto_pickup round strays.
|
|
14
|
+
// No DB, no network: fake pools, the real router, stubbed GitHub reads.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/tweak_unattended.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
25
|
+
|
|
26
|
+
process.env.NODE_ENV = 'test';
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
29
|
+
const { test, summary } = makeRunner();
|
|
30
|
+
|
|
31
|
+
// ---- 1. halts ---------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const halt = require('../scripts/gds/tweak-halt.js');
|
|
34
|
+
|
|
35
|
+
await test('each ADR 0371 D8 halt has fixed wording; an unknown code has none', () => {
|
|
36
|
+
for (const code of ['claim_refused', 'nothing_applied', 'ship_check_stale', 'grader_unavailable']) {
|
|
37
|
+
assert.equal(typeof halt.haltNote(code), 'string', code);
|
|
38
|
+
}
|
|
39
|
+
assert.equal(halt.haltNote('anything the worker says'), null);
|
|
40
|
+
assert.throws(() => halt.releaseWithNote(9, 'free text', { spawn: () => ({ status: 0 }) }), /unknown halt code/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await test('copy-apply exits map to halts; a clean apply is not a halt', () => {
|
|
44
|
+
assert.equal(halt.haltForCopyApplyExit(0), null);
|
|
45
|
+
assert.equal(halt.haltForCopyApplyExit(2), 'nothing_applied');
|
|
46
|
+
assert.equal(halt.haltForCopyApplyExit(3), 'registry_mismatch');
|
|
47
|
+
assert.equal(halt.haltForCopyApplyExit(4), 'reread_failed');
|
|
48
|
+
assert.equal(halt.haltForCopyApplyExit(5), 'wrong_page');
|
|
49
|
+
assert.equal(halt.haltForCopyApplyExit(99), 'nothing_applied', 'an unknown failure still halts');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await test('a halt RELEASES the round through release.js, with the fixed note as its reason', () => {
|
|
53
|
+
const calls = [];
|
|
54
|
+
const exit = halt.releaseWithNote(4321, 'grader_unavailable', { spawn: (cmd, argv) => { calls.push(argv); return { status: 0 }; } });
|
|
55
|
+
assert.equal(exit, 0);
|
|
56
|
+
assert.equal(calls.length, 1);
|
|
57
|
+
const [script, task, flag, reason] = calls[0];
|
|
58
|
+
assert.match(script.replace(/\\/g, '/'), /scripts\/gds\/release\.js$/);
|
|
59
|
+
assert.equal(task, '4321');
|
|
60
|
+
assert.equal(flag, '--reason');
|
|
61
|
+
assert.equal(reason, `[tweak-unattended:grader_unavailable] ${halt.HALT_NOTES.grader_unavailable}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await test('copy-apply --unattended releases on every non-zero batch exit (wired, not just documented)', () => {
|
|
65
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/copy-apply.js'), 'utf8');
|
|
66
|
+
const at = src.indexOf("hasFlag('--unattended') && code !== 0");
|
|
67
|
+
assert.ok(at > 0, 'the unattended branch tests every non-zero exit');
|
|
68
|
+
assert.ok(src.slice(at, at + 300).includes('halt.releaseWithNote(taskId, halt.haltForCopyApplyExit(code))'), 'and releases through tweak-halt');
|
|
69
|
+
assert.match(src, /arg\('--page'\)/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('the skill documents the mode and never offers --skip-grade to it', () => {
|
|
73
|
+
const skill = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/skills/tweak/SKILL.md'), 'utf8');
|
|
74
|
+
const section = skill.slice(skill.indexOf('## Unattended mode'), skill.indexOf('## What happens next'));
|
|
75
|
+
assert.ok(section.length > 200, 'the unattended section exists');
|
|
76
|
+
assert.match(section, /--page <page-id> --unattended/);
|
|
77
|
+
assert.match(section, /tweak-halt\.js/);
|
|
78
|
+
assert.match(section, /Never `--skip-grade`/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---- 2. no land without the artist -----------------------------------------
|
|
82
|
+
|
|
83
|
+
const achievements = require('../modules/economy/achievements.js');
|
|
84
|
+
achievements.evaluateAfterShip = async () => [];
|
|
85
|
+
achievements.evaluateAfterCreditAward = async () => [];
|
|
86
|
+
achievements.evaluateAfterClaim = async () => [];
|
|
87
|
+
achievements.reconcileBuilderAchievements = async () => [];
|
|
88
|
+
const seams = require('../src/module-seams.js');
|
|
89
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
90
|
+
const db = require('../modules/lifecycle/db.js');
|
|
91
|
+
|
|
92
|
+
// Every lifecycle statement that sets a TASK to confirmed, found from the source.
|
|
93
|
+
// A new one reds this test until it is listed below with why it holds.
|
|
94
|
+
const CONFIRM_WRITERS = {
|
|
95
|
+
'db-grade.js': 'promoteToConfirmed: applyGrade withholds it for a page tweak (isPageTweak), approveHeldTweak is the artist',
|
|
96
|
+
'db-overrides.js': "decideOverrideRequest: the owner's override request",
|
|
97
|
+
'db-ship.js': 'confirmTask: refuses a page tweak (PAGE_TWEAK_NEEDS_ARTIST)',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await test('every confirm path in the lifecycle is listed, and each holds the artist gate', () => {
|
|
101
|
+
const dir = path.join(ROOT, 'modules/lifecycle');
|
|
102
|
+
const found = [];
|
|
103
|
+
for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.js'))) {
|
|
104
|
+
const src = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
105
|
+
const n = (src.match(/UPDATE tasks\s+SET status = 'confirmed'/g) || []).length;
|
|
106
|
+
for (let i = 0; i < n; i++) found.push(f);
|
|
107
|
+
}
|
|
108
|
+
assert.deepEqual(found.sort(), Object.keys(CONFIRM_WRITERS).sort(), 'one confirm write per listed file, and no other');
|
|
109
|
+
const grade = fs.readFileSync(path.join(dir, 'db-grade.js'), 'utf8');
|
|
110
|
+
assert.match(grade, /held = tweakHold\.isPageTweak/);
|
|
111
|
+
assert.match(grade, /gradeResult\.passed && task\.status === 'completed' && !held/);
|
|
112
|
+
const approve = fs.readFileSync(path.join(dir, 'page-tweak-approve.js'), 'utf8');
|
|
113
|
+
assert.match(approve, /isHeldForArtist/, 'approve runs the promotion only on a held round');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function confirmPool(source) {
|
|
117
|
+
return makeSqlAwareClient((s) => {
|
|
118
|
+
if (/SELECT builder_id FROM claims/.test(s)) return { rows: [{ builder_id: 25 }] };
|
|
119
|
+
if (/^SELECT source FROM tasks/.test(s)) return { rows: [{ source }] };
|
|
120
|
+
if (/^UPDATE tasks/.test(s)) return { rows: [{ id: 7, title: 't', credits_reward: 10, kind: 'feature' }] };
|
|
121
|
+
return { rows: [] };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await test('confirmTask (the --skip-grade path) REFUSES a page tweak and writes nothing', async () => {
|
|
126
|
+
const pool = confirmPool('page-tweak');
|
|
127
|
+
await assert.rejects(() => db.confirmTask({ taskId: 7, builderId: 1 }, { pool }), (e) => e.code === 'PAGE_TWEAK_NEEDS_ARTIST');
|
|
128
|
+
assert.ok(!pool._client.queries.some((q) => /^\s*UPDATE tasks/.test(String(q.sql))), 'no status write');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('confirmTask on every other task confirms as before', async () => {
|
|
132
|
+
for (const source of ['planning-session', 'page-tweak-unplaced', null]) {
|
|
133
|
+
const out = await db.confirmTask({ taskId: 7, builderId: 1 }, { pool: confirmPool(source) });
|
|
134
|
+
assert.equal(out.taskStatus, 'confirmed', String(source));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('POST /tasks/:id/confirm maps the refusal to a 409 that names the artist', () => {
|
|
139
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
140
|
+
assert.match(src, /err\.code === 'PAGE_TWEAK_NEEDS_ARTIST'\) return res\.fail\('page_tweak_needs_artist', \{ status: 409/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---- 3. no file outside the page -------------------------------------------
|
|
144
|
+
|
|
145
|
+
const apf = require('../modules/lifecycle/autopickup-files.js');
|
|
146
|
+
const pcheck = require('../src/bongos/permission-path-check.js');
|
|
147
|
+
const INV = { pages: [{ id: 'landing:index', files: ['modules/public-landing/public/index.html', 'modules/public-landing/public/landing.css'], states_files: ['modules/public-landing/public/index.states.json'] }] };
|
|
148
|
+
const ROUND = { id: 7, source: 'page-tweak', source_ref: 'page-tweak/landing:index/r2', auto_pickup: true };
|
|
149
|
+
const files = (...p) => p.map((x) => ({ path: x }));
|
|
150
|
+
const run = (over = {}, deps = {}) => apf.autoPickupFileRefusal({ taskId: 7, prNumber: 77 }, {
|
|
151
|
+
readRound: async () => ({ ...ROUND, ...over }),
|
|
152
|
+
readInventory: () => INV,
|
|
153
|
+
getPullRequestFiles: async () => files('modules/public-landing/public/index.html', 'docs/copy-registry.json', 'docs/page-readings.json'),
|
|
154
|
+
matchProtected: pcheck.matchProtected,
|
|
155
|
+
...deps,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('an auto_pickup round touching only its page and the regenerated files publishes', async () => {
|
|
159
|
+
assert.equal(await run(), null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('a file outside the page is refused and named', async () => {
|
|
163
|
+
const r = await run({}, { getPullRequestFiles: async () => files('modules/public-landing/public/index.html', 'modules/hall-ui/public/goals.js') });
|
|
164
|
+
assert.equal(r.code, 'auto_pickup_outside_page');
|
|
165
|
+
assert.deepEqual(r.outside, ['modules/hall-ui/public/goals.js']);
|
|
166
|
+
assert.match(apf.refusalMessage(r), /goals\.js/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await test('a protected path is refused even when it is on the regenerated list', async () => {
|
|
170
|
+
const r = await run({}, { getPullRequestFiles: async () => files('modules/public-landing/public/index.html', 'CLAUDE.md') });
|
|
171
|
+
assert.equal(r.code, 'auto_pickup_outside_page');
|
|
172
|
+
assert.deepEqual(r.protected, ['CLAUDE.md']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('fail closed: unreadable files, a list at the cap, an unknown page, a bad ref', async () => {
|
|
176
|
+
assert.equal((await run({}, { getPullRequestFiles: async () => { throw new Error('502'); } })).code, 'auto_pickup_files_unreadable');
|
|
177
|
+
const cap = Array.from({ length: 300 }, () => ({ path: 'modules/public-landing/public/index.html' }));
|
|
178
|
+
assert.equal((await run({}, { getPullRequestFiles: async () => cap })).code, 'auto_pickup_files_unreadable');
|
|
179
|
+
assert.equal((await run({ source_ref: 'page-tweak/landing:gone/r1' })).code, 'auto_pickup_page_unknown');
|
|
180
|
+
assert.equal((await run({ source_ref: 'nonsense' })).code, 'auto_pickup_page_unknown');
|
|
181
|
+
assert.equal((await run({}, { readInventory: () => { throw new Error('ENOENT'); } })).code, 'auto_pickup_page_unknown');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test('an UNMARKED claim, or any other task, is never checked (no GitHub read)', async () => {
|
|
185
|
+
let reads = 0;
|
|
186
|
+
const count = { getPullRequestFiles: async () => { reads++; return files('anything.js'); } };
|
|
187
|
+
assert.equal(await run({ auto_pickup: false }, count), null);
|
|
188
|
+
assert.equal(await run({ auto_pickup: null }, count), null);
|
|
189
|
+
assert.equal(await run({ source: 'planning-session' }, count), null);
|
|
190
|
+
assert.equal(await apf.autoPickupFileRefusal({ taskId: 7, prNumber: 77 }, { readRound: async () => null, ...count }), null);
|
|
191
|
+
assert.equal(reads, 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await test('the round read is one query naming the latest claim\'s mark', async () => {
|
|
195
|
+
const pool = makeSqlAwareClient(() => ({ rows: [ROUND] }));
|
|
196
|
+
const r = await apf.readRound(7, pool._client || pool);
|
|
197
|
+
assert.equal(r.auto_pickup, true);
|
|
198
|
+
const q = (pool._client || pool).queries.map((x) => String(x.sql).replace(/\s+/g, ' '));
|
|
199
|
+
assert.equal(q.length, 1);
|
|
200
|
+
assert.match(q[0], /SELECT c\.auto_pickup FROM claims c WHERE c\.task_id = t\.id ORDER BY c\.claimed_at DESC, c\.id DESC LIMIT 1/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await test('the page files come from the real inventory for a real page', () => {
|
|
204
|
+
const inv = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/page-inventory.json'), 'utf8'));
|
|
205
|
+
const page = inv.pages.find((p) => (p.files || []).length);
|
|
206
|
+
assert.ok(apf.pageFiles(inv, page.id).includes(page.files[0]));
|
|
207
|
+
assert.equal(apf.pageFiles(inv, 'nope:nope'), null);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// The real publish route.
|
|
211
|
+
const express = require('express');
|
|
212
|
+
const api = require('../src/module-api.js');
|
|
213
|
+
const gp = require('../modules/lifecycle/github-push.js');
|
|
214
|
+
const prAssign = require('../modules/lifecycle/pr-assign.js');
|
|
215
|
+
const prClose = require('../modules/lifecycle/github-pr-close.js');
|
|
216
|
+
const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
|
|
217
|
+
|
|
218
|
+
let round = ROUND;
|
|
219
|
+
let prFiles = files('modules/public-landing/public/index.html');
|
|
220
|
+
let armCalls = 0, closed = [];
|
|
221
|
+
api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
|
|
222
|
+
db.getLastClaimHolder = async () => ({ builder_id: 5, github_login: 'some-builder' });
|
|
223
|
+
db.setClaimPublishedSha = async () => {};
|
|
224
|
+
api.pool.query = async () => ({ rows: [round] });
|
|
225
|
+
gp.isConfigured = () => true;
|
|
226
|
+
gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
|
|
227
|
+
gp.openOrFindPullRequest = async () => ({ number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' });
|
|
228
|
+
gp.enableAutoMergeDetailed = async () => { armCalls++; return { ok: true }; };
|
|
229
|
+
gp.getPullRequestFiles = async () => prFiles;
|
|
230
|
+
gp.setCommitStatus = async () => true;
|
|
231
|
+
prAssign.assignPullRequest = async () => ({ assigned: true });
|
|
232
|
+
prClose.closePullRequest = async ({ number }) => { closed.push(number); return { ok: true }; };
|
|
233
|
+
pageTweakReads.heldForArtistFailClosed = async () => true;
|
|
234
|
+
|
|
235
|
+
const app = express();
|
|
236
|
+
app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
|
|
237
|
+
app.use(require('../modules/lifecycle/routes/tasks.js')());
|
|
238
|
+
const server = http.createServer(app);
|
|
239
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
240
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
241
|
+
const publish = async () => {
|
|
242
|
+
const res = await fetch(`${base}/tasks/7/publish-branch`, {
|
|
243
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({ branch: 'task-7', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'Page tweak.' }),
|
|
245
|
+
});
|
|
246
|
+
return { status: res.status, body: await res.json() };
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
await test('publish: an auto_pickup round that strays is REFUSED 409, its PR closed, never armed', async () => {
|
|
250
|
+
round = ROUND; prFiles = files('modules/public-landing/public/index.html', 'src/server.js'); armCalls = 0; closed = [];
|
|
251
|
+
const r = await publish();
|
|
252
|
+
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
253
|
+
assert.equal(r.body.error, 'auto_pickup_outside_page');
|
|
254
|
+
assert.ok(r.body.outside.includes('src/server.js'));
|
|
255
|
+
assert.deepEqual(closed, [77]);
|
|
256
|
+
assert.equal(armCalls, 0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('publish: an auto_pickup round inside its page publishes (held, so not armed)', async () => {
|
|
260
|
+
round = ROUND; prFiles = files('modules/public-landing/public/index.html'); closed = [];
|
|
261
|
+
const r = await publish();
|
|
262
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
263
|
+
assert.deepEqual(closed, []);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await test('publish: an ordinary task is not checked, whatever its files', async () => {
|
|
267
|
+
round = { id: 7, source: 'planning-session', source_ref: null, auto_pickup: null }; prFiles = files('src/server.js'); closed = [];
|
|
268
|
+
const r = await publish();
|
|
269
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
270
|
+
assert.deepEqual(closed, []);
|
|
271
|
+
});
|
|
272
|
+
server.close();
|
|
273
|
+
|
|
274
|
+
summary();
|