@bongos/core 1.21.4 → 1.21.6
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 +233 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +37 -2
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +541 -658
- package/docs/copy-registry.json +1205 -2340
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +47 -28
- package/docs/page-readings.json +1128 -1339
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/governor-docket.js +101 -0
- package/modules/autonomy/routes/autonomy.js +27 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/discord/board-broadcast.js +3 -1
- package/modules/discord/craft-broadcast.js +107 -0
- package/modules/discord/routes/discord.js +10 -0
- package/modules/discord/ship-broadcast.js +30 -2
- package/modules/economy/reward.js +3 -0
- package/modules/government/board.js +20 -5
- package/modules/government/docket-vocab.js +82 -0
- package/modules/government/docket.js +458 -0
- package/modules/government/routes/government.js +34 -0
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/genesis-home.js +9 -10
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/ideas/governor-docket.js +93 -0
- package/modules/ideas/routes/blockers.js +5 -0
- package/modules/lifecycle/governor-docket.js +192 -0
- package/modules/lifecycle/routes/gate-approvals.js +78 -33
- package/modules/lifecycle/routes/lifecycle.js +3 -0
- package/modules/onboarding/routes/access-requests.js +68 -27
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/assets/cosmos.css +6 -50
- package/modules/public-landing/public/projects.html +1170 -1659
- package/modules/public-landing/public/projects.probes.json +18 -18
- package/modules/public-landing/public/projects.states.json +24 -31
- package/modules/security/routes/reports.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +72 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/module-rechecks.js +77 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/core-update.js +68 -2
- package/src/bongos/routes/modules.js +17 -0
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/discord_craft_broadcast.mjs +162 -0
- package/tests/genesis_home.mjs +1 -1
- package/tests/government_routes.mjs +3 -1
- package/tests/governor_docket.mjs +552 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/onboard.mjs +10 -4
- package/tests/platform_boot.mjs +1 -1
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/projects_hub.mjs +196 -89
- package/tests/projects_hub_app_status.mjs +3 -1
- package/tests/projects_hub_app_step.mjs +72 -48
- package/tests/projects_hub_dns_ready.mjs +10 -2
- package/tests/projects_hub_look.mjs +32 -22
- package/tests/projects_hub_module_picker.mjs +112 -62
- package/tests/projects_hub_pre_uat.mjs +48 -41
- package/tests/provision.mjs +78 -0
- package/tests/public_landing_projects.mjs +12 -6
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_demo.mjs +37 -11
- package/tests/wizard_draft_resume.mjs +34 -22
- package/tests/wizard_front_door.mjs +173 -103
- package/tests/wizard_intent_resume.mjs +28 -25
- package/tests/wizard_physics.mjs +94 -76
- package/tests/wizard_physics_more.mjs +428 -277
- package/tests/wizard_six_screens.mjs +126 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// tests/settings_main_role.mjs — the Settings main-role picker (task 1004541 /
|
|
2
|
+
// WA6.RP12, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
3
|
+
//
|
|
4
|
+
// The owner's rule (2026-09-30): the builder CHOOSES their main role. The server
|
|
5
|
+
// side existed (builders.main_discipline, PATCH /me/disciplines main_discipline,
|
|
6
|
+
// task 1004433) with nothing in the hall to make the choice. This EXECUTES the
|
|
7
|
+
// real modules/hall-ui/public/settings.js in a minimal DOM stub against a canned
|
|
8
|
+
// /me and a recording PATCH, and pins the control's contract:
|
|
9
|
+
//
|
|
10
|
+
// - it seeds from GET /me's main_discipline (and /me now carries it:
|
|
11
|
+
// db-kernel getBuilderById selects the column);
|
|
12
|
+
// - the options are "Let my work decide" plus exactly the roles ticked above,
|
|
13
|
+
// and ticking/unticking a role adds/removes its option;
|
|
14
|
+
// - unticking the main role hands the choice back to "Let my work decide";
|
|
15
|
+
// - Save sends main_discipline in the same PATCH — the pick, or null to clear —
|
|
16
|
+
// and re-seeds from the server's echo.
|
|
17
|
+
//
|
|
18
|
+
// Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import vm from 'node:vm';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
|
|
27
|
+
const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
|
|
28
|
+
const KERNEL = fs.readFileSync(path.join(ROOT, 'src/bongos/db-kernel.js'), 'utf8');
|
|
29
|
+
|
|
30
|
+
let passed = 0, failed = 0;
|
|
31
|
+
async function t(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
33
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DISCIPLINES = ['engineer', 'artist', 'ideator', 'ui'];
|
|
37
|
+
|
|
38
|
+
// A permissive element: everything settings.js touches at boot, with listeners
|
|
39
|
+
// recorded so a test can fire them.
|
|
40
|
+
function makeEl() {
|
|
41
|
+
const listeners = {};
|
|
42
|
+
return {
|
|
43
|
+
hidden: false, textContent: '', value: '', disabled: false, href: '', className: '',
|
|
44
|
+
dataset: {}, style: {}, children: [], _html: '',
|
|
45
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
46
|
+
addEventListener(type, fn) { (listeners[type] || (listeners[type] = [])).push(fn); },
|
|
47
|
+
fire(type, ev) { for (const fn of listeners[type] || []) fn(ev); },
|
|
48
|
+
appendChild(c) { this.children.push(c); return c; },
|
|
49
|
+
append() {}, prepend() {}, remove() {}, setAttribute() {}, removeAttribute() {}, getAttribute() { return null; },
|
|
50
|
+
querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; },
|
|
51
|
+
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
|
52
|
+
focus() {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function boot({ builder }) {
|
|
57
|
+
const els = new Map();
|
|
58
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
59
|
+
const patches = [];
|
|
60
|
+
const api = {
|
|
61
|
+
async request(method, url, opts = {}) {
|
|
62
|
+
if (method === 'GET' && url === '/api/bongos/me') return { status: 200, ok: true, data: { builder } };
|
|
63
|
+
if (method === 'PATCH' && url === '/api/bongos/me/disciplines') {
|
|
64
|
+
const body = opts.body;
|
|
65
|
+
patches.push(body);
|
|
66
|
+
const prefs = DISCIPLINES.filter((d) => body.preferred_disciplines.includes(d));
|
|
67
|
+
const main = body.main_discipline === undefined ? builder.main_discipline : body.main_discipline;
|
|
68
|
+
return { status: 200, ok: true, data: { builder: { ...builder, preferred_disciplines: prefs, main_discipline: main } } };
|
|
69
|
+
}
|
|
70
|
+
return { status: 404, ok: false, data: null };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
74
|
+
const document = {
|
|
75
|
+
getElementById: (id) => $(`#${id}`),
|
|
76
|
+
createElement: () => makeEl(),
|
|
77
|
+
dispatchEvent() {}, addEventListener() {},
|
|
78
|
+
querySelector: () => null, querySelectorAll: () => [],
|
|
79
|
+
};
|
|
80
|
+
const window = {
|
|
81
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
82
|
+
BongosClient: { createClient: () => api },
|
|
83
|
+
OTB: {
|
|
84
|
+
$, escapeHtml, toast() {},
|
|
85
|
+
disciplines: () => DISCIPLINES,
|
|
86
|
+
roleLabel: (d) => (d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
87
|
+
hubOrigin: () => null,
|
|
88
|
+
settingsPanels: () => [],
|
|
89
|
+
},
|
|
90
|
+
OTBKit: { emptyStateHtml: (m) => m, makeSectionRail: () => ({ refresh() {}, activate() {} }) },
|
|
91
|
+
location: { hash: '', pathname: '/settings', search: '', assign() {} },
|
|
92
|
+
scrollTo() {}, confirm: () => false, addEventListener() {},
|
|
93
|
+
};
|
|
94
|
+
const sandbox = {
|
|
95
|
+
window, document, console: { log() {}, warn() {}, error() {}, info() {} },
|
|
96
|
+
history: { replaceState() {} }, URLSearchParams, setTimeout, clearTimeout,
|
|
97
|
+
};
|
|
98
|
+
sandbox.globalThis = sandbox;
|
|
99
|
+
vm.createContext(sandbox);
|
|
100
|
+
vm.runInContext(SRC, sandbox, { filename: 'settings.js' });
|
|
101
|
+
for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r));
|
|
102
|
+
|
|
103
|
+
// The control as a test sees it.
|
|
104
|
+
const options = () => [...$('#craft-main-opts').innerHTML.matchAll(/value="([^"]*)"(\s*checked)?>([^<]*)<\/label>/g)]
|
|
105
|
+
.map((m) => ({ value: m[1], checked: !!m[2], label: m[3] }));
|
|
106
|
+
const checked = () => (options().find((o) => o.checked) || {}).value;
|
|
107
|
+
const toggleRole = (d, on) => {
|
|
108
|
+
const chip = { dataset: {} };
|
|
109
|
+
const box = { value: d, checked: on, closest: (sel) => (sel === '.craft-pref' ? box : sel === '.craft-chip' ? chip : null) };
|
|
110
|
+
$('#craft-chips').fire('change', { target: box });
|
|
111
|
+
};
|
|
112
|
+
const pickMain = (v) => {
|
|
113
|
+
const pick = { value: v, closest: (sel) => (sel === '.craft-main-pick' ? pick : null) };
|
|
114
|
+
$('#craft-main-opts').fire('change', { target: pick });
|
|
115
|
+
};
|
|
116
|
+
const save = async () => {
|
|
117
|
+
$('#craft-save').fire('click', {});
|
|
118
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
|
|
119
|
+
};
|
|
120
|
+
return { $, options, checked, toggleRole, pickMain, save, patches };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const B = (over = {}) => ({ id: 4, github_login: 'b4', display_name: 'Builder 4', preferred_disciplines: ['engineer', 'ideator'], main_discipline: null, ...over });
|
|
124
|
+
|
|
125
|
+
console.log('\nthe main-role picker:');
|
|
126
|
+
|
|
127
|
+
await t('the markup carries the Main role group inside Preferred roles', () => {
|
|
128
|
+
const craft = HTML.slice(HTML.indexOf('id="craft-scroll"'), HTML.indexOf('id="craft-save"'));
|
|
129
|
+
assert.match(craft, /<fieldset class="craft-main" id="craft-main">/);
|
|
130
|
+
assert.match(craft, /<legend class="craft-main__h">Main role<\/legend>/);
|
|
131
|
+
assert.match(craft, /id="craft-main-opts"/);
|
|
132
|
+
assert.match(craft, /leads your profile and your leaderboard entry/, 'it says what a main role does');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await t('GET /me carries the saved main role (getBuilderById selects the column)', () => {
|
|
136
|
+
const sel = KERNEL.slice(KERNEL.indexOf('async function getBuilderById'), KERNEL.indexOf('FROM builders WHERE id = $1'));
|
|
137
|
+
assert.match(sel, /\bmain_discipline\b/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('options are "Let my work decide" plus exactly the ticked roles; nothing saved → by work', async () => {
|
|
141
|
+
const p = await boot({ builder: B() });
|
|
142
|
+
assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'ideator']);
|
|
143
|
+
assert.equal(p.options()[0].label, 'Let my work decide');
|
|
144
|
+
assert.deepEqual(p.options().map((o) => o.label).slice(1), ['Engineer', 'Ideator']);
|
|
145
|
+
assert.equal(p.checked(), '', 'no saved main role → "Let my work decide" is chosen');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await t('a saved main role is the one chosen on load', async () => {
|
|
149
|
+
const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
|
|
150
|
+
assert.equal(p.checked(), 'ideator');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await t('ticking a role adds its option; unticking the MAIN role hands the choice back to your work', async () => {
|
|
154
|
+
const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
|
|
155
|
+
p.toggleRole('artist', true);
|
|
156
|
+
assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'artist', 'ideator'], 'in the instance\'s role order');
|
|
157
|
+
assert.equal(p.checked(), 'ideator', 'adding a role keeps the pick');
|
|
158
|
+
p.toggleRole('ideator', false);
|
|
159
|
+
assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'artist']);
|
|
160
|
+
assert.equal(p.checked(), '', 'a role you no longer have cannot lead');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('Save sends the pick in the same PATCH, and the echo re-seeds it', async () => {
|
|
164
|
+
const p = await boot({ builder: B() });
|
|
165
|
+
p.pickMain('ideator');
|
|
166
|
+
await p.save();
|
|
167
|
+
assert.equal(p.patches.length, 1);
|
|
168
|
+
assert.deepEqual(JSON.parse(JSON.stringify(p.patches[0])), { preferred_disciplines: ['engineer', 'ideator'], main_discipline: 'ideator' });
|
|
169
|
+
assert.equal(p.checked(), 'ideator', 'the server\'s echo is what the control shows after saving');
|
|
170
|
+
assert.match(p.$('#craft-status').textContent, /Main role: Ideator\./);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await t('"Let my work decide" saves null, which clears a saved main role', async () => {
|
|
174
|
+
const p = await boot({ builder: B({ main_discipline: 'engineer' }) });
|
|
175
|
+
p.pickMain('');
|
|
176
|
+
await p.save();
|
|
177
|
+
assert.equal(p.patches[0].main_discipline, null);
|
|
178
|
+
assert.equal(p.checked(), '');
|
|
179
|
+
assert.doesNotMatch(p.$('#craft-status').textContent, /Main role/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await t('unticking the main role, then saving, sends null — never a role outside the set', async () => {
|
|
183
|
+
const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
|
|
184
|
+
p.toggleRole('ideator', false);
|
|
185
|
+
await p.save();
|
|
186
|
+
assert.deepEqual(JSON.parse(JSON.stringify(p.patches[0])), { preferred_disciplines: ['engineer'], main_discipline: null });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
190
|
+
if (failed) process.exit(1);
|
|
@@ -194,7 +194,7 @@ const STUB_UPDATE = { reading: 'ok', running: '1.19.10', offered: '1.19.13' };
|
|
|
194
194
|
test('the platform\'s hall: the Update button goes to its own door', async () => {
|
|
195
195
|
const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: true, idp: null }) });
|
|
196
196
|
assert.equal(r.status, 200);
|
|
197
|
-
assert.deepEqual(r.body.update, STUB_UPDATE);
|
|
197
|
+
assert.deepEqual(r.body.update, { ...STUB_UPDATE, channel_source: 'default' }, 'the reader\'s answer, plus which rule it used');
|
|
198
198
|
assert.deepEqual(r.body.door, { kind: 'local', url: '/deploy' });
|
|
199
199
|
assert.equal(r.body.can_update, true);
|
|
200
200
|
});
|
|
@@ -447,3 +447,58 @@ test('the platform, which runs the door, keeps /deploy behind its own page gate'
|
|
|
447
447
|
assert.equal(r.status, 302, 'a visitor with no session meets the page gate');
|
|
448
448
|
assert.match(String(r.location), /auth|sign/i, 'and is sent to sign in, as before');
|
|
449
449
|
});
|
|
450
|
+
|
|
451
|
+
// ── the project's OWN update rule reaches the offer (task 1004535) ────────────
|
|
452
|
+
//
|
|
453
|
+
// The live defect, 2026-10-02: cloudbongos's rule on /deploy is `minor` and the sweep
|
|
454
|
+
// follows it, but GET /software-update answered `"channel": "patch"` — the platform
|
|
455
|
+
// default — so a step-up release would read "up to date" here while the sweep installed it.
|
|
456
|
+
|
|
457
|
+
test('the route hands the reader the project\'s stored update rule (task 1004535)', async () => {
|
|
458
|
+
let seen;
|
|
459
|
+
const r = await drive({ softwareUpdate: async (a) => { seen = a; return STUB_UPDATE; }, selfChannel: async () => 'minor',
|
|
460
|
+
canUpdate: async () => true, doorInputs: () => ({ provisioning: true, idp: null }) });
|
|
461
|
+
assert.equal(r.status, 200);
|
|
462
|
+
assert.equal(seen.channel, 'minor', 'the rule set on /deploy, not the platform default');
|
|
463
|
+
assert.equal(r.body.update.channel_source, 'deploy', 'and the answer says which rule it used');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('no stored rule, or a failed read of it, falls back to the default — never a 500 (task 1004535)', async () => {
|
|
467
|
+
for (const selfChannel of [async () => null, async () => { throw new Error('db down'); }]) {
|
|
468
|
+
let seen;
|
|
469
|
+
const r = await drive({ softwareUpdate: async (a) => { seen = a; return STUB_UPDATE; }, selfChannel,
|
|
470
|
+
canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
|
|
471
|
+
assert.equal(r.status, 200);
|
|
472
|
+
assert.equal(seen.channel, null, 'null → the reader applies DEFAULT_CHANNEL');
|
|
473
|
+
assert.equal(r.body.update.channel_source, 'default');
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('the REAL default reads the provisioning.selfUpdateChannel port — and with no provider, the default (task 1004535)', async () => {
|
|
478
|
+
// No selfChannel injected: the shipped storedSelfChannel → seams.resolveOptional path runs.
|
|
479
|
+
const seams = require('../src/module-seams.js');
|
|
480
|
+
const stub = { softwareUpdate: async (a) => ({ reading: 'ok', channel: a.channel }), canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) };
|
|
481
|
+
try {
|
|
482
|
+
seams._reset();
|
|
483
|
+
let r = await drive(stub);
|
|
484
|
+
assert.equal(r.status, 200);
|
|
485
|
+
assert.equal(r.body.update.channel, null, 'module off: no provider, so no rule is claimed');
|
|
486
|
+
assert.equal(r.body.update.channel_source, 'default');
|
|
487
|
+
seams.registerProvider('provisioning.selfUpdateChannel', async () => 'minor');
|
|
488
|
+
r = await drive(stub);
|
|
489
|
+
assert.equal(r.body.update.channel, 'minor', 'module on: the port the provisioning module registers is the one read');
|
|
490
|
+
assert.equal(r.body.update.channel_source, 'deploy');
|
|
491
|
+
} finally {
|
|
492
|
+
seams._reset();
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('[source] the port the route reads is the port the provisioning module registers and declares (task 1004535)', () => {
|
|
497
|
+
const port = 'provisioning.selfUpdateChannel';
|
|
498
|
+
const route = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
|
|
499
|
+
const reg = fs.readFileSync(path.join(ROOT, 'modules/provisioning/seams.js'), 'utf8');
|
|
500
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/provisioning/module.json'), 'utf8'));
|
|
501
|
+
assert.ok(route.includes(`'${port}'`), 'the route asks for it');
|
|
502
|
+
assert.ok(reg.includes(`'${port}'`), 'the module registers it');
|
|
503
|
+
assert.ok(manifest.provides.includes(port), 'and declares it');
|
|
504
|
+
});
|
|
@@ -500,7 +500,7 @@ test('both commitPin successes report committed:true', () => {
|
|
|
500
500
|
|
|
501
501
|
// A resolveFrom/load pair standing in for the target instance's core, so the probe is
|
|
502
502
|
// exercised without a second core on disk.
|
|
503
|
-
function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows = false } = {}) {
|
|
503
|
+
function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows = false, own = () => null } = {}) {
|
|
504
504
|
const seen = [];
|
|
505
505
|
const resolveFrom = (dir, rel) => {
|
|
506
506
|
if (rel === 'scripts/gds/upgrade.js' && api === null) return null;
|
|
@@ -515,7 +515,7 @@ function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows =
|
|
|
515
515
|
if (materializeThrows) throw new Error('materialize blew up');
|
|
516
516
|
return { materializeWillRewrite: willRewrite === undefined ? undefined : (args) => { seen.push(args); return willRewrite; } };
|
|
517
517
|
};
|
|
518
|
-
return { resolveFrom, load, seen };
|
|
518
|
+
return { resolveFrom, load, seen, own };
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
test('laneGeneratedPaths unions the API artifacts with what materialize WOULD rewrite', () => {
|
|
@@ -545,7 +545,8 @@ test('laneGeneratedPaths is FAIL-SOFT in every direction — an empty set, never
|
|
|
545
545
|
// The direction matters. An instance this cannot interrogate keeps the OLD, stricter guard
|
|
546
546
|
// (still wedged, and visibly so), rather than gaining a looser one that commits a stranger's work.
|
|
547
547
|
assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: null, willRewrite: null })), []);
|
|
548
|
-
|
|
548
|
+
// A core that predates the preview, and no .claude/skills to walk under it: nothing to fall back on.
|
|
549
|
+
assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: {}, willRewrite: undefined, own: () => () => ['CLAUDE.md'] })), []);
|
|
549
550
|
assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ upgradeThrows: true, materializeThrows: true })), []);
|
|
550
551
|
// A core that exports API_ARTIFACT_FILES but whose materialize throws still contributes the
|
|
551
552
|
// half it could answer for.
|
|
@@ -555,6 +556,57 @@ test('laneGeneratedPaths is FAIL-SOFT in every direction — an empty set, never
|
|
|
555
556
|
);
|
|
556
557
|
});
|
|
557
558
|
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// task 1004519 — a core too old to preview its own rewrite. hoodify-finance took one bump to
|
|
561
|
+
// 1.19.871 (before materializeWillRewrite, 1.19.933), was left with 33 materialized skills
|
|
562
|
+
// uncommitted, and halted on them every sweep because its core answered nothing for them.
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
function oldCoreOnDisk(skills) {
|
|
566
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'sweep-oldcore-'));
|
|
567
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
568
|
+
for (const name of skills) {
|
|
569
|
+
mkdirSync(path.join(core, '.claude', 'skills', name), { recursive: true });
|
|
570
|
+
writeFileSync(path.join(core, '.claude', 'skills', name, 'SKILL.md'), `# ${name}\n`);
|
|
571
|
+
}
|
|
572
|
+
// An instance-owned CLAUDE.md, so the starter is not "would write" here.
|
|
573
|
+
writeFileSync(path.join(dir, 'CLAUDE.md'), '# instance\n');
|
|
574
|
+
return dir;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
test('laneGeneratedPaths falls back to the sweep\'s own preview when the instance core predates it', () => {
|
|
578
|
+
const dir = oldCoreOnDisk(['ask-for-help', 'blocker-review']);
|
|
579
|
+
// The instance's core resolves claude-materialize.js but exports no materializeWillRewrite.
|
|
580
|
+
const core = fakeCore({ api: { g: ['docs/api/openapi.json'] }, willRewrite: undefined, own: () => require('../scripts/gds/claude-materialize.js').materializeWillRewrite });
|
|
581
|
+
const got = S.laneGeneratedPaths(dir, core);
|
|
582
|
+
for (const f of ['.claude/skills/ask-for-help/SKILL.md', '.claude/skills/blocker-review/SKILL.md', 'docs/api/openapi.json']) {
|
|
583
|
+
assert.ok(got.includes(f), `${f} is the lane's own output and must be classified so`);
|
|
584
|
+
}
|
|
585
|
+
assert.ok(!got.includes('CLAUDE.md'), 'an instance-owned CLAUDE.md is never the lane\'s');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('real git: the hoodify-finance wedge clears — old-core skills sort as generated, a hand-written skill still halts', () => {
|
|
589
|
+
const dir = oldCoreOnDisk(['ask-for-help']);
|
|
590
|
+
const g = (...a) => spawnSync('git', ['-C', dir, ...a], { encoding: 'utf8' });
|
|
591
|
+
writeFileSync(path.join(dir, '.gitignore'), 'node_modules/\n');
|
|
592
|
+
mkdirSync(path.join(dir, '.claude', 'skills', 'ask-for-help'), { recursive: true });
|
|
593
|
+
mkdirSync(path.join(dir, '.claude', 'skills', 'my-own'), { recursive: true });
|
|
594
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'ask-for-help', 'SKILL.md'), 'old\n');
|
|
595
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'my-own', 'SKILL.md'), 'mine\n');
|
|
596
|
+
g('init', '-q'); g('add', '-A'); g('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
|
|
597
|
+
const core = fakeCore({ api: {}, willRewrite: undefined, own: () => require('../scripts/gds/claude-materialize.js').materializeWillRewrite });
|
|
598
|
+
// The bump rewrote the materialized skill and left it uncommitted.
|
|
599
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'ask-for-help', 'SKILL.md'), 'new\n');
|
|
600
|
+
let pre = S.pinState(dir, spawnSync, S.laneGeneratedPaths(dir, core));
|
|
601
|
+
assert.deepEqual(pre.other, []);
|
|
602
|
+
assert.deepEqual(S.healDecision(pre, 'push').action, 'commit');
|
|
603
|
+
// An owner's own skill is not in the installed core's .claude/, so it is still a human's work.
|
|
604
|
+
writeFileSync(path.join(dir, '.claude', 'skills', 'my-own', 'SKILL.md'), 'edited\n');
|
|
605
|
+
pre = S.pinState(dir, spawnSync, S.laneGeneratedPaths(dir, core));
|
|
606
|
+
assert.deepEqual(pre.other.map((e) => e.file), ['.claude/skills/my-own/SKILL.md']);
|
|
607
|
+
assert.deepEqual(S.healDecision(pre, 'push'), { action: 'halt' });
|
|
608
|
+
});
|
|
609
|
+
|
|
558
610
|
test('the core still exports the two things the probe reads', () => {
|
|
559
611
|
// laneGeneratedPaths reads them BY NAME off a module it resolves at runtime, so a rename in the
|
|
560
612
|
// core degrades it silently to the empty set — the pre-fix wedge, back again and invisible.
|
package/tests/wizard_demo.mjs
CHANGED
|
@@ -238,51 +238,77 @@ function bootHub() {
|
|
|
238
238
|
const b = new El(`step-${k}${by}`); b.attrs['data-demo-step'] = k; b.attrs['data-by'] = by; return b;
|
|
239
239
|
});
|
|
240
240
|
const shown = [];
|
|
241
|
-
|
|
242
|
-
const
|
|
241
|
+
// the doors wait for the fork's tick (task 1004504), so the founder here has given it
|
|
242
|
+
const state = { step: 1, demo: null, description: '', teamShape: null, termsAck: true, confirmed: { team: true } };
|
|
243
|
+
const sandbox = { $, state, saveDraft() {}, show: (n) => shown.push(n), eyebrowFor: () => 'Demo', root: { querySelectorAll: () => steppers } };
|
|
243
244
|
vm.createContext(sandbox);
|
|
244
245
|
vm.runInContext(SLICE, sandbox);
|
|
245
246
|
const click = (id) => $(id).listeners.click.call($(id));
|
|
246
247
|
return { $, state, shown, steppers, click, sandbox };
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
test('the door opens
|
|
250
|
+
test('the door opens ONE setup screen, the steppers stay in their limits, and the first read follows them', () => {
|
|
250
251
|
const h = bootHub();
|
|
252
|
+
h.state.termsAck = false;
|
|
253
|
+
h.click('wizDemo');
|
|
254
|
+
assert.equal(h.$('wizDemo').attrs['aria-expanded'], undefined, 'an unticked box opens nothing (task 1004504)');
|
|
255
|
+
assert.equal(h.$('forkIntro').hidden, false, 'the doors stay');
|
|
256
|
+
h.state.termsAck = true;
|
|
251
257
|
h.click('wizDemo');
|
|
252
258
|
assert.equal(h.$('forkIntro').hidden, true);
|
|
253
259
|
assert.equal(h.$('demoSetup').hidden, false);
|
|
254
|
-
assert.equal(h.$('demoRing').hidden, false, 'the time-box ring on the planet');
|
|
255
260
|
assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'true');
|
|
261
|
+
assert.equal(h.$('wizBack').hidden, false, 'the one Back walks back to the doors');
|
|
256
262
|
for (let i = 0; i < 20; i += 1) h.steppers[0].listeners.click.call(h.steppers[0]);
|
|
257
263
|
assert.equal(h.$('demoPeople').textContent, '12', 'people stop at twelve');
|
|
258
264
|
for (let i = 0; i < 20; i += 1) h.steppers[3].listeners.click.call(h.steppers[3]);
|
|
259
265
|
assert.equal(h.$('demoHours').textContent, '1', 'hours stop at one');
|
|
260
266
|
assert.equal(h.$('demoFitSum').textContent, '12 people × 1 hour = 12 builder-hours.');
|
|
261
267
|
assert.equal(h.$('demoFitVerdict').textContent, demo.fitCheck(12, 1).sentence);
|
|
262
|
-
h.
|
|
268
|
+
h.sandbox.demoShow('intro');
|
|
263
269
|
assert.equal(h.$('forkIntro').hidden, false, 'Back returns to the two doors');
|
|
264
270
|
assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'false');
|
|
271
|
+
assert.equal(h.$('wizBack').hidden, true);
|
|
272
|
+
assert.match(HUB, /if \(state\.step === 1 && !\$\('demoSetup'\)\.hidden\) \{ demoShow\('intro'\); \$\('wizDemo'\)\.focus\(\); return; \}/, 'which is what #wizBack does from the demo screen');
|
|
273
|
+
assert.doesNotMatch(HUB, /id="demoReady"|id="demoRing"|id="demoNext1"|id="demoBack1"|id="demoBack2"|id="demoStageCap"/, 'the second demo screen, its ring and its own Back buttons are gone');
|
|
265
274
|
});
|
|
266
275
|
|
|
267
|
-
test('
|
|
276
|
+
test('the demo screen\'s Continue carries the demo into the ordinary wizard: the idea, the team, the invites', () => {
|
|
268
277
|
const h = bootHub();
|
|
269
278
|
h.click('wizDemo');
|
|
279
|
+
assert.equal(h.$('demoInvites').hidden, false, 'the invites sit on the same screen');
|
|
270
280
|
h.$('demoIdea').value = 'A shared reading list.';
|
|
271
281
|
h.$('demoIdea').listeners.input.call(h.$('demoIdea'));
|
|
272
|
-
h.click('demoNext1');
|
|
273
|
-
assert.equal(h.$('demoReady').hidden, false);
|
|
274
282
|
h.$('demoInvite1').value = '@avery';
|
|
275
283
|
h.$('demoInvite2').value = 'not a handle!';
|
|
276
284
|
h.click('demoStart');
|
|
277
285
|
assert.deepEqual(JSON.parse(JSON.stringify(h.state.demo)), { people: 3, hours_each: 4, idea: 'A shared reading list.', invites: ['avery'] });
|
|
278
286
|
assert.equal(h.state.description, 'A shared reading list.', 'the idea is the description');
|
|
287
|
+
assert.equal(h.$('f-desc').value, 'A shared reading list.', 'and the sentence field shows it');
|
|
279
288
|
assert.equal(h.state.teamShape, 'small-team');
|
|
280
|
-
assert.deepEqual(h.
|
|
289
|
+
assert.deepEqual(JSON.parse(JSON.stringify(h.state.confirmed)), {}, 'the demo\'s own rows are asked again on the review');
|
|
290
|
+
assert.deepEqual(h.shown, [2], 'on to "What are you making?" (task 1004504)');
|
|
291
|
+
assert.match(HUB, /<input type="text" id="demoIdea" maxlength="600"/, 'the idea is one line now');
|
|
292
|
+
assert.match(HUB, /<button type="button" class="btn" id="demoStart">Continue<\/button>/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('a demo skips the quick checks and sends only its team shape', () => {
|
|
296
|
+
assert.match(HUB, /if \(next === 3 && state\.demo\) \{ physDefaults\(\); show\(4, true\); return; \}/, 'the What screen goes straight to the review for a demo');
|
|
297
|
+
const fn = (HUB.match(/function createDetail\(\) \{[\s\S]*?\n {4}\}/) || [null])[0];
|
|
298
|
+
assert.ok(fn, 'createDetail() was found');
|
|
299
|
+
const box = { physDetail: () => ({ framing: 'hybrid', reward_intents: ['cash'] }), state: {} };
|
|
300
|
+
vm.createContext(box);
|
|
301
|
+
vm.runInContext(fn, box);
|
|
302
|
+
box.state = { demo: { people: 3, hours_each: 4 }, teamShape: 'small-team' };
|
|
303
|
+
assert.deepEqual(JSON.parse(JSON.stringify(box.createDetail())), { team_shape: 'small-team' }, 'the server applies the demo\'s picked answers');
|
|
304
|
+
box.state = { demo: null, teamShape: 'small-team' };
|
|
305
|
+
assert.deepEqual(JSON.parse(JSON.stringify(box.createDetail())), { framing: 'hybrid', reward_intents: ['cash'], team_shape: 'small-team' }, 'a real project sends its answers');
|
|
306
|
+
assert.match(HUB, /if \(state\.demo && \(key === 'team' \|\| key === 'rewards' \|\| key === 'run'\)\) return true;/, 'the demo\'s own rows are fixed on the review');
|
|
281
307
|
});
|
|
282
308
|
|
|
283
|
-
test('the create sends the demo, the
|
|
309
|
+
test('the create sends the demo, the recap names it, and the project page asks the question', () => {
|
|
284
310
|
assert.match(HUB, /demo: state\.demo \? \{ people: state\.demo\.people, hours_each: state\.demo\.hours_each \} : undefined,/);
|
|
285
|
-
assert.match(HUB, /if \(state\.demo\)
|
|
311
|
+
assert.match(HUB, /if \(state\.demo\) bits\.push\('demo'\);/, 'the look screen\'s recap says it is a demo');
|
|
286
312
|
assert.match(HUB, /<p class="demoKeepH">Keep your planet\?<\/p>/);
|
|
287
313
|
assert.match(HUB, /data-demo-settle="carried">Carry it over</);
|
|
288
314
|
assert.match(HUB, /data-demo-settle="kept">Keep it as a demo</);
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// NOT covered here, deliberately: the team shape. The first draft of this task claimed the
|
|
25
25
|
// OAuth hop dropped it; that was wrong — teamShape, projType and modules all survive a
|
|
26
26
|
// reload (verified live). What looked like a drop was the documented "clicking the chosen
|
|
27
|
-
// pill again UNSAYS it" toggle being re-clicked by hand.
|
|
27
|
+
// pill again UNSAYS it" toggle being re-clicked by hand (a toggle task 1004504 retired; slice 3).
|
|
28
28
|
//
|
|
29
29
|
// Run: node --test --test-reporter=tap tests/wizard_draft_resume.mjs
|
|
30
30
|
|
|
@@ -70,29 +70,33 @@ function runStep({ authKnown, draftStep }) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
test('pre-auth: the placeholder renders step 1 but the draft KEEPS the real step', () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
assert.
|
|
73
|
+
// step 4 is the review since task 1004504 — where the code row's repo access is granted
|
|
74
|
+
const { rendered, persisted } = runStep({ authKnown: false, draftStep: 4 });
|
|
75
|
+
assert.deepEqual(rendered, [1], 'still renders step 1 (the fork, since task 1004504) before identity settles');
|
|
76
|
+
assert.equal(persisted.step, 4, 'the draft must not be overwritten with the placeholder');
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
test('pre-auth: the in-memory step is restored too, so onAuth() can re-enter to it', () => {
|
|
79
80
|
// onAuth() calls enter() again and reads state.step from MEMORY, not from storage — so
|
|
80
81
|
// restoring only the stored copy would fix nothing.
|
|
81
|
-
const
|
|
82
|
-
|
|
82
|
+
for (const step of [2, 3, 4, 5]) {
|
|
83
|
+
const { state } = runStep({ authKnown: false, draftStep: step });
|
|
84
|
+
assert.equal(state.step, step, `step ${step} comes back`);
|
|
85
|
+
}
|
|
83
86
|
});
|
|
84
87
|
|
|
85
88
|
test('post-auth: the real step is rendered', () => {
|
|
86
|
-
const { rendered, persisted } = runStep({ authKnown: true, draftStep:
|
|
87
|
-
assert.deepEqual(rendered, [
|
|
88
|
-
assert.equal(persisted.step,
|
|
89
|
+
const { rendered, persisted } = runStep({ authKnown: true, draftStep: 4 });
|
|
90
|
+
assert.deepEqual(rendered, [4]);
|
|
91
|
+
assert.equal(persisted.step, 4);
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
test('a draft already at step 1 is unchanged, and an out-of-range step is not resurrected', () => {
|
|
92
95
|
assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
+
// 6 is the done panel since task 1004504 (12 before it, 11 before task 1004421's Look step, 9 before task
|
|
97
|
+
// 1004417's front door, 8 before task 1004351), reached by its own branch above this slice; 0 is the gate.
|
|
98
|
+
// Neither is a resumable wizard position, so the placeholder is allowed to stand.
|
|
99
|
+
for (const bad of [0, 6, 13, 99]) {
|
|
96
100
|
assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
|
|
97
101
|
}
|
|
98
102
|
});
|
|
@@ -153,9 +157,11 @@ test('while the connect offer is up, the way back is not', () => {
|
|
|
153
157
|
// ── slice 3 · why this task's original team-shape claim was wrong ─────────────────
|
|
154
158
|
// The bug was filed claiming a GitHub round-trip drops the team shape. It does not: a
|
|
155
159
|
// planted draft reads teamShape back intact after a reload (verified live on 1.19.580).
|
|
156
|
-
// The
|
|
157
|
-
// already-chosen shape
|
|
158
|
-
//
|
|
160
|
+
// The explanation then was this handler — the pill was an UNSAY toggle, so re-clicking the
|
|
161
|
+
// already-chosen shape cleared it. Since task 1004504 the pills live on the review's Team
|
|
162
|
+
// size row, where a team always has a shape ("solo" follows a team of one), so the pill is
|
|
163
|
+
// a plain pick: a second click keeps the answer, and nothing in the handler can drop it.
|
|
164
|
+
// Pinned here so the claim rests on the handler's actual behaviour rather than on prose.
|
|
159
165
|
// Anchored on addEventListener: the same selector also appears inside paintTeam(), which
|
|
160
166
|
// only writes aria-pressed. Matching that one instead yields a slice that will not parse.
|
|
161
167
|
const TEAM_RE = /root\.querySelectorAll\('#teamCards \.tpill'\)\.forEach\(function \(b\) \{\s*b\.addEventListener[\s\S]*?\n {4}\}\);/;
|
|
@@ -166,17 +172,20 @@ test('the team-pill slice is still findable', () => {
|
|
|
166
172
|
assert.match(teamSlice, /state\.teamShape/, 'the handler owns the team-shape answer');
|
|
167
173
|
});
|
|
168
174
|
|
|
169
|
-
test('the team pill
|
|
175
|
+
test('the team pill is a plain pick on the Team size row — a second click keeps the answer (task 1004504)', () => {
|
|
170
176
|
const state = { teamShape: null };
|
|
171
177
|
const handlers = [];
|
|
178
|
+
const unconfirmed = [];
|
|
179
|
+
let saves = 0, paints = 0;
|
|
172
180
|
const pill = (team) => ({
|
|
173
181
|
getAttribute: (a) => (a === 'data-team' ? team : null),
|
|
174
182
|
addEventListener: (_e, fn) => handlers.push({ team, fn }),
|
|
175
183
|
});
|
|
176
184
|
const sandbox = {
|
|
177
185
|
state,
|
|
178
|
-
root: { querySelectorAll: () => [pill('
|
|
179
|
-
paintTeam() {},
|
|
186
|
+
root: { querySelectorAll: () => [pill('small-team'), pill('community')] },
|
|
187
|
+
paintTeam() {}, saveDraft() { saves += 1; }, paintSetup() { paints += 1; },
|
|
188
|
+
unconfirm(keys) { unconfirmed.push(...keys); },
|
|
180
189
|
};
|
|
181
190
|
sandbox.globalThis = sandbox;
|
|
182
191
|
vm.createContext(sandbox);
|
|
@@ -186,9 +195,12 @@ test('the team pill UNSAYS on a second click — the answer is not dropped, it i
|
|
|
186
195
|
click('small-team');
|
|
187
196
|
assert.equal(state.teamShape, 'small-team', 'first click answers');
|
|
188
197
|
click('small-team');
|
|
189
|
-
assert.equal(state.teamShape,
|
|
190
|
-
click('small-team');
|
|
191
|
-
assert.equal(state.teamShape, 'small-team', 'and a third click answers again');
|
|
198
|
+
assert.equal(state.teamShape, 'small-team', 'the SAME pill again keeps it — a team always has a shape');
|
|
192
199
|
click('community');
|
|
193
|
-
assert.equal(state.teamShape, 'community', 'a DIFFERENT pill replaces
|
|
200
|
+
assert.equal(state.teamShape, 'community', 'a DIFFERENT pill replaces it');
|
|
201
|
+
assert.equal(saves, 3, 'every pick is saved into the draft, so the GitHub round-trip keeps it');
|
|
202
|
+
assert.deepEqual([...new Set(unconfirmed)], ['extras'], 'the suggested extras follow the team, so the review asks about them again');
|
|
203
|
+
assert.equal(paints, 3, 'and the review repaints');
|
|
204
|
+
assert.deepEqual([...HUB.matchAll(/data-team="([a-z-]+)"/g)].map((m) => m[1]), ['small-team', 'community'],
|
|
205
|
+
'the row offers who can join a team; one person is the "One person" pill, not a team shape');
|
|
194
206
|
});
|