@bongos/core 1.21.44 → 1.21.46

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.
Files changed (63) hide show
  1. package/.bongos-core.json +85 -65
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/api/openapi.json +2 -0
  4. package/docs/copy-inventory.md +23 -11
  5. package/docs/copy-registry.json +131 -13
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/page-readings.json +755 -751
  8. package/modules/hall-ui/public/atlas.html +3 -3
  9. package/modules/hall-ui/public/blockers.html +3 -3
  10. package/modules/hall-ui/public/board-room.html +3 -3
  11. package/modules/hall-ui/public/city.html +3 -3
  12. package/modules/hall-ui/public/collab.html +3 -3
  13. package/modules/hall-ui/public/commands.html +3 -3
  14. package/modules/hall-ui/public/copy-desk.html +3 -3
  15. package/modules/hall-ui/public/craft-mode.js +8 -1
  16. package/modules/hall-ui/public/deploy.html +3 -3
  17. package/modules/hall-ui/public/diagrams.html +3 -3
  18. package/modules/hall-ui/public/drachmae.html +3 -3
  19. package/modules/hall-ui/public/fleet.html +3 -3
  20. package/modules/hall-ui/public/gate.html +3 -3
  21. package/modules/hall-ui/public/goal-map.html +3 -3
  22. package/modules/hall-ui/public/goals.html +3 -3
  23. package/modules/hall-ui/public/government.html +3 -3
  24. package/modules/hall-ui/public/idea.html +3 -3
  25. package/modules/hall-ui/public/ideas.html +3 -3
  26. package/modules/hall-ui/public/index.html +3 -3
  27. package/modules/hall-ui/public/modules.html +3 -3
  28. package/modules/hall-ui/public/paintbrush.js +398 -0
  29. package/modules/hall-ui/public/primer.html +3 -3
  30. package/modules/hall-ui/public/profile.html +3 -3
  31. package/modules/hall-ui/public/project-settings.html +3 -3
  32. package/modules/hall-ui/public/ranks.html +3 -3
  33. package/modules/hall-ui/public/roadmap.html +3 -3
  34. package/modules/hall-ui/public/roster.html +3 -3
  35. package/modules/hall-ui/public/sessions.html +3 -3
  36. package/modules/hall-ui/public/settings.html +3 -3
  37. package/modules/hall-ui/public/street.html +3 -3
  38. package/modules/hall-ui/public/studio.html +3 -3
  39. package/modules/hall-ui/public/style.css +78 -0
  40. package/modules/hall-ui/public/task.html +3 -3
  41. package/modules/hall-ui/public/thinking.html +3 -3
  42. package/modules/hall-ui/public/tweak-editor.html +3 -3
  43. package/modules/hall-ui/public/watch.html +3 -3
  44. package/modules/hall-ui/public/work.html +3 -3
  45. package/modules/hall-ui/records/craft-mode.md +9 -0
  46. package/modules/provisioning/manifest-truth.js +63 -0
  47. package/modules/provisioning/provisioning.js +3 -11
  48. package/modules/provisioning/routes/provisioning.js +3 -1
  49. package/modules/public-landing/public/projects.html +38 -11
  50. package/package-lock.json +2 -2
  51. package/package.json +1 -1
  52. package/release-notes.json +12 -0
  53. package/scripts/gds/provision.js +4 -2
  54. package/src/bongos/routes/auth.js +15 -2
  55. package/src/module-api.js +1 -1
  56. package/tests/hall_tweak_editor.mjs +1 -1
  57. package/tests/page_notes_panel.mjs +4 -3
  58. package/tests/paintbrush.mjs +351 -0
  59. package/tests/projects_hub_pre_uat.mjs +3 -2
  60. package/tests/provision.mjs +53 -0
  61. package/tests/provisioning_github_app_routes.mjs +23 -0
  62. package/tests/provisioning_settings.mjs +6 -4
  63. package/tests/wizard_github_feedback.mjs +239 -0
