@bongos/core 1.21.83 → 1.21.85

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.
Files changed (36) hide show
  1. package/.bongos-core.json +50 -40
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +1 -0
  4. package/docs/api/openapi.json +7 -0
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +32 -28
  7. package/docs/copy-registry.json +70 -34
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/page-inventory.json +2 -1
  10. package/docs/page-readings.json +121 -113
  11. package/modules/lifecycle/page-tweak-hold.js +12 -0
  12. package/modules/lifecycle/routes/tasks.js +4 -3
  13. package/modules/lifecycle/ship-card.js +18 -1
  14. package/modules/provisioning/birth.js +28 -1
  15. package/modules/provisioning/routes/provisioning.js +3 -1
  16. package/modules/public-landing/public/projects.html +46 -5
  17. package/modules/public-landing/public/projects.states.json +1 -0
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/release-notes.json +12 -0
  21. package/scripts/gds/ship-finish.js +4 -2
  22. package/scripts/gds/ship-flow.js +4 -2
  23. package/scripts/gds/ship-grade-steps.js +7 -2
  24. package/scripts/gds/ship-land.js +2 -2
  25. package/src/bongos/project-settings.js +16 -2
  26. package/src/branding.js +3 -0
  27. package/src/module-api.js +1 -1
  28. package/tests/held_tweak_reporting.mjs +164 -0
  29. package/tests/page_tweak_hold.mjs +1 -2
  30. package/tests/projects_hub.mjs +1 -1
  31. package/tests/projects_hub_app_step.mjs +1 -1
  32. package/tests/provision_settings_apply.mjs +4 -1
  33. package/tests/provisioning_settings_env.mjs +3 -1
  34. package/tests/wizard_physics_more.mjs +5 -5
  35. package/tests/wizard_ship_mode.mjs +187 -0
  36. package/tests/wizard_six_screens.mjs +3 -2
@@ -78,6 +78,7 @@ ${NEUTRAL_PREFIX}_LOGO_URL=
78
78
  ${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
79
79
  ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
80
80
  ${NEUTRAL_PREFIX}_DEMO_HOURS=
81
+ ${NEUTRAL_PREFIX}_SHIP_MODE=
81
82
  ${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
82
83
  `;
83
84
 
@@ -148,6 +149,8 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
148
149
  [`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
149
150
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
150
151
  [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
152
+ // the wizard's ship mode (task 1004629), empty when none was picked
153
+ [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '',
151
154
  // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
152
155
  [`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
153
156
  });
@@ -250,7 +253,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
250
253
  assert.equal(r.ok, true);
251
254
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
252
255
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
253
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
256
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_SHIP_MODE, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
254
257
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
255
258
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
256
259
  });
@@ -127,6 +127,8 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
127
127
  // The birth marker and a demo's box (task 1004506): empty on a row the create
128
128
  // route never stamped, which is every row made before it shipped (spec D8).
129
129
  CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
130
+ // The wizard's ship mode (task 1004629): empty when none was picked.
131
+ CLOUDBONGOS_SHIP_MODE: '',
130
132
  });
131
133
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
132
134
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -135,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
135
137
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
136
138
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
137
139
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
138
- X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '',
140
+ X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
139
141
  }, 'defaults, explicitly');
140
142
  });
141
143
 
@@ -377,13 +377,13 @@ test('rewards are hidden for a solo founder: the real table hides the row, and t
377
377
  assert.equal(w.run("rowApplies('rewards')"), false, 'the REAL softLimitsFor hides reward_intents for one-and-one');
378
378
  assert.equal(w.row('rewards').hidden, true, 'and the row goes with it');
379
379
  assert.ok(!w.run('unconfirmedRows()').includes('rewards'), 'nothing to confirm for a question never asked');
380
- assert.equal(w.run('unconfirmedRows()').length, 6, 'six rows apply');
380
+ assert.equal(w.run('unconfirmedRows()').length, 7, 'seven rows apply');
381
381
  assert.equal(w.run('createDetail()').reward_intents, undefined);
382
382
  w.change('team');
383
383
  w.q('[data-phys-solo="many"]')[0].click();
384
384
  await w.settle();
385
385
  assert.equal(w.row('rewards').hidden, false, 'a team brings the question back, once the table is asked again');
386
- assert.equal(w.run('unconfirmedRows()').length, 7);
386
+ assert.equal(w.run('unconfirmedRows()').length, 8);
387
387
  });
388
388
 
389
389
  test('rewards: a greyed option is disabled and leaves the list, and each reason is said once', async () => {
@@ -482,9 +482,9 @@ test('one "Looks right, continue" confirms every row that applies, each through
482
482
  assert.equal(w.go().disabled, false, 'Continue is never locked: it is the confirmation');
483
483
  assert.equal(w.go().textContent, 'Looks right, continue');
484
484
  assert.equal(w.row('code').querySelector('[data-row-ok]'), null, 'no row carries a Looks right of its own');
485
- assert.equal(w.run('unconfirmedRows()').length, 7, 'nothing is confirmed before the founder says so');
485
+ assert.equal(w.run('unconfirmedRows()').length, 8, 'nothing is confirmed before the founder says so');
486
486
  assert.equal(w.run('validateStep(4)'), true, 'one Continue confirms them all');
487
- for (const key of ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run']) assert.equal(w.state.confirmed[key], true, key);
487
+ for (const key of ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship']) assert.equal(w.state.confirmed[key], true, key);
488
488
  assert.equal(w.state.modules && plain(w.state.modules).join(), 'discord', 'confirming the extras is the answer the create sends');
489
489
  w.change('team');
490
490
  assert.equal(w.state.confirmed.team, undefined, 'Edit takes a row back');
@@ -629,7 +629,7 @@ test('no path reaches Look without the founder confirming the address: a prefill
629
629
  assert.equal(w.state.confirmed.address, undefined, `${label}: the prefills leave the address unconfirmed`);
630
630
  assert.equal(w.run("rowFixed('address')"), false, `${label}: the address is never a fixed row`);
631
631
  assert.equal(w.row('address').querySelector('[data-row-chg]').hidden, false, `${label}: it can always be changed`);
632
- for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
632
+ for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run', 'ship']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
633
633
  assert.deepEqual(plain(w.run('unconfirmedRows()')), ['address'], `${label}: every other row confirmed, the address still asked`);
634
634
  // the address is on the page the founder confirms (task 1004550): only their own
635
635
  // "Looks right, continue" — or Done on the row — confirms it, and its check still runs
@@ -0,0 +1,187 @@
1
+ // tests/wizard_ship_mode.mjs — the project-creation wizard asks how finished work ships,
2
+ // and the new project is born with that answer (task 1004629, ADR 0369).
3
+ //
4
+ // Pinned here, end to end:
5
+ // 1. the wizard's review has a Shipping row: Auto first and checked, one plain sentence
6
+ // each, "you can change it later", and the create request carries ship_mode;
7
+ // 2. the create route accepts only auto|manual and stamps it, write-once, on the row it
8
+ // just made (modules/provisioning/birth.js);
9
+ // 3. the stamp rides the new instance's web.env as <PREFIX>_SHIP_MODE, which
10
+ // src/branding.js maps onto the server-only founding.shipMode;
11
+ // 4. the instance's resolveShipMode reads it as a CHOSEN mode while no project_settings
12
+ // row exists, and a row (the settings page, the CLI) always wins.
13
+ //
14
+ // Run: node --test tests/wizard_ship_mode.mjs
15
+ import { strict as assert } from 'node:assert';
16
+ import { test, after } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import { execFileSync } from 'node:child_process';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import vm from 'node:vm';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
27
+
28
+ // Destructured from literal relative paths, so the dead-code scan (knip) can trace them.
29
+ const { SHIP_MODE_KEY, stampShipMode, BIRTH_COMPANION_ENV } = require('../modules/provisioning/birth.js');
30
+ const { resolveShipMode } = require('../src/bongos/project-settings.js');
31
+ const provisioning = require('../modules/provisioning/provisioning.js');
32
+ const poolMod = require('../src/bongos/pool.js');
33
+
34
+ function slice(start, end, what) {
35
+ const a = HUB.indexOf(start), b = HUB.indexOf(end, a);
36
+ assert.ok(a > 0 && b > a, `slice for ${what} not found — a rename moved it`);
37
+ return HUB.slice(a, b);
38
+ }
39
+
40
+ // ── 1. the wizard ──────────────────────────────────────────────────────────
41
+
42
+ test('the review asks how finished work ships: Auto (recommended) first and checked, Manual second', () => {
43
+ const row = slice('<div class="setRow" data-row="ship">', '<div class="hint" id="h-review">', 'the ship row');
44
+ const auto = row.indexOf('value="auto"'), manual = row.indexOf('value="manual"');
45
+ assert.ok(auto > 0 && manual > auto, 'Auto comes first');
46
+ assert.match(row, /<input type="radio" name="shipMode" value="auto" checked>/, 'and is the pick until the founder says otherwise');
47
+ assert.match(row, /<b>Auto \(recommended\)<\/b>/);
48
+ assert.match(row, /<b>Manual<\/b>/);
49
+ assert.match(row, /ships finished work on its own/, 'one plain sentence for Auto');
50
+ assert.match(row, /waits for you to approve it/, 'and one for Manual');
51
+ });
52
+
53
+ test('the row says what was picked and that it can be changed later; an older draft reads Auto', () => {
54
+ const fns = slice(' /* the ship row (task 1004629)', ' var ROWS = [', 'the ship choice');
55
+ const ctx = { state: {} };
56
+ vm.runInNewContext(`${fns}; this.choice = shipModeChoice;`, ctx);
57
+ assert.equal(ctx.choice(), 'auto', 'a draft with no shipMode is Auto');
58
+ ctx.state.shipMode = 'manual';
59
+ assert.equal(ctx.choice(), 'manual');
60
+ ctx.state.shipMode = 'yolo';
61
+ assert.equal(ctx.choice(), 'auto', 'anything but manual is Auto');
62
+ const say = slice(" if (key === 'ship') {", " return { v: '', s: '' };", "the ship row's words");
63
+ assert.match(say, /v: 'Auto \(recommended\)'/);
64
+ assert.match(say, /v: 'Manual'/);
65
+ assert.equal((say.match(/You can change this later in your project’s settings\./g) || []).length, 2, 'both say it can be changed later');
66
+ });
67
+
68
+ test('the draft remembers the pick, and Create sends it as ship_mode', () => {
69
+ assert.match(HUB, /appPlatform: 'later', shipMode: 'auto', physics: null/, 'the initial state starts on Auto');
70
+ assert.equal((HUB.match(/shipMode: 'auto'/g) || []).length, 2, 'and so does the reset (the two literals stay equal)');
71
+ assert.match(HUB, /if \(type === 'change' && ev\.target\.name === 'shipMode'\) \{ state\.shipMode = ev\.target\.value; saveDraft\(\); \}/);
72
+ assert.match(HUB, /ship_mode: shipModeChoice\(\)\n {10}\}\)/, 'the create request carries it');
73
+ });
74
+
75
+ // ── 2. the stamp ───────────────────────────────────────────────────────────
76
+
77
+ test('stampShipMode writes a valid answer once, and writes nothing for none or junk', async () => {
78
+ const seen = [];
79
+ const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } };
80
+ assert.equal(await stampShipMode(db, 41, 'manual'), true);
81
+ assert.equal(seen.length, 1);
82
+ assert.match(seen[0].sql, /UPDATE provisioning_instances/);
83
+ assert.match(seen[0].sql, /WHERE id = \$1 AND NOT \(COALESCE\(settings, '\{\}'::jsonb\) \? \$2::text\)/, 'write-once');
84
+ assert.deepEqual(seen[0].params, [41, SHIP_MODE_KEY, 'manual']);
85
+ assert.equal(await stampShipMode(db, 41, undefined), false, 'no answer: the project asks at its first ship instead');
86
+ assert.equal(await stampShipMode(db, 41, 'yolo'), false);
87
+ assert.equal(seen.length, 1, 'nothing written for either');
88
+ });
89
+
90
+ test('the stamp is not a setting: no client patch can write it and it never projects as one', () => {
91
+ assert.ok(!(SHIP_MODE_KEY in provisioning.SETTINGS_VOCAB));
92
+ assert.ok(!(SHIP_MODE_KEY in provisioning.PLANET_VOCAB));
93
+ assert.ok(!(SHIP_MODE_KEY in provisioning.effectiveSettings({ settings: { [SHIP_MODE_KEY]: 'manual' } })));
94
+ });
95
+
96
+ // ── 3. the transport ───────────────────────────────────────────────────────
97
+
98
+ test('the stamped mode rides web.env as SHIP_MODE; an unstamped or mangled row sends empty', () => {
99
+ assert.equal(typeof BIRTH_COMPANION_ENV.SHIP_MODE, 'function');
100
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'manual' } }, 'X').X_SHIP_MODE, 'manual');
101
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'auto' } }, 'X').X_SHIP_MODE, 'auto');
102
+ assert.equal(provisioning.settingsEnvVars({ settings: {} }, 'X').X_SHIP_MODE, '');
103
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'sometimes' } }, 'X').X_SHIP_MODE, '');
104
+ });
105
+
106
+ test('the instance reads it into founding.shipMode, and never publishes it to the client', () => {
107
+ const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
108
+ "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({m:(r.founding||{}).shipMode||null,c:'founding' in b.clientBranding(r)}))"],
109
+ { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
110
+ assert.deepEqual(read({ CLOUDBONGOS_SHIP_MODE: 'manual' }), { m: 'manual', c: false });
111
+ assert.deepEqual(read({ CLOUDBONGOS_SHIP_MODE: '' }), { m: null, c: false }, 'the empty line every other instance carries is nothing');
112
+ });
113
+
114
+ // ── 4. the instance's own resolver ─────────────────────────────────────────
115
+
116
+ async function withRow(value, fn) {
117
+ const pool = poolMod.pool;
118
+ const saved = pool.query;
119
+ pool.query = async () => ({ rows: value == null ? [] : [{ key: 'ship.mode', value, updated_at: null, updated_by: 1 }] });
120
+ try { return await fn(); } finally { pool.query = saved; }
121
+ }
122
+ const packWith = (shipMode) => () => ({ founding: { shipMode } });
123
+
124
+ test('a wizard pick counts as chosen while no setting row exists', async () => {
125
+ const r = await withRow(null, () => resolveShipMode({ branding: packWith('manual') }));
126
+ assert.deepEqual(r, { mode: 'manual', source: 'wizard', chosen: true, invalid: null });
127
+ const auto = await withRow(null, () => resolveShipMode({ branding: packWith('auto') }));
128
+ assert.equal(auto.chosen, true, 'picking Auto in the wizard is a choice, so the first ship does not ask again');
129
+ });
130
+
131
+ test('the settings page (a row) always wins over the wizard; junk or no pick is the default, unchosen', async () => {
132
+ const r = await withRow('auto', () => resolveShipMode({ branding: packWith('manual') }));
133
+ assert.equal(r.mode, 'auto');
134
+ assert.equal(r.source, 'project_settings');
135
+ assert.deepEqual(await withRow(null, () => resolveShipMode({ branding: packWith('') })), { mode: 'auto', source: 'default', chosen: false, invalid: null });
136
+ assert.deepEqual(await withRow(null, () => resolveShipMode({ branding: packWith('maybe') })), { mode: 'auto', source: 'default', chosen: false, invalid: null });
137
+ assert.equal((await withRow(null, () => resolveShipMode({ branding: () => { throw new Error('no pack'); } }))).source, 'default', 'a pack that cannot be read is no pick');
138
+ });
139
+
140
+ // ── 2b. the create route ───────────────────────────────────────────────────
141
+
142
+ const api = require('../src/module-api.js');
143
+ const birth = require('../modules/provisioning/birth.js');
144
+ provisioning.getInstanceBySlug = async () => null;
145
+ provisioning.getInstanceByDomain = async () => null;
146
+ birth.stampBirth = async () => {};
147
+ provisioning.createInstance = async (_db, args) => ({
148
+ instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: {} }, created: true,
149
+ });
150
+ provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
151
+ provisioning.getOpenIntent = async () => null;
152
+ provisioning.recordEvent = async () => {};
153
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
154
+ const stamped = [];
155
+ api.pool.query = async (sql, params) => { if (/jsonb_build_object/.test(sql) && params[1] === SHIP_MODE_KEY) stamped.push(params[2]); return { rows: [] }; };
156
+
157
+ const express = require('express');
158
+ const app = express();
159
+ app.use(express.json());
160
+ app.use((_req, res, next) => {
161
+ res.fail = (code, opts) => {
162
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
163
+ res.status(status).json({ error: { code, message: opts && opts.message } });
164
+ };
165
+ next();
166
+ });
167
+ app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
168
+ const server = app.listen(0);
169
+ after(() => server.close());
170
+ async function create(body) {
171
+ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
172
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances`, {
173
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
174
+ });
175
+ return { status: res.status, body: await res.json() };
176
+ }
177
+
178
+ test('the create route stamps the pick on the new row, and refuses anything but auto|manual', async () => {
179
+ stamped.length = 0;
180
+ const r = await create({ slug: 'ships-by-hand', hosting_shape: 'byo-host', ship_mode: 'manual' });
181
+ assert.equal(r.status, 201, JSON.stringify(r.body));
182
+ assert.deepEqual(stamped, ['manual']);
183
+ stamped.length = 0;
184
+ assert.equal((await create({ slug: 'no-pick', hosting_shape: 'byo-host' })).status, 201, 'the answer is optional (an API caller)');
185
+ assert.deepEqual(stamped, [], 'and none is stamped when none is given');
186
+ assert.equal((await create({ slug: 'bad-pick', hosting_shape: 'byo-host', ship_mode: 'sometimes' })).status, 400);
187
+ });
@@ -81,10 +81,10 @@ test('planet words survive only as the light touch: the fork and the alive momen
81
81
  assert.match(SCRIPT, /if \(inst\.last_verified_up\) \{\n\s*\/\*[^*]*\*\/\n\s*\$\('done-h'\)\.textContent =/, 'said only once the project has answered (Live is a verified claim)');
82
82
  });
83
83
 
84
- test('the review cannot be skipped: seven rows, one Edit each, and one Continue that confirms them all (task 1004550)', () => {
84
+ test('the review cannot be skipped: eight rows, one Edit each, and one Continue that confirms them all (task 1004550)', () => {
85
85
  const p = panel('panel-review');
86
86
  const rows = [...p.matchAll(/<div class="setRow" data-row="([a-z]+)">/g)].map((m) => m[1]);
87
- assert.deepEqual(rows, ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run']);
87
+ assert.deepEqual(rows, ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship']);
88
88
  for (const key of rows) {
89
89
  const r = (p.match(new RegExp(`<div class="setRow" data-row="${key}">[\\s\\S]*?<span class="acts">[^\\n]*\\n`)) || [''])[0];
90
90
  assert.match(r, /<span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit<\/button><\/span>/, `${key}: one Edit, nothing else`);
@@ -110,6 +110,7 @@ test('nothing the old steps could do is lost: their controls are moved whole int
110
110
  has('extras', ['modQ', 'modCards', 'modWhy', 'modCoreNote']);
111
111
  has('team', ['physTeam', 'physStartN', 'physExpected', 'teamCards']);
112
112
  has('run', ['physSuite']);
113
+ has('ship', ['shipOpts']);
113
114
  for (const id of ['physCountry', 'physRegion', 'physAges', 'physIntent', 'physForms', 'physHaveEntity', 'checksStop', 'physStopMsg', 'physFromAccount']) {
114
115
  assert.match(panel('panel-checks'), new RegExp(`id="${id}"`), `the quick checks host #${id}`);
115
116
  }