@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.
- package/.bongos-core.json +200 -75
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +36 -2
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +761 -112
- package/docs/api-reference.md +23 -3
- package/docs/copy-inventory.md +118 -108
- package/docs/copy-registry.json +315 -172
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +62 -62
- package/modules/app-host/CLAUDE.md +15 -0
- package/modules/app-host/module.json +20 -0
- package/modules/app-host/public/settings-widget.js +143 -0
- package/modules/app-host/routes/app-host.js +88 -0
- package/modules/government/charter-advice.js +103 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/government/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +190 -0
- package/modules/lifecycle/first-read.js +330 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +101 -0
- package/modules/onboarding/genesis-work.js +101 -4
- package/modules/onboarding/routes/genesis-work.js +2 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso-app-status.js +65 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/pollers/app-liveness.js +23 -1
- package/modules/provisioning/render-sandbox.js +113 -0
- package/modules/provisioning/render-standup.js +3 -1
- package/modules/provisioning/routes/provisioning.js +4 -1
- package/modules/provisioning/routes/render-standup.js +49 -1
- package/modules/provisioning/seams.js +32 -1
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/public-landing/public/assets/guide-core.js +475 -0
- package/modules/public-landing/public/assets/guide-hall.css +121 -0
- package/modules/public-landing/public/assets/guide-hall.js +164 -0
- package/modules/public-landing/public/assets/guide-scene.css +9 -0
- package/modules/public-landing/public/assets/guide-scene.js +58 -381
- package/modules/public-landing/public/index.html +46 -1
- package/modules/public-landing/public/projects.html +112 -6
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/bongos/first-read.js +150 -0
- package/scripts/bongos/genesis.js +21 -1
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/bongos/provision-render-sandbox.js +171 -0
- package/scripts/bongos/provision-render.js +37 -2
- package/scripts/bongos/provision-teardown.js +1 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/scripts/hall-preview/server.js +2 -0
- package/src/bongos/auth-admission.js +64 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes/core-update.js +3 -16
- package/src/bongos/routes/modules.js +11 -0
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +6 -1
- package/src/module-loader/loader.js +2 -2
- package/tests/app_host.mjs +413 -0
- package/tests/first_read.mjs +479 -0
- package/tests/genesis_work.mjs +74 -3
- package/tests/government_act_alone_buildings.mjs +2 -0
- package/tests/government_charter_advice.mjs +150 -0
- package/tests/landing_guide_hall.mjs +364 -0
- package/tests/landing_guide_scene.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_where.mjs +13 -3
- package/tests/provisioning_app_status.mjs +3 -2
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +50 -0
- package/tests/render_api.mjs +5 -2
- 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(
|
|
478
|
-
const urls = [...
|
|
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.
|
|
481
|
-
assert.
|
|
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
|
|
package/tests/module_api.mjs
CHANGED
|
@@ -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
|
package/tests/module_loader.mjs
CHANGED
|
@@ -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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
});
|