@bongos/core 1.19.515 → 1.19.516
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 +48 -28
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +12 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +224 -218
- package/docs/copy-registry.json +357 -291
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
- package/modules/provisioning/provisioning.js +24 -4
- package/modules/provisioning/routes/provisioning.js +59 -2
- package/modules/provisioning/starter-bundles.js +91 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +230 -35
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +5 -4
- package/modules/ui-design/kit/serve.js +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/projects_hub.mjs +72 -7
- package/tests/projects_hub_module_picker.mjs +369 -0
- package/tests/provisioning_module_selection.mjs +286 -0
- package/tests/provisioning_starter_bundles.mjs +131 -0
package/tests/projects_hub.mjs
CHANGED
|
@@ -706,14 +706,15 @@ test('the About answers travel on the create request, and an empty one is OMITTE
|
|
|
706
706
|
});
|
|
707
707
|
|
|
708
708
|
test('a renumbering of the wizard invalidates old drafts — the key carries the version', () => {
|
|
709
|
-
// task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again
|
|
710
|
-
//
|
|
711
|
-
//
|
|
709
|
+
// task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again;
|
|
710
|
+
// task 1002339 (the module picker) is the third. A draft replayed under a
|
|
711
|
+
// different numbering lands on the wrong panel with the rail already marking
|
|
712
|
+
// the skipped steps done.
|
|
712
713
|
const key = (SCRIPT.match(/var DRAFT_KEY = '([^']+)'/) || [, ''])[1];
|
|
713
714
|
assert.match(key, /-v\d+$/, 'the draft key is versioned');
|
|
714
715
|
const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
|
|
715
716
|
const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
|
|
716
|
-
assert.equal(count,
|
|
717
|
+
assert.equal(count, 9, 'gate + seven numbered steps + done — bump DRAFT_KEY when this changes');
|
|
717
718
|
});
|
|
718
719
|
|
|
719
720
|
test('every row lays the same five parts — the ledger has no ragged row', () => {
|
|
@@ -2058,7 +2059,9 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
|
|
|
2058
2059
|
assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
|
|
2059
2060
|
const block = COSMOS.slice(at);
|
|
2060
2061
|
assert.match(block, /#view-new \.card\{background:none;border-radius:0;box-shadow:none;/, 'no card ground');
|
|
2061
|
-
|
|
2062
|
+
// seven tracks since task 1002339 added the Modules step; the count is the
|
|
2063
|
+
// rail's own, so it moves with the panels map rather than being a magic number
|
|
2064
|
+
assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(7,1fr\);/, 'the strip lays a track per numbered step');
|
|
2062
2065
|
assert.match(block, /#view-new \.steps li::before\{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;/, 'the connector becomes the 7px dot');
|
|
2063
2066
|
assert.match(block, /#view-new \.steps li\.now::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid/, 'the lit step carries the accent and an outline halo');
|
|
2064
2067
|
assert.match(block, /#view-new \.panel h1\{font-weight:300;[^}]*text-transform:lowercase;/, 'the step title in the display voice, lowercase');
|
|
@@ -2105,7 +2108,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2105
2108
|
assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
|
|
2106
2109
|
assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
|
|
2107
2110
|
assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
|
|
2108
|
-
assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="
|
|
2111
|
+
assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="6">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
|
|
2109
2112
|
assert.match(HUB, /<h2 class="stepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style');
|
|
2110
2113
|
// what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
|
|
2111
2114
|
assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
|
|
@@ -2114,7 +2117,69 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2114
2117
|
assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
|
|
2115
2118
|
// the kit walks every step and files against the stub
|
|
2116
2119
|
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
2117
|
-
for (const s of ['type', 'about', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
|
|
2120
|
+
for (const s of ['type', 'about', 'modules', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
|
|
2118
2121
|
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
|
|
2119
2122
|
assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name) && p.steps.some((st) => st[1] === '#doCreate')), 'the wizard driver files a project end to end');
|
|
2120
2123
|
});
|
|
2124
|
+
|
|
2125
|
+
test('the Modules step ships as real markup and is skippable like About (task 1002339)', () => {
|
|
2126
|
+
const panel = (HUB.match(/<section class="panel" id="panel-modules"[\s\S]*?<\/section>/) || [''])[0];
|
|
2127
|
+
assert.ok(panel, '#panel-modules ships as real markup, not built by script');
|
|
2128
|
+
assert.match(panel, /id="modCards"/, 'the band the picker paints its rows into');
|
|
2129
|
+
assert.match(panel, /class="typeCards"/, 'and it is the SAME band the type and team questions use — no new grammar');
|
|
2130
|
+
assert.match(panel, /id="modCoreNote"/, 'the always-on core is stated on the panel');
|
|
2131
|
+
// the skip must be its own control, not "leave it and press Next" — the rule
|
|
2132
|
+
// task 1002334 wrote for the one other optional step
|
|
2133
|
+
assert.match(panel, /id="skipModules"/, 'the skip is a control, not an inference');
|
|
2134
|
+
assert.match(panel, /Optional/, 'and the panel says so before it asks');
|
|
2135
|
+
// nothing in validateStep may gate it
|
|
2136
|
+
const validate = SCRIPT.slice(SCRIPT.indexOf('function validateStep('));
|
|
2137
|
+
const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
|
|
2138
|
+
const modStep = Number((map.match(/(\d+):\s*'panel-modules'/) || [, 0])[1]);
|
|
2139
|
+
assert.ok(modStep > 0, 'the Modules panel is in the map');
|
|
2140
|
+
assert.ok(!validate.slice(0, validate.indexOf('function ', 1)).includes(`if (step === ${modStep}) {`),
|
|
2141
|
+
'the optional step has no validation clause — Next past an untouched picker is the point');
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
test('the picker reads ONE public declaration, and it is the one the bundles come from (task 1002339)', () => {
|
|
2145
|
+
// A second source for "what may be chosen" is exactly what would let a
|
|
2146
|
+
// preselect name a module the panel has no row for (ADR 0237 §5).
|
|
2147
|
+
assert.match(SCRIPT, /fetch\(API \+ '\/provisioning\/starter-bundles'/, 'the picker reads the starter-bundles route');
|
|
2148
|
+
assert.equal((SCRIPT.match(/'\/provisioning\/starter-bundles'/g) || []).length, 1, 'exactly once');
|
|
2149
|
+
// and it is a public read — no other module-catalog call joins it
|
|
2150
|
+
assert.doesNotMatch(SCRIPT, /fetch\(API \+ '\/modules'/, 'no signed-in catalog read from a signed-out wizard');
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
test('the picker\'s answer rides the create request, and an untouched picker sends nothing (task 1002339)', () => {
|
|
2154
|
+
// absent means SKIPPED, and the server then applies the same starter bundle
|
|
2155
|
+
// the panel preselected — the same contract `description` and `detail` carry
|
|
2156
|
+
const body = SCRIPT.slice(SCRIPT.indexOf('body: JSON.stringify({'));
|
|
2157
|
+
assert.ok(body.includes('modules: modulesForCreate(),'), 'the create body carries the picker\'s ids');
|
|
2158
|
+
assert.match(SCRIPT, /function modulesForCreate\(\) \{\s*return state\.modules === null \? undefined : state\.modules\.slice\(\);/,
|
|
2159
|
+
'and modulesForCreate() omits the key while nothing has been said');
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
test('the review names what the project will actually come with (task 1002339)', () => {
|
|
2163
|
+
// The picker is optional, so the last screen before Create must show the set
|
|
2164
|
+
// the owner is about to get — their picks, or the preselect they walked past.
|
|
2165
|
+
const render = SCRIPT.slice(SCRIPT.indexOf('function renderReview()'));
|
|
2166
|
+
const rows = render.slice(0, render.indexOf('$(\'reviewList\').innerHTML'));
|
|
2167
|
+
assert.match(rows, /\['Extras', extrasRow\(\)\]/, 'the ledger carries an Extras row');
|
|
2168
|
+
// What that row SAYS has three cases (picked / preselect / catalog not loaded)
|
|
2169
|
+
// and they live with the picker's own rules, executed by
|
|
2170
|
+
// tests/projects_hub_module_picker.mjs. This pins that the review defers to them.
|
|
2171
|
+
assert.match(SCRIPT, /function extrasRow\(\) \{/, 'and it is the picker that decides what it says');
|
|
2172
|
+
assert.match(SCRIPT, /'None — just the core'/, 'an empty selection reads as a fact, not a blank');
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
test('the kit stub answers the picker\'s read, so the panel renders in the design kit (task 1002339)', () => {
|
|
2176
|
+
// The stub is not the server (this module's CLAUDE.md trap): it serves
|
|
2177
|
+
// fixtures. A 404 here would have rendered the couldn't-load clause in every
|
|
2178
|
+
// state and probe, and nobody would have seen the picker at all.
|
|
2179
|
+
const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
2180
|
+
assert.match(stub, /rest === '\/provisioning\/starter-bundles'/, 'the stub serves the picker\'s read');
|
|
2181
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
|
|
2182
|
+
assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name)
|
|
2183
|
+
&& p.steps.some((st) => st[1] === '#panel-modules button[type=submit]')),
|
|
2184
|
+
'and the end-to-end driver walks the new step rather than stopping at it');
|
|
2185
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// tests/projects_hub_module_picker.mjs — the create wizard's module picker,
|
|
2
|
+
// EXECUTED (BV1.R20, task 1002339; goal 1000046 criterion `proj-module-select`).
|
|
3
|
+
//
|
|
4
|
+
// tests/projects_hub.mjs pins this surface statically, on purpose (C7: "real
|
|
5
|
+
// markup, not JS-built"). But the picker is the one part of the wizard whose
|
|
6
|
+
// whole behaviour is decisions — what is preselected, what a toggle does, what a
|
|
7
|
+
// skip means — and a static regex cannot tell a preselect that works from one
|
|
8
|
+
// that silently paints nothing. So this file runs the picker's own source in
|
|
9
|
+
// `vm` under a small DOM stub and asserts what an owner would actually see.
|
|
10
|
+
//
|
|
11
|
+
// The precedent is tests/task_detail_ui.mjs (there is no jsdom in this repo).
|
|
12
|
+
// The difference: task-detail.js is a whole file, while the picker lives inside
|
|
13
|
+
// projects.html's one big inline <script> — so the block is sliced out by its
|
|
14
|
+
// own comment sentinels and run with the five things it closes over ($, root,
|
|
15
|
+
// esc, state, saveDraft/show) supplied by the harness. That slice is itself an
|
|
16
|
+
// assertion: rename the sentinels and this file fails loudly rather than
|
|
17
|
+
// quietly testing nothing.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test --test-reporter=tap tests/projects_hub_module_picker.mjs
|
|
20
|
+
|
|
21
|
+
import test from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
30
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
31
|
+
|
|
32
|
+
const START = '/* ── the module picker (task 1002339, BV1.R20) ──';
|
|
33
|
+
const END = "$('skipModules').addEventListener('click', function () { show(5, true); });";
|
|
34
|
+
const from = HUB.indexOf(START);
|
|
35
|
+
const to = HUB.indexOf(END);
|
|
36
|
+
assert.ok(from > -1, 'the picker block opens with its sentinel comment');
|
|
37
|
+
assert.ok(to > from, 'the picker block closes with the skip wiring');
|
|
38
|
+
const PICKER = HUB.slice(from, to + END.length).replace(/\r\n/g, '\n');
|
|
39
|
+
|
|
40
|
+
// The payload GET /provisioning/starter-bundles serves (ADR 0237 + task 1002339).
|
|
41
|
+
// Deliberately a FIXTURE and not a live require: this asserts the picker reads
|
|
42
|
+
// the shape, and tests/provisioning_starter_bundles.mjs asserts the route emits
|
|
43
|
+
// it. Two files, one shape, neither trusting the other's word for it.
|
|
44
|
+
const PAYLOAD = {
|
|
45
|
+
core: ['autonomy', 'builder-settings', 'copy-desk', 'economy', 'government', 'grading',
|
|
46
|
+
'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'public-landing',
|
|
47
|
+
'security', 'sessions', 'status-ui', 'ui-design'],
|
|
48
|
+
bundles: [
|
|
49
|
+
{ type: 'game', bundle: ['dev-box', 'discord'] },
|
|
50
|
+
{ type: 'research', bundle: ['agents'] },
|
|
51
|
+
{ type: 'business', bundle: ['agents', 'dev-box'] },
|
|
52
|
+
{ type: 'non-profit', bundle: ['discord'] },
|
|
53
|
+
{ type: 'not-sure', bundle: [] },
|
|
54
|
+
],
|
|
55
|
+
optional: [
|
|
56
|
+
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets.' },
|
|
57
|
+
{ key: 'dev-box', label: 'Dev boxes', summary: 'A cloud workspace where a change can be tried out first.' },
|
|
58
|
+
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news.' },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function makeEl(id) {
|
|
63
|
+
const classes = new Set();
|
|
64
|
+
const attrs = new Map();
|
|
65
|
+
return {
|
|
66
|
+
id, _html: '', textContent: '', hidden: false, _handlers: {},
|
|
67
|
+
set innerHTML(v) { this._html = String(v); },
|
|
68
|
+
get innerHTML() { return this._html; },
|
|
69
|
+
classList: {
|
|
70
|
+
add: (c) => classes.add(c),
|
|
71
|
+
remove: (c) => classes.delete(c),
|
|
72
|
+
toggle: (c, on) => { if (on) classes.add(c); else classes.delete(c); },
|
|
73
|
+
contains: (c) => classes.has(c),
|
|
74
|
+
},
|
|
75
|
+
setAttribute(k, v) { attrs.set(k, String(v)); },
|
|
76
|
+
getAttribute(k) { return attrs.has(k) ? attrs.get(k) : null; },
|
|
77
|
+
addEventListener(type, fn) { this._handlers[type] = fn; },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Parse the rows the picker WROTE, so a selector resolves only when the markup
|
|
82
|
+
// is genuinely there — the point of the harness.
|
|
83
|
+
function rowsOf(html) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const re = /<button type="button" class="tpill" data-mod="([^"]+)" aria-pressed="(true|false)"><span><b>([^<]*)<\/b><small>([^<]*)<\/small><\/span><\/button>/g;
|
|
86
|
+
let m;
|
|
87
|
+
while ((m = re.exec(html))) out.push({ key: m[1], pressed: m[2] === 'true', label: m[3], summary: m[4] });
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Boot the picker with a given draft state. `fetchOk: false` drives the
|
|
92
|
+
// couldn't-load branch. Returns the handles the assertions drive it through.
|
|
93
|
+
function boot({ state: seed = {}, fetchOk = true } = {}) {
|
|
94
|
+
const els = new Map();
|
|
95
|
+
const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
|
96
|
+
const form = makeEl('panel-modules-form');
|
|
97
|
+
const state = { step: 4, projType: null, modules: null, ...seed };
|
|
98
|
+
const shown = [];
|
|
99
|
+
let saves = 0;
|
|
100
|
+
let fetches = 0;
|
|
101
|
+
let reviews = 0;
|
|
102
|
+
|
|
103
|
+
const sandbox = {
|
|
104
|
+
$,
|
|
105
|
+
root: { querySelector: (sel) => (sel === '#panel-modules form' ? form : null) },
|
|
106
|
+
esc: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
107
|
+
state,
|
|
108
|
+
saveDraft: () => { saves++; },
|
|
109
|
+
show: (step) => { shown.push(step); },
|
|
110
|
+
// the block repaints the review when the read lands on step 7 (a resumed
|
|
111
|
+
// draft can reach it having never opened the picker)
|
|
112
|
+
renderReview: () => { reviews++; },
|
|
113
|
+
API: '/api/bongos',
|
|
114
|
+
fetch: () => { fetches++; return Promise.resolve(fetchOk
|
|
115
|
+
? { ok: true, json: () => Promise.resolve(PAYLOAD) }
|
|
116
|
+
: { ok: false, json: () => Promise.resolve(null) }); },
|
|
117
|
+
Promise, JSON, Math, Object, Array, String, Number, console,
|
|
118
|
+
};
|
|
119
|
+
sandbox.globalThis = sandbox;
|
|
120
|
+
vm.createContext(sandbox);
|
|
121
|
+
vm.runInContext(PICKER, sandbox, { filename: 'projects.html#module-picker' });
|
|
122
|
+
|
|
123
|
+
const band = $('modCards');
|
|
124
|
+
return {
|
|
125
|
+
state, shown, sandbox, form,
|
|
126
|
+
saves: () => saves,
|
|
127
|
+
fetches: () => fetches,
|
|
128
|
+
reviews: () => reviews,
|
|
129
|
+
extrasRow: () => sandbox.extrasRow(),
|
|
130
|
+
// The read is LAZY: the first render kicks it, and its .then repaints when it
|
|
131
|
+
// lands. open() is that whole sequence, which is exactly what entering the
|
|
132
|
+
// step does — so a test never asserts against a paint the browser skips.
|
|
133
|
+
settle: async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); },
|
|
134
|
+
render: () => sandbox.renderModules(),
|
|
135
|
+
open: async function () { sandbox.renderModules(); for (let i = 0; i < 20; i++) await Promise.resolve(); },
|
|
136
|
+
rows: () => rowsOf(band.innerHTML),
|
|
137
|
+
coreNote: () => $('modCoreNote').textContent,
|
|
138
|
+
coreShown: () => $('modCoreNote').classList.contains('on'),
|
|
139
|
+
click: (key) => {
|
|
140
|
+
const target = {
|
|
141
|
+
closest: (sel) => (sel === '.tpill[data-mod]' ? { getAttribute: (k) => (k === 'data-mod' ? key : null) } : null),
|
|
142
|
+
};
|
|
143
|
+
band._handlers.click({ target });
|
|
144
|
+
},
|
|
145
|
+
next: () => form._handlers.submit({}),
|
|
146
|
+
skip: () => $('skipModules')._handlers.click(),
|
|
147
|
+
forCreate: () => sandbox.modulesForCreate(),
|
|
148
|
+
picked: () => sandbox.pickedModules(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
test('the declared project type preselects its bundle, and the rest of the catalog is offered switched off', async () => {
|
|
153
|
+
// Arrange — the owner said "game" on the Type step and has touched nothing here
|
|
154
|
+
const h = boot({ state: { projType: 'game' } });
|
|
155
|
+
|
|
156
|
+
// Act
|
|
157
|
+
await h.open();
|
|
158
|
+
|
|
159
|
+
// Assert — every opt-in module is a row; the bundle's two are on
|
|
160
|
+
const rows = h.rows();
|
|
161
|
+
assert.deepEqual(rows.map((r) => r.key), ['agents', 'dev-box', 'discord'], 'the whole opt-in catalog is offered');
|
|
162
|
+
assert.deepEqual(rows.filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord'], 'the game bundle is preselected');
|
|
163
|
+
assert.equal(rows.find((r) => r.key === 'agents').pressed, false, 'and nothing else is');
|
|
164
|
+
// each row carries the copy an owner can actually read
|
|
165
|
+
assert.equal(rows[1].label, 'Dev boxes');
|
|
166
|
+
assert.match(rows[1].summary, /cloud workspace/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a different type preselects a different bundle, and "not sure" preselects nothing', async () => {
|
|
170
|
+
for (const [type, expected] of [['research', ['agents']], ['business', ['agents', 'dev-box']], ['non-profit', ['discord']], ['not-sure', []], [null, []]]) {
|
|
171
|
+
const h = boot({ state: { projType: type } });
|
|
172
|
+
await h.open();
|
|
173
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), expected, `type=${type}`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the always-on core is stated, not rendered as sixteen toggles nobody may touch', async () => {
|
|
178
|
+
// A disabled control reads as a choice the owner failed to make. The core was
|
|
179
|
+
// never a choice, so it is a clause — visible, and outside the band.
|
|
180
|
+
const h = boot({ state: { projType: 'game' } });
|
|
181
|
+
await h.open();
|
|
182
|
+
|
|
183
|
+
assert.equal(h.coreShown(), true, 'the core is stated on the panel, not hidden');
|
|
184
|
+
assert.match(h.coreNote(), /16 core parts/, 'and it names how many');
|
|
185
|
+
assert.match(h.coreNote(), /always on/, 'and that they are not part of this choice');
|
|
186
|
+
for (const key of PAYLOAD.core) {
|
|
187
|
+
assert.ok(!h.rows().some((r) => r.key === key), `"${key}" is never a toggle`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('a toggle flips one module and leaves the rest of the preselect standing', async () => {
|
|
192
|
+
// Arrange
|
|
193
|
+
const h = boot({ state: { projType: 'game' } });
|
|
194
|
+
await h.open();
|
|
195
|
+
|
|
196
|
+
// Act — switch the bundle's discord off, switch agents on
|
|
197
|
+
h.click('discord');
|
|
198
|
+
h.click('agents');
|
|
199
|
+
|
|
200
|
+
// Assert
|
|
201
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'dev-box']);
|
|
202
|
+
assert.deepEqual(h.state.modules, ['agents', 'dev-box'], 'and the draft holds the real answer, in catalog order');
|
|
203
|
+
assert.ok(h.saves() >= 2, 'each toggle is written to the draft');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('the first toggle is what turns "not answered" into an answer', async () => {
|
|
207
|
+
const h = boot({ state: { projType: 'game' } });
|
|
208
|
+
await h.open();
|
|
209
|
+
|
|
210
|
+
assert.equal(h.state.modules, null, 'before a touch, nothing has been said');
|
|
211
|
+
assert.equal(h.forCreate(), undefined, 'so the create request carries no modules key');
|
|
212
|
+
|
|
213
|
+
h.click('agents');
|
|
214
|
+
assert.deepEqual(h.forCreate(), ['agents', 'dev-box', 'discord'], 'after a touch it carries the whole set');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('switching every module off is a real answer, not a reset to the bundle', async () => {
|
|
218
|
+
// The case a NOT NULL DEFAULT [] column could never have expressed: the owner
|
|
219
|
+
// said "none", and Next must not hand them the bundle back.
|
|
220
|
+
const h = boot({ state: { projType: 'game' } });
|
|
221
|
+
await h.open();
|
|
222
|
+
h.click('dev-box');
|
|
223
|
+
h.click('discord');
|
|
224
|
+
|
|
225
|
+
assert.deepEqual(h.state.modules, []);
|
|
226
|
+
assert.deepEqual(h.forCreate(), [], 'an empty answer travels — it is not the same as absent');
|
|
227
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed), [], 'and the panel shows it');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('Skip walks past WITHOUT answering, so the server applies the bundle the panel showed', async () => {
|
|
231
|
+
// Arrange
|
|
232
|
+
const h = boot({ state: { projType: 'game' } });
|
|
233
|
+
await h.open();
|
|
234
|
+
|
|
235
|
+
// Act
|
|
236
|
+
h.skip();
|
|
237
|
+
|
|
238
|
+
// Assert — skipping is the one path that must leave nothing said, because the
|
|
239
|
+
// preselect and the server's fallback are the same declaration (ADR 0237)
|
|
240
|
+
assert.deepEqual(h.shown, [5], 'it advances to the Home step');
|
|
241
|
+
assert.equal(h.state.modules, null, 'and answers nothing');
|
|
242
|
+
assert.equal(h.forCreate(), undefined);
|
|
243
|
+
assert.deepEqual(h.picked(), ['dev-box', 'discord'], 'while the set they were shown is still the set they get');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('Skip does not erase a toggle already made', async () => {
|
|
247
|
+
// The About step's rule: a visitor who changed something and then pressed Skip
|
|
248
|
+
// meant "I'm done here", not "throw that away".
|
|
249
|
+
const h = boot({ state: { projType: 'game' } });
|
|
250
|
+
await h.open();
|
|
251
|
+
h.click('agents');
|
|
252
|
+
h.skip();
|
|
253
|
+
|
|
254
|
+
assert.deepEqual(h.state.modules, ['agents', 'dev-box', 'discord']);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('Next CONFIRMS the set on screen — looking at it and accepting it is not the same as never being asked', async () => {
|
|
258
|
+
const h = boot({ state: { projType: 'game' } });
|
|
259
|
+
await h.open();
|
|
260
|
+
|
|
261
|
+
h.next();
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(h.state.modules, ['dev-box', 'discord'], 'the preselect becomes the answer');
|
|
264
|
+
assert.deepEqual(h.forCreate(), ['dev-box', 'discord'], 'and rides the create request');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('changing the project type re-preselects while nothing has been said, and never after', async () => {
|
|
268
|
+
// The arrow runs type -> modules (ADR 0237 §2). It must not run backwards over
|
|
269
|
+
// a choice the owner already made.
|
|
270
|
+
const h = boot({ state: { projType: 'game' } });
|
|
271
|
+
await h.open();
|
|
272
|
+
|
|
273
|
+
h.state.projType = 'research';
|
|
274
|
+
h.render();
|
|
275
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents'], 'an untouched picker follows the type');
|
|
276
|
+
|
|
277
|
+
h.click('discord');
|
|
278
|
+
h.state.projType = 'non-profit';
|
|
279
|
+
h.render();
|
|
280
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'discord'],
|
|
281
|
+
'an answered picker keeps the answer');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('a draft resumed with a stored answer repaints that answer, not the bundle', async () => {
|
|
285
|
+
// sessionStorage survives a reload; the panel must not quietly re-apply the
|
|
286
|
+
// preselect over what the owner picked before they closed the tab.
|
|
287
|
+
const h = boot({ state: { projType: 'game', modules: ['agents'] } });
|
|
288
|
+
await h.open();
|
|
289
|
+
|
|
290
|
+
assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents']);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('a catalog that will not load degrades to a clause — it never blocks a project from being created', async () => {
|
|
294
|
+
// Arrange
|
|
295
|
+
const h = boot({ state: { projType: 'game' }, fetchOk: false });
|
|
296
|
+
|
|
297
|
+
// Act
|
|
298
|
+
await h.open();
|
|
299
|
+
|
|
300
|
+
// Assert
|
|
301
|
+
assert.deepEqual(h.rows(), [], 'no rows are invented');
|
|
302
|
+
assert.match(h.coreNote(), /couldn’t load/, 'the panel says so in the owner\'s words');
|
|
303
|
+
assert.equal(h.state.modules, null, 'nothing is answered on their behalf');
|
|
304
|
+
h.skip();
|
|
305
|
+
assert.deepEqual(h.shown, [5], 'and the wizard still walks on — the server applies the bundle');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('every rendered value goes through the page escaper', async () => {
|
|
309
|
+
// The rows are built with innerHTML (renderReview's existing idiom in this
|
|
310
|
+
// file), so the escaping is the safety property and it is asserted, not assumed.
|
|
311
|
+
const h = boot({ state: { projType: 'game' } });
|
|
312
|
+
await h.open();
|
|
313
|
+
h.sandbox.starterBundles = {
|
|
314
|
+
core: [], bundles: [],
|
|
315
|
+
optional: [{ key: 'x"><script>', label: '<b>bad</b>', summary: 'a & b' }],
|
|
316
|
+
};
|
|
317
|
+
h.render();
|
|
318
|
+
|
|
319
|
+
const html = h.sandbox.$('modCards').innerHTML;
|
|
320
|
+
assert.ok(!html.includes('<script>'), 'a hostile key cannot break out of the attribute');
|
|
321
|
+
assert.ok(html.includes('<b>bad</b>'), 'a label is escaped');
|
|
322
|
+
assert.ok(html.includes('a & b'), 'and so is a summary');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('the catalog read is LAZY and asked once — /projects serves four views from one file', async () => {
|
|
326
|
+
// The map view has no business paying for the picker's read, and re-entering
|
|
327
|
+
// the step must not re-ask. fetchPlan() beside it memoises for the same reason.
|
|
328
|
+
// The counter is wired INTO the sandbox before the block runs, so a fetch at
|
|
329
|
+
// boot would be caught — a counter installed afterwards would prove nothing.
|
|
330
|
+
const h = boot({ state: { projType: 'game' } });
|
|
331
|
+
|
|
332
|
+
assert.equal(h.fetches(), 0, 'loading the page does not ask');
|
|
333
|
+
await h.open();
|
|
334
|
+
await h.open();
|
|
335
|
+
assert.equal(h.fetches(), 1, 'entering the step asks exactly once, however often it is entered');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('the review names the set even when the picker was never opened (task 1002339)', async () => {
|
|
339
|
+
// A resumed draft lands straight on the review, so the catalog has not loaded
|
|
340
|
+
// and the module NAMES are not known. "None — just the core" would be a false
|
|
341
|
+
// promise on the last screen before Create: the server is about to apply the
|
|
342
|
+
// type's bundle. The row says that instead, then repaints when the read lands.
|
|
343
|
+
const h = boot({ state: { projType: 'game', step: 7 } });
|
|
344
|
+
|
|
345
|
+
// Arrange + Act — the review asks for the value before the read can answer
|
|
346
|
+
const first = h.extrasRow();
|
|
347
|
+
|
|
348
|
+
// Assert
|
|
349
|
+
assert.match(first, /suggested set/, 'it promises what the owner will actually get');
|
|
350
|
+
assert.equal(h.fetches(), 1, 'and asking for it is what starts the read');
|
|
351
|
+
|
|
352
|
+
await h.settle();
|
|
353
|
+
assert.equal(h.reviews(), 1, 'the review repaints itself once the names are known');
|
|
354
|
+
assert.equal(h.extrasRow(), 'Dev boxes, Discord', 'and then it names them');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('the review reads an answered picker back, empty included', async () => {
|
|
358
|
+
const chosen = boot({ state: { projType: 'game', modules: ['agents'], step: 7 } });
|
|
359
|
+
// before the catalog lands the row says the true thing without the names —
|
|
360
|
+
// never the raw module keys, which are jargon on an owner-facing screen
|
|
361
|
+
assert.equal(chosen.extrasRow(), 'The extras you picked');
|
|
362
|
+
await chosen.settle();
|
|
363
|
+
assert.equal(chosen.extrasRow(), 'Extra agents', 'and names them once it has');
|
|
364
|
+
|
|
365
|
+
const none = boot({ state: { projType: 'game', modules: [], step: 7 } });
|
|
366
|
+
none.extrasRow();
|
|
367
|
+
await none.settle();
|
|
368
|
+
assert.equal(none.extrasRow(), 'None — just the core', 'a deliberate "none" is stated as one');
|
|
369
|
+
});
|