@bongos/core 1.25.16 → 1.25.18

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 (93) hide show
  1. package/.bongos-core.json +200 -75
  2. package/bin/bongos.js +1 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +24 -0
  5. package/clients/bongos-client/index.cjs +24 -0
  6. package/clients/bongos-client/index.d.ts +36 -2
  7. package/clients/bongos-client/index.mjs +24 -0
  8. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  9. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  10. package/docs/api/openapi.json +761 -112
  11. package/docs/api-reference.md +23 -3
  12. package/docs/copy-inventory.md +118 -108
  13. package/docs/copy-registry.json +315 -172
  14. package/docs/file-map.md +2 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +3 -0
  17. package/docs/page-readings.json +62 -62
  18. package/modules/app-host/CLAUDE.md +15 -0
  19. package/modules/app-host/module.json +20 -0
  20. package/modules/app-host/public/settings-widget.js +143 -0
  21. package/modules/app-host/routes/app-host.js +88 -0
  22. package/modules/government/charter-advice.js +103 -0
  23. package/modules/government/decision-rules.js +1 -0
  24. package/modules/government/presets.js +42 -0
  25. package/modules/government/recommendations.js +102 -7
  26. package/modules/government/routes/government.js +1 -0
  27. package/modules/lifecycle/first-read-catalog.js +62 -0
  28. package/modules/lifecycle/first-read-db.js +190 -0
  29. package/modules/lifecycle/first-read.js +330 -0
  30. package/modules/lifecycle/kickoff-checklist.js +12 -3
  31. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  32. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  33. package/modules/lifecycle/module.json +2 -1
  34. package/modules/lifecycle/routes/first-read.js +101 -0
  35. package/modules/onboarding/genesis-work.js +101 -4
  36. package/modules/onboarding/routes/genesis-work.js +2 -2
  37. package/modules/platform-identity/module.json +1 -1
  38. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  39. package/modules/provisioning/module.json +1 -0
  40. package/modules/provisioning/pollers/app-liveness.js +23 -1
  41. package/modules/provisioning/render-sandbox.js +113 -0
  42. package/modules/provisioning/render-standup.js +3 -1
  43. package/modules/provisioning/routes/provisioning.js +4 -1
  44. package/modules/provisioning/routes/render-standup.js +49 -1
  45. package/modules/provisioning/seams.js +32 -1
  46. package/modules/provisioning/starter-bundles.js +1 -0
  47. package/modules/public-landing/public/assets/guide-core.js +475 -0
  48. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  49. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  50. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  51. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  52. package/modules/public-landing/public/index.html +46 -1
  53. package/modules/public-landing/public/projects.html +112 -6
  54. package/modules/ui-design/kit/serve.js +1 -1
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/release-notes.json +28 -0
  58. package/scripts/bongos/first-read.js +150 -0
  59. package/scripts/bongos/genesis.js +21 -1
  60. package/scripts/bongos/government-walk.js +4 -4
  61. package/scripts/bongos/provision-render-sandbox.js +171 -0
  62. package/scripts/bongos/provision-render.js +37 -2
  63. package/scripts/bongos/provision-teardown.js +1 -1
  64. package/scripts/bongos/publish-manifest.js +1 -0
  65. package/scripts/bongos/render-api.js +33 -3
  66. package/scripts/bongos/render-payload.js +39 -1
  67. package/scripts/bongos/seam-wiring-guard.js +3 -2
  68. package/scripts/gds/first-read.js +7 -0
  69. package/scripts/gds/provision-render-sandbox.js +6 -0
  70. package/scripts/hall-preview/server.js +2 -0
  71. package/src/bongos/auth-admission.js +64 -0
  72. package/src/bongos/auth.js +2 -1
  73. package/src/bongos/module-scope-map.js +4 -0
  74. package/src/bongos/routes/core-update.js +3 -16
  75. package/src/bongos/routes/modules.js +11 -0
  76. package/src/bongos/serve-internal.js +20 -6
  77. package/src/module-api.js +6 -1
  78. package/src/module-loader/loader.js +2 -2
  79. package/tests/app_host.mjs +413 -0
  80. package/tests/first_read.mjs +479 -0
  81. package/tests/genesis_work.mjs +74 -3
  82. package/tests/government_act_alone_buildings.mjs +2 -0
  83. package/tests/government_charter_advice.mjs +150 -0
  84. package/tests/landing_guide_hall.mjs +364 -0
  85. package/tests/landing_guide_scene.mjs +8 -5
  86. package/tests/module_api.mjs +1 -0
  87. package/tests/module_loader.mjs +1 -1
  88. package/tests/npm_release_where.mjs +13 -3
  89. package/tests/provisioning_app_status.mjs +3 -2
  90. package/tests/provisioning_render_route.mjs +1 -1
  91. package/tests/provisioning_teardown_intent.mjs +50 -0
  92. package/tests/render_api.mjs +5 -2
  93. package/tests/render_sandbox.mjs +447 -0