@@ -0,0 +1,351 @@
1
+ // tests/paintbrush.mjs — the Artistry paintbrush (task 1004497): a page's own
2
+ // words, edited where they stand, as a proposed wording change.
3
+ //
4
+ // Runs the REAL paintbrush.js, with the REAL tweak-editor-lib.js and
5
+ // studio-lib.js it shares with the editor and the studio, over a small page
6
+ // (there is no jsdom here; the fake-DOM idiom of tests/page_notes_panel.mjs).
7
+ // The task's done-when, as cases: an artist toggles editing, changes the words
8
+ // a page shows, reviews the changes and sends them as one proposal; nobody
9
+ // else's view changes — the page's words come back when editing ends, and the
10
+ // only writes are the copy desk's own page-round routes (owner, 2026-10-04: the
11
+ // edits are real wording changes, kept apart from the palette's feedback docs).
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
21
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
22
+ const BRUSH = read('paintbrush.js');
23
+ const EDITOR_LIB = read('tweak-editor-lib.js');
24
+ const STUDIO_LIB = read('studio-lib.js');
25
+ const CRAFT = read('craft-mode.js');
26
+ const CSS = read('style.css');
27
+
28
+ // ---------- a small DOM: enough for the selectors paintbrush.js asks ----------
29
+ function matchesOne(el, sel) {
30
+ sel = sel.trim();
31
+ const m = /^([a-z]+)?(?:#([\w-]+))?(?:\.([\w-]+))?(?:\[([\w-]+)(?:="([^"]*)")?\])?$/i.exec(sel);
32
+ if (!m) throw new Error('fake DOM cannot match ' + sel);
33
+ const [, tag, id, cls, attr, val] = m;
34
+ if (tag && el.tagName !== tag.toUpperCase()) return false;
35
+ if (id && el.id !== id) return false;
36
+ if (cls && !el.classList.contains(cls)) return false;
37
+ if (attr) {
38
+ if (attr === 'hidden') return !!el.hidden;
39
+ if (!el.hasAttribute(attr)) return false;
40
+ if (val !== undefined && el.getAttribute(attr) !== val) return false;
41
+ }
42
+ return true;
43
+ }
44
+ const matches = (el, sel) => sel.split(',').some((s) => matchesOne(el, s));
45
+
46
+ class El {
47
+ constructor(tag, text) {
48
+ this.tagName = tag.toUpperCase();
49
+ this.attrs = {};
50
+ this.children = [];
51
+ this.parentNode = null;
52
+ this.id = '';
53
+ this.hidden = false;
54
+ this.dataset = {};
55
+ this.listeners = {};
56
+ this._text = text == null ? '' : String(text);
57
+ this._html = '';
58
+ const cls = new Set();
59
+ this.classList = {
60
+ add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)),
61
+ contains: (c) => cls.has(c), toggle: (c, on) => { if (on === undefined ? !cls.has(c) : on) cls.add(c); else cls.delete(c); },
62
+ };
63
+ }
64
+ add(child) { child.parentNode = this; this.children.push(child); return child; }
65
+ appendChild(c) { return this.add(c); }
66
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; }
67
+ get childElementCount() { return this.children.length; }
68
+ get textContent() { return this.children.length ? this.children.map((c) => c.textContent).join('') : this._text; }
69
+ set textContent(v) { this.children = []; this._text = String(v); }
70
+ get innerHTML() { return this._html; }
71
+ set innerHTML(v) { this._html = String(v); }
72
+ set className(v) { String(v).split(/\s+/).filter(Boolean).forEach((c) => this.classList.add(c)); }
73
+ setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'contenteditable') this.contentEditable = String(v); }
74
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
75
+ hasAttribute(k) { return k in this.attrs; }
76
+ removeAttribute(k) { delete this.attrs[k]; if (k === 'contenteditable') this.contentEditable = 'inherit'; }
77
+ closest(sel) { for (let n = this; n; n = n.parentNode) if (n.tagName && matches(n, sel)) return n; return null; }
78
+ querySelectorAll(sel) {
79
+ const out = [];
80
+ const walk = (n) => n.children.forEach((c) => { if (sel === '*' || matches(c, sel)) out.push(c); walk(c); });
81
+ walk(this);
82
+ return out;
83
+ }
84
+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
85
+ addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
86
+ focus() { this.ownerDocument.activeElement = this; }
87
+ blur() {}
88
+ }
89
+
90
+ // A page with words the reading lists, and words it must leave alone.
91
+ function page() {
92
+ const body = new El('body');
93
+ body.dataset.page = 'roadmap';
94
+ body.dataset.pageTitle = 'Roadmap';
95
+ const main = body.add(new El('main'));
96
+ const h1 = main.add(new El('h1', 'Where the build is going'));
97
+ const lede = main.add(new El('p', 'Every version, from first idea to shipped.'));
98
+ const link = main.add(new El('a'));
99
+ link.setAttribute('href', '/goals');
100
+ const linkWords = link.add(new El('span', 'See every goal'));
101
+ const count = main.add(new El('p', '12 versions shipped')); // a {…} hole line: the editor's
102
+ const btn = main.add(new El('button', 'Star this version')); // a control: never editable
103
+ const stray = main.add(new El('p', 'Words the reading has never seen'));
104
+ const hiddenBox = main.add(new El('div'));
105
+ hiddenBox.hidden = true;
106
+ const hiddenWords = hiddenBox.add(new El('p', 'Nothing in flight right now.'));
107
+ const footer = body.add(new El('footer', 'Skip to content')); // outside main
108
+ return { body, main, h1, lede, link, linkWords, count, btn, stray, hiddenWords, footer };
109
+ }
110
+ const READING = {
111
+ reading_hash: 'abc123',
112
+ read: true,
113
+ lines: [
114
+ { key: 'L0001', section: 'skip link', text: 'Skip to content', placement: 'shared' },
115
+ { key: 'L0002', section: 'page top', text: 'Where the build is going', placement: 'placed' },
116
+ { key: 'L0003', section: 'page top', text: 'Every version, from first idea to shipped.', placement: 'placed' },
117
+ { key: 'L0004', section: 'versions', text: 'See every goal', placement: 'placed' },
118
+ { key: 'L0005', section: 'versions', text: '{…} versions shipped', placement: 'placed' },
119
+ { key: 'L0006', section: 'versions', text: 'Star this version', placement: 'placed' },
120
+ { key: 'L0007', section: 'versions', text: 'Nothing in flight right now.', placement: 'unplaced' },
121
+ ],
122
+ };
123
+
124
+ function boot({ round = null, draft = null, claim = { status: 200, body: { ok: true } }, submit = { status: 200, body: { ok: true, outcome: 'submitted' } } } = {}) {
125
+ const p = page();
126
+ const head = new El('head');
127
+ const calls = [];
128
+ const docListeners = {};
129
+ const document = {
130
+ body: p.body, head, documentElement: new El('html'), activeElement: null,
131
+ currentScript: { src: 'https://h/builders/paintbrush.js?v=2026-10-04-test' },
132
+ querySelector: (sel) => p.body.querySelector(sel),
133
+ getElementById: (id) => p.body.querySelectorAll('*').find((n) => n.id === id) || null,
134
+ createElement: (t) => { const e = new El(t); e.ownerDocument = document; return e; },
135
+ addEventListener: (t, fn) => { (docListeners[t] ||= []).push(fn); },
136
+ removeEventListener: (t, fn) => { docListeners[t] = (docListeners[t] || []).filter((f) => f !== fn); },
137
+ execCommand() { return true; },
138
+ title: 'Roadmap — Builders hall',
139
+ };
140
+ for (const n of [p.body, ...p.body.querySelectorAll('*')]) n.ownerDocument = document;
141
+ let state = { round, draft };
142
+ const respond = (status, body) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body) });
143
+ const fetch = (url, opts = {}) => {
144
+ const method = opts.method || 'GET';
145
+ calls.push({ method, url, body: opts.body ? JSON.parse(opts.body) : undefined, keepalive: !!opts.keepalive });
146
+ if (!url.startsWith('/api/bongos/copy-desk/pages/builders%3Aroadmap/')) return respond(404, {});
147
+ const rest = url.slice('/api/bongos/copy-desk/pages/builders%3Aroadmap/'.length);
148
+ if (method === 'GET' && rest === 'draft') return respond(200, { ok: true, page: { id: 'builders:roadmap', title: 'Roadmap' }, reading: READING, round: state.round, draft: state.draft });
149
+ if (method === 'POST' && rest === 'claim') {
150
+ if (claim.status < 400) state = { round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } }, draft: null };
151
+ return respond(claim.status, claim.body);
152
+ }
153
+ if (method === 'PUT' && rest === 'draft') return respond(200, { ok: true, outcome: 'saved' });
154
+ if (method === 'POST' && rest === 'submit') return respond(submit.status, submit.body);
155
+ return respond(404, {});
156
+ };
157
+ const winListeners = {};
158
+ const window = { location: { pathname: '/roadmap' }, addEventListener: (t, fn) => { (winListeners[t] ||= []).push(fn); }, removeEventListener: (t, fn) => { winListeners[t] = (winListeners[t] || []).filter((f) => f !== fn); } };
159
+ const sandbox = { window, document, fetch, Promise, setTimeout, clearTimeout, JSON, Math, String, Array, Object };
160
+ window.window = window;
161
+ vm.createContext(sandbox);
162
+ // The two shared libs, as the page would have them once the tool loads them.
163
+ vm.runInContext(EDITOR_LIB, sandbox, { filename: 'tweak-editor-lib.js' });
164
+ vm.runInContext(STUDIO_LIB, sandbox, { filename: 'studio-lib.js' });
165
+ vm.runInContext(BRUSH, sandbox, { filename: 'paintbrush.js' });
166
+ const bar = () => p.body.querySelector('#paintbrush');
167
+ const fire = (type, ev) => (docListeners[type] || []).slice().forEach((fn) => fn(ev));
168
+ // Press a bar button by its data attribute.
169
+ const press = (attr) => {
170
+ const html = bar().innerHTML;
171
+ assert.match(html, new RegExp(attr), `the bar offers [${attr}]`);
172
+ const btn = new El('button');
173
+ btn.setAttribute(attr, '');
174
+ btn.parentNode = bar();
175
+ bar().listeners.click.forEach((fn) => fn({ target: btn }));
176
+ };
177
+ const type = (el, text) => { el.textContent = text; fire('input', { target: el }); };
178
+ const editable = () => p.body.querySelectorAll('*').filter((n) => n.hasAttribute('contenteditable'));
179
+ return { ...p, leave: () => (winListeners.pagehide || []).forEach((fn) => fn()), brush: window.OTBPaintbrush, calls, bar, press, type, fire, editable, html: document.documentElement, writes: () => calls.filter((c) => c.method !== 'GET') };
180
+ }
181
+ const settle = () => new Promise((r) => setTimeout(r, 5));
182
+
183
+ test('craft-mode.js puts the paintbrush beside the palette in Artistry, loading its own script', () => {
184
+ assert.match(CRAFT, /\{ id: 'paintbrush', icon: 'brush', short: 'Edit words', label: 'Edit this page\\'s words', script: 'paintbrush.js', api: 'OTBPaintbrush' \}/);
185
+ const tools = /var TOOLS = \{([\s\S]*?)\n \};/.exec(CRAFT)[1];
186
+ assert.match(tools, /^\s*artistry: \[/m, 'it is an Artistry tool');
187
+ assert.doesNotMatch(tools, /building:|ideating:|governing:/, 'and no other mode has one');
188
+ });
189
+
190
+ test('pressing it on a page nobody holds OFFERS the page — nothing is editable and nothing is written until the artist takes it', async () => {
191
+ const p = boot();
192
+ p.brush.open();
193
+ await settle();
194
+ assert.match(p.bar().innerHTML, /Take this page/);
195
+ assert.match(p.bar().innerHTML, /Nobody else sees them until then/);
196
+ assert.equal(p.editable().length, 0);
197
+ assert.deepEqual(p.writes(), [], 'reading the page writes nothing');
198
+ assert.equal(p.calls[0].url, '/api/bongos/copy-desk/pages/builders%3Aroadmap/draft', 'it reads this page by its reading id');
199
+ });
200
+
201
+ test('taking the page makes the words the reading lists editable where they stand — not shared chrome, holes, controls, hidden or unknown words', async () => {
202
+ const p = boot();
203
+ p.brush.open();
204
+ await settle();
205
+ p.press('data-pb-take');
206
+ await settle();
207
+ assert.deepEqual(p.writes().map((c) => c.method + ' ' + c.url.split('/').pop()), ['POST claim'], 'one claim, through the copy desk');
208
+ assert.deepEqual(p.editable().map((n) => n.textContent), ['Where the build is going', 'Every version, from first idea to shipped.', 'See every goal']);
209
+ for (const n of [p.count, p.btn, p.stray, p.hiddenWords, p.footer]) assert.ok(!n.hasAttribute('contenteditable'), `"${n.textContent}" stays as it is`);
210
+ assert.equal(p.h1.getAttribute('data-pb-key'), 'L0002');
211
+ assert.ok(p.html.classList.contains('pb-editing'));
212
+ assert.match(p.bar().innerHTML, /0 changes/);
213
+ assert.match(p.bar().innerHTML, /tweak editor/, 'the lines it cannot reach point to the editor');
214
+ });
215
+
216
+ test('a refused claim says why, in the artist\'s words, and leaves the page alone', async () => {
217
+ const p = boot({ claim: { status: 403, body: { ok: false, error: { code: 'not_an_artist', message: 'no' } } } });
218
+ p.brush.open();
219
+ await settle();
220
+ p.press('data-pb-take');
221
+ await settle();
222
+ assert.match(p.bar().innerHTML, /Only an artist can take a page/);
223
+ assert.equal(p.editable().length, 0);
224
+ });
225
+
226
+ test('a page another artist holds is not offered, and one already on its way is not either', async () => {
227
+ const held = boot({ round: { task_id: '9', state: 'writing', held_by_you: false, holder: { login: 'mira' } } });
228
+ held.brush.open();
229
+ await settle();
230
+ assert.match(held.bar().innerHTML, /mira is tweaking this page right now/);
231
+ assert.doesNotMatch(held.bar().innerHTML, /Take this page/);
232
+ const flight = boot({ round: { task_id: '9', state: 'submitted', held_by_you: false, holder: null } });
233
+ flight.brush.open();
234
+ await settle();
235
+ assert.match(flight.bar().innerHTML, /on its way to the site/);
236
+ assert.deepEqual([...held.writes(), ...flight.writes()], []);
237
+ });
238
+
239
+ test('typing saves the WHOLE draft to the page round, and a click on words inside a link does not follow it', async () => {
240
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
241
+ p.brush.open();
242
+ await settle();
243
+ assert.equal(p.editable().length, 3, 'a page you already hold opens straight into editing');
244
+ p.type(p.lede, 'Every version, from the first spark to shipped.');
245
+ p.fire('focusout', { target: p.lede });
246
+ await settle();
247
+ const put = p.writes().find((c) => c.method === 'PUT');
248
+ assert.ok(put, 'a save went out');
249
+ assert.match(put.url, /builders%3Aroadmap\/draft$/);
250
+ assert.deepEqual(JSON.parse(JSON.stringify(put.body)), { reading_hash: 'abc123', lines: [{ key: 'L0003', after: 'Every version, from the first spark to shipped.' }] });
251
+ assert.ok(p.lede.classList.contains('pb-word--changed'));
252
+ let prevented = false;
253
+ p.fire('click', { target: p.linkWords, preventDefault() { prevented = true; } });
254
+ assert.ok(prevented, 'the link around the words does not navigate while editing');
255
+ });
256
+
257
+ test('REVIEW lists every change as was/now, and SEND submits the page once and puts its own words back', async () => {
258
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
259
+ p.brush.open();
260
+ await settle();
261
+ p.type(p.h1, 'Where we are going');
262
+ p.type(p.linkWords, 'Every goal <b>here</b>');
263
+ p.press('data-pb-review');
264
+ await settle();
265
+ const html = p.bar().innerHTML;
266
+ assert.match(html, /Was<\/span> Where the build is going/);
267
+ assert.match(html, /Now<\/span> Where we are going/);
268
+ assert.match(html, /Every goal &lt;b&gt;here&lt;\/b&gt;/, 'what the artist typed is escaped in the review');
269
+ p.press('data-pb-send');
270
+ await settle();
271
+ const submits = p.writes().filter((c) => c.method === 'POST' && c.url.endsWith('/submit'));
272
+ assert.equal(submits.length, 1, 'one proposal for the page');
273
+ const lastPut = p.writes().filter((c) => c.method === 'PUT').pop();
274
+ assert.deepEqual(JSON.parse(JSON.stringify(lastPut.body.lines)), [{ key: 'L0002', after: 'Where we are going' }, { key: 'L0004', after: 'Every goal <b>here</b>' }], 'the saved draft held both changes before the submit');
275
+ assert.match(p.bar().innerHTML, /Sent for approval: 2 changes/);
276
+ assert.equal(p.h1.textContent, 'Where the build is going', 'the page shows its own words again');
277
+ assert.equal(p.linkWords.textContent, 'See every goal');
278
+ assert.equal(p.editable().length, 0);
279
+ assert.ok(!p.html.classList.contains('pb-editing'));
280
+ });
281
+
282
+ test('STOP EDITING keeps the draft saved and puts the page\'s words back', async () => {
283
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
284
+ p.brush.open();
285
+ await settle();
286
+ p.type(p.h1, 'A new heading');
287
+ p.press('data-pb-stop');
288
+ await settle();
289
+ assert.ok(p.writes().some((c) => c.method === 'PUT'), 'the change was saved first');
290
+ assert.ok(!p.writes().some((c) => c.url.endsWith('/submit')), 'stopping sends nothing');
291
+ assert.equal(p.h1.textContent, 'Where the build is going');
292
+ assert.equal(p.editable().length, 0);
293
+ assert.equal(p.bar(), null, 'the bar is gone');
294
+ });
295
+
296
+ test('a draft saved earlier comes back: the words show the artist\'s rewrite when editing resumes', async () => {
297
+ const p = boot({
298
+ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } },
299
+ draft: { reading_hash: 'abc123', lines: [{ key: 'L0002', after: 'Where it all goes' }] },
300
+ });
301
+ p.brush.open();
302
+ await settle();
303
+ assert.equal(p.h1.textContent, 'Where it all goes');
304
+ assert.ok(p.h1.classList.contains('pb-word--changed'));
305
+ assert.match(p.bar().innerHTML, /1 change/);
306
+ });
307
+
308
+ test('the only routes it writes are the copy desk\'s page round — never page-notes, never anything else', () => {
309
+ const routes = [...BRUSH.matchAll(/request\('(POST|PUT|PATCH|DELETE)', [^,]*?'\/([a-z-]+)'/g)].map((m) => m[1] + ' ' + m[2]);
310
+ assert.deepEqual(routes.sort(), ['POST claim', 'POST submit', 'PUT draft']);
311
+ assert.match(BRUSH, /var API = '\/api\/bongos\/copy-desk\/pages\/';/);
312
+ assert.doesNotMatch(BRUSH, /page-notes/);
313
+ });
314
+
315
+ test('its styles live in style.css, on the token tier', () => {
316
+ for (const sel of ['.pb {', '.pb__btn--go', '.pb-word {', '.pb-word--changed', '.pb__changes']) assert.ok(CSS.includes(sel), `style.css styles ${sel}`);
317
+ const block = CSS.slice(CSS.indexOf('/* THE PAINTBRUSH'), CSS.indexOf('/* THE BACK LINK'));
318
+ assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'no colour literals: tokens only');
319
+ });
320
+
321
+ test('leaving the page mid-edit sends the last save with keepalive, so closing the tab does not lose it', async () => {
322
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
323
+ p.brush.open();
324
+ await settle();
325
+ p.type(p.h1, 'Typed, then the tab closed');
326
+ p.leave();
327
+ await settle();
328
+ const put = p.writes().find((c) => c.method === 'PUT');
329
+ assert.ok(put && put.keepalive, 'the save on the way out is a keepalive request');
330
+ assert.equal(put.body.lines[0].after, 'Typed, then the tab closed');
331
+ });
332
+
333
+ test('words only: bold, italic and underline keys do nothing in the words; elsewhere they are left alone', async () => {
334
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
335
+ p.brush.open();
336
+ await settle();
337
+ let prevented = 0;
338
+ for (const key of ['b', 'i', 'u']) p.fire('keydown', { key, ctrlKey: true, target: p.h1, preventDefault() { prevented++; } });
339
+ assert.equal(prevented, 3);
340
+ p.fire('keydown', { key: 'b', ctrlKey: true, target: p.stray, preventDefault() { prevented++; } });
341
+ assert.equal(prevented, 3, 'a key outside the edited words is not touched');
342
+ });
343
+
344
+ test('a word on the page matches its line however its spaces wrap, and one table serves every element', async () => {
345
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
346
+ p.lede.textContent = ' Every version,\n from first idea to shipped. ';
347
+ p.brush.open();
348
+ await settle();
349
+ assert.equal(p.lede.getAttribute('data-pb-key'), 'L0003');
350
+ assert.doesNotMatch(BRUSH, /matchLine\(/, 'no per-element pattern matching: the hole-free lines are looked up by their words');
351
+ });
@@ -206,7 +206,8 @@ test('the fallback checklist carries every human step the canonical plan has', (
206
206
 
207
207
  /* ── the manifest leg: acknowledges GitHub, offers a way out, survives a reload ── */
208
208
  test('the manifest step acknowledges code_received, offers a restart after a stall, and rides the CREATED record', () => {
209
- assert.match(SCRIPT, /if \(live\.manifest === 'code_received'\)/, 'GitHub’s half is acknowledged');
209
+ // 'converting' is the same stage — it used to fall through to "waiting for approval" (task 1004572)
210
+ assert.match(SCRIPT, /if \(live\.manifest === 'code_received' \|\| live\.manifest === 'converting'\)/, 'GitHub’s half is acknowledged');
210
211
  assert.match(SCRIPT, /GitHub’s part is done/);
211
212
  assert.match(SCRIPT, /manifestStale \? '<button type="button" class="linklike" data-action="manifest-start">/, 'a stalled wait gets a restart door');
212
213
  assert.match(SCRIPT, /\(Date\.now\(\) - manifestStartedAt\) > 90000/, 'after ~90 seconds');
@@ -558,7 +559,7 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
558
559
  assert.match(steps, /\(raw \? '<details><summary>Technical details<\/summary><p>' \+ esc\(raw\) \+ '<\/p><\/details>' : ''\)/);
559
560
  assert.match(SCRIPT, /e\.code = \(d\.error && d\.error\.code\) \|\| '';/, 'the route code travels with the error');
560
561
  assert.match(SCRIPT, /live\.manifestCode = String\(\(err && err\.code\) \|\| ''\);/);
561
- assert.equal((SCRIPT.match(/manifestMsg: '', manifestCode: '', appSlug: null/g) || []).length, 2, 'both resets carry the code');
562
+ assert.equal((SCRIPT.match(/manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null/g) || []).length, 2, 'both resets carry the code (and, since task 1004572, the failure stage)');
562
563
  });
563
564
 
564
565
  test('the progress rail is gone (task 1004504): no painter, no repaint at the done panel, no static rail', () => {
@@ -1723,6 +1723,59 @@ await ta('a SPENT code is never re-converted — terminal error, no code_receive
1723
1723
  assert.ok(!resolves.some((x) => x.status === 'code_received'), 'never reset for a doomed retry');
1724
1724
  });
1725
1725
 
1726
+ // Task 1004572: once the credentials are placed, an App EXISTS on the owner's GitHub
1727
+ // account whether or not the project then confirms sign-in. The failed row must keep
1728
+ // its id + slug, or the wizard can only say "GitHub setup didn't start" — false, and
1729
+ // it sends the founder to make a second App (live on mercury, 2026-10-04).
1730
+ await ta('a sign-in check that never confirms still records WHICH App GitHub made (task 1004572)', async () => {
1731
+ const inst = { ...coTenant, domain: 'mercury.example.com' };
1732
+ const deps = {
1733
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1734
+ convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 5189633, appSlug: 'mercury-cloudbongos-com' }),
1735
+ exec: () => ({ ok: true, stdout: '{"ok":true,"auth_configured":false}' }),
1736
+ writeFile: () => ({ ok: true }),
1737
+ };
1738
+ const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1739
+ assert.equal(r.ok, false);
1740
+ assert.equal(r.appId, 5189633, 'the App id rides the failure');
1741
+ assert.equal(r.appSlug, 'mercury-cloudbongos-com', 'the App slug rides the failure');
1742
+
1743
+ const resolves = [];
1744
+ let claims = 0;
1745
+ await P.drainOAuthManifests({
1746
+ ...deps, db: {},
1747
+ provisioning: {
1748
+ claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 18, instance_id: 53, github_code: 'C', attempts: 1 } : null),
1749
+ getInstanceById: async () => inst,
1750
+ resolveOAuthManifest: async (_db, _id, status, patch = {}) => { resolves.push({ status, patch }); },
1751
+ recordEvent: async () => {},
1752
+ },
1753
+ });
1754
+ assert.equal(resolves.length, 1);
1755
+ assert.equal(resolves[0].status, 'error');
1756
+ assert.equal(resolves[0].patch.app_slug, 'mercury-cloudbongos-com', 'the error row names the App');
1757
+ assert.equal(resolves[0].patch.app_id, 5189633);
1758
+ assert.equal(resolves[0].patch.github_code, null, 'the burned code is still scrubbed');
1759
+ });
1760
+
1761
+ await ta('a failure BEFORE GitHub handed over credentials claims no App (task 1004572)', async () => {
1762
+ const resolves = [];
1763
+ let claims = 0;
1764
+ await P.drainOAuthManifests({
1765
+ apply: true, privileged: false, log: () => {}, sleep: async () => {}, db: {},
1766
+ exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
1767
+ convertManifestCode: async () => { throw new Error('GitHub manifest conversion failed: code expired'); },
1768
+ provisioning: {
1769
+ claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 19, instance_id: 53, github_code: 'C', attempts: 1 } : null),
1770
+ getInstanceById: async () => ({ ...coTenant, domain: 'mercury.example.com' }),
1771
+ resolveOAuthManifest: async (_db, _id, status, patch = {}) => { resolves.push({ status, patch }); },
1772
+ recordEvent: async () => {},
1773
+ },
1774
+ });
1775
+ assert.ok(resolves.length >= 1);
1776
+ assert.ok(resolves.every((x) => !('app_slug' in x.patch)), 'no App is named when none was handed over');
1777
+ });
1778
+
1726
1779
  await ta('a PRE-conversion failure stays retryable — the narrowing spared the retry path (F23)', async () => {
1727
1780
  let claims = 0; const resolves = [];
1728
1781
  const deps = {
@@ -158,6 +158,29 @@ console.log('\nGET /provisioning/github-app/status (the page poll → auto-open
158
158
  t("status 'done' → ready:true + terminal:true", res.status === 200 && j.ready === true && j.terminal === true, JSON.stringify(j));
159
159
  provisioning.getOAuthManifestByState = prev;
160
160
  }
161
+ {
162
+ // Task 1004572: a setup link nobody finished lapses after 30 minutes, and the
163
+ // callback refuses it from then on — so the poll must say 'expired' (terminal)
164
+ // rather than 'pending' forever, or the page waits on a link that can never land.
165
+ const prev = provisioning.getOAuthManifestByState;
166
+ provisioning.getOAuthManifestByState = async () => ({ id: 17, instance_id: 42, status: 'pending', attempts: 0, expires_at: new Date(Date.now() - 1000).toISOString() });
167
+ const res = await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`);
168
+ const j = await res.json();
169
+ t('a LAPSED pending link reads expired + terminal (task 1004572)', res.status === 200 && j.status === 'expired' && j.terminal === true && j.ready === false && j.failure === null, JSON.stringify(j));
170
+ provisioning.getOAuthManifestByState = prev;
171
+ }
172
+ {
173
+ // A failure after GitHub handed over credentials: the poll names how far it got,
174
+ // and never the raw last_error (this route is public by state token).
175
+ const prev = provisioning.getOAuthManifestByState;
176
+ provisioning.getOAuthManifestByState = async () => ({ id: 18, instance_id: 42, status: 'error', attempts: 1, app_slug: 'mercury-signin', last_error: 'the credentials are placed, but the project did not report sign-in ready in time', expires_at: new Date(Date.now() - 1000).toISOString() });
177
+ const res = await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`);
178
+ const raw = await res.text();
179
+ const j = JSON.parse(raw);
180
+ t("a post-approval failure says failure:'sign_in_unconfirmed' (task 1004572)", j.status === 'error' && j.failure === 'sign_in_unconfirmed' && j.terminal === true, raw);
181
+ t('…and never leaks last_error', !/did not report/.test(raw) && !('last_error' in j), raw);
182
+ provisioning.getOAuthManifestByState = prev;
183
+ }
161
184
  {
162
185
  const res = await fetch(`${base}/provisioning/github-app/status?state=NOPE`);
163
186
  t('unknown state → 404 (no oracle)', res.status === 404, `status=${res.status}`);
@@ -115,10 +115,12 @@ await ta('owner reads their own instance', async () => {
115
115
 
116
116
  await ta('the read carries the GitHub-App leg\'s truth — github_app {status, app_slug} from the latest manifest attempt (task 1003235)', async () => {
117
117
  reset();
118
- manifest = { id: 3, instance_id: 18, status: 'done', app_slug: 'spike-bongos', github_code: 'never-surfaced', state: 'never-surfaced' };
118
+ manifest = { id: 3, instance_id: 18, status: 'done', app_slug: 'spike-bongos', github_code: 'never-surfaced', state: 'never-surfaced', last_error: 'never-surfaced', created_at: '2026-10-04T20:20:56.930Z' };
119
119
  const r = await get('/provisioning/instances/18');
120
120
  assert.equal(r.status, 200, r.raw);
121
- assert.deepEqual(r.body.github_app, { status: 'done', app_slug: 'spike-bongos' }, 'an allow-list projection — the code and the state token never ride the read');
121
+ // task 1004572 added failure + started_at to the allow-list; nothing else rides
122
+ assert.deepEqual(r.body.github_app, { status: 'done', app_slug: 'spike-bongos', failure: null, started_at: '2026-10-04T20:20:56.930Z' }, 'an allow-list projection — the code, the state token and last_error never ride the read');
123
+ assert.doesNotMatch(r.raw, /never-surfaced/);
122
124
  assert.equal(r.body.instance.slug, 'spike', 'the instance projection is untouched');
123
125
  });
124
126
 
@@ -134,8 +136,8 @@ await ta('no manifest attempt yet → github_app is null, not absent (a reload c
134
136
  await ta('publicGithubApp is a pure allow-list projection with the status route\'s vocabulary', async () => {
135
137
  assert.equal(provisioning.publicGithubApp(null), null);
136
138
  assert.equal(provisioning.publicGithubApp({ app_slug: 'x' }), null, 'a row with no status is no truth');
137
- assert.deepEqual(provisioning.publicGithubApp({ status: 'pending', github_code: 'secret' }), { status: 'pending', app_slug: null });
138
- assert.deepEqual(provisioning.publicGithubApp({ status: 'code_received', app_slug: 'mercury-bongos' }), { status: 'code_received', app_slug: 'mercury-bongos' });
139
+ assert.deepEqual(provisioning.publicGithubApp({ status: 'pending', github_code: 'secret' }), { status: 'pending', app_slug: null, failure: null, started_at: null });
140
+ assert.deepEqual(provisioning.publicGithubApp({ status: 'code_received', app_slug: 'mercury-bongos' }), { status: 'code_received', app_slug: 'mercury-bongos', failure: null, started_at: null });
139
141
  });
140
142
 
141
143
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {