@bongos/core 1.20.48 → 1.20.50
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 +84 -54
- package/.claude/skills/new-project/SKILL.md +5 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +3 -0
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +434 -400
- package/docs/copy-registry.json +816 -508
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +11 -2
- package/docs/page-readings.json +733 -690
- package/modules/hall-ui/public/criterion-uat-ui.js +280 -0
- package/modules/hall-ui/public/goal-map.html +5 -3
- package/modules/hall-ui/public/goals-page.js +23 -44
- package/modules/hall-ui/public/goals-render.js +24 -15
- package/modules/hall-ui/public/goals.css +45 -0
- package/modules/hall-ui/public/goals.html +5 -2
- package/modules/hall-ui/public/goals.states.json +38 -0
- package/modules/hall-ui/public/sky-data.js +9 -1
- package/modules/hall-ui/public/sky-interaction.js +1 -1
- package/modules/hall-ui/public/sky-panel.js +1 -1
- package/modules/hall-ui/public/thinking.html +5 -3
- package/modules/lifecycle/done-when.js +3 -1
- package/modules/lifecycle/sky.js +23 -8
- package/modules/platform-identity/routes/sso.js +5 -0
- package/modules/provisioning/migrations/provisioning_033_terms_ack.sql +33 -0
- package/modules/provisioning/onboard-plan.js +5 -0
- package/modules/provisioning/provisioning.js +7 -4
- package/modules/provisioning/routes/provisioning.js +3 -3
- package/modules/provisioning/terms-ack.js +36 -0
- package/modules/public-landing/public/projects.html +226 -77
- package/modules/public-landing/public/projects.probes.json +8 -2
- package/modules/public-landing/public/projects.states.json +16 -12
- package/modules/ui-design/kit/serve.js +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/claim_action.mjs +6 -1
- package/tests/criterion_uat_ui.mjs +113 -0
- package/tests/goal_criterion_confirm_ui.mjs +188 -91
- package/tests/goal_map_page.mjs +2 -1
- package/tests/hall_sky.mjs +16 -0
- package/tests/helpers.mjs +3 -1
- package/tests/projects_hub.mjs +4 -2
- package/tests/projects_hub_app_status.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +9 -9
- package/tests/projects_hub_module_picker.mjs +7 -7
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provisioning_terms_ack.mjs +216 -0
- package/tests/sky_read.mjs +7 -1
- package/tests/wizard_draft_resume.mjs +4 -4
- package/tests/wizard_front_door.mjs +271 -0
- package/tests/wizard_intent_resume.mjs +31 -19
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// tests/goal_criterion_confirm_ui.mjs — in-hall criterion confirmation (task 1002164)
|
|
1
|
+
// tests/goal_criterion_confirm_ui.mjs — in-hall criterion confirmation (task 1002164),
|
|
2
|
+
// now the in-hall UAT (task 1004402, ADR 0351): the bare Confirm button (a POST
|
|
3
|
+
// /satisfy) is gone, and a criterion awaiting UAT is tested and signed off here.
|
|
2
4
|
//
|
|
3
5
|
// The goal's bx-hall criterion says core task actions must work in the builders
|
|
4
6
|
// hall WITHOUT invoking the LLM. Criterion confirmation violated that: the goal
|
|
@@ -47,20 +49,39 @@ const GOALS_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/go
|
|
|
47
49
|
const GOAL_FOREST_RENDER_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goal-forest-render.js'), 'utf8');
|
|
48
50
|
const GOALS_RENDER_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-render.js'), 'utf8');
|
|
49
51
|
|
|
52
|
+
const UAT_UI_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/criterion-uat-ui.js'), 'utf8');
|
|
53
|
+
|
|
50
54
|
const { test, summary } = makeRunner();
|
|
51
55
|
|
|
56
|
+
// task 1004402: the server sends each criterion's UAT state (ADR 0351). The
|
|
57
|
+
// "legacy" set is a server too old to send it.
|
|
52
58
|
const CRIT = {
|
|
53
|
-
satisfied: { id: 501, criterion_id: 'c-done', criterion_md: 'Already confirmed', satisfied: true, pending_review: false },
|
|
54
|
-
pending: { id: 502, criterion_id: 'c-pending', criterion_md: 'All its tasks shipped', satisfied: false, pending_review: true },
|
|
55
|
-
open: { id: 503, criterion_id: 'c-open', criterion_md: 'Still has open work', satisfied: false, pending_review: false },
|
|
59
|
+
satisfied: { id: 501, criterion_id: 'c-done', criterion_md: 'Already confirmed', satisfied: true, pending_review: false, uat_state: 'satisfied_uat' },
|
|
60
|
+
pending: { id: 502, criterion_id: 'c-pending', criterion_md: 'All its tasks shipped', satisfied: false, pending_review: true, uat_state: 'awaiting_uat', awaiting_uat: true },
|
|
61
|
+
open: { id: 503, criterion_id: 'c-open', criterion_md: 'Still has open work', satisfied: false, pending_review: false, uat_state: 'open' },
|
|
56
62
|
};
|
|
63
|
+
const LEGACY = Object.values(CRIT).map(({ uat_state, awaiting_uat, ...c }) => c);
|
|
64
|
+
|
|
65
|
+
// A fake element the UAT wiring can query: data-* lookups answer from `parts`.
|
|
66
|
+
function fakeNode(parts = {}) {
|
|
67
|
+
return {
|
|
68
|
+
dataset: {}, hidden: false, textContent: '', disabled: false, isConnected: true, children: [],
|
|
69
|
+
querySelector: (sel) => {
|
|
70
|
+
for (const [k, v] of Object.entries(parts)) if (sel.includes(k)) return v;
|
|
71
|
+
return null;
|
|
72
|
+
},
|
|
73
|
+
appendChild(child) { this.children.push(child); return child; },
|
|
74
|
+
setAttribute() {},
|
|
75
|
+
closest() { return null; },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
57
78
|
|
|
58
79
|
// Boot goals-page.js against a fake API and return what it rendered + recorded.
|
|
59
80
|
// `rank` drives the client-side control gate; `goalStatus` drives the goal state.
|
|
60
|
-
async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.values(CRIT),
|
|
61
|
-
const calls = { requests: [], toasts: [] };
|
|
81
|
+
async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.values(CRIT), criterionlessTaskCount, uatPost, uatView, upload } = {}) {
|
|
82
|
+
const calls = { requests: [], toasts: [], fetches: [] };
|
|
62
83
|
const els = new Map();
|
|
63
|
-
const listeners = new Map(); // selector -> [
|
|
84
|
+
const listeners = new Map(); // selector -> { click: [], submit: [] }
|
|
64
85
|
|
|
65
86
|
const makeEl = (key) => {
|
|
66
87
|
const el = {
|
|
@@ -68,11 +89,10 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
68
89
|
innerHTML: '',
|
|
69
90
|
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
70
91
|
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
71
|
-
appendChild() {}, focus() {}, click() {}, querySelectorAll() { return []; },
|
|
92
|
+
appendChild() {}, focus() {}, click() {}, querySelectorAll() { return []; }, querySelector() { return null; },
|
|
72
93
|
addEventListener(type, fn) {
|
|
73
|
-
if (
|
|
74
|
-
if (
|
|
75
|
-
listeners.get(key).push(fn);
|
|
94
|
+
if (!listeners.has(key)) listeners.set(key, { click: [], submit: [] });
|
|
95
|
+
if (listeners.get(key)[type]) listeners.get(key)[type].push(fn);
|
|
76
96
|
},
|
|
77
97
|
};
|
|
78
98
|
return el;
|
|
@@ -87,18 +107,17 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
87
107
|
const detail = { goal, members: [], criteria, dependencies: [], criterionless_task_count: criterionlessTaskCount };
|
|
88
108
|
|
|
89
109
|
const api = {
|
|
90
|
-
request(method, p) {
|
|
91
|
-
calls.requests.push([method, p]);
|
|
92
|
-
const reply = (data, status = 200) => Promise.resolve({ status, ok:
|
|
110
|
+
request(method, p, opts) {
|
|
111
|
+
calls.requests.push([method, p, opts && opts.body]);
|
|
112
|
+
const reply = (data, status = 200) => Promise.resolve({ status, ok: status < 400, data });
|
|
93
113
|
if (method === 'GET' && /\/me$/.test(p)) return reply({ builder: { id: 1, rank }, active_claims: [] });
|
|
94
114
|
if (method === 'GET' && /\/goals$/.test(p)) return reply({ goals: [goal] });
|
|
95
115
|
if (method === 'GET' && /\/goals\/7$/.test(p)) return reply(detail);
|
|
96
116
|
if (method === 'GET' && /\/tasks\?/.test(p)) return reply({ tasks: [] });
|
|
97
117
|
if (method === 'GET' && /\/conflicts$/.test(p)) return reply({ pairs: [] });
|
|
98
118
|
if (method === 'GET' && /\/requests$/.test(p)) return reply({ requests: [] });
|
|
99
|
-
if (method === '
|
|
100
|
-
|
|
101
|
-
}
|
|
119
|
+
if (method === 'GET' && /\/done-when\/\d+\/uat$/.test(p)) return Promise.resolve(uatView || reply({ criterion: { id: 502 }, uat_state: 'awaiting_uat', backend_only: false, checks: { code: true, live: { available: false }, uat: null } }));
|
|
120
|
+
if (method === 'POST' && /\/done-when\/\d+\/uat$/.test(p)) return Promise.resolve(uatPost || { status: 201, ok: true, data: { ok: true, criterion_closed: true, achieved_goals: [] } });
|
|
102
121
|
return reply({});
|
|
103
122
|
},
|
|
104
123
|
};
|
|
@@ -146,11 +165,17 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
146
165
|
getElementById: (id) => $(`#${id}`),
|
|
147
166
|
querySelector: (sel) => $(sel),
|
|
148
167
|
querySelectorAll: () => [],
|
|
149
|
-
createElement: () => makeEl('created'),
|
|
168
|
+
createElement: () => ({ ...makeEl('created'), remove() {} }),
|
|
150
169
|
addEventListener() {},
|
|
151
170
|
};
|
|
171
|
+
// The recording upload is a raw fetch (the API client only speaks JSON).
|
|
172
|
+
const fetchStub = async (url, init) => {
|
|
173
|
+
calls.fetches.push([url, init]);
|
|
174
|
+
const out = upload || { status: 201, body: { ok: true, recording: 'uat-502-0123456789abcdef.webm' } };
|
|
175
|
+
return { ok: out.status < 400, status: out.status, json: async () => out.body };
|
|
176
|
+
};
|
|
152
177
|
|
|
153
|
-
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise };
|
|
178
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise, fetch: fetchStub, CSS: { escape: (s) => String(s) } };
|
|
154
179
|
sandbox.globalThis = sandbox;
|
|
155
180
|
vm.createContext(sandbox);
|
|
156
181
|
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
@@ -161,37 +186,62 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
161
186
|
vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/bulk-toolbar.js'), 'utf8'), sandbox, { filename: 'bulk-toolbar.js' });
|
|
162
187
|
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
163
188
|
vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
|
|
189
|
+
// task 1004402: criterion-uat-ui.js loads before goals-render.js (goals.html).
|
|
190
|
+
vm.runInContext(UAT_UI_SRC, sandbox, { filename: 'criterion-uat-ui.js' });
|
|
164
191
|
vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
|
|
165
192
|
vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
|
|
166
193
|
|
|
167
194
|
// Let boot()'s promise chain settle (me → goals → details → route → render).
|
|
168
|
-
for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0));
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
for (const fn of
|
|
179
|
-
|
|
180
|
-
return
|
|
195
|
+
const settle = async () => { for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
196
|
+
await settle();
|
|
197
|
+
|
|
198
|
+
const band = () => listeners.get('#goal-completion-body') || { click: [], submit: [] };
|
|
199
|
+
|
|
200
|
+
// Click "Test and sign off" on a criterion: the panel is appended to its row.
|
|
201
|
+
const openPanel = async (critId) => {
|
|
202
|
+
const row = fakeNode({});
|
|
203
|
+
const btn = { dataset: { uatOpen: String(critId) }, setAttribute() {}, closest: (sel) => (sel === '.crit' ? row : null) };
|
|
204
|
+
assert.ok(band().click.length > 0, 'a click listener must be wired on the completion band');
|
|
205
|
+
for (const fn of band().click) fn({ target: { closest: (sel) => (sel === '[data-uat-open]' ? btn : null) }, preventDefault() {} });
|
|
206
|
+
await settle();
|
|
207
|
+
return row;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Submit a sign-off form: `file` given (even null = none chosen) = a UAT form
|
|
211
|
+
// with its file input; omitted = a backend sign-off form, which has none.
|
|
212
|
+
const submitForm = async ({ critId = 502, file, note = '', attest = null }) => {
|
|
213
|
+
const error = { hidden: true, textContent: '' };
|
|
214
|
+
const submitBtn = { disabled: false, textContent: 'Sign off', isConnected: true };
|
|
215
|
+
const parts = { 'button[type="submit"]': submitBtn, '[data-uat-note]': { value: note } };
|
|
216
|
+
if (file !== undefined) parts['[data-uat-file]'] = { files: file ? [file] : [] };
|
|
217
|
+
if (attest !== null) parts['[data-uat-attest]'] = { checked: attest };
|
|
218
|
+
const form = { ...fakeNode(parts), dataset: { uatForm: String(critId) } };
|
|
219
|
+
documentObj.querySelector = (sel) => (sel.includes('data-uat-error') ? error : $(sel));
|
|
220
|
+
const root = els.get('#goal-completion-body');
|
|
221
|
+
root.querySelector = (sel) => (sel.includes('data-uat-error') ? error : null);
|
|
222
|
+
for (const fn of band().submit) fn({ target: { closest: (sel) => (sel === '[data-uat-form]' ? form : null) }, preventDefault() {} });
|
|
223
|
+
await settle();
|
|
224
|
+
return { error, submitBtn };
|
|
181
225
|
};
|
|
182
226
|
|
|
183
|
-
return {
|
|
227
|
+
return {
|
|
228
|
+
calls,
|
|
229
|
+
band: $('#goal-completion-body').innerHTML,
|
|
230
|
+
full: $('#goal-completion-full').innerHTML,
|
|
231
|
+
openPanel, submitForm,
|
|
232
|
+
};
|
|
184
233
|
}
|
|
185
234
|
|
|
235
|
+
const posts = (calls, re) => calls.requests.filter(([m, p]) => m === 'POST' && re.test(p));
|
|
236
|
+
|
|
186
237
|
// =========================================================================
|
|
187
|
-
// The
|
|
238
|
+
// The criterion states
|
|
188
239
|
// =========================================================================
|
|
189
240
|
|
|
190
|
-
await test('the goal detail renders
|
|
241
|
+
await test('the goal detail renders the criterion states distinguishably', async () => {
|
|
191
242
|
const { band } = await render();
|
|
192
|
-
assert.match(band, /crit--done/, 'a
|
|
193
|
-
assert.match(band, /crit--pending/, '
|
|
194
|
-
// The open criterion is the one with neither modifier.
|
|
243
|
+
assert.match(band, /crit--done/, 'a closed criterion keeps its done styling');
|
|
244
|
+
assert.match(band, /crit--pending/, 'an awaiting-UAT criterion gets its own state');
|
|
195
245
|
const items = band.match(/<li class="crit[^"]*"/g) || [];
|
|
196
246
|
assert.equal(items.length, 3, `three criteria render — got ${JSON.stringify(items)}`);
|
|
197
247
|
assert.equal(items.filter((s) => /crit--done/.test(s)).length, 1, 'exactly one done');
|
|
@@ -199,58 +249,82 @@ await test('the goal detail renders all three criterion states distinguishably',
|
|
|
199
249
|
assert.equal(items.filter((s) => !/crit--(done|pending)/.test(s)).length, 1, 'exactly one plain/open');
|
|
200
250
|
});
|
|
201
251
|
|
|
202
|
-
await test('
|
|
252
|
+
await test('each state reads in the server\'s own words (Awaiting UAT, Satisfied (UAT))', async () => {
|
|
253
|
+
const { band } = await render();
|
|
254
|
+
assert.match(band, /class="crit-state crit-state--awaiting_uat">Awaiting UAT</);
|
|
255
|
+
assert.match(band, /class="crit-state crit-state--satisfied_uat">Satisfied \(UAT\)</);
|
|
256
|
+
assert.doesNotMatch(band, /crit-state--open/, 'an open criterion carries no pill: the empty mark already says so');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('a Backend-only criterion carries a visible tag', async () => {
|
|
260
|
+
const { band } = await render({ criteria: [{ ...CRIT.pending, backend_only: true }] });
|
|
261
|
+
assert.match(band, /class="crit-tag"[^>]*>Backend-only</);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await test('an Awaiting UAT criterion is the ONLY one offering "Test and sign off" (Metic)', async () => {
|
|
203
265
|
const { band } = await render({ rank: 'metic' });
|
|
204
|
-
const buttons = band.match(/data-
|
|
205
|
-
assert.equal(buttons.length, 1, `exactly one
|
|
206
|
-
assert.match(buttons[0], new RegExp(`"${CRIT.pending.id}"`), 'it targets the
|
|
266
|
+
const buttons = band.match(/data-uat-open="(\d+)"[^>]*>Test and sign off/g) || [];
|
|
267
|
+
assert.equal(buttons.length, 1, `exactly one sign-off control — got ${JSON.stringify(buttons)}`);
|
|
268
|
+
assert.match(buttons[0], new RegExp(`"${CRIT.pending.id}"`), 'it targets the awaiting criterion');
|
|
269
|
+
assert.match(band, /data-uat-open="501"[^>]*>See the UAT/, 'a UAT-closed criterion offers its evidence instead');
|
|
207
270
|
});
|
|
208
271
|
|
|
209
|
-
await test('an Archon also gets
|
|
272
|
+
await test('an Archon also gets the control (Metic+ means at-or-above, not equals)', async () => {
|
|
210
273
|
const { band } = await render({ rank: 'archon' });
|
|
211
|
-
assert.match(band,
|
|
274
|
+
assert.match(band, />Test and sign off</);
|
|
212
275
|
});
|
|
213
276
|
|
|
214
|
-
await test('a Xenos sees the
|
|
277
|
+
await test('a Xenos sees the Awaiting UAT state but never the sign-off control', async () => {
|
|
215
278
|
const { band } = await render({ rank: 'xenos' });
|
|
216
279
|
assert.match(band, /crit--pending/, 'the state is still visible to everyone');
|
|
217
|
-
assert.doesNotMatch(band,
|
|
218
|
-
assert.match(band, /crit__flag
|
|
280
|
+
assert.doesNotMatch(band, />Test and sign off</, 'the action is hidden below Metic');
|
|
281
|
+
assert.match(band, /class="crit__flag"[^>]*>Awaiting UAT</, 'a read-only flag stands in for the button');
|
|
219
282
|
});
|
|
220
283
|
|
|
221
|
-
await test('a thetes (below Metic) also gets no
|
|
284
|
+
await test('a thetes (below Metic) also gets no sign-off control', async () => {
|
|
222
285
|
const { band } = await render({ rank: 'thetes' });
|
|
223
|
-
assert.doesNotMatch(band,
|
|
286
|
+
assert.doesNotMatch(band, />Test and sign off</);
|
|
224
287
|
});
|
|
225
288
|
|
|
226
|
-
await test('an achieved goal offers no
|
|
227
|
-
|
|
228
|
-
|
|
289
|
+
await test('an achieved or archived goal offers no sign-off', async () => {
|
|
290
|
+
for (const goalStatus of ['achieved', 'archived']) {
|
|
291
|
+
const { band } = await render({ goalStatus });
|
|
292
|
+
assert.doesNotMatch(band, />Test and sign off</, goalStatus);
|
|
293
|
+
}
|
|
229
294
|
});
|
|
230
295
|
|
|
231
|
-
await test('
|
|
232
|
-
const
|
|
233
|
-
|
|
296
|
+
await test('THE BYPASS IS GONE: nothing on the page can POST /satisfy', async () => {
|
|
297
|
+
for (const criteria of [Object.values(CRIT), LEGACY]) {
|
|
298
|
+
const { band, full } = await render({ rank: 'archon', criteria });
|
|
299
|
+
assert.doesNotMatch(band + full, /data-confirm-crit|\/satisfy/, 'no bare Confirm survives, on a new or an old server');
|
|
300
|
+
}
|
|
301
|
+
assert.doesNotMatch(SRC, /\/satisfy`/, 'goals-page.js holds no /satisfy call at all');
|
|
234
302
|
});
|
|
235
303
|
|
|
236
304
|
// =========================================================================
|
|
237
|
-
// The summary line
|
|
305
|
+
// The summary line
|
|
238
306
|
// =========================================================================
|
|
239
307
|
|
|
240
308
|
await test('the summary no longer sends the builder to the /goal-review CLI skill', async () => {
|
|
241
309
|
const { band } = await render();
|
|
242
|
-
assert.doesNotMatch(band, /goal-review
|
|
310
|
+
assert.doesNotMatch(band, /goal-review/);
|
|
243
311
|
});
|
|
244
312
|
|
|
245
|
-
await test('the summary counts what is
|
|
313
|
+
await test('the summary counts what is awaiting UAT', async () => {
|
|
246
314
|
const { band } = await render();
|
|
247
|
-
assert.match(band, /1 criterion is
|
|
315
|
+
assert.match(band, /1 criterion is awaiting UAT: the work has shipped/, 'singular phrasing for one awaiting criterion');
|
|
248
316
|
});
|
|
249
317
|
|
|
250
|
-
await test('with nothing
|
|
318
|
+
await test('with nothing awaiting, the summary explains when a criterion is ready for its UAT', async () => {
|
|
251
319
|
const { band } = await render({ criteria: [CRIT.satisfied, CRIT.open] });
|
|
252
|
-
assert.doesNotMatch(band, /
|
|
253
|
-
assert.match(band, /once every task linked to it has shipped
|
|
320
|
+
assert.doesNotMatch(band, /awaiting UAT:/);
|
|
321
|
+
assert.match(band, /ready for its UAT once every task linked to it has shipped/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test('an older server (no uat_state) keeps the old sentence and draws no UAT control', async () => {
|
|
325
|
+
const { band } = await render({ criteria: LEGACY });
|
|
326
|
+
assert.match(band, /1 criterion is met and ready to confirm/);
|
|
327
|
+
assert.doesNotMatch(band, /data-uat-open|crit-state/, 'no UAT vocabulary the server did not send');
|
|
254
328
|
});
|
|
255
329
|
|
|
256
330
|
// =========================================================================
|
|
@@ -289,47 +363,70 @@ await test('an achieved goal keeps its own summary line', async () => {
|
|
|
289
363
|
});
|
|
290
364
|
|
|
291
365
|
// =========================================================================
|
|
292
|
-
// The action itself
|
|
366
|
+
// The action itself — the real wiring, driven by events
|
|
293
367
|
// =========================================================================
|
|
294
368
|
|
|
295
|
-
await test('
|
|
296
|
-
const { calls,
|
|
297
|
-
await
|
|
298
|
-
|
|
299
|
-
assert.equal(
|
|
300
|
-
assert.match(
|
|
369
|
+
await test('opening a criterion reads GET /done-when/:id/uat and draws the panel under its row', async () => {
|
|
370
|
+
const { calls, openPanel } = await render();
|
|
371
|
+
const row = await openPanel(CRIT.pending.id);
|
|
372
|
+
assert.ok(calls.requests.some(([m, p]) => m === 'GET' && /\/done-when\/502\/uat$/.test(p)), 'the UAT view is read');
|
|
373
|
+
assert.equal(row.children.length, 1, 'one panel appended to the row');
|
|
374
|
+
assert.match(row.children[0].innerHTML, /data-uat-form="502"/, 'with the sign-off form');
|
|
375
|
+
assert.match(row.children[0].innerHTML, /I tried this on the live site/, 'and the attestation, since this site cannot read its deploy');
|
|
301
376
|
});
|
|
302
377
|
|
|
303
|
-
await test('
|
|
304
|
-
const { calls,
|
|
378
|
+
await test('a UAT uploads the recording as raw bytes, THEN signs off naming it, then re-reads the goal', async () => {
|
|
379
|
+
const { calls, submitForm } = await render();
|
|
305
380
|
const before = calls.requests.filter(([m, p]) => m === 'GET' && /\/goals\/7$/.test(p)).length;
|
|
306
|
-
|
|
381
|
+
const file = { type: 'video/webm', size: 1024 };
|
|
382
|
+
await submitForm({ file, note: 'made a rank, assigned it', attest: true });
|
|
383
|
+
assert.equal(calls.fetches.length, 1, 'one upload');
|
|
384
|
+
assert.match(calls.fetches[0][0], /\/done-when\/502\/uat\/recording$/);
|
|
385
|
+
assert.equal(calls.fetches[0][1].body, file, 'the file itself is the body, not base64');
|
|
386
|
+
assert.equal(calls.fetches[0][1].headers['Content-Type'], 'video/webm');
|
|
387
|
+
const signed = posts(calls, /\/done-when\/502\/uat$/);
|
|
388
|
+
assert.equal(signed.length, 1);
|
|
389
|
+
// Compared through JSON: an object born in the vm is never strictly deepEqual.
|
|
390
|
+
assert.equal(JSON.stringify(signed[0][2]), JSON.stringify({ kind: 'uat', recording: 'uat-502-0123456789abcdef.webm', note: 'made a rank, assigned it', live_attested: true }));
|
|
307
391
|
const after = calls.requests.filter(([m, p]) => m === 'GET' && /\/goals\/7$/.test(p)).length;
|
|
308
|
-
assert.ok(after > before, 'the detail is force-reloaded
|
|
392
|
+
assert.ok(after > before, 'the detail is force-reloaded so the row closes in place');
|
|
393
|
+
assert.ok(calls.toasts.some((t) => /criterion is closed/.test(t)));
|
|
309
394
|
});
|
|
310
395
|
|
|
311
|
-
await test('
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
396
|
+
await test('a backend sign-off sends no recording and requires saying how it was checked', async () => {
|
|
397
|
+
const empty = await render();
|
|
398
|
+
const { error } = await empty.submitForm({ note: ' ' });
|
|
399
|
+
assert.equal(error.textContent, 'Say how you checked it.');
|
|
400
|
+
assert.equal(posts(empty.calls, /\/uat$/).length, 0, 'nothing is sent without it');
|
|
401
|
+
const ok = await render();
|
|
402
|
+
await ok.submitForm({ note: 'the audit log has the row' });
|
|
403
|
+
assert.equal(JSON.stringify(posts(ok.calls, /\/done-when\/502\/uat$/)[0][2]), JSON.stringify({ kind: 'backend_signoff', note: 'the audit log has the row' }));
|
|
404
|
+
assert.equal(ok.calls.fetches.length, 0, 'no upload for a backend sign-off');
|
|
317
405
|
});
|
|
318
406
|
|
|
319
|
-
await test('
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
407
|
+
await test('the page refuses before sending: no file, wrong type, over 100 MB, attestation unticked', async () => {
|
|
408
|
+
const cases = [
|
|
409
|
+
[{ file: null, attest: true }, 'Choose the recording first.'],
|
|
410
|
+
[{ file: { type: 'image/png', size: 10 }, attest: true }, 'A UAT recording must be an mp4 or webm video.'],
|
|
411
|
+
[{ file: { type: 'video/mp4', size: 100 * 1024 * 1024 + 1 }, attest: true }, /over the 100 MB cap/],
|
|
412
|
+
[{ file: { type: 'video/mp4', size: 10 }, attest: false }, 'Confirm that you tried it on the live site.'],
|
|
413
|
+
];
|
|
414
|
+
for (const [input, expected] of cases) {
|
|
415
|
+
const r = await render();
|
|
416
|
+
const { error } = await r.submitForm(input);
|
|
417
|
+
if (expected instanceof RegExp) assert.match(error.textContent, expected); else assert.equal(error.textContent, expected);
|
|
418
|
+
assert.equal(r.calls.fetches.length, 0, 'nothing was uploaded');
|
|
419
|
+
assert.equal(posts(r.calls, /\/uat$/).length, 0, 'nothing was signed');
|
|
420
|
+
}
|
|
324
421
|
});
|
|
325
422
|
|
|
326
|
-
await test('a
|
|
327
|
-
const {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
await
|
|
331
|
-
|
|
332
|
-
assert.
|
|
423
|
+
await test('a server refusal is shown in the server\'s own words, and the goal achieved on the last one is announced', async () => {
|
|
424
|
+
const refused = await render({ uatPost: { status: 403, ok: false, data: { error: { code: 'signer_shipped_this_work', message: 'You shipped work linked to this criterion, so someone else has to sign it off.' } } } });
|
|
425
|
+
const { error } = await refused.submitForm({ note: 'checked' });
|
|
426
|
+
assert.equal(error.textContent, 'You shipped work linked to this criterion, so someone else has to sign it off.');
|
|
427
|
+
const last = await render({ uatPost: { status: 201, ok: true, data: { ok: true, criterion_closed: true, achieved_goals: [7] } } });
|
|
428
|
+
await last.submitForm({ note: 'checked' });
|
|
429
|
+
assert.ok(last.calls.toasts.some((t) => /goal is achieved/.test(t)), JSON.stringify(last.calls.toasts));
|
|
333
430
|
});
|
|
334
431
|
|
|
335
432
|
summary();
|
package/tests/goal_map_page.mjs
CHANGED
|
@@ -427,7 +427,8 @@ await test('the page is the sky\'s own surface — the same sheet and the same s
|
|
|
427
427
|
assert.match(html, /<h1>the sky <em id="lensWord">— the whole project<\/em><\/h1>/, 'the h1 is in static markup, so every state has one');
|
|
428
428
|
assert.equal(/shell\.js|style\.css/.test(html), false, 'no hall chrome yet — /thinking joined it (task 1004327), the project sky did not');
|
|
429
429
|
const scripts = (h) => [...h.matchAll(/<script src="\/builders\/([^"?]+)\?v=([^"]+)"/g)].map((m) => [m[1], m[2]]);
|
|
430
|
-
|
|
430
|
+
// criterion-uat-ui.js (task 1004402): the rings' UAT state words, shared with the goal page.
|
|
431
|
+
assert.deepEqual(scripts(html).map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
|
|
431
432
|
// /thinking also loads the shell (task 1004327); the SKY's files are the same.
|
|
432
433
|
const skyScripts = (h) => scripts(h).filter((s) => s[0] !== 'shell.js');
|
|
433
434
|
assert.deepEqual(skyScripts(html), skyScripts(thinking), 'the two doors load the same sky files at the same stamp');
|
package/tests/hall_sky.mjs
CHANGED
|
@@ -497,4 +497,20 @@ await test('a capped trail is spoken, never a silent slice', async () => {
|
|
|
497
497
|
});
|
|
498
498
|
|
|
499
499
|
console.log('');
|
|
500
|
+
await test('a ring carries its UAT state in the goal page words, and says it on hover and in the panel (task 1004402)', async () => {
|
|
501
|
+
const w = well({ criteria: [
|
|
502
|
+
{ id: '70', criterion_id: 'c1', criterion_md: 'Awaiting ring', satisfied: false, pending_review: true, uat_state: 'awaiting_uat', backend_only: true, task_ids: ['500'] },
|
|
503
|
+
{ id: '71', criterion_id: 'c2', criterion_md: 'Closed ring', satisfied: true, pending_review: false, uat_state: 'closed_before_uat', task_ids: ['500'] },
|
|
504
|
+
{ id: '72', criterion_id: 'c3', criterion_md: 'Open ring', satisfied: false, pending_review: false, uat_state: 'open', task_ids: ['501'] },
|
|
505
|
+
] });
|
|
506
|
+
const p = await boot({ sky: sky([w]) });
|
|
507
|
+
const crit = p.S().goals[0].crit;
|
|
508
|
+
assert.equal(crit[0].uatLabel, 'Awaiting UAT');
|
|
509
|
+
assert.equal(crit[0].backendOnly, true);
|
|
510
|
+
assert.equal(crit[1].uatLabel, 'Closed before UAT', 'every state, not only awaiting');
|
|
511
|
+
assert.equal(crit[2].uatLabel, null, 'an open ring needs no word: its open ring already says so');
|
|
512
|
+
const src = FS.readFileSync('modules/hall-ui/public/sky-interaction.js', 'utf8') + FS.readFileSync('modules/hall-ui/public/sky-panel.js', 'utf8');
|
|
513
|
+
assert.equal((src.match(/esc\(c\.uatLabel\)/g) || []).length, 2, 'the hover card and the panel both print the label, escaped');
|
|
514
|
+
});
|
|
515
|
+
|
|
500
516
|
summary();
|
package/tests/helpers.mjs
CHANGED
|
@@ -767,7 +767,9 @@ export const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
|
767
767
|
// carry the same markup and the same scripts and differ in the body's
|
|
768
768
|
// `data-sky-lens` (which lens is home). A test names the `page`; each is parsed
|
|
769
769
|
// from its OWN file, so a button lost from either one fails.
|
|
770
|
-
|
|
770
|
+
// criterion-uat-ui.js (task 1004402): both sky pages load it before sky-data,
|
|
771
|
+
// which reads the rings' UAT state words from it.
|
|
772
|
+
const SKY_FILES = ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
|
|
771
773
|
const SKY_SRC = SKY_FILES.map((f) => [f, fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8')]);
|
|
772
774
|
const SKY_PAGES = {};
|
|
773
775
|
function skyPage(page) {
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -714,7 +714,9 @@ test('a renumbering of the wizard invalidates old drafts — the key carries the
|
|
|
714
714
|
assert.match(key, /-v\d+$/, 'the draft key is versioned');
|
|
715
715
|
const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
|
|
716
716
|
const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
|
|
717
|
-
|
|
717
|
+
// task 1004417 put the front door's two screens (terms, fork) in front: the sixth renumbering
|
|
718
|
+
assert.equal(count, 12, 'gate + the front door\'s two + eight numbered steps + done — bump DRAFT_KEY when this changes');
|
|
719
|
+
assert.equal(key, 'cb-create-draft-v6', 'and the key moved with the front door');
|
|
718
720
|
});
|
|
719
721
|
|
|
720
722
|
test('every row lays the same five parts — the ledger has no ragged row', () => {
|
|
@@ -2182,7 +2184,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
|
|
|
2182
2184
|
assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
|
|
2183
2185
|
assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
|
|
2184
2186
|
assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
|
|
2185
|
-
assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="
|
|
2187
|
+
assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="9">← 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');
|
|
2186
2188
|
assert.match(HUB, /<h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style (the id is where a skipped invite step hands focus on, task 1002346)');
|
|
2187
2189
|
// what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
|
|
2188
2190
|
assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
|
|
@@ -240,7 +240,7 @@ test('a repaint never lands on top of an open Retry — from this watch or a fre
|
|
|
240
240
|
});
|
|
241
241
|
|
|
242
242
|
test('both surfaces use it: the done panel and the project page', () => {
|
|
243
|
-
assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step ===
|
|
243
|
+
assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step === 11; \}, 5000\);/);
|
|
244
244
|
assert.match(HUB, /<div id="doneApp" hidden><\/div>/);
|
|
245
245
|
assert.match(HUB, /<div class="card mSec" id="mAppCard" hidden>\n\s*<h2>Where its app runs<\/h2>\n\s*<div id="mApp"><\/div>/);
|
|
246
246
|
assert.match(HUB, /loadAppStatus\(inst\.id \|\| asked, inst\);/);
|
|
@@ -142,17 +142,17 @@ test('the panel is real markup: Render and Decide later, Decide later the defaul
|
|
|
142
142
|
assert.doesNotMatch(PANEL, /Coming soon/, 'and is no longer marked soon');
|
|
143
143
|
assert.match(PANEL, /<input type="radio" name="appPlatform" value="later" checked>/, 'Decide later is the default');
|
|
144
144
|
assert.match(PANEL, /We host your Builders' hall/, 'the step says the hall is ours to host (ADR 0345 D1), in the page\'s one spelling of it');
|
|
145
|
-
assert.match(PANEL, /<form data-next="
|
|
146
|
-
assert.match(PANEL, /data-back="
|
|
145
|
+
assert.match(PANEL, /<form data-next="10">/, 'Next leads to the review');
|
|
146
|
+
assert.match(PANEL, /data-back="8"/, 'Back returns to the address step');
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
test('the step sits between Address and Review in the panels map and on the rail', () => {
|
|
150
|
-
assert.match(HUB, /
|
|
151
|
-
assert.match(HUB, /<li data-step="
|
|
152
|
-
assert.match(HUB, /<li data-step="
|
|
153
|
-
assert.match(HUB, /data-back="
|
|
154
|
-
assert.match(HUB, /if \(step ===
|
|
155
|
-
assert.match(HUB, /if \(step ===
|
|
150
|
+
assert.match(HUB, /8: 'panel-address', 9: 'panel-app', 10: 'panel-review', 11: 'panel-done'/);
|
|
151
|
+
assert.match(HUB, /<li data-step="9"><span class="snum">7<\/span><span class="slbl">App<\/span><\/li>/);
|
|
152
|
+
assert.match(HUB, /<li data-step="10"><span class="snum">8<\/span><span class="slbl">Create<\/span><\/li>/);
|
|
153
|
+
assert.match(HUB, /data-back="9">← Back<\/button>\n\s*<button type="button" class="btn big" id="doCreate">/, "the review's Back returns to the app step");
|
|
154
|
+
assert.match(HUB, /if \(step === 9\) \{ syncAppStep\(\); syncRenderSetup\(\); \}\n\s*if \(step === 10\) renderReview\(\);/, 'entering the step repaints it, Render setup included');
|
|
155
|
+
assert.match(HUB, /if \(step === 9\) return validateRender\(\);/, 'Next on the step runs the Render check');
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
test('a fresh draft reads "decide later", and the Render setup stays hidden', () => {
|
|
@@ -455,5 +455,5 @@ test('the review names exactly what was chosen, prices included', () => {
|
|
|
455
455
|
});
|
|
456
456
|
|
|
457
457
|
test('Create hands the app to Render straight after the project is filed', () => {
|
|
458
|
-
assert.match(HUB, /show\(
|
|
458
|
+
assert.match(HUB, /show\(11, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
|
|
459
459
|
});
|
|
@@ -41,7 +41,7 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
41
41
|
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
42
42
|
|
|
43
43
|
const START = '/* ── the module picker (task 1002339, BV1.R20) ──';
|
|
44
|
-
const END = "$('skipModules').addEventListener('click', function () { show(
|
|
44
|
+
const END = "$('skipModules').addEventListener('click', function () { show(7, true); });";
|
|
45
45
|
const from = HUB.indexOf(START);
|
|
46
46
|
const to = HUB.indexOf(END);
|
|
47
47
|
assert.ok(from > -1, 'the picker block opens with its sentinel comment');
|
|
@@ -160,7 +160,7 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
|
|
|
160
160
|
const els = new Map();
|
|
161
161
|
const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
|
162
162
|
const form = makeEl('panel-modules-form');
|
|
163
|
-
const state = { step:
|
|
163
|
+
const state = { step: 6, projType: null, teamShape: null, modules: null, ...seed };
|
|
164
164
|
const shown = [];
|
|
165
165
|
let saves = 0;
|
|
166
166
|
let fetches = 0;
|
|
@@ -312,7 +312,7 @@ test('Skip walks past WITHOUT answering, so the server applies the bundle the pa
|
|
|
312
312
|
|
|
313
313
|
// Assert — skipping is the one path that must leave nothing said, because the
|
|
314
314
|
// preselect and the server's fallback are the same declaration (ADR 0237)
|
|
315
|
-
assert.deepEqual(h.shown, [
|
|
315
|
+
assert.deepEqual(h.shown, [7], 'it advances to the Home step (7 since task 1004417)');
|
|
316
316
|
assert.equal(h.state.modules, null, 'and answers nothing');
|
|
317
317
|
assert.equal(h.forCreate(), undefined);
|
|
318
318
|
assert.deepEqual(h.picked(), ['npm-release', 'discord'], 'while the set they were shown is still the set they get');
|
|
@@ -377,7 +377,7 @@ test('a catalog that will not load degrades to a clause — it never blocks a pr
|
|
|
377
377
|
assert.match(h.coreNote(), /couldn’t load/, 'the panel says so in the owner\'s words');
|
|
378
378
|
assert.equal(h.state.modules, null, 'nothing is answered on their behalf');
|
|
379
379
|
h.skip();
|
|
380
|
-
assert.deepEqual(h.shown, [
|
|
380
|
+
assert.deepEqual(h.shown, [7], 'and the wizard still walks on — the server applies the bundle');
|
|
381
381
|
});
|
|
382
382
|
|
|
383
383
|
test('every rendered value goes through the page escaper', async () => {
|
|
@@ -415,7 +415,7 @@ test('the review names the set even when the picker was never opened (task 10023
|
|
|
415
415
|
// and the module NAMES are not known. "None — just the core" would be a false
|
|
416
416
|
// promise on the last screen before Create: the server is about to apply the
|
|
417
417
|
// type's bundle. The row says that instead, then repaints when the read lands.
|
|
418
|
-
const h = boot({ state: { projType: 'game', step:
|
|
418
|
+
const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
|
|
419
419
|
|
|
420
420
|
// Arrange + Act — the review asks for the value before the read can answer
|
|
421
421
|
const first = h.extrasRow();
|
|
@@ -510,7 +510,7 @@ test('Skip after an answered About step still yields the set the panel showed',
|
|
|
510
510
|
|
|
511
511
|
h.skip();
|
|
512
512
|
|
|
513
|
-
assert.deepEqual(h.shown, [
|
|
513
|
+
assert.deepEqual(h.shown, [7]);
|
|
514
514
|
assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
|
|
515
515
|
assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
|
|
516
516
|
assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
|
|
@@ -821,7 +821,7 @@ test('with nothing switched on the count says only what is shown', async () => {
|
|
|
821
821
|
});
|
|
822
822
|
|
|
823
823
|
test('the review names the hidden picks too — a filter never reaches the create request', async () => {
|
|
824
|
-
const h = boot({ state: { projType: 'game', step:
|
|
824
|
+
const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
|
|
825
825
|
await h.open();
|
|
826
826
|
h.click('agents');
|
|
827
827
|
assert.deepEqual(h.forCreate(), ['agents', 'npm-release', 'discord']);
|
|
@@ -550,7 +550,10 @@ test('the rail at the done panel keeps its marker on Create until the project an
|
|
|
550
550
|
assert.doesNotMatch(fn('show'), /classList\.toggle\('done'/, 'and no longer marks the rail on its own');
|
|
551
551
|
assert.ok(SCRIPT.includes(`if (state.step === ${DONE}) paintRail(${DONE});`),
|
|
552
552
|
'the done-panel poll repaints when the status moves');
|
|
553
|
-
|
|
553
|
+
// the first NUMBERED beat carries it — since task 1004417 the front door's two screens come
|
|
554
|
+
// before the rail, and the card statically wears wizFront so a no-script visitor sees no rail there
|
|
555
|
+
assert.match(HUB, new RegExp(`<li class="now" data-step="${STEP['panel-name']}" aria-current="step"><span class="snum">1</span>`), 'the static rail starts with the same marker');
|
|
556
|
+
assert.match(HUB, /<section id="view-new" hidden>\s*<div class="wrap viewWrap">\s*<div class="card wizFront">/, 'and steps aside on the static first screen');
|
|
554
557
|
});
|
|
555
558
|
|
|
556
559
|
test('every action result announces, nothing under the pointer is deleted, and focus stays where it was', () => {
|