@bongos/core 1.19.670 → 1.19.672
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 +37 -22
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +9 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +146 -140
- package/docs/copy-registry.json +254 -198
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/provisioning.js +13 -1
- package/modules/provisioning/routes/body-validators.js +94 -0
- package/modules/provisioning/routes/provisioning.js +31 -55
- package/modules/public-landing/public/assets/cosmos.css +6 -0
- package/modules/public-landing/public/projects.html +228 -38
- package/modules/public-landing/public/projects.states.json +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/project_type_editable.mjs +427 -0
- package/tests/projects_hub_pre_uat.mjs +10 -3
- package/tests/wizard_intent_resume.mjs +216 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// tests/wizard_intent_resume.mjs — "New project" means a NEW project (task 1003503).
|
|
2
|
+
//
|
|
3
|
+
// The hub's create wizard stores the instance it just stood up (`cb-created-instance`),
|
|
4
|
+
// and enter() resumed that record's done panel on EVERY arrival. Right mid-standup — the
|
|
5
|
+
// watch continues across a reload — but once the standup finished, the "New project" nav
|
|
6
|
+
// kept landing on the old checklist for the rest of the tab session. The only escape was
|
|
7
|
+
// the done panel's own "Start another project" button.
|
|
8
|
+
//
|
|
9
|
+
// Owner call (2026-09-11, the two options the task offered): intent-driven resume.
|
|
10
|
+
// • focus === false — a reload, Back, or the boot's identity settle. The SAME visit
|
|
11
|
+
// continuing, so the done panel and its human-steps checklist come back untouched.
|
|
12
|
+
// • focus === true — a deliberate nav click. An ask for ANOTHER project: a fresh
|
|
13
|
+
// step 1, with the standup one click away on a "still watching …" link.
|
|
14
|
+
// The record is never cleared by this, so nothing is lost either way — which is what
|
|
15
|
+
// ruled out the task's other option (drop the record at a terminal state).
|
|
16
|
+
//
|
|
17
|
+
// The reset fires only when there is a SECOND DRAFT to lose, and the draft's own step is
|
|
18
|
+
// what says so. A landed create clears DRAFT_KEY — but show(8) then persists the done
|
|
19
|
+
// panel's position straight back into it, so a plain "is there a draft?" test is always
|
|
20
|
+
// yes and would skip the reset forever, leaving the finished project's name sitting in
|
|
21
|
+
// the fresh form. (That is exactly what the first cut of this change shipped into a
|
|
22
|
+
// render: "still watching Mercury" above a name field still reading "Mercury".) Only
|
|
23
|
+
// steps 1-7 are wizard steps; anything else is the done panel's own bookkeeping.
|
|
24
|
+
//
|
|
25
|
+
// Harness: there is no jsdom in this repo, so this follows tests/wizard_draft_resume.mjs
|
|
26
|
+
// and tests/project_door_ui.mjs — slice the real region out of the page's INLINE script
|
|
27
|
+
// and run it in a vm under a minimal DOM stub. Both slice boundaries are asserted, so a
|
|
28
|
+
// rename fails loudly instead of leaving this file quietly testing nothing.
|
|
29
|
+
//
|
|
30
|
+
// projects.html is CRLF on a Windows checkout, so this file normalises at read time the
|
|
31
|
+
// way tests/projects_hub_pre_uat.mjs does — a vm cannot run a slice with stray \r in it.
|
|
32
|
+
//
|
|
33
|
+
// Run: node --test --test-reporter=tap tests/wizard_intent_resume.mjs
|
|
34
|
+
|
|
35
|
+
import assert from 'node:assert/strict';
|
|
36
|
+
import { test } from 'node:test';
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import vm from 'node:vm';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
44
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
45
|
+
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8').replace(/\r\n/g, '\n');
|
|
46
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
47
|
+
|
|
48
|
+
// ── the two slices ───────────────────────────────────────────────────────────────
|
|
49
|
+
// Each function closes on the first `}` at the wizard IIFE's own four-space indent.
|
|
50
|
+
const ENTER_RE = /function enterCreated\(created, focus\) \{[\s\S]*?\n {4}\}/;
|
|
51
|
+
const OFFER_RE = /function paintResumeOffer\(created\) \{[\s\S]*?\n {4}\}/;
|
|
52
|
+
const enterSlice = (SCRIPT.match(ENTER_RE) || [null])[0];
|
|
53
|
+
const offerSlice = (SCRIPT.match(OFFER_RE) || [null])[0];
|
|
54
|
+
|
|
55
|
+
test('both slices are still findable (a rename must fail loudly, not silently pass)', () => {
|
|
56
|
+
assert.ok(enterSlice, 'enterCreated() was not found in projects.html');
|
|
57
|
+
assert.ok(offerSlice, 'paintResumeOffer() was not found in projects.html');
|
|
58
|
+
assert.match(enterSlice, /if \(focus && !resumeIntent\) \{/, 'the intent branch is part of the slice');
|
|
59
|
+
assert.match(enterSlice, /startProgress\(created\.id\);/, 'so is the resume branch it guards');
|
|
60
|
+
assert.match(enterSlice, /draft\.step >= 1 && draft\.step <= 7/,
|
|
61
|
+
'the STORED draft\'s step decides, not state.step — state.step is 8 for the whole life of a finished standup');
|
|
62
|
+
assert.match(SCRIPT, /if \(created && created\.id\) \{ enterCreated\(created, !!focus\); return; \}/,
|
|
63
|
+
'enter() still hands the created record straight to it');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// A faithful miniature: the stubs record what enterCreated ASKS FOR, which is the whole
|
|
67
|
+
// contract — which panel is shown, whether the answers were reset, and what the offer
|
|
68
|
+
// above step 1 was told to name.
|
|
69
|
+
function runEnter({ focus, resumeIntent = false, draft = null, record }) {
|
|
70
|
+
const store = new Map();
|
|
71
|
+
if (draft) store.set('draft', JSON.stringify(draft));
|
|
72
|
+
const calls = { show: [], resets: 0, offer: [], done: 0, progress: [], polls: 0, focused: 0 };
|
|
73
|
+
// state.step is the DONE step for the whole life of a finished standup — the value
|
|
74
|
+
// a draft-blind reading would have trusted
|
|
75
|
+
const state = { step: draft ? draft.step : 8, name: 'Mercury', slug: 'mercury', domain: '', mode: 'greenfield', noAddress: false, modules: null };
|
|
76
|
+
const sandbox = {
|
|
77
|
+
record, focus, resumeIntent, state,
|
|
78
|
+
DRAFT_KEY: 'draft',
|
|
79
|
+
live: { manifest: null, appSlug: null },
|
|
80
|
+
manifestState: null,
|
|
81
|
+
manifestPending: false,
|
|
82
|
+
sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null) },
|
|
83
|
+
resetForNewProject() { calls.resets += 1; state.step = 1; state.name = ''; state.slug = ''; },
|
|
84
|
+
paintResumeOffer(c) { calls.offer.push(c ? (c.name || c.slug) : null); },
|
|
85
|
+
show(step, moveFocus) { state.step = step; calls.show.push([step, moveFocus]); },
|
|
86
|
+
renderDone() { calls.done += 1; },
|
|
87
|
+
startProgress(id) { calls.progress.push(id); },
|
|
88
|
+
resumePolls() { calls.polls += 1; },
|
|
89
|
+
$: () => ({ focus() { calls.focused += 1; } }),
|
|
90
|
+
};
|
|
91
|
+
sandbox.globalThis = sandbox;
|
|
92
|
+
vm.createContext(sandbox);
|
|
93
|
+
vm.runInContext(`${enterSlice}\nenterCreated(record, focus);`, sandbox);
|
|
94
|
+
return { calls, state, sandbox };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const REC = { id: 'inst-1', name: 'Acme', slug: 'acme', domain: 'acme.example', mode: 'greenfield' };
|
|
98
|
+
|
|
99
|
+
test('a reload / Back / identity settle (focus false) still resumes the done panel, watch and all', () => {
|
|
100
|
+
const { calls, state } = runEnter({ focus: false, record: REC });
|
|
101
|
+
assert.deepEqual(calls.show, [[8, false]], 'the done panel, and no focus steal on a non-deliberate arrival');
|
|
102
|
+
assert.equal(calls.resets, 0, 'nothing is reset — this is the same standup continuing');
|
|
103
|
+
assert.equal(calls.done, 1, 'the checklist is re-rendered');
|
|
104
|
+
assert.deepEqual(calls.progress, ['inst-1'], 'the progress watch is restarted from the record');
|
|
105
|
+
assert.equal(calls.polls, 1, 'and the parked manifest poll is resumed');
|
|
106
|
+
assert.equal(state.name, 'Acme', 'the record re-seeds the state the done panel reads');
|
|
107
|
+
assert.deepEqual(calls.offer, [null], 'the offer is down — the done panel is on stage, not step 1');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a deliberate "New project" click after a finished standup opens a FRESH step 1', () => {
|
|
111
|
+
const { calls, state } = runEnter({ focus: true, record: REC });
|
|
112
|
+
assert.equal(calls.done, 0, 'the previous project\'s done panel is not painted');
|
|
113
|
+
assert.equal(calls.progress.length, 0, 'and its watch is not restarted');
|
|
114
|
+
assert.equal(calls.resets, 1, 'with no second draft to lose, the wizard forgets the finished project');
|
|
115
|
+
assert.deepEqual(calls.show, [[1, false]], 'step 1, and step 1 does not take the heading focus');
|
|
116
|
+
assert.equal(calls.focused, 1, 'the name field does — the deliberate-nav rule enter() already applies');
|
|
117
|
+
assert.equal(state.name, '', 'the old name is gone from the form');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('…and the standup stays one click away, named, on the resume link', () => {
|
|
121
|
+
const { calls } = runEnter({ focus: true, record: REC });
|
|
122
|
+
assert.deepEqual(calls.offer, ['Acme'], 'the offer names the project the record holds');
|
|
123
|
+
const noName = runEnter({ focus: true, record: { id: 'inst-2', slug: 'beta' } });
|
|
124
|
+
assert.deepEqual(noName.calls.offer, ['beta'], 'falling back to the handle when the record carries no name');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the resume link gets the done panel back — the record was never cleared', () => {
|
|
128
|
+
const { calls, sandbox } = runEnter({ focus: true, resumeIntent: true, record: REC });
|
|
129
|
+
assert.deepEqual(calls.show, [[8, true]], 'a deliberate resume moves focus to the done panel');
|
|
130
|
+
assert.deepEqual(calls.progress, ['inst-1'], 'the watch comes back with it');
|
|
131
|
+
assert.equal(sandbox.resumeIntent, false, 'the flag is ONE-SHOT — the next plain nav click must mean "new" again');
|
|
132
|
+
assert.deepEqual(calls.offer, [null], 'and the offer comes down behind it');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a second draft already under way is NOT wiped by navigating back to the wizard', () => {
|
|
136
|
+
// The regression this guards: with the reset unconditional, typing a name, stepping
|
|
137
|
+
// away to "Your projects" and clicking "New project" again would blank the draft —
|
|
138
|
+
// trading the reported bug for a worse one.
|
|
139
|
+
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 5 } });
|
|
140
|
+
assert.equal(calls.resets, 0, 'a live draft is never reset');
|
|
141
|
+
assert.deepEqual(calls.show, [[5, true]], 'the second draft resumes where it was left, heading focused');
|
|
142
|
+
assert.equal(state.step, 5);
|
|
143
|
+
assert.deepEqual(calls.offer, ['Acme'], 'the offer stays up beside it');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('the done panel\'s own saved step is NOT a second draft — it must not block the reset', () => {
|
|
147
|
+
// Caught in a render, not in a unit test: show(8) calls saveDraft(), so DRAFT_KEY is
|
|
148
|
+
// non-empty again the moment a create lands. Reading "is there a draft?" left the
|
|
149
|
+
// finished project's name in the fresh form, under a line offering to resume it.
|
|
150
|
+
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 8, name: 'Mercury' } });
|
|
151
|
+
assert.equal(calls.resets, 1, 'the done step is bookkeeping, not a project being typed');
|
|
152
|
+
assert.deepEqual(calls.show, [[1, false]], 'out-of-range steps land on the name step');
|
|
153
|
+
assert.equal(state.name, '', 'and the finished project\'s name does not survive into it');
|
|
154
|
+
assert.equal(calls.done, 0, 'never back onto the done panel');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a draft whose step is missing or junk resets too — only 1-7 preserves anything', () => {
|
|
158
|
+
for (const draft of [{ step: 0 }, { step: 9 }, { name: 'half-typed' }]) {
|
|
159
|
+
const { calls } = runEnter({ focus: true, record: REC, draft });
|
|
160
|
+
assert.equal(calls.resets, 1, `step ${JSON.stringify(draft.step)} is not a wizard step`);
|
|
161
|
+
assert.deepEqual(calls.show, [[1, false]]);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── the offer itself ─────────────────────────────────────────────────────────────
|
|
166
|
+
test('paintResumeOffer names the project by textContent and hides on null (no live DOM from strings)', () => {
|
|
167
|
+
const els = { wizResume: { hidden: true }, wizResumeName: { textContent: '' } };
|
|
168
|
+
const sandbox = { $: (id) => els[id] };
|
|
169
|
+
sandbox.globalThis = sandbox;
|
|
170
|
+
vm.createContext(sandbox);
|
|
171
|
+
vm.runInContext(`${offerSlice}
|
|
172
|
+
paintResumeOffer({ id: 'i', name: 'Acme' });
|
|
173
|
+
var shown = { hidden: $('wizResume').hidden, name: $('wizResumeName').textContent };
|
|
174
|
+
paintResumeOffer(null);
|
|
175
|
+
var hid = $('wizResume').hidden;`, sandbox);
|
|
176
|
+
assert.equal(sandbox.shown.hidden, false, 'a record shows the line');
|
|
177
|
+
assert.equal(sandbox.shown.name, 'Acme', 'written as text, never as markup');
|
|
178
|
+
assert.equal(sandbox.hid, true, 'null takes it down');
|
|
179
|
+
assert.doesNotMatch(offerSlice, /innerHTML/, 'this surface builds no live DOM from strings');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('the offer ships as real markup inside step 1, hidden until there is something to resume', () => {
|
|
183
|
+
const panel = HUB.slice(HUB.indexOf('id="panel-name"'), HUB.indexOf('id="panel-type"'));
|
|
184
|
+
assert.match(panel, /<p class="resumeOffer" id="wizResume" hidden>still watching <strong id="wizResumeName">your project<\/strong> — <a href="#" id="wizResumeGo">resume it<\/a><\/p>/,
|
|
185
|
+
'baked into the panel (C7), carrying its own hidden state');
|
|
186
|
+
assert.ok(panel.indexOf('id="wizResume"') < panel.indexOf('<form id="newForm"'),
|
|
187
|
+
'it sits above the name form, where a visitor reads it before typing');
|
|
188
|
+
assert.match(SCRIPT, /\$\('wizResumeGo'\)\.addEventListener\('click', function \(ev\) \{\n\s*ev\.preventDefault\(\);\n\s*resumeIntent = true;\n\s*enter\(true\);/,
|
|
189
|
+
'the link raises the one-shot intent and re-enters, rather than href-navigating');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('the offer clears the 24px hit floor as a box, like every other link on this panel', () => {
|
|
193
|
+
assert.match(COSMOS, /#view-new \.resumeOffer a\{display:inline-block;padding:3px 0;min-height:24px;/,
|
|
194
|
+
'an inline link\'s rect is its content box — the floor needs the box');
|
|
195
|
+
assert.match(COSMOS, /#view-new \.resumeOffer\{[^}]*color:var\(--ink-soft\)/, 'quiet ink: this is an offer, not a banner');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── the reset the two doors share ────────────────────────────────────────────────
|
|
199
|
+
test('"Start another project" is the deliberate goodbye: the record goes, and the offer with it', () => {
|
|
200
|
+
const again = SCRIPT.slice(SCRIPT.indexOf('function wireAgain()'), SCRIPT.indexOf('function createdRecord()'));
|
|
201
|
+
assert.match(again, /resetForNewProject\(\);/, 'it shares the reset with the intent-driven start');
|
|
202
|
+
assert.match(again, /sessionStorage\.removeItem\(CREATED_KEY\)/, 'and it alone drops the record');
|
|
203
|
+
assert.match(again, /paintResumeOffer\(null\)/, 'so the offer must not survive it');
|
|
204
|
+
assert.doesNotMatch(enterSlice, /removeItem\(CREATED_KEY\)/,
|
|
205
|
+
'the intent-driven path must NEVER clear the record — the link is the whole point');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('the reset re-declares every key the initial state declares (modules is the one that bites)', () => {
|
|
209
|
+
const initial = SCRIPT.match(/var state = \{ step: 0,([^}]*)\}/);
|
|
210
|
+
const reset = SCRIPT.match(/function resetForNewProject\(\)[\s\S]*?state = \{ step: 1,([^}]*)\}/);
|
|
211
|
+
assert.ok(initial && reset, 'both state literals must be findable');
|
|
212
|
+
const keys = (s) => [...s.matchAll(/(\w+):/g)].map((m) => m[1]).sort();
|
|
213
|
+
assert.deepEqual(keys(reset[1]), keys(initial[1]),
|
|
214
|
+
'a key left out reads as undefined, not as its sentinel — pickedModules() called .slice() on it and the second project\'s Modules step threw');
|
|
215
|
+
assert.match(reset[1], /modules: null/, 'null is the picker\'s "nothing said" answer (task 1002339)');
|
|
216
|
+
});
|