@bongos/core 1.20.76 → 1.20.78
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 +93 -63
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
- package/docs/adr/0360-bongos-follows-every-candidate-every-other-project-follows-releases.md +66 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +70 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +531 -458
- package/docs/copy-registry.json +1269 -589
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +11 -1
- package/docs/page-readings.json +1208 -1082
- package/docs/recipes/upgrading-the-core.md +11 -2
- package/modules/copy-desk/artist-stats.js +15 -1
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-status.js +41 -0
- package/modules/copy-desk/routes/copy-desk-artist.js +44 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +11 -2
- package/modules/hall-ui/public/deploy.states.json +1 -1
- package/modules/hall-ui/public/profile-crafts.js +231 -0
- package/modules/hall-ui/public/profile.css +60 -0
- package/modules/hall-ui/public/profile.html +31 -12
- package/modules/hall-ui/public/profile.js +180 -75
- package/modules/hall-ui/public/profile.states.json +34 -0
- package/modules/hall-ui/public/sky-draw.js +66 -39
- 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.css +52 -24
- package/modules/hall-ui/records/profile-roles.md +18 -0
- package/modules/npm-release/public/work.js +72 -0
- package/modules/npm-release/work.js +67 -1
- package/modules/provisioning/planet-physics.js +8 -0
- package/modules/provisioning/soft-limits.js +9 -0
- package/modules/public-landing/public/projects.html +579 -37
- package/modules/public-landing/public/projects.probes.json +6 -3
- package/modules/public-landing/public/projects.states.json +20 -14
- package/modules/ui-design/kit/serve.js +26 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/update-channel.js +68 -1
- package/scripts/gds/update-sweep.js +100 -4
- package/scripts/migrate.sh +6 -2
- package/src/module-api.js +1 -1
- package/tests/hall_sky.mjs +77 -0
- package/tests/helpers.mjs +32 -7
- package/tests/migrate_applied_check.mjs +94 -0
- package/tests/npm_release_page.mjs +67 -0
- package/tests/npm_release_work.mjs +82 -0
- package/tests/profile_activity.mjs +23 -15
- package/tests/profile_role_headline.mjs +4 -4
- package/tests/profile_role_tabs.mjs +163 -0
- package/tests/projects_hub_app_step.mjs +4 -1
- package/tests/provisioning_planet_physics.mjs +24 -2
- package/tests/update_channel.mjs +80 -1
- package/tests/update_subscription_engine.mjs +99 -0
- package/tests/wizard_physics.mjs +11 -4
- package/tests/wizard_physics_more.mjs +470 -0
|
@@ -468,27 +468,35 @@ await test('the other three outcomes each say what happened', async () => {
|
|
|
468
468
|
assert.ok(/Still open/.test(html), 'AN OPEN IDEA READS AS OPEN — not as nothing');
|
|
469
469
|
});
|
|
470
470
|
|
|
471
|
-
|
|
472
|
-
|
|
471
|
+
// Task 1004435 (WA6.RP05): one tab per role the person has, each its craft's
|
|
472
|
+
// own record. The engineer tab is the works and goals, WHOLE (no craft filter:
|
|
473
|
+
// most of the corpus is unclassified); the ideator tab is the trail; the artist
|
|
474
|
+
// tab is the approved pages. No tab is "tasks carrying that label" any more.
|
|
475
|
+
await test('each role tab asks the server for that craft\'s OWN record', async () => {
|
|
476
|
+
const p = bootProfile({ roles: ['ideator', 'engineer', 'artist'] });
|
|
473
477
|
await settle(); await settle(); await settle();
|
|
474
|
-
|
|
478
|
+
assert.ok(p.requests.some((u) => u.includes('/inbox/by-builder/')), 'the first tab (the ideator) reads the trail');
|
|
479
|
+
assert.ok(!p.requests.some((u) => u.includes('section=goals')), 'and no goals ledger until the engineer tab opens');
|
|
480
|
+
let before = p.requests.length;
|
|
481
|
+
p.switchCraft('engineer');
|
|
482
|
+
await settle(); await settle();
|
|
483
|
+
let after = p.requests.slice(before);
|
|
484
|
+
assert.ok(after.some((u) => u.includes('section=tasks') && !u.includes('role=')), 'the engineer tab reads the whole works ledger');
|
|
485
|
+
assert.ok(after.some((u) => u.includes('section=goals') && !u.includes('role=')), 'and the whole goals ledger');
|
|
486
|
+
before = p.requests.length;
|
|
475
487
|
p.switchCraft('artist');
|
|
476
488
|
await settle(); await settle();
|
|
477
|
-
|
|
478
|
-
assert.ok(after.some((u) =>
|
|
479
|
-
assert.ok(after.some((u) => u.includes('
|
|
489
|
+
after = p.requests.slice(before);
|
|
490
|
+
assert.ok(after.some((u) => /\/copy-desk\/artists\/95\/pages\?limit=\d+&offset=0/.test(u)), 'the artist tab reads their approved pages, a few at a time');
|
|
491
|
+
assert.ok(!after.some((u) => u.includes('role=artist')), 'never "tasks labelled artist"');
|
|
480
492
|
});
|
|
481
493
|
|
|
482
|
-
await test('the
|
|
483
|
-
const p = bootProfile();
|
|
494
|
+
await test('a role the person does not have gets no tab and no read', async () => {
|
|
495
|
+
const p = bootProfile({ roles: ['engineer'] });
|
|
484
496
|
await settle(); await settle(); await settle();
|
|
485
|
-
assert.
|
|
486
|
-
p.
|
|
487
|
-
|
|
488
|
-
assert.equal(p.el('activity-ideas-panel').hidden, true, 'an engineer view is engineer work');
|
|
489
|
-
p.switchCraft('ideator');
|
|
490
|
-
await settle();
|
|
491
|
-
assert.equal(p.el('activity-ideas-panel').hidden, false, 'filing an idea IS ideator work');
|
|
497
|
+
assert.ok(p.requests.some((u) => u.includes('section=tasks')), 'the only tab is the engineer\'s');
|
|
498
|
+
assert.ok(!p.requests.some((u) => u.includes('/inbox/by-builder/')), 'no trail read for a person with no ideator role');
|
|
499
|
+
assert.ok(!p.requests.some((u) => u.includes('/copy-desk/artists/')), 'no pages read for a person with no artist role');
|
|
492
500
|
});
|
|
493
501
|
|
|
494
502
|
await test('the ledgers page server-side — the request carries a window, not the whole record', async () => {
|
|
@@ -64,11 +64,11 @@ await test('an artist who never shipped a task leads with pages approved and the
|
|
|
64
64
|
|
|
65
65
|
await test('an ideator leads with thoughts, size and what is still alive', () => {
|
|
66
66
|
const v = headline(IDEATOR, OPTS);
|
|
67
|
-
assert.deepEqual(words(v.tally), ['186 credits', '
|
|
68
|
-
assert.deepEqual(words(v.tiles), ['
|
|
67
|
+
assert.deepEqual(words(v.tally), ['186 credits', '14 thoughts filed', '7 ignited']);
|
|
68
|
+
assert.deepEqual(words(v.tiles), ['14 thoughts filed', '7 ignited', '3 Full Ideas', '186~ total size', '72% still alive', '3 karma']);
|
|
69
69
|
assert.equal(v.tiles[2].f, '1 ratified by the board');
|
|
70
70
|
assert.match(v.tiles[3].title, /Includes 24 credits of historical estimate/, 'the ~ says what it means');
|
|
71
|
-
assert.equal(v.sub, 'Builder 9 — Contributor ·
|
|
71
|
+
assert.equal(v.sub, 'Builder 9 — Contributor · 14 thoughts filed');
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
await test('several crafts each show, main first, in the server\'s order', () => {
|
|
@@ -164,7 +164,7 @@ await test('the real page draws the artist\'s tally, chips, tiles and surface ro
|
|
|
164
164
|
|
|
165
165
|
await test('the real page draws the ideator\'s and the engineer\'s own headlines', async () => {
|
|
166
166
|
const i = await boot(IDEATOR);
|
|
167
|
-
assert.match(text(i.head), /186 credits
|
|
167
|
+
assert.match(text(i.head), /186 credits 14 thoughts filed 7 ignited/);
|
|
168
168
|
assert.match(text(i.nums), /186~ total size/);
|
|
169
169
|
assert.match(text(i.nums), /72% still alive/);
|
|
170
170
|
assert.ok(!i.nums.includes('kit-bars'), 'no surface rows for an ideator');
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// tests/profile_role_tabs.mjs — the profile's Ideator and Artist tabs show the
|
|
2
|
+
// role's own record (task 1004435 / WA6.RP05, goal 1000095, criterion
|
|
3
|
+
// wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// The done-when: "both tabs match the mock for fixtures, and the old
|
|
6
|
+
// task-labelled lists are gone from those tabs". Run for real, end to end:
|
|
7
|
+
//
|
|
8
|
+
// 1. THE ARTIST'S READ. composeArtistPages over the copy desk's own round
|
|
9
|
+
// fixture, then the new signed-in route over a stubbed lifecycle port:
|
|
10
|
+
// only that artist's SHIPPED rounds, newest first, each with its first
|
|
11
|
+
// changed line; a stranger gets a 401, a bad id a 400.
|
|
12
|
+
// 2. THE IDEATOR'S TRAIL. profile-crafts.js over the harness's ideator fixture
|
|
13
|
+
// (the mock's fourteen thoughts): one mark per thought, each tied to its
|
|
14
|
+
// row, the sky's five words, size and brightness as separate channels.
|
|
15
|
+
// 3. THE PAGE. The REAL profile.js in a vm with the artist tab open, fed the
|
|
16
|
+
// route's own output, so a field the route renames cannot leave this green.
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import vm from 'node:vm';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { makeRunner, bootProfilePage, settle } from './helpers.mjs';
|
|
26
|
+
|
|
27
|
+
process.env.NODE_ENV = 'test';
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
30
|
+
|
|
31
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
32
|
+
const pageStatus = require('../modules/copy-desk/page-status.js');
|
|
33
|
+
const { test, summary } = makeRunner();
|
|
34
|
+
|
|
35
|
+
// ---- 1. the artist's read ---------------------------------------------------
|
|
36
|
+
|
|
37
|
+
await test('an artist\'s approved pages: only theirs, only shipped, newest first, with the first changed line', () => {
|
|
38
|
+
const alice = pageStatus.composeArtistPages({ builderId: F.ALICE.id, inventory: F.INVENTORY, tasks: F.TASKS });
|
|
39
|
+
assert.deepEqual(alice.map((p) => p.task_id), ['201', '101'], 'Sep 20 before Sep 1; Bob\'s round and the abandoned one are not hers');
|
|
40
|
+
assert.equal(alice[0].page_id, 'builders:studio');
|
|
41
|
+
assert.equal(alice[0].surface_label, 'Builders hall');
|
|
42
|
+
assert.equal(alice[0].line_count, 1);
|
|
43
|
+
assert.deepEqual(alice[0].first, { before: 'Welcome', after: 'Welcome home' });
|
|
44
|
+
assert.equal(alice[0].approved_at, '2026-09-20T10:00:00.000Z');
|
|
45
|
+
const bob = pageStatus.composeArtistPages({ builderId: F.BOB.id, inventory: F.INVENTORY, tasks: F.TASKS });
|
|
46
|
+
assert.deepEqual(bob.map((p) => p.task_id), ['102'], 'his active round is not an approved page');
|
|
47
|
+
assert.equal(bob[0].line_count, 2);
|
|
48
|
+
assert.deepEqual(bob[0].first, { before: 'Every projekt', after: 'Every project' }, 'the FIRST changed line');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await test('the route: signed in only, a bad id refused, paged, and it asks for THIS artist\'s rounds', async () => {
|
|
52
|
+
const api = require('../src/module-api.js');
|
|
53
|
+
const seams = require('../src/module-seams.js');
|
|
54
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
55
|
+
const asked = [];
|
|
56
|
+
seams._reset();
|
|
57
|
+
seams.registerProvider('lifecycle', { async listPageTweakTasks(opts) { asked.push(opts); return F.TASKS; } });
|
|
58
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'artist-pages-'));
|
|
59
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
60
|
+
fs.writeFileSync(path.join(tmp, 'docs', 'page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
61
|
+
pageData._setRoot(tmp);
|
|
62
|
+
let signedIn = true;
|
|
63
|
+
const realRequire = api.requireBuilder;
|
|
64
|
+
api.requireBuilder = (req, res, next) => (signedIn ? next() : res.status(401).json({ error: 'unauthorized' }));
|
|
65
|
+
const express = require('express');
|
|
66
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
67
|
+
const app = express();
|
|
68
|
+
app.use(attachFail);
|
|
69
|
+
app.use(require('../modules/copy-desk/routes/copy-desk-artist.js')());
|
|
70
|
+
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
|
|
71
|
+
const get = async (p) => { const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`); return { status: r.status, body: await r.json() }; };
|
|
72
|
+
try {
|
|
73
|
+
const all = await get(`/copy-desk/artists/${F.ALICE.id}/pages`);
|
|
74
|
+
assert.equal(all.status, 200);
|
|
75
|
+
assert.deepEqual(all.body.rows.map((r) => r.task_id), ['201', '101']);
|
|
76
|
+
assert.equal(all.body.total, 2);
|
|
77
|
+
assert.deepEqual(asked.at(-1), { artistId: F.ALICE.id }, 'the read is scoped to the artist, never every round');
|
|
78
|
+
const page2 = await get(`/copy-desk/artists/${F.ALICE.id}/pages?limit=1&offset=1`);
|
|
79
|
+
assert.deepEqual(page2.body.rows.map((r) => r.task_id), ['101']);
|
|
80
|
+
assert.equal(page2.body.total, 2, 'the total is the whole set, not the page');
|
|
81
|
+
assert.equal((await get('/copy-desk/artists/abc/pages')).status, 400);
|
|
82
|
+
assert.equal((await get('/copy-desk/artists/0/pages')).status, 400);
|
|
83
|
+
signedIn = false;
|
|
84
|
+
assert.equal((await get(`/copy-desk/artists/${F.ALICE.id}/pages`)).status, 401, 'owner decision (a): the pages and their words need a sign-in');
|
|
85
|
+
} finally {
|
|
86
|
+
api.requireBuilder = realRequire;
|
|
87
|
+
pageData._setRoot(null);
|
|
88
|
+
seams._reset();
|
|
89
|
+
server.closeAllConnections();
|
|
90
|
+
await new Promise((r) => server.close(r));
|
|
91
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---- 2. the ideator's trail -------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function loadCrafts() {
|
|
98
|
+
const sandbox = { window: {}, console };
|
|
99
|
+
sandbox.globalThis = sandbox;
|
|
100
|
+
vm.createContext(sandbox);
|
|
101
|
+
for (const f of ['idea-objects.js', 'profile-crafts.js']) {
|
|
102
|
+
vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8'), sandbox, { filename: f });
|
|
103
|
+
}
|
|
104
|
+
return sandbox.window.OTBProfileCrafts;
|
|
105
|
+
}
|
|
106
|
+
const crafts = loadCrafts();
|
|
107
|
+
const TRAIL = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/inbox__by-builder__2002.json'), 'utf8')).ideas;
|
|
108
|
+
|
|
109
|
+
await test('the trail draws every thought once, each mark tied to its ledger row', () => {
|
|
110
|
+
const svg = crafts.trailSvgHtml(TRAIL, { width: 900 });
|
|
111
|
+
const ids = [...svg.matchAll(/data-i="(\d+)"/g)].map((m) => Number(m[1]));
|
|
112
|
+
assert.deepEqual(ids.sort((a, b) => a - b), TRAIL.map((_, i) => i), 'one mark per thought, none missing, none doubled');
|
|
113
|
+
assert.match(svg, /role="img"/);
|
|
114
|
+
assert.match(svg, /aria-label="14 thoughts filed, by the day each was filed\. Each is also a row in the table below\."/);
|
|
115
|
+
assert.ok(!/<(?:path|polyline|rect)\b/.test(svg), 'circles and lines only: series geometry is the kit\'s (hall_audit §6b)');
|
|
116
|
+
assert.equal((svg.match(/class="pt-tether"/g) || []).length, 2, 'each folded-in thought is tethered to the one it joined');
|
|
117
|
+
for (const cls of ['pt-star', 'pt-remnant', 'pt-cold', 'pt-ring']) assert.ok(svg.includes(cls), `the ${cls} mark is drawn`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('size and brightness are separate channels', () => {
|
|
121
|
+
const big = crafts.trailSvgHtml([TRAIL[0]], { width: 600 }); // 64 cr, all alive
|
|
122
|
+
const half = crafts.trailSvgHtml([TRAIL[1]], { width: 600 }); // 42 cr, 60% alive
|
|
123
|
+
const r = (s) => Number(/class="pt-m pt-star"[^>]*r="([\d.]+)"/.exec(s)[1]);
|
|
124
|
+
const op = (s) => Number(/fill-opacity="([\d.]+)"/.exec(s)[1]);
|
|
125
|
+
assert.ok(r(big) > r(half), 'more credits, a wider mark');
|
|
126
|
+
assert.equal(op(big), 1);
|
|
127
|
+
assert.equal(op(half), 0.72, 'less of its work alive, a dimmer mark (0.3 + 0.7 x 0.6)');
|
|
128
|
+
const remnant = crafts.trailSvgHtml([TRAIL[4]], { width: 600 });
|
|
129
|
+
assert.match(remnant, /pt-remnant[^>]*r="(?!3\.2")/, 'a burned-out thought keeps its size');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('every row says its state in the sky\'s words, its size and what is still alive', () => {
|
|
133
|
+
const href = (id) => `/idea/${id}`;
|
|
134
|
+
const outcome = () => 'OUTCOME';
|
|
135
|
+
const row = (i) => crafts.thoughtRowHtml(TRAIL[i], i, { outcomeHtml: outcome, ideaHref: href });
|
|
136
|
+
assert.match(row(0), /burning<\/span>/);
|
|
137
|
+
assert.match(row(0), />64 cr</);
|
|
138
|
+
assert.match(row(1), />60%</);
|
|
139
|
+
assert.match(row(2), /waiting<\/span>[\s\S]*no size/);
|
|
140
|
+
assert.match(row(4), /burned out[\s\S]*22 cr<span class="pt-est" title="12 of it estimated[^"]*"> ~<\/span>[\s\S]*>0%</, 'the ~ marks the historical estimate');
|
|
141
|
+
assert.match(row(5), /folded in/);
|
|
142
|
+
assert.match(row(6), /declined/);
|
|
143
|
+
assert.match(row(0), /<tr id="pt-row-0" data-i="0" tabindex="0">/, 'a row is focusable, so a mark\'s click can land on it');
|
|
144
|
+
assert.match(row(0), /OUTCOME/, 'the old trail\'s "what became of it" is folded in, not dropped');
|
|
145
|
+
const legend = crafts.legendHtml();
|
|
146
|
+
for (const w of ['burning', 'waiting', 'folded in', 'burned out', 'declined']) assert.ok(legend.includes(w), `the legend names "${w}"`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---- 3. the page --------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
await test('the real page\'s artist tab draws the route\'s own rows, before and after', async () => {
|
|
152
|
+
const rows = pageStatus.composeArtistPages({ builderId: F.ALICE.id, inventory: F.INVENTORY, tasks: F.TASKS });
|
|
153
|
+
const wire = JSON.parse(JSON.stringify(rows));
|
|
154
|
+
const p = bootProfilePage({ roles: ['artist'], artistPages: wire });
|
|
155
|
+
await settle(); await settle(); await settle(); await settle();
|
|
156
|
+
const html = p.el('activity-pages').innerHTML;
|
|
157
|
+
assert.match(html, /Studio[\s\S]*Builders hall · approved Sep 20[\s\S]*1 line changed/);
|
|
158
|
+
assert.match(html, /pg-ba__was">Welcome<[\s\S]*pg-ba__now">Welcome home</);
|
|
159
|
+
assert.match(html, /Projects[\s\S]*Landing · approved Sep 1/);
|
|
160
|
+
assert.ok(!p.requests.some((u) => u.includes('role=artist')), 'the old "tasks labelled artist" list is gone');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
summary();
|
|
@@ -114,7 +114,10 @@ function load({ draft = {}, answers = {} } = {}) {
|
|
|
114
114
|
const watches = [];
|
|
115
115
|
const ctx = { root, state, $, document, Object, API: '/api/bongos', fetch, JSON, String, Number, encodeURIComponent, Array, saveDraft: () => saves.push(JSON.stringify(state)),
|
|
116
116
|
// the shared status watch (task 1004353) lives outside this block; record that it is started
|
|
117
|
-
watchApp: (id, box) => { watches.push({ id, box }); return () => {}; }
|
|
117
|
+
watchApp: (id, box) => { watches.push({ id, box }); return () => {}; },
|
|
118
|
+
// the planet-physics step skip (task 1004419) lives outside this block too; the
|
|
119
|
+
// framing that skips this step is wizard_physics_more.mjs's to test
|
|
120
|
+
physStepSkipped: () => false };
|
|
118
121
|
vm.createContext(ctx);
|
|
119
122
|
vm.runInContext(ESC + '\n' + APPEL + '\n' + CONNECT + '\n' + BLOCK + '\nthis.api = { appPlatformChoice, syncAppStep, appRow, syncRenderSetup, validateRender, renderRequestBody, sendRenderSetup, planLabel };', ctx);
|
|
120
123
|
const api = ctx.api;
|
|
@@ -24,7 +24,7 @@ import { createRequire } from 'node:module';
|
|
|
24
24
|
import http from 'node:http';
|
|
25
25
|
// ESM named imports off the CJS modules on purpose — knip cannot trace a
|
|
26
26
|
// createRequire() import (the tests/provisioning_module_selection.mjs note).
|
|
27
|
-
import { EU_COUNTRIES, COUNTRY_CODES, REGION_CODES, crossAnswerFault }
|
|
27
|
+
import { EU_COUNTRIES, COUNTRY_CODES, REGION_CODES, crossAnswerFault, canonicalAnswer as physicsCanonical }
|
|
28
28
|
from '../modules/provisioning/planet-physics.js';
|
|
29
29
|
import { SOFT_LIMIT_RULES, EFFECTS, softLimitsFor } from '../modules/provisioning/soft-limits.js';
|
|
30
30
|
import { SCREENING_RULES, STOP_MESSAGE, screenProject } from '../modules/provisioning/screening.js';
|
|
@@ -164,7 +164,11 @@ await t('every rule carries a reason, a known effect, and names only real questi
|
|
|
164
164
|
const q = provisioning.PROJECT_DETAIL_QUESTIONS[key];
|
|
165
165
|
assert.ok(q, `${rule.id}: ${key} is a question`);
|
|
166
166
|
const named = test.in || [test.has ?? test.lacks];
|
|
167
|
-
|
|
167
|
+
// a count question has a range, not a list (task 1004419's solo rule)
|
|
168
|
+
for (const v of named) {
|
|
169
|
+
const real = q.kind === 'count' ? physicsCanonical(q, v) !== undefined : q.values.includes(v);
|
|
170
|
+
assert.ok(real, `${rule.id}: ${key}=${v} is a real answer`);
|
|
171
|
+
}
|
|
168
172
|
}
|
|
169
173
|
}
|
|
170
174
|
});
|
|
@@ -221,6 +225,24 @@ await t('the table hides whole questions too: a new planet has no existing work,
|
|
|
221
225
|
assert.ok(!opts({ framing: 'hybrid' }).includes('services'));
|
|
222
226
|
});
|
|
223
227
|
|
|
228
|
+
await t('rewards are hidden for a founder building alone with nobody expected, and only then (task 1004419)', () => {
|
|
229
|
+
const hidden = (answers) => softLimitsFor(answers).limits.some((l) => l.option === 'reward_intents' && l.effect === 'hide');
|
|
230
|
+
assert.equal(hidden({ builders_at_start: 1, builders_expected: 1 }), true, 'solo');
|
|
231
|
+
assert.equal(hidden({ builders_at_start: 1, builders_expected: 4 }), false, 'alone now, company expected');
|
|
232
|
+
assert.equal(hidden({ builders_at_start: 3, builders_expected: 8 }), false, 'a team');
|
|
233
|
+
assert.equal(hidden({ builders_at_start: 1 }), false, 'the growth guess never answered is not "nobody"');
|
|
234
|
+
const reason = softLimitsFor({ builders_at_start: 1, builders_expected: 1 }).limits.find((l) => l.option === 'reward_intents').reason;
|
|
235
|
+
assert.match(reason, /building solo/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await t('the voice is a stored detail: planet or professional, null to un-answer, anything else refused (spec D5)', () => {
|
|
239
|
+
assert.deepEqual(provisioning.PROJECT_DETAIL_VOCAB.voice, ['planet', 'professional']);
|
|
240
|
+
assert.equal(badProjectDetail({ voice: 'professional' }), null);
|
|
241
|
+
assert.equal(badProjectDetail({ voice: null }), null);
|
|
242
|
+
assert.match(badProjectDetail({ voice: 'pirate' }).message, /"voice" must be one of: planet, professional/);
|
|
243
|
+
assert.deepEqual(provisioning.normalizeProjectDetail({ voice: 'planet', entity: 'for-profit' }), { entity: 'for-profit', voice: 'planet' });
|
|
244
|
+
});
|
|
245
|
+
|
|
224
246
|
await t('the soft limits read back on every owner read of the project, from the stored answers', () => {
|
|
225
247
|
const view = provisioning.publicInstance({ id: 1, slug: 'kestrel', detail: provisioning.normalizeProjectDetail({ entity: 'nonprofit' }) });
|
|
226
248
|
assert.equal(view.soft_limits.enforced, false);
|
package/tests/update_channel.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { strict as assert } from 'node:assert';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
|
|
@@ -338,6 +338,85 @@ t('describeStaleness: silent when healthy, explicit about "the upgrader stopped"
|
|
|
338
338
|
assert.match(msg, /upgrader has most likely stopped/);
|
|
339
339
|
});
|
|
340
340
|
|
|
341
|
+
// ---- the follow: every candidate, or released only (task 1004297, ADR 0360) -
|
|
342
|
+
|
|
343
|
+
t('normalizeFollow: released and candidates are the words; anything else reads as the safer released', () => {
|
|
344
|
+
assert.equal(uc.DEFAULT_FOLLOW, 'released');
|
|
345
|
+
assert.deepEqual(uc.VALID_FOLLOWS, ['released', 'candidates']);
|
|
346
|
+
assert.equal(uc.normalizeFollow('candidates'), 'candidates');
|
|
347
|
+
assert.equal(uc.normalizeFollow(' Candidates '), 'candidates');
|
|
348
|
+
for (const v of [undefined, null, '', 'every', 'latest', 'next', 42]) assert.equal(uc.normalizeFollow(v), 'released', String(v));
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
t('versionsForFollow: candidates takes every version; released stops at the released label', () => {
|
|
352
|
+
const available = ['1.19.1038', '1.19.1039', '1.19.1040', '1.19.1041'];
|
|
353
|
+
assert.deepEqual(uc.versionsForFollow({ follow: 'candidates', available, released: '1.19.1039' }), available);
|
|
354
|
+
assert.deepEqual(uc.versionsForFollow({ follow: 'released', available, released: '1.19.1039' }), ['1.19.1038', '1.19.1039']);
|
|
355
|
+
assert.deepEqual(uc.versionsForFollow({ available, released: '1.19.1039' }), ['1.19.1038', '1.19.1039'], 'no follow given reads as released');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
t('versionsForFollow: a follower of releases takes NOTHING when nobody can say what is released', () => {
|
|
359
|
+
const available = ['1.19.1040', '1.19.1041'];
|
|
360
|
+
for (const released of [null, undefined, '', 'not-a-version']) {
|
|
361
|
+
assert.deepEqual(uc.versionsForFollow({ follow: 'released', available, released }), [], String(released));
|
|
362
|
+
}
|
|
363
|
+
assert.deepEqual(uc.versionsForFollow({ follow: 'candidates', available, released: null }), available, 'candidates never needs the label');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
t('the follow composes with the channel: released caps which versions exist, the channel caps the jump', () => {
|
|
367
|
+
const available = ['1.19.1040', '1.19.1041', '1.20.0'];
|
|
368
|
+
const pick = (follow, released) => uc.resolveChannelTarget({
|
|
369
|
+
installed: '1.19.1039', channel: 'patch', available: uc.versionsForFollow({ follow, available, released }),
|
|
370
|
+
});
|
|
371
|
+
assert.equal(pick('candidates', '1.19.1040'), '1.19.1041', 'Bongos takes the newest candidate its channel allows');
|
|
372
|
+
assert.equal(pick('released', '1.19.1040'), '1.19.1040', 'every other project stops at the release');
|
|
373
|
+
assert.equal(pick('released', '1.20.0'), '1.19.1041', 'and the patch channel still refuses the new line');
|
|
374
|
+
assert.equal(pick('released', null), null, 'an unreadable label moves nobody');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
t('readReleaseTags: the released version is the latest label, read fail-soft', () => {
|
|
378
|
+
const seen = [];
|
|
379
|
+
const run = (cmd, args, o) => { seen.push([cmd, args, o.timeout]); return { status: 0, stdout: '{"latest":"1.19.1040","next":"1.19.1042"}' }; };
|
|
380
|
+
assert.deepEqual(uc.readReleaseTags({ run }), { ok: true, released: '1.19.1040', tags: { latest: '1.19.1040', next: '1.19.1042' } });
|
|
381
|
+
assert.deepEqual(seen[0].slice(0, 2), ['npm', ['view', '@bongos/core', 'dist-tags', '--json']]);
|
|
382
|
+
assert.ok(seen[0][2] > 0, 'the read carries a timeout');
|
|
383
|
+
|
|
384
|
+
const noLatest = uc.readReleaseTags({ run: () => ({ status: 0, stdout: '{"next":"1.19.1042"}' }) });
|
|
385
|
+
assert.equal(noLatest.ok, false);
|
|
386
|
+
assert.equal(noLatest.released, null);
|
|
387
|
+
const pre = uc.readReleaseTags({ run: () => ({ status: 0, stdout: '{"latest":"2.0.0-rc.1"}' }) });
|
|
388
|
+
assert.equal(pre.ok, false, 'a prerelease is never the release');
|
|
389
|
+
assert.equal(uc.readReleaseTags({ run: () => ({ status: 1, stderr: 'E404 Not Found' }) }).ok, false);
|
|
390
|
+
assert.match(uc.readReleaseTags({ run: () => ({ status: 1, stderr: 'E404 Not Found' }) }).error, /E404/);
|
|
391
|
+
assert.equal(uc.readReleaseTags({ run: () => ({ status: 0, stdout: 'not json' }) }).ok, false);
|
|
392
|
+
assert.equal(uc.readReleaseTags({ run: () => ({ status: 0, stdout: '["1.0.0"]' }) }).ok, false);
|
|
393
|
+
assert.equal(uc.readReleaseTags({ run: () => { throw new Error('spawn ENOENT'); } }).ok, false);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
t('listAvailableVersions is untouched by the follow: same command, same shape', () => {
|
|
397
|
+
const seen = [];
|
|
398
|
+
const r = uc.listAvailableVersions({ run: (c, a) => { seen.push(a); return { status: 0, stdout: '["1.0.0"]' }; } });
|
|
399
|
+
assert.deepEqual(seen[0], ['view', '@bongos/core', 'versions', '--json']);
|
|
400
|
+
assert.deepEqual(r, { ok: true, versions: ['1.0.0'] });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
t('loadSubscriptions: each entry carries a follow, released unless it says candidates', () => {
|
|
404
|
+
const dir = mkdtempSync(join(tmpdir(), 'uc-follow-'));
|
|
405
|
+
mkdirSync(join(dir, 'config'), { recursive: true });
|
|
406
|
+
writeFileSync(join(dir, 'config', 'update-subscriptions.json'), JSON.stringify({ instances: [
|
|
407
|
+
{ slug: 'cloudbongos', dir: '/srv/cb', follow: 'candidates' },
|
|
408
|
+
{ slug: 'tenant', dir: '/srv/t' },
|
|
409
|
+
{ slug: 'typo', dir: '/srv/x', follow: 'everything' },
|
|
410
|
+
] }));
|
|
411
|
+
const r = uc.loadSubscriptions({ instanceDir: dir });
|
|
412
|
+
assert.equal(r.defaultFollow, 'released');
|
|
413
|
+
assert.deepEqual(r.instances.map((i) => [i.slug, i.follow]), [['cloudbongos', 'candidates'], ['tenant', 'released'], ['typo', 'released']]);
|
|
414
|
+
|
|
415
|
+
writeFileSync(join(dir, 'config', 'update-subscriptions.json'), JSON.stringify({ defaultFollow: 'candidates', instances: [{ slug: 'a', dir: '/a' }, { slug: 'b', dir: '/b', follow: 'released' }] }));
|
|
416
|
+
const d = uc.loadSubscriptions({ instanceDir: dir });
|
|
417
|
+
assert.deepEqual(d.instances.map((i) => i.follow), ['candidates', 'released'], 'a roster-wide default, overridable per entry');
|
|
418
|
+
});
|
|
419
|
+
|
|
341
420
|
console.log(`\nupdate_channel: ${passed} passed, ${failed} failed`);
|
|
342
421
|
process.exit(failed ? 1 : 0);
|
|
343
422
|
|
|
@@ -766,3 +766,102 @@ test('the post-upgrade net re-probes, rather than reusing the set from before th
|
|
|
766
766
|
assert.match(beforeSpawn, /const pre = pinState\(inst\.dir, spawnSync, generated\);/,
|
|
767
767
|
'the heal runs before the bump, so it reads the core installed THEN');
|
|
768
768
|
});
|
|
769
|
+
|
|
770
|
+
// ── the follow (task 1004297, ADR 0360) ─────────────────────────────────────────────
|
|
771
|
+
//
|
|
772
|
+
// followTarget is the sweep's whole decision about WHICH version an instance moves to. It
|
|
773
|
+
// runs the REAL update-channel.js math; only the registry read is stood in for, the way
|
|
774
|
+
// every other case here injects its externals.
|
|
775
|
+
|
|
776
|
+
const UC = require('../scripts/gds/update-channel.js');
|
|
777
|
+
const PUBLISHED = ['1.19.1039', '1.19.1040', '1.19.1041', '1.20.0'];
|
|
778
|
+
// The real module, with its dist-tags read answering from `tags` (or failing).
|
|
779
|
+
function channelWith(tags) {
|
|
780
|
+
return {
|
|
781
|
+
...UC,
|
|
782
|
+
readReleaseTags: () => (tags ? UC.readReleaseTags({ run: () => ({ status: 0, stdout: JSON.stringify(tags) }) })
|
|
783
|
+
: UC.readReleaseTags({ run: () => ({ status: 1, stderr: 'npm ERR! network timeout' }) })),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
const aim = (inst, channel) => S.followTarget({ inst: entry(inst), installed: '1.19.1039', channel, versions: PUBLISHED, env: {} });
|
|
787
|
+
|
|
788
|
+
test('follow candidates: the platform takes the newest version its channel allows, whatever is released', () => {
|
|
789
|
+
assert.deepEqual(aim({ follow: 'candidates' }, channelWith({ latest: '1.19.1039' })), { follow: 'candidates', target: '1.19.1041', held: null });
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('follow released: every other project stops at the released label', () => {
|
|
793
|
+
assert.deepEqual(aim({ follow: 'released' }, channelWith({ latest: '1.19.1040', next: '1.19.1041' })), { follow: 'released', target: '1.19.1040', held: null });
|
|
794
|
+
assert.deepEqual(aim({ follow: 'released' }, channelWith({ latest: '1.19.1039' })), { follow: 'released', target: null, held: null }, 'up to date on the release');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('follow released with an unreadable label is SKIPPED, never upgraded on a guess', () => {
|
|
798
|
+
const r = aim({ follow: 'released' }, channelWith(null));
|
|
799
|
+
assert.equal(r.target, undefined);
|
|
800
|
+
assert.match(r.skip, /could not read which version is released \(npm ERR! network timeout\)/);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test('a core that predates the follow keeps the old reading: every published version', () => {
|
|
804
|
+
const { readReleaseTags, versionsForFollow, ...oldCore } = UC;
|
|
805
|
+
assert.equal(aim({}, oldCore).target, '1.19.1041', 'no follow on the entry — the roster came from an older core');
|
|
806
|
+
assert.equal(aim({ follow: 'released' }, oldCore).target, '1.19.1041', 'no dist-tags reader: before the split every version was a release');
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test('the sweep asks followTarget, and says the follow on every line it prints', () => {
|
|
810
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
811
|
+
const main = src.slice(src.indexOf('function main('));
|
|
812
|
+
assert.match(main, /followTarget\(\{ inst, installed, channel, versions: listed\.versions/);
|
|
813
|
+
assert.doesNotMatch(main, /resolveChannelTarget\(\{ installed, channel: inst\.channel, available: listed\.versions/, 'the unfiltered list never reaches the target');
|
|
814
|
+
assert.match(main, /follows: \$\{aim\.follow\}/);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// ── a version that failed here is held, not retried every sweep (task 1004297) ──────
|
|
818
|
+
//
|
|
819
|
+
// At a 15-minute cadence an unguarded lane would install a bad version, fail its health
|
|
820
|
+
// check, restart the service and roll back four times an hour until a fix was published.
|
|
821
|
+
|
|
822
|
+
const NOW = Date.parse('2026-09-26T18:00:00Z');
|
|
823
|
+
const hoursAgo = (h) => new Date(NOW - h * 3600000).toISOString();
|
|
824
|
+
|
|
825
|
+
test('a held version is skipped and named; a NEWER version is still taken at once', () => {
|
|
826
|
+
const held = S.heldVersions({ demo: [{ version: '1.19.1041', at: hoursAgo(1), reason: 'exit 1' }] }, 'demo', NOW);
|
|
827
|
+
const r = S.followTarget({ inst: entry({ follow: 'candidates' }), installed: '1.19.1039', channel: UC, versions: ['1.19.1040', '1.19.1041'], env: {}, held });
|
|
828
|
+
assert.equal(r.target, '1.19.1040', 'the held version is not retried');
|
|
829
|
+
assert.deepEqual(r.held, { version: '1.19.1041', at: hoursAgo(1), reason: 'exit 1' });
|
|
830
|
+
|
|
831
|
+
const fixed = S.followTarget({ inst: entry({ follow: 'candidates' }), installed: '1.19.1039', channel: UC, versions: ['1.19.1040', '1.19.1041', '1.19.1042'], env: {}, held });
|
|
832
|
+
assert.equal(fixed.target, '1.19.1042', 'the fix is taken the moment it is published');
|
|
833
|
+
assert.equal(fixed.held, null, 'and no hold is reported, because none changed the answer');
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test('a hold is per instance and expires after a day', () => {
|
|
837
|
+
const refusals = { demo: [{ version: '1.19.1041', at: hoursAgo(25) }, { version: '1.19.1042', at: hoursAgo(2) }], other: [{ version: '1.19.1043', at: hoursAgo(1) }] };
|
|
838
|
+
assert.deepEqual([...S.heldVersions(refusals, 'demo', NOW).keys()], ['1.19.1042'], 'a day-old failure is retried');
|
|
839
|
+
assert.deepEqual([...S.heldVersions(refusals, 'nobody', NOW).keys()], []);
|
|
840
|
+
assert.equal(S.REFUSAL_HOLD_MS, 24 * 3600000);
|
|
841
|
+
for (const bad of [null, 'x', { demo: 'x' }, { demo: [null, { version: 7 }, { version: '1.0.0', at: 'not a date' }] }]) {
|
|
842
|
+
assert.deepEqual([...S.heldVersions(bad, 'demo', NOW).keys()], [], JSON.stringify(bad));
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test('recordRefusal writes outside the instance, keeps only live holds, and never throws', () => {
|
|
847
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'refusals-'));
|
|
848
|
+
const file = path.join(dir, 'nested', 'core-update-refused.json');
|
|
849
|
+
assert.equal(S.recordRefusal({ file, slug: 'demo', version: '1.19.1041', reason: 'exit 1', now: NOW - 30 * 3600000 }), true);
|
|
850
|
+
assert.equal(S.recordRefusal({ file, slug: 'demo', version: '1.19.1042', reason: 'exit 1', now: NOW }), true);
|
|
851
|
+
const all = JSON.parse(readFileSync(file, 'utf8'));
|
|
852
|
+
assert.deepEqual(all.demo.map((r) => r.version), ['1.19.1042'], 'the expired hold was dropped on write');
|
|
853
|
+
assert.equal(S.recordRefusal({ file, slug: 'demo', version: '1.19.1042', now: NOW + 1000 }), true);
|
|
854
|
+
assert.equal(JSON.parse(readFileSync(file, 'utf8')).demo.length, 1, 'the same version is one row, re-dated');
|
|
855
|
+
assert.equal(S.recordRefusal({ file: null, slug: 'demo', version: '1' }), false);
|
|
856
|
+
assert.equal(S.recordRefusal({ file, slug: 'demo', version: '1', writeFile: () => { throw new Error('EROFS'); } }), false, 'a failed write costs a retry, not the sweep');
|
|
857
|
+
assert.deepEqual(S.readRefusals(path.join(dir, 'missing.json')), {});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('the sweep holds a version only when it did not go in', () => {
|
|
861
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
862
|
+
const at = src.indexOf('if (res.status !== 0) {');
|
|
863
|
+
const failBranch = src.slice(at, src.indexOf('failed++; continue;', at));
|
|
864
|
+
assert.match(failBranch, /if \(readInstalledCoreVersion\(inst\.dir\) !== target\) \{\s*const held = recordRefusal\(/);
|
|
865
|
+
assert.match(src, /held: heldVersions\(refusals, label, Date\.now\(\)\)/, 'every target is chosen with the holds applied');
|
|
866
|
+
assert.match(src, /return path\.join\(require\('\.\.\/\.\.\/src\/instance-config\.js'\)\.configHome\(\), REFUSALS_FILE\)/, 'the record lives in the config home, never the instance tree');
|
|
867
|
+
});
|
package/tests/wizard_physics.mjs
CHANGED
|
@@ -93,7 +93,8 @@ test('a builder who loops the check is slowed down, and another builder is not',
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
// ── the page's vocabularies are the server's ─────────────────────────────────
|
|
96
|
-
|
|
96
|
+
// questions 5 to 7 joined the same region (task 1004419, tests/wizard_physics_more.mjs)
|
|
97
|
+
const SCRIPT = HUB.slice(HUB.indexOf('/* ── planet physics, questions 1 to 7 (tasks 1004418 and 1004419, BV2.PS07 + PS11) ──'),
|
|
97
98
|
HUB.indexOf(' /* per-panel validation on Next */'));
|
|
98
99
|
|
|
99
100
|
test('the page carries the server\'s countries, regions and entity forms, exactly', () => {
|
|
@@ -115,7 +116,9 @@ test('every choice the questions offer is an answer the server accepts', () => {
|
|
|
115
116
|
const seen = [...panel.matchAll(/data-phys="([a-z_]+)" data-val="([^"]+)"/g)];
|
|
116
117
|
assert.ok(seen.length >= 20, 'the four questions\' choices');
|
|
117
118
|
for (const [, key, raw] of seen) {
|
|
118
|
-
const
|
|
119
|
+
const one = raw === 'true' ? true : raw === 'false' ? false : raw;
|
|
120
|
+
// a list question's choice is one member of the list it sends (task 1004419)
|
|
121
|
+
const value = physics.PLANET_PHYSICS_QUESTIONS[key] && physics.PLANET_PHYSICS_QUESTIONS[key].kind === 'many' ? [one] : one;
|
|
119
122
|
assert.notEqual(physics.canonicalAnswer(physics.PLANET_PHYSICS_QUESTIONS[key], value), undefined, `${key}=${raw}`);
|
|
120
123
|
}
|
|
121
124
|
for (const [, work] of panel.matchAll(/data-work="([^"]+)"/g)) {
|
|
@@ -196,7 +199,11 @@ test('Continue walks the questions and moves the wizard on only after the last',
|
|
|
196
199
|
assert.deepEqual(w.shown, [], 'no entity: still here');
|
|
197
200
|
a().entity = 'for-profit';
|
|
198
201
|
w.run('physAdvance()');
|
|
199
|
-
|
|
202
|
+
await tick(); await tick();
|
|
203
|
+
// task 1004419: questions 5 to 7 and the summary follow inside the same step
|
|
204
|
+
// (walked to the name step in tests/wizard_physics_more.mjs)
|
|
205
|
+
assert.equal(w.state.physics.q, 5, 'on to question 5');
|
|
206
|
+
assert.deepEqual(w.shown, [], 'and the wizard has not moved on');
|
|
200
207
|
});
|
|
201
208
|
|
|
202
209
|
test('a stopped description lands on the stop screen with the server\'s words', async () => {
|
|
@@ -216,7 +223,7 @@ test('the create sends the answers as detail: existing work only for an existing
|
|
|
216
223
|
Object.assign(w.state.physics.answers, { project_state: 'new', existing_work: ['code'], main_work: 'code', home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'intent-solo' });
|
|
217
224
|
w.state.teamShape = 'solo';
|
|
218
225
|
assert.deepEqual(JSON.parse(JSON.stringify(w.run('createDetail()'))), {
|
|
219
|
-
project_state: 'new', home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'intent-solo', team_shape: 'solo',
|
|
226
|
+
project_state: 'new', home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'intent-solo', voice: 'planet', team_shape: 'solo',
|
|
220
227
|
});
|
|
221
228
|
w.state.physics.answers = {}; w.state.teamShape = null;
|
|
222
229
|
assert.equal(w.run('createDetail()'), undefined, 'nothing answered: the detail is omitted, as before');
|