@@ -0,0 +1,150 @@
1
+ // tests/government_charter_advice.mjs — the genesis charter stage's recommendation
2
+ // from the founder's own answers (task 1005119).
3
+ //
4
+ // The rules held here:
5
+ // • ONE recommender: the answers widen recommendedCharter; with none it answers
6
+ // exactly as the size ladder always has, for every type and size;
7
+ // • every recommendation is an adoptable form, says why in the founder's answers,
8
+ // and a Foundation still brings its council rank;
9
+ // • the principles point at a strictness preset (or none), and saying what a preset
10
+ // does is part of the answer;
11
+ // • the drafted bylaws list the recommended preset's own laws word for word, so the
12
+ // prose and the law book agree, and say plainly when no laws are switched on;
13
+ // • the closed answers' keys are the genesis questions' choices (two modules, one
14
+ // vocabulary, held equal here because neither may import the other).
15
+ import assert from 'node:assert/strict';
16
+ import { test } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const reco = require(path.join(ROOT, 'modules', 'government', 'recommendations.js'));
24
+ const presets = require(path.join(ROOT, 'modules', 'government', 'presets.js'));
25
+ const charter = require(path.join(ROOT, 'modules', 'government', 'charter.js'));
26
+ const advice = require(path.join(ROOT, 'modules', 'government', 'charter-advice.js'));
27
+ const gw = require(path.join(ROOT, 'modules', 'onboarding', 'genesis-work.js'));
28
+
29
+ const TYPES = [...reco.PROJECT_TYPES.map((t) => t.key), undefined];
30
+ const SIZES = [0, 1, 2, 3, 8, 9, 49, 50, 149, 150, 1000];
31
+
32
+ test('the answer keys are exactly the charter questions\' choices', () => {
33
+ const closed = Object.fromEntries(gw.QUESTIONS.charter.filter((q) => q.choices).map((q) => [q.id, q.choices.map((c) => c.key)]));
34
+ const expected = { ...reco.CHARTER_ANSWERS, ...presets.PRINCIPLE_ANSWERS };
35
+ assert.deepEqual(Object.keys(closed).sort(), Object.keys(expected).sort());
36
+ for (const [id, keys] of Object.entries(expected)) assert.deepEqual(closed[id], [...keys], id);
37
+ });
38
+
39
+ test('with no answers (or only "not sure"), the ladder answers exactly as before', () => {
40
+ for (const type of TYPES) {
41
+ for (const n of SIZES) {
42
+ const plain = reco.recommendedCharter(n, type);
43
+ for (const a of [undefined, {}, { final_say: 'maybe', agreement: null }]) {
44
+ const r = reco.recommendedCharter(n, type, a);
45
+ assert.equal(r.key, plain.key);
46
+ assert.equal(r.why, plain.why);
47
+ assert.equal(r.from_answers, false);
48
+ }
49
+ }
50
+ }
51
+ });
52
+
53
+ test('the founder\'s final say picks the form, and why quotes them', () => {
54
+ const r = reco.recommendedCharter(1, 'game', { final_say: 'everyone' });
55
+ assert.equal(r.key, 'direct-democracy', 'a solo founder who wants everyone deciding is not told monarchy');
56
+ assert.match(r.why, /You said everyone on the project should have the final say/);
57
+ assert.equal(r.from_answers, true);
58
+ const council = reco.recommendedCharter(5, 'research', { final_say: 'council' });
59
+ assert.equal(council.key, 'foundation');
60
+ assert.equal(council.council_rank, 'steering-committee', 'a Foundation still brings its council');
61
+ });
62
+
63
+ test('the other answers move the franchise when the final say is not given', () => {
64
+ assert.equal(reco.recommendedCharter(5, null, { answers_to: 'yes' }).key, 'foundation');
65
+ const back = reco.recommendedCharter(1, null, { step_back: 'yes' });
66
+ assert.equal(back.key, 'timocratic-republic');
67
+ assert.match(back.why, /step back/);
68
+ assert.equal(reco.recommendedCharter(5, null, { strangers: 'yes' }).key, 'timocratic-republic');
69
+ const fast = reco.recommendedCharter(5, null, { speed: 'fast' });
70
+ assert.equal(fast.key, 'direct-democracy', 'speed alone keeps the ladder\'s form');
71
+ assert.match(fast.why, /Who votes follows your size/, 'the part the answers did not decide says the ladder decided it');
72
+ });
73
+
74
+ test('how much agreement tunes the pass rule and says where it is set', () => {
75
+ const r = reco.recommendedCharter(20, null, { agreement: 'majority' });
76
+ assert.equal(r.key, 'timocratic-republic');
77
+ assert.equal(r.pass_rule, 'majority');
78
+ assert.equal(r.pass_rule_tuned, true);
79
+ assert.match(r.why, /legislation stage/);
80
+ const solo = reco.recommendedCharter(1, null, { final_say: 'founder', agreement: 'unanimous' });
81
+ assert.equal(solo.pass_rule, 'first_ratifier', 'a board of one is decided by its one yes');
82
+ assert.match(solo.why, /only voter/);
83
+ });
84
+
85
+ test('every answer combination recommends an adoptable form whose settings build a proposal', () => {
86
+ const A = reco.CHARTER_ANSWERS;
87
+ const available = charter.FORMS.filter((f) => f.available).map((f) => f.key);
88
+ const live = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
89
+ for (const fs of [undefined, ...A.final_say]) for (const ag of [undefined, ...A.agreement]) for (const yn of [undefined, 'yes', 'no']) {
90
+ const r = reco.recommendedCharter(5, 'business', { final_say: fs, agreement: ag, strangers: yn, answers_to: yn, step_back: yn });
91
+ assert.ok(available.includes(r.key), r.key);
92
+ assert.ok(typeof r.why === 'string' && r.why.length > 10);
93
+ const built = charter.buildCharterProposal(live, { franchise: r.franchise, passRule: r.pass_rule, councilRank: r.council_rank });
94
+ assert.equal(built.ok, true, JSON.stringify(r));
95
+ assert.equal(charter.readCharter(built.proposal).franchise, r.franchise);
96
+ }
97
+ });
98
+
99
+ test('principles point at a preset, or at none', () => {
100
+ assert.equal(presets.recommendedPreset({}), null, 'no preset is a fine answer');
101
+ assert.equal(presets.recommendedPreset({ trust_level: 'a_lot' }).key, 'open');
102
+ assert.equal(presets.recommendedPreset({ trust_level: 'some' }).key, 'guild');
103
+ assert.equal(presets.recommendedPreset({ trust_level: 'little' }).key, 'factory');
104
+ assert.equal(presets.recommendedPreset({ trust_level: 'a_lot', catch_or_fix: 'catch_early' }).key, 'guild');
105
+ assert.equal(presets.recommendedPreset({ trust_level: 'little', catch_or_fix: 'move_fast' }).key, 'guild');
106
+ const p = presets.recommendedPreset({ trust_level: 'some' });
107
+ assert.match(p.why, /You said/);
108
+ assert.match(p.is, /not a style guide/);
109
+ assert.ok(p.laws.length > 0);
110
+ });
111
+
112
+ test('the draft names the recommended preset\'s own laws word for word', () => {
113
+ const a = advice.buildAdvice({
114
+ answers: { final_say: 'trusted', trust_level: 'little' },
115
+ prose: { feel: 'Calm and careful.', leave_behaviour: 'Lying about shipped work.', write_down: 'We never ship on Fridays.' },
116
+ headcount: 12,
117
+ });
118
+ assert.equal(a.charter.key, 'timocratic-republic');
119
+ assert.equal(a.preset.key, 'factory');
120
+ const factory = presets.PRESETS.find((p) => p.key === 'factory');
121
+ for (const law of factory.laws) assert.ok(a.draft_md.includes(`**${law.text}.** ${law.effect_on_ai}`), law.text);
122
+ assert.ok(a.preset.locks.some((l) => l.setting === 'wandering'), 'what it would lock is said');
123
+ assert.match(a.draft_md, /Timocratic Republic/);
124
+ assert.match(a.draft_md, /Calm and careful\./);
125
+ assert.match(a.draft_md, /Lying about shipped work\./);
126
+ assert.match(a.draft_md, /We never ship on Fridays\./);
127
+ });
128
+
129
+ test('with no preset the draft says no laws are switched on', () => {
130
+ const a = advice.buildAdvice({ answers: {}, headcount: 1 });
131
+ assert.equal(a.preset, null);
132
+ assert.match(a.draft_md, /No laws are switched on/);
133
+ assert.equal(a.charter.key, 'monarchy');
134
+ assert.doesNotMatch(a.draft_md, /Code of conduct/, 'no section for words the founder has not written');
135
+ });
136
+
137
+ test('the port entry reads the head count and the law book', async () => {
138
+ const calls = [];
139
+ const db = {
140
+ countActiveBuilders: async () => { calls.push('count'); return 40; },
141
+ listLaws: async () => { calls.push('laws'); return []; },
142
+ };
143
+ const live = await advice.charterAdvice({ answers: {}, prose: { feel: ' ' } }, { db });
144
+ assert.equal(live.headcount, 40);
145
+ assert.equal(live.charter.key, 'timocratic-republic');
146
+ assert.ok(calls.includes('laws'));
147
+ const expected = await advice.charterAdvice({ answers: {}, headcount: 2 }, { db });
148
+ assert.equal(expected.headcount, 2, 'the founder\'s expected number wins over the live count');
149
+ assert.equal(expected.charter.key, 'monarchy');
150
+ });
@@ -0,0 +1,364 @@
1
+ // tests/landing_guide_hall.mjs — the start-a-project guide, plain (builders' hall) version,
2
+ // and the switch between it and the game scene (task 1005276, goal 1000106).
3
+ //
4
+ // The two presentations share ONE conversation (assets/guide-core.js): the state machine,
5
+ // the server's client rules and the doors are pinned by tests/landing_guide_scene.mjs. What
6
+ // this file holds is the hall half and the switch, RUN under a small DOM stub:
7
+ // * the hall walk: open → chat → the first send held → the cap → the plan asked for and
8
+ // drawn under the talk → "Sign up and get started"; Escape hands focus back;
9
+ // * the fallback (a "form" start) and a retry error that gives the words back;
10
+ // * which one a door opens: the game unless this browser picked the plain one; the switch
11
+ // both ways mid-chat keeps the same chat (no second start) and is remembered;
12
+ // * the switch is offered only when the other presentation is on the page;
13
+ // * the markup and the sheet: hidden labelled dialog, the honeypot out of the tab order,
14
+ // the core loaded first, tokens only (no typed colour, size, corner, shadow or breakpoint).
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { createRequire } from 'node:module';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
26
+ const HTML = fs.readFileSync(path.join(LANDING, 'index.html'), 'utf8').replace(/\r\n/g, '\n');
27
+ const CSS = fs.readFileSync(path.join(LANDING, 'assets', 'guide-hall.css'), 'utf8').replace(/\r\n/g, '\n');
28
+ const HALL_SRC = fs.readFileSync(path.join(LANDING, 'assets', 'guide-hall.js'), 'utf8');
29
+ const Core = require(path.join(LANDING, 'assets', 'guide-core.js'));
30
+ const Scene = require(path.join(LANDING, 'assets', 'guide-scene.js'));
31
+ const Hall = require(path.join(LANDING, 'assets', 'guide-hall.js'));
32
+
33
+ // ── a small DOM: the two dialogs, each with its named parts ───────────────────────
34
+ const GAME_PARTS = ['log', 'err', 'form', 'text', 'website', 'send', 'end', 'go', 'left', 'segs', 'plan-body', 'plan-toggle', 'plan-close', 'close', 'skip', 'make-plan', 'plan-again', 'saved', 'switch'];
35
+ const HALL_PARTS = ['log', 'err', 'form', 'text', 'website', 'send', 'end', 'go', 'left', 'body', 'plan', 'plan-body', 'saved', 'close', 'make-plan', 'plan-again', 'switch'];
36
+
37
+ function makeDom({ game = true, hall = true } = {}) {
38
+ const listeners = new Map();
39
+ const on = (target) => (type, fn) => { if (!listeners.has(target)) listeners.set(target, {}); (listeners.get(target)[type] ||= []).push(fn); };
40
+ const fire = (target, type, ev) => ((listeners.get(target) || {})[type] || []).forEach((fn) => fn(ev));
41
+ const doc = { activeElement: null };
42
+ function node(tag = 'div') {
43
+ const n = {
44
+ tagName: tag.toUpperCase(), children: [], className: '', textContent: '', hidden: false, disabled: false, value: '', maxLength: 0, attrs: {}, scrollTop: 0, scrollHeight: 0, offsetTop: 0, offsetParent: {},
45
+ get firstChild() { return this.children[0] || null; },
46
+ appendChild(c) { this.children.push(c); return c; },
47
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); return c; },
48
+ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
49
+ removeAttribute(k) { delete this.attrs[k]; },
50
+ classList: { set: new Set(), add(c) { this.set.add(c); }, remove(c) { this.set.delete(c); }, toggle(c, v) { if (v) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } },
51
+ focus() { doc.activeElement = n; },
52
+ querySelectorAll() { return []; },
53
+ querySelector() { return null; },
54
+ };
55
+ n.addEventListener = on(n);
56
+ Object.defineProperty(n, 'innerHTML', { set() { throw new Error('innerHTML is not allowed'); } });
57
+ return n;
58
+ }
59
+ function dialog(attr, names) {
60
+ const parts = {};
61
+ names.forEach((k) => { parts[k] = node(); });
62
+ const root = node();
63
+ root.hidden = true;
64
+ root.querySelector = (sel) => {
65
+ const m = sel.match(new RegExp('^\\[' + attr + '="([^"]+)"\\]$'));
66
+ return m ? parts[m[1]] || null : null;
67
+ };
68
+ return { root, parts };
69
+ }
70
+ const g = game ? dialog('data-gd', GAME_PARTS) : null;
71
+ if (g) g.parts['plan-toggle'].offsetParent = null; // desktop: the plan stands beside the talk
72
+ const h = hall ? dialog('data-gh', HALL_PARTS) : null;
73
+ Object.assign(doc, {
74
+ documentElement: node('html'),
75
+ head: node('head'),
76
+ getElementById: (id) => (id === 'guide' && g ? g.root : id === 'guideHall' && h ? h.root : null),
77
+ createElement: (t) => node(t),
78
+ querySelector: () => null,
79
+ });
80
+ doc.addEventListener = on(doc);
81
+ return { doc, game: g, hall: h, fire, node };
82
+ }
83
+
84
+ function makeWin(mods, routes) {
85
+ const calls = [];
86
+ const timers = [];
87
+ const win = {
88
+ __MODULES__: mods,
89
+ localStorage: { data: {}, getItem(k) { return k in this.data ? this.data[k] : null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } },
90
+ setTimeout: (fn, ms) => { timers.push({ fn, ms }); },
91
+ fetch: (url, opts = {}) => {
92
+ calls.push({ url, method: opts.method || 'GET', body: opts.body ? JSON.parse(opts.body) : null, headers: opts.headers || {} });
93
+ const r = routes(url, opts);
94
+ return Promise.resolve({ status: r.status, json: () => Promise.resolve(r.body) });
95
+ },
96
+ };
97
+ return { win, calls, timers };
98
+ }
99
+ const ON = { enabled: { 'front-door-guide': true } };
100
+ const tick = () => new Promise((r) => setImmediate(r));
101
+ const settle = async (n = 4) => { for (let i = 0; i < n; i++) await tick(); };
102
+ function click(dom, door) {
103
+ let prevented = false;
104
+ const ev = { target: { closest: (sel) => (sel === '[data-guide-door]' ? door : null) }, button: 0, preventDefault() { prevented = true; }, get defaultPrevented() { return prevented; } };
105
+ dom.fire(dom.doc, 'click', ev);
106
+ return prevented;
107
+ }
108
+ // boot both, in the page's script order (the core is required by each)
109
+ function bootAll(win, dom) { Scene.boot(win, dom.doc); Hall.boot(win, dom.doc); }
110
+
111
+ const planAnswer = {
112
+ ok: true, mode: 'plan', message: 'Here is your plan.', next: { action: 'sign_up', label: 'Sign up and get started' }, saved: { message: 'Saved.' },
113
+ conversation: { messages_used: 2, messages_left: 0 },
114
+ plan: { summary: 'A booking site for a dance school.', how_it_works: ['Students pick a class'], steps: [{ title: 'Sign up', detail: 'Make your account' }], modules: [], needs: [], open_questions: ['Payments?'], cost_and_time: 'Cost and time depend on you.' },
115
+ };
116
+ function chatRoutes(over = {}) {
117
+ let left = 2;
118
+ return (url, opts) => {
119
+ const m = (opts.method || 'GET') + ' ' + url;
120
+ if (over[m]) return over[m](opts);
121
+ if (url.endsWith('/status')) return { status: 200, body: { ok: true, mode: 'chat', limits: { max_messages: 2, max_message_chars: 1000 } } };
122
+ if (m === 'POST /api/bongos/front-door-guide/conversations') return { status: 201, body: { ok: true, mode: 'chat', greeting: 'Hi!', conversation: { id: '9', token: 'tk', messages_used: 0, messages_left: 2, max_message_chars: 1000 }, saved: { message: 'This chat is saved on this device.', keep_days: 30 } } };
123
+ if (url.endsWith('/plan')) return { status: 200, body: planAnswer };
124
+ left -= 1;
125
+ return left > 0
126
+ ? { status: 200, body: { ok: true, mode: 'chat', reply: { text: 'Tell me more.' }, conversation: { messages_used: 1, messages_left: left } } }
127
+ : { status: 200, body: { ok: true, mode: 'ended', reason: 'message_cap', next_step: 'plan', message: 'That is all.', reply: { text: 'Thanks.' }, conversation: { messages_used: 2, messages_left: 0 } } };
128
+ };
129
+ }
130
+ const texts = (list) => list.children.map((li) => li.children.map((c) => c.textContent).join(' | '));
131
+
132
+ // ── module off ─────────────────────────────────────────────────────────────────────
133
+ test('module off: the hall reads nothing and takes no door', async () => {
134
+ const dom = makeDom({ game: false });
135
+ const { win, calls } = makeWin({ enabled: { 'front-door-guide': false } }, () => ({ status: 500, body: {} }));
136
+ Hall.boot(win, dom.doc);
137
+ await tick();
138
+ assert.equal(calls.length, 0);
139
+ assert.equal(click(dom, dom.node('a')), false);
140
+ assert.equal(dom.hall.root.hidden, true);
141
+ });
142
+
143
+ // ── the hall walk ──────────────────────────────────────────────────────────────────
144
+ test('the hall walk: open, the first send held, the cap asks for the plan, the plan under the talk, sign up', async () => {
145
+ const dom = makeDom();
146
+ const { win, calls, timers } = makeWin(ON, chatRoutes());
147
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
148
+ bootAll(win, dom);
149
+ await tick();
150
+ const door = dom.node('a');
151
+ assert.equal(click(dom, door), true, 'the door is taken');
152
+ const P = dom.hall.parts;
153
+ assert.equal(dom.hall.root.hidden, false, 'this browser picked the plain one');
154
+ assert.equal(dom.game.root.hidden, true, 'and the game stays down');
155
+ assert.ok(dom.doc.documentElement.classList.contains('gd-open'), 'the page under it does not scroll');
156
+ assert.deepEqual(texts(P.log), ['Guide | Your guide is thinking…'], 'the waiting line is words');
157
+ await settle();
158
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'chat');
159
+ assert.deepEqual(calls[1].body, { website: '' }, 'the hidden field is sent as it stands');
160
+ assert.deepEqual(JSON.parse(win.localStorage.data[Core.STORE_KEY]), { id: '9', token: 'tk' }, 'the same chat key the hand-off reads');
161
+ assert.deepEqual(texts(P.log), ['Guide | Hi!']);
162
+ assert.equal(P.left.textContent, '2 of 2 messages left');
163
+ assert.equal(P.saved.textContent, 'This chat is saved on this device.');
164
+ assert.equal(P.plan.hidden, true, 'no plan section while talking');
165
+ assert.equal(P['make-plan'].hidden, true);
166
+ assert.equal(dom.doc.activeElement, P.text, 'focus lands in the box');
167
+ assert.equal(P.switch.hidden, false, 'the game is offered');
168
+
169
+ P.text.value = 'A booking site';
170
+ dom.fire(P.form, 'submit', { preventDefault() {} });
171
+ assert.equal(P.text.value, '');
172
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'sending');
173
+ assert.ok(timers[0].ms > 1000, 'the first message waits out the bot check');
174
+ timers.shift().fn();
175
+ await settle();
176
+ assert.deepEqual(calls[2].body, { token: 'tk', text: 'A booking site', website: '' });
177
+ assert.equal(P.left.textContent, '1 of 2 messages left');
178
+ assert.equal(P['make-plan'].hidden, false, 'after a message the plan can be asked for early');
179
+
180
+ // Enter sends too
181
+ P.text.value = 'Students book';
182
+ dom.fire(P.text, 'keydown', { key: 'Enter', shiftKey: false, preventDefault() {} });
183
+ assert.equal(timers[0].ms, 0, 'later messages never wait');
184
+ timers.shift().fn();
185
+ await settle(6);
186
+ assert.ok(calls.some((c) => c.url.endsWith('/conversations/9/plan')), 'POST …/plan once the cap ended the chat');
187
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'ended');
188
+ assert.equal(P.form.hidden, true);
189
+ assert.equal(P.end.hidden, false);
190
+ assert.equal(P.plan.hidden, false, 'the plan sits under the talk');
191
+ const plan = P['plan-body'].children.map((c) => c.textContent);
192
+ assert.equal(plan[0], 'A booking site for a dance school.');
193
+ assert.ok(plan.includes('How it works') && plan.includes('Steps') && plan.includes('Still to decide'));
194
+ assert.equal(plan.at(-1), 'Cost and time depend on you.');
195
+ assert.equal(P.left.textContent, 'No messages left');
196
+ assert.equal(P.go.textContent, 'Sign up and get started');
197
+ assert.equal(P['plan-again'].hidden, true);
198
+ assert.equal(dom.doc.activeElement, P.go, 'focus moves to the way on');
199
+
200
+ dom.fire(dom.hall.root, 'keydown', { key: 'Escape', preventDefault() {} });
201
+ assert.equal(dom.hall.root.hidden, true);
202
+ assert.equal(dom.doc.activeElement, door, 'focus goes back to the door');
203
+ assert.ok(!dom.doc.documentElement.classList.contains('gd-open'));
204
+ });
205
+
206
+ test('the hall: a "form" start is the plain door, never an error', async () => {
207
+ const dom = makeDom();
208
+ const { win } = makeWin(ON, chatRoutes({ 'POST /api/bongos/front-door-guide/conversations': () => ({ status: 200, body: { ok: true, mode: 'form', reason: 'daily_budget', message: 'Our guide is busy today.' } }) }));
209
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
210
+ bootAll(win, dom);
211
+ await tick();
212
+ click(dom, dom.node('a'));
213
+ await settle();
214
+ const P = dom.hall.parts;
215
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'fallback');
216
+ assert.deepEqual(texts(P.log), ['Guide | Our guide is busy today.']);
217
+ assert.equal(P.err.hidden, true);
218
+ assert.equal(P.form.hidden, true);
219
+ assert.equal(P.go.textContent, 'Start a project');
220
+ assert.equal(P.left.hidden, true, 'no count on the fallback');
221
+ assert.equal(P.saved.hidden, true);
222
+ });
223
+
224
+ test('the hall: too long says so and gives the words back; an empty box says so and sends nothing', async () => {
225
+ const dom = makeDom();
226
+ const { win, calls, timers } = makeWin(ON, chatRoutes({ 'POST /api/bongos/front-door-guide/conversations/9/messages': () => ({ status: 400, body: { ok: false, error: 'too_long', max_message_chars: 500 } }) }));
227
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
228
+ bootAll(win, dom);
229
+ await tick();
230
+ click(dom, dom.node('a'));
231
+ await settle();
232
+ const P = dom.hall.parts;
233
+ P.text.value = ' ';
234
+ dom.fire(P.form, 'submit', { preventDefault() {} });
235
+ assert.equal(P.err.textContent, Core.COPY.empty);
236
+ assert.equal(timers.length, 0, 'nothing sent');
237
+ P.text.value = 'my long words';
238
+ dom.fire(P.form, 'submit', { preventDefault() {} });
239
+ timers.shift().fn();
240
+ await settle();
241
+ assert.equal(P.err.hidden, false);
242
+ assert.equal(P.err.textContent, 'That is too long. Keep it under 500 characters.');
243
+ assert.equal(P.text.value, 'my long words', 'the words come back');
244
+ assert.equal(P.text.maxLength, 500);
245
+ assert.equal(calls.filter((c) => c.url.endsWith('/messages')).length, 1);
246
+ });
247
+
248
+ // ── which one, and the switch ──────────────────────────────────────────────────────
249
+ test('a door opens the game when this browser has not picked; the switch carries the chat both ways and is remembered', async () => {
250
+ const dom = makeDom();
251
+ const { win, calls, timers } = makeWin(ON, chatRoutes());
252
+ bootAll(win, dom);
253
+ await tick();
254
+ click(dom, dom.node('a'));
255
+ await settle();
256
+ assert.equal(dom.game.root.hidden, false, 'the game by default');
257
+ assert.equal(dom.hall.root.hidden, true);
258
+ assert.equal(dom.game.parts.switch.hidden, false, '"Use the simple version" is there');
259
+
260
+ dom.game.parts.text.value = 'A booking site';
261
+ dom.fire(dom.game.parts.form, 'submit', { preventDefault() {} });
262
+ timers.shift().fn();
263
+ await settle();
264
+
265
+ dom.fire(dom.game.parts.switch, 'click', {});
266
+ assert.equal(dom.game.root.hidden, true);
267
+ assert.equal(dom.hall.root.hidden, false);
268
+ assert.ok(dom.doc.documentElement.classList.contains('gd-open'));
269
+ assert.equal(win.localStorage.data[Core.VIEW_KEY], 'hall', 'the pick is kept on this browser');
270
+ assert.deepEqual(texts(dom.hall.parts.log), ['Guide | Hi!', 'You | A booking site', 'Guide | Tell me more.'], 'the same chat');
271
+ assert.equal(dom.hall.parts.left.textContent, '1 of 2 messages left');
272
+ assert.equal(dom.doc.activeElement, dom.hall.parts.text);
273
+ assert.equal(calls.filter((c) => c.method === 'POST' && c.url.endsWith('/conversations')).length, 1, 'never a second chat');
274
+
275
+ // keep talking in the plain one: the same conversation id
276
+ dom.hall.parts.text.value = 'Students book';
277
+ dom.fire(dom.hall.parts.form, 'submit', { preventDefault() {} });
278
+ timers.shift().fn();
279
+ await settle(6);
280
+ assert.equal(calls.filter((c) => c.url.endsWith('/conversations/9/messages')).length, 2);
281
+ assert.equal(dom.hall.root.getAttribute('data-phase'), 'ended');
282
+
283
+ // and back to the game, plan and all
284
+ dom.fire(dom.hall.parts.switch, 'click', {});
285
+ assert.equal(dom.hall.root.hidden, true);
286
+ assert.equal(dom.game.root.hidden, false);
287
+ assert.equal(win.localStorage.data[Core.VIEW_KEY], 'game');
288
+ assert.equal(dom.game.root.getAttribute('data-phase'), 'ended');
289
+ assert.equal(dom.game.parts['plan-body'].children[0].textContent, 'A booking site for a dance school.');
290
+
291
+ // closed and opened again: the last pick
292
+ dom.fire(dom.game.root, 'keydown', { key: 'Escape', preventDefault() {} });
293
+ dom.fire(dom.game.parts.switch, 'click', {}); // nothing open: the press opens nothing
294
+ assert.equal(dom.hall.root.hidden, true);
295
+ assert.equal(win.localStorage.data[Core.VIEW_KEY], 'game');
296
+ win.localStorage.data[Core.VIEW_KEY] = 'hall';
297
+ click(dom, dom.node('a'));
298
+ assert.equal(dom.hall.root.hidden, false);
299
+ assert.equal(dom.game.root.hidden, true);
300
+ });
301
+
302
+ test('the switch is offered only when the other one is on the page', async () => {
303
+ const gameOnly = makeDom({ hall: false });
304
+ const a = makeWin(ON, chatRoutes());
305
+ Scene.boot(a.win, gameOnly.doc);
306
+ await tick();
307
+ click(gameOnly, gameOnly.node('a'));
308
+ assert.equal(gameOnly.game.parts.switch.hidden, true);
309
+
310
+ const hallOnly = makeDom({ game: false });
311
+ const b = makeWin(ON, chatRoutes());
312
+ Hall.boot(b.win, hallOnly.doc);
313
+ await tick();
314
+ click(hallOnly, hallOnly.node('a'));
315
+ assert.equal(hallOnly.hall.root.hidden, false, 'with no game the plain one opens');
316
+ assert.equal(hallOnly.hall.parts.switch.hidden, true);
317
+ });
318
+
319
+ test('a stored pick that is not a presentation falls back to the game', async () => {
320
+ const dom = makeDom();
321
+ const { win } = makeWin(ON, chatRoutes());
322
+ win.localStorage.data[Core.VIEW_KEY] = 'bogus';
323
+ bootAll(win, dom);
324
+ await tick();
325
+ click(dom, dom.node('a'));
326
+ assert.equal(dom.game.root.hidden, false);
327
+ });
328
+
329
+ // ── markup and sheet ───────────────────────────────────────────────────────────────
330
+ test('the plain dialog is markup, hidden, labelled, with the honeypot out of the tab order and the same closing action', () => {
331
+ assert.match(HTML, /<div class="gh" id="guideHall" role="dialog" aria-modal="true" aria-labelledby="ghTitle" hidden>/);
332
+ assert.match(HTML, /<input class="gh__hp" type="text" name="website" data-gh="website" tabindex="-1" autocomplete="off" aria-hidden="true">/);
333
+ assert.match(HTML, /<label class="gh__label" for="ghText">Your message<\/label>/);
334
+ assert.match(HTML, /<a class="door door--go" data-gh="go" href="\/projects\?view=new">Sign up and get started<\/a>/);
335
+ assert.match(HTML, /<button type="button" class="gh__link" data-gh="switch">Use the game version<\/button>/);
336
+ assert.match(HTML, /<button type="button" class="gd__switch" data-gd="switch" hidden>Use the simple version<\/button>/);
337
+ assert.match(HTML, /<script src="\/assets\/guide-core\.js"><\/script>\n<script src="\/assets\/guide-scene\.js"><\/script>\n<script src="\/assets\/guide-hall\.js"><\/script>/);
338
+ assert.match(HTML, /<link rel="stylesheet" href="\/assets\/guide-hall\.css">/);
339
+ assert.ok(HTML.indexOf('/assets/world.css') < HTML.indexOf('/assets/guide-hall.css'));
340
+ });
341
+
342
+ test('the hall script writes no innerHTML and asks the server nothing itself', () => {
343
+ const code = HALL_SRC.replace(/\/\*[\s\S]*?\*\//g, '');
344
+ assert.doesNotMatch(code, /innerHTML/);
345
+ assert.doesNotMatch(code, /fetch\(|localStorage|sessionStorage/, 'the routes and the storage are the core\'s');
346
+ });
347
+
348
+ test('the sheet: tokens only, no :root, no breakpoint, no !important, no motion', () => {
349
+ const code = CSS.replace(/\/\*[\s\S]*?\*\//g, '');
350
+ assert.doesNotMatch(code, /#[0-9a-f]{3,8}\b/i, 'no hex colour');
351
+ assert.doesNotMatch(code, /\brgba?\((?!0, 0, 0,)/, 'the only literal is black with alpha (the scrim)');
352
+ assert.doesNotMatch(code, /font-size:\s*\d/, 'type from the scale');
353
+ assert.doesNotMatch(code, /border-radius:\s*\d/, 'corners from the scale');
354
+ assert.doesNotMatch(code, /:root|@media|!important|animation|transition/);
355
+ });
356
+
357
+ test('every control in the plain dialog clears the 24px floor', () => {
358
+ for (const [sel, min] of [['.gh__x', /width: 40px; height: 40px/], ['.gh__link', /min-height: 24px/]]) {
359
+ const rule = CSS.slice(CSS.indexOf(sel + ' {'));
360
+ assert.match(rule.slice(0, rule.indexOf('}')), min, sel);
361
+ }
362
+ // the presses are the landing's own door pills (48px, index.html .door)
363
+ assert.match(HTML, /\.door\{display:inline-flex;align-items:center;justify-content:center;min-height:48px;/);
364
+ });
@@ -31,6 +31,8 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
31
31
  const HTML = fs.readFileSync(path.join(LANDING, 'index.html'), 'utf8').replace(/\r\n/g, '\n');
32
32
  const CSS = fs.readFileSync(path.join(LANDING, 'assets', 'guide-scene.css'), 'utf8').replace(/\r\n/g, '\n');
33
33
  const JS_SRC = fs.readFileSync(path.join(LANDING, 'assets', 'guide-scene.js'), 'utf8');
34
+ // the conversation's own half (the routes, the storage) is shared with the plain twin since task 1005276
35
+ const CORE_SRC = fs.readFileSync(path.join(LANDING, 'assets', 'guide-core.js'), 'utf8');
34
36
  const G = require(path.join(LANDING, 'assets', 'guide-scene.js'));
35
37
 
36
38
  const chatStart = (over = {}) => ({
@@ -462,7 +464,7 @@ test('the scene is markup, hidden, a labelled modal dialog, with the honeypot ou
462
464
  assert.match(HTML, /<button type="button" class="gd__more" data-gd="make-plan" hidden>Make my plan<\/button>/);
463
465
  assert.match(HTML, /<button type="button" class="gd__more" data-gd="plan-again" hidden>Make my plan<\/button>/);
464
466
  assert.match(HTML, /<p class="gd__saved" data-gd="saved" hidden><\/p>/);
465
- assert.match(HTML, /<script src="\/assets\/guide-scene\.js"><\/script>/);
467
+ assert.match(HTML, /<script src="\/assets\/guide-core\.js"><\/script>\n<script src="\/assets\/guide-scene\.js"><\/script>/, 'the shared core loads first');
466
468
  assert.match(HTML, /<link rel="stylesheet" href="\/assets\/guide-scene\.css">/);
467
469
  // the guide is the Patron sprite the hall already serves, not a second drawing
468
470
  assert.match(HTML, /src="\/modules\/hall-ui\/public\/characters\/patron\.png"/);
@@ -474,11 +476,12 @@ test('a signed-in visitor keeps the plain doors: the /me read takes the hook off
474
476
  });
475
477
 
476
478
  test('the script writes no innerHTML and talks only to the guide routes', () => {
477
- assert.doesNotMatch(JS_SRC.replace(/\/\*[\s\S]*?\*\//g, ''), /innerHTML/);
478
- const urls = [...JS_SRC.matchAll(/API \+ '([^']+)'/g)].map((m) => m[1]);
479
+ for (const src of [JS_SRC, CORE_SRC]) assert.doesNotMatch(src.replace(/\/\*[\s\S]*?\*\//g, ''), /innerHTML/);
480
+ const urls = [...CORE_SRC.matchAll(/API \+ '([^']+)'/g)].map((m) => m[1]);
479
481
  assert.deepEqual([...new Set(urls)].sort(), ['/conversations', '/conversations/', '/status']);
480
- assert.match(JS_SRC, /convUrl\('\/plan'\)/, 'the plan route');
481
- assert.doesNotMatch(JS_SRC, /sessionStorage/, 'the chat is kept in local storage, as the server\'s contract says');
482
+ assert.doesNotMatch(JS_SRC, /fetch\(/, 'the scene asks the server nothing itself: the core does');
483
+ assert.match(CORE_SRC, /convUrl\('\/plan'\)/, 'the plan route');
484
+ assert.doesNotMatch(CORE_SRC + JS_SRC, /sessionStorage/, 'the chat is kept in local storage, as the server\'s contract says');
482
485
  assert.equal(G.API, '/api/bongos/front-door-guide');
483
486
  });
484
487
 
@@ -55,6 +55,7 @@ const PUBLISHED_SURFACE = [
55
55
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
56
56
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
57
57
  'readApplicantViewFromHub', // added by task 1002304 (ADR 0365 D5): the same NARROW hub read as readApplicantProfileFromHub, answering { profile, guild } so the reviewer queue can group a guild's applicants. The guild rides only beside a profile, and the older port is kept so a module on it reads exactly what it did
58
+ 'readAppStatusFromHub', // added by task 1004571: where THIS project's app runs, read from the hub for the options menu's app-host panel. The same NARROW-port reason as readApplicantProfileFromHub: it carries this instance's hub client secret, so it takes no argument and a module can neither widen nor re-address it
58
59
  'notifyHubOfInvite', // added by task 1002818 (ADR 0353): tell the hub about one of THIS project's invites (or its rescind) so the invitee sees it in My Projects. The same NARROW-port reason as readApplicantProfileFromHub: the call carries this instance's hub client secret, so a module passes one login and one direction and can neither widen nor re-address it
59
60
  'inviteExpiry', // added by task 1002969 (ADR 0356): the ONE read-time definition of when a recruit invite's door closes. The sign-in gate reads it in core; the onboarding queues and the hub's notice list are modules, so without the doorway each would restate the 30 days and the predicate, and two copies can close a door on different days
60
61
  'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
@@ -278,7 +278,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
278
278
  // That is precisely how the agents regression reached an open PR (task 1002485).
279
279
  loader._reset();
280
280
  const snap = loader.loaderSnapshot();
281
- assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'front-door-guide', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
281
+ assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'app-host', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'front-door-guide', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
282
282
  assert.deepEqual(snap.errors, []);
283
283
  assert.deepEqual(snap.skipped, []);
284
284
  loader._reset();
@@ -290,8 +290,14 @@ test('the module declares the task page section and ships its widget; the seam g
290
290
  assert.deepEqual(manifest.contributes.uiSections, ['task-where']);
291
291
  assert.ok(manifest.contributes.routes.includes('task-where'));
292
292
  assert.ok(fs.existsSync(path.join(ROOT, 'modules/npm-release/public/task-widget.js')));
293
- const guard = fs.readFileSync(path.join(ROOT, 'scripts/bongos/seam-wiring-guard.js'), 'utf8');
294
- assert.match(guard, /alternates: \(dir\) => \[path\.join\(dir, 'public', 'task-widget\.js'\)\]/);
293
+ // Ask the guard itself (task 1004571 converted this from a source-text match): a module
294
+ // whose only widget is task-widget.js counts as wired, and with no widget it does not.
295
+ const { scanSeamWiring } = require('../scripts/bongos/seam-wiring-guard.js');
296
+ const dir = path.join(ROOT, 'modules/npm-release');
297
+ const only = (file) => ({ exists: (p) => p === path.join(dir, 'public', file) });
298
+ const mod = [{ key: 'npm-release', dir, manifest: { contributes: { uiSections: ['task-where'] } } }];
299
+ assert.deepEqual(scanSeamWiring(mod, only('task-widget.js')).findings, []);
300
+ assert.equal(scanSeamWiring(mod, { exists: () => false }).findings.length, 1);
295
301
  });
296
302
 
297
303
  test('the server injects task widgets just before task.js, and names no module doing it', () => {
@@ -302,8 +308,12 @@ test('the server injects task widgets just before task.js, and names no module d
302
308
  assert.ok(out.indexOf('/builders/md-reader.js') < out.indexOf('task-widget.js'));
303
309
  assert.match(out, /<script src="\/modules\/npm-release\/public\/task-widget\.js" defer><\/script>/);
304
310
  assert.equal(injectModuleHallWidgets(page, [], 'task'), page, 'no module, no change');
305
- const index = '<script src="/builders/builders.js?v=1"></script>';
311
+ // The index's builders.js is deferred, so its widgets are too (unchanged). Since task
312
+ // 1004571 a widget loads the way its consumer does, so a plain consumer gets a plain one.
313
+ const index = '<script src="/builders/builders.js?v=1" defer></script>';
306
314
  assert.match(injectModuleHallWidgets(index, [{ key: 'x', src: '/m.js' }]), /^<script src="\/m\.js" defer><\/script><script src="\/builders\/builders\.js/, 'the index injection is unchanged');
315
+ const plain = '<script src="/builders/settings.js?v=1"></script>';
316
+ assert.match(injectModuleHallWidgets(plain, [{ key: 'x', src: '/m.js' }], 'settings'), /^<script src="\/m\.js"><\/script><script src="\/builders\/settings\.js/);
307
317
  const si = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
308
318
  assert.match(si, /hallWidgetScripts\(\{ isEnabled: isModuleEnabled, file: 'task-widget\.js' \}\),\s*\n\s*'task',/);
309
319
  });
@@ -193,6 +193,7 @@ await ta('it knocks only on an onrender.com address, and follows no redirect', a
193
193
  await ta('a sweep marks only answering apps live, compare-and-set on the state it read', async () => {
194
194
  const writes = [];
195
195
  const db = { query: async (sql, params) => {
196
+ if (/app_host->'sandbox'/.test(sql)) return { rows: [] }; // the sandbox read (task 1005289)
196
197
  if (/^\s*SELECT/.test(sql)) {
197
198
  assert.match(sql, /app_host->>'state' IN \('deploying', 'linked'\)/);
198
199
  return { rows: [
@@ -213,7 +214,7 @@ await ta('a sweep marks only answering apps live, compare-and-set on the state i
213
214
  await ta('the knocks run a few at a time, never all at once and never one by one', async () => {
214
215
  let inFlight = 0, peak = 0;
215
216
  const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, slug: `a${i}`, app_host: { state: 'deploying', serviceUrl: `https://a${i}.onrender.com` } }));
216
- const db = { query: async (sql) => (/^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
217
+ const db = { query: async (sql) => (/app_host->'sandbox'/.test(sql) ? { rows: [] } : /^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
217
218
  const fetchImpl = async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return res(200); };
218
219
  const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
219
220
  assert.deepEqual(out, { checked: 12, live: 12 });
@@ -233,7 +234,7 @@ await ta('a run that starts while the previous sweep is in flight is skipped (ta
233
234
  const [firstOut, second] = await Promise.all([first, secondRun]);
234
235
  assert.deepEqual(second, { skipped: true });
235
236
  assert.deepEqual(firstOut, { checked: 0, live: 0 });
236
- assert.equal(reads, 1, 'the overlapping run never touched the database');
237
+ assert.equal(reads, 2, 'the overlapping run never touched the database (the one run read the apps, then the sandboxes)');
237
238
  assert.deepEqual(await liveness.tick({ pool: { query: async () => ({ rows: [] }) }, log: () => {} }), { checked: 0, live: 0 },
238
239
  'once the first finished, the next run goes ahead');
239
240
  });