@bongos/core 1.20.61 → 1.20.63
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 +82 -37
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +2 -0
- package/docs/api/openapi.json +96 -3
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +372 -324
- package/docs/copy-registry.json +881 -417
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +544 -481
- package/docs/recipes/upgrading-the-core.md +32 -1
- package/modules/provisioning/demo.js +176 -0
- package/modules/provisioning/migrations/provisioning_035_demo.sql +26 -0
- package/modules/provisioning/module.json +5 -3
- package/modules/provisioning/pollers/demo-archive.js +61 -0
- package/modules/provisioning/provisioning.js +2 -2
- package/modules/provisioning/routes/demo.js +54 -0
- package/modules/provisioning/routes/provisioning.js +7 -4
- package/modules/public-landing/public/projects-demo.states.json +53 -0
- package/modules/public-landing/public/projects.html +300 -23
- package/modules/public-landing/public/projects.probes.json +3 -3
- package/modules/public-landing/public/projects.states.json +2 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +114 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/provision-core-upgrade.js +16 -1
- package/scripts/gds/update-channel.js +11 -2
- package/scripts/gds/update-sweep.js +717 -0
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +13 -0
- package/tests/update_channel_db.mjs +13 -6
- package/tests/update_subscription_engine.mjs +5 -5
- package/tests/update_sweep_home.mjs +100 -0
- package/tests/wizard_demo.mjs +264 -0
- package/tests/wizard_front_door.mjs +16 -20
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.63'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -219,6 +219,19 @@ await t('a version that is not published yet is refused, and the message says th
|
|
|
219
219
|
assert.match(f, /tag appears several minutes before/);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
await t('the channel refusal names a step that EXISTS on /deploy, never "change the channel first" (task 1004405)', () => {
|
|
223
|
+
const patch = R.targetFault('1.20.1', ['1.19.1081', '1.20.1'], '1.19.1081', 'patch', () => false);
|
|
224
|
+
assert.ok(!/Change the channel first/.test(patch), 'the old wording named nothing a person could do');
|
|
225
|
+
assert.match(patch, /\/deploy/);
|
|
226
|
+
assert.match(patch, /Fixes and new releases/, 'patch → names the rule that admits a minor jump');
|
|
227
|
+
const pinned = R.targetFault('1.19.1082', ['1.19.1081', '1.19.1082'], '1.19.1081', 'pinned', () => false);
|
|
228
|
+
assert.match(pinned, /Stay put/);
|
|
229
|
+
assert.match(pinned, /Fixes only/);
|
|
230
|
+
const major = R.targetFault('2.0.0', ['1.19.1081', '2.0.0'], '1.19.1081', 'minor', () => false);
|
|
231
|
+
assert.match(major, /no update rule/i, 'minor is the widest rule, so pointing at a wider one would be a lie');
|
|
232
|
+
assert.match(major, /upgrading-the-core\.md/);
|
|
233
|
+
});
|
|
234
|
+
|
|
222
235
|
await t('a registry that could not be read is refused DIFFERENTLY from a version that does not exist', () => {
|
|
223
236
|
const empty = R.targetFault('1.19.760', [], '1.19.758', 'patch', () => true);
|
|
224
237
|
assert.match(empty, /could not read the published core versions/);
|
|
@@ -46,8 +46,8 @@ test('DB pinned: the sweep skips it even when the roster says minor', () => {
|
|
|
46
46
|
const [inst] = uc.applyDbChannels([entry({ channel: 'minor' })], dbOf({ cloudbongos: 'pinned' }));
|
|
47
47
|
assert.equal(inst.channel, 'pinned');
|
|
48
48
|
assert.equal(targetFor(inst), null);
|
|
49
|
-
//
|
|
50
|
-
const src = readFileSync(path.join(ROOT, '
|
|
49
|
+
// update-sweep.js skips on exactly this value, and decides it AFTER the DB rule is applied.
|
|
50
|
+
const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
|
|
51
51
|
const applied = src.indexOf('channel.applyDbChannels(');
|
|
52
52
|
const skip = src.indexOf("inst.channel === 'pinned'");
|
|
53
53
|
assert.ok(applied > 0 && skip > applied, 'the pinned skip must read the channel after the /deploy rule is applied');
|
|
@@ -69,10 +69,17 @@ test('readDbChannels: no database named → not read, with the reason, and psql
|
|
|
69
69
|
let spawned = false;
|
|
70
70
|
const r = uc.readDbChannels({ env: {}, run: () => { spawned = true; } });
|
|
71
71
|
assert.equal(r.ok, false);
|
|
72
|
-
assert.match(r.reason, /no
|
|
72
|
+
assert.match(r.reason, /no UPDATE_CHANNEL_DB \(or DATABASE_URL \/ PGDATABASE\)/);
|
|
73
73
|
assert.equal(spawned, false);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test('readDbChannels: UPDATE_CHANNEL_DB wins, so a unit need not set PGDATABASE (which leaks into tenant upgrades)', () => {
|
|
77
|
+
let target;
|
|
78
|
+
uc.readDbChannels({ env: { UPDATE_CHANNEL_DB: 'cloudbongos', DATABASE_URL: 'postgres://other', PGDATABASE: 'tenant' }, run: (_c, a) => { target = a[1]; return { status: 0, stdout: '' }; } });
|
|
79
|
+
assert.equal(target, 'cloudbongos');
|
|
80
|
+
assert.equal(uc.CHANNEL_DB_ENV, 'UPDATE_CHANNEL_DB');
|
|
81
|
+
});
|
|
82
|
+
|
|
76
83
|
test('readDbChannels: parses psql -At tab output into slug → channel, from the sweep env only', () => {
|
|
77
84
|
let args;
|
|
78
85
|
const run = (cmd, a) => { args = [cmd, ...a]; return { status: 0, stdout: 'cloudbongos\tminor\ncharter\tpatch\n\n', stderr: '' }; };
|
|
@@ -112,12 +119,12 @@ test('channelLine: the sweep log names the channel and where it came from', () =
|
|
|
112
119
|
[entry(), entry({ slug: 'charter', channel: 'patch' })], dbOf({ cloudbongos: 'minor' }));
|
|
113
120
|
assert.equal(uc.channelLine(fromDeploy), ' • cloudbongos: channel minor, set on /deploy (roster says patch, ignored)');
|
|
114
121
|
assert.equal(uc.channelLine(fromRoster), ' • charter: channel patch, from the roster — no project row for this slug');
|
|
115
|
-
const src = readFileSync(path.join(ROOT, '
|
|
122
|
+
const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
|
|
116
123
|
assert.match(src, /console\.log\(channel\.channelLine\(inst\)\)/);
|
|
117
124
|
});
|
|
118
125
|
|
|
119
|
-
test('
|
|
120
|
-
const src = readFileSync(path.join(ROOT, '
|
|
126
|
+
test('update-sweep.js reads the DB from its own env, never an entry env (which names that instance\'s DB)', () => {
|
|
127
|
+
const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
|
|
121
128
|
assert.match(src, /channel\.readDbChannels\(\{ env: process\.env \}\)/);
|
|
122
129
|
// A core too old to carry the reader must keep the roster rather than crash the sweep.
|
|
123
130
|
assert.match(src, /typeof channel\.readDbChannels === 'function'/);
|
|
@@ -334,7 +334,7 @@ test('the heal branch is reachable only when the dirty files ARE the pin', () =>
|
|
|
334
334
|
// ---------------------------------------------------------------------------
|
|
335
335
|
|
|
336
336
|
test('every commitPin call site in the lane decides about push', () => {
|
|
337
|
-
const src = readFileSync(path.join(ROOT, '
|
|
337
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
338
338
|
// Every CALL, excluding the definition (which is `function commitPin({ ... push = true })` —
|
|
339
339
|
// its default is what these sites must not silently inherit).
|
|
340
340
|
const calls = [];
|
|
@@ -358,7 +358,7 @@ test('every commitPin call site in the lane decides about push', () => {
|
|
|
358
358
|
});
|
|
359
359
|
|
|
360
360
|
test('the direct-upgrade fallback asks pinFlagsFor rather than deciding inline', () => {
|
|
361
|
-
const src = readFileSync(path.join(ROOT, '
|
|
361
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
362
362
|
// The fourth version of this assertion. It used to pin the inline `if` verbatim, which froze
|
|
363
363
|
// the bug of task 1004122 in place: the line was correct about NOT pushing a co-tenant pin and
|
|
364
364
|
// wrong about how, and a test that quotes an expression can only ever confirm it is still there.
|
|
@@ -610,7 +610,7 @@ test('preflightFlagsFor has the precedent it claims — go-live.js really does s
|
|
|
610
610
|
});
|
|
611
611
|
|
|
612
612
|
test('the direct-upgrade fallback asks preflightFlagsFor rather than deciding inline', () => {
|
|
613
|
-
const src = readFileSync(path.join(ROOT, '
|
|
613
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
614
614
|
assert.match(src, /upArgs\.push\(\.\.\.preflightFlagsFor\(pinMode\)\)/,
|
|
615
615
|
'the clean-tree policy is a pin-mode decision and must go through the named function');
|
|
616
616
|
});
|
|
@@ -618,7 +618,7 @@ test('the direct-upgrade fallback asks preflightFlagsFor rather than deciding in
|
|
|
618
618
|
test('every commitPin call site in the lane also decides what the upgrade generated', () => {
|
|
619
619
|
// The sibling of the push assertion in section 8, and for the same reason: a site that inherits
|
|
620
620
|
// the `generated = []` default silently reverts to the two-bucket guard that wedged the lane.
|
|
621
|
-
const src = readFileSync(path.join(ROOT, '
|
|
621
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
622
622
|
const calls = [];
|
|
623
623
|
const re = /commitPin\(\{/g;
|
|
624
624
|
let m;
|
|
@@ -756,7 +756,7 @@ test('the post-upgrade net re-probes, rather than reusing the set from before th
|
|
|
756
756
|
// A skill the NEW core adds is absent from the pre-upgrade answer, so reusing it would drop that
|
|
757
757
|
// file into `other` and wedge the lane one release later — this defect again, wearing the next
|
|
758
758
|
// version number. The heal, which runs BEFORE the bump, must keep using the pre-bump set.
|
|
759
|
-
const src = readFileSync(path.join(ROOT, '
|
|
759
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
760
760
|
const afterSpawn = src.split('const generatedAfter = laneGeneratedPaths(inst.dir);')[1];
|
|
761
761
|
assert.ok(afterSpawn, 'the post-upgrade re-probe is gone');
|
|
762
762
|
assert.equal((afterSpawn.match(/generated: generatedAfter,/g) || []).length, 2,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// tests/update_sweep_home.mjs — the update sweep lives in the core package and runs from it
|
|
2
|
+
// (task 1004307).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The sweep used to live only under .claude/scheduled-tasks/, which the core package never
|
|
5
|
+
// ships, so cloudbongos.com's sweep ran a checkout frozen at 2026-08-25 and every later lane fix was
|
|
6
|
+
// inert there. It now lives at scripts/gds/update-sweep.js. These pin the three things that keep it
|
|
7
|
+
// runnable from node_modules/@bongos/core: it publishes, it needs nothing the package lacks (the
|
|
8
|
+
// .claude/hooks resolver is topology-excluded), and it reads the roster from where it is told,
|
|
9
|
+
// not from where its own file sits.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/update_sweep_home.mjs
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const SWEEP = path.join(ROOT, 'scripts', 'gds', 'update-sweep.js');
|
|
25
|
+
const sweep = require(SWEEP);
|
|
26
|
+
const { isPublishable } = require('../scripts/gds/publish-manifest.js');
|
|
27
|
+
|
|
28
|
+
test('the sweep and every core file it requires are in the published package', () => {
|
|
29
|
+
for (const f of ['scripts/gds/update-sweep.js', 'scripts/gds/update-channel.js', 'scripts/gds/upgrade.js', 'src/module-api.js']) {
|
|
30
|
+
assert.equal(isPublishable(f), true, `${f} must publish, or the installed sweep cannot run`);
|
|
31
|
+
}
|
|
32
|
+
// The reason it moved: the old home never ships.
|
|
33
|
+
assert.equal(isPublishable('.claude/scheduled-tasks/core-update-subscription/subscribe.js'), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the sweep requires nothing the package lacks (no .claude/ path, no hook resolver)', () => {
|
|
37
|
+
const src = readFileSync(SWEEP, 'utf8');
|
|
38
|
+
const requires = [...src.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]);
|
|
39
|
+
for (const r of requires) {
|
|
40
|
+
assert.ok(!r.includes('.claude') && !r.includes('_core-resolve'), `update-sweep.js requires ${r}, which the core package does not ship`);
|
|
41
|
+
if (r.startsWith('.')) assert.equal(isPublishable(path.posix.join('scripts/gds', r).replace(/\\/g, '/')), true, `${r} must publish`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('its target-instance resolver searches the same packages, in the same order, as _core-resolve.js', () => {
|
|
46
|
+
const hook = readFileSync(path.join(ROOT, '.claude', 'hooks', '_core-resolve.js'), 'utf8');
|
|
47
|
+
const m = hook.match(/const CORE_PKG_NAMES = (\[[^\]]*\]);/);
|
|
48
|
+
assert.ok(m, '_core-resolve.js CORE_PKG_NAMES not found');
|
|
49
|
+
assert.deepEqual(sweep.CORE_PKG_NAMES, JSON.parse(m[1].replace(/'/g, '"')));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('coreModulePathFrom finds a target instance\'s INSTALLED core, root first', () => {
|
|
53
|
+
const inst = mkdtempSync(path.join(tmpdir(), 'sweep-home-'));
|
|
54
|
+
const pkg = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
|
|
55
|
+
mkdirSync(pkg, { recursive: true });
|
|
56
|
+
writeFileSync(path.join(pkg, 'upgrade.js'), '');
|
|
57
|
+
assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/upgrade.js'), path.join(pkg, 'upgrade.js'));
|
|
58
|
+
mkdirSync(path.join(inst, 'scripts', 'gds'), { recursive: true });
|
|
59
|
+
writeFileSync(path.join(inst, 'scripts', 'gds', 'upgrade.js'), '');
|
|
60
|
+
assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/upgrade.js'), path.join(inst, 'scripts', 'gds', 'upgrade.js'), 'a monolith root wins, as in _core-resolve.js');
|
|
61
|
+
assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/absent.js'), null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('run from elsewhere, it reads the roster named by --instance and says which core is sweeping', () => {
|
|
65
|
+
const rosterRoot = mkdtempSync(path.join(tmpdir(), 'sweep-roster-'));
|
|
66
|
+
mkdirSync(path.join(rosterRoot, 'config'));
|
|
67
|
+
writeFileSync(path.join(rosterRoot, 'config', 'update-subscriptions.json'),
|
|
68
|
+
JSON.stringify({ instances: [{ slug: 'held', dir: path.join(rosterRoot, 'nowhere'), channel: 'pinned' }] }));
|
|
69
|
+
const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
|
|
70
|
+
const r = spawnSync(process.execPath, [SWEEP, '--instance', rosterRoot], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
|
|
71
|
+
assert.equal(r.status, 0, r.stderr);
|
|
72
|
+
assert.match(r.stdout, new RegExp(`update-sweep · running from .+ \\(core \\d+\\.\\d+\\.\\d+\\) · roster ${rosterRoot.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')}`));
|
|
73
|
+
assert.match(r.stdout, /1 subscribed instance/);
|
|
74
|
+
assert.match(r.stdout, /held: pinned — skipped/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('without --instance it reads the roster in its working directory (the unit\'s WorkingDirectory)', () => {
|
|
78
|
+
const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
|
|
79
|
+
const r = spawnSync(process.execPath, [SWEEP], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
|
|
80
|
+
assert.equal(r.status, 0, r.stderr);
|
|
81
|
+
assert.match(r.stdout, /no roster file — nothing subscribed/);
|
|
82
|
+
assert.ok(r.stdout.includes(`roster ${cwd}`) || r.stdout.includes(`roster ${path.resolve(cwd)}`), r.stdout);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the subscribe.js shim runs the same body, with its instance root as the default roster root', () => {
|
|
86
|
+
const shim = require('../.claude/scheduled-tasks/core-update-subscription/subscribe.js');
|
|
87
|
+
assert.equal(shim, sweep, 'the shim re-exports the core body, so tests and callers see one sweep');
|
|
88
|
+
const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
|
|
89
|
+
assert.match(src, /requireCore\('scripts\/gds\/update-sweep\.js'\)/);
|
|
90
|
+
assert.match(src, /sweep\.main\(\{ instanceRoot: INSTANCE_ROOT \}\)/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('the recipe\'s drop-in names the file that really ships, and the safe database setting', () => {
|
|
94
|
+
const recipe = readFileSync(path.join(ROOT, 'docs', 'recipes', 'upgrading-the-core.md'), 'utf8');
|
|
95
|
+
const execs = [...recipe.matchAll(/node_modules\/@bongos\/core\/(scripts\/gds\/[\w-]+\.js)/g)].map((m) => m[1]);
|
|
96
|
+
assert.ok(execs.length >= 2, 'the drop-in and its dry run both name the installed sweep');
|
|
97
|
+
for (const rel of execs) assert.equal(rel, 'scripts/gds/update-sweep.js');
|
|
98
|
+
assert.match(recipe, /Environment=UPDATE_CHANNEL_DB=/);
|
|
99
|
+
assert.doesNotMatch(recipe, /Environment=PGDATABASE=/, 'a unit-level PGDATABASE leaks into tenant upgrades');
|
|
100
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// tests/wizard_demo.mjs — the demo route: training wheels, a soft time box, and "keep
|
|
2
|
+
// your planet?" (task 1004420 / BV2.PS09, goal 1000121; spec D3 + D9).
|
|
3
|
+
//
|
|
4
|
+
// Done-when: a demo can be created, run and carried over. D3: the time box is SOFT — a
|
|
5
|
+
// countdown, then the question; work is never frozen. D9: a demo is a normal project
|
|
6
|
+
// marked "demo"; one nobody keeps goes offline after 30 days through the existing
|
|
7
|
+
// take-offline path, with the owner told first.
|
|
8
|
+
//
|
|
9
|
+
// Three layers, all RUN: modules/provisioning/demo.js (the marker, the defaults, the
|
|
10
|
+
// view, the answer, the sweep); the create route and the keep route over a mounted
|
|
11
|
+
// router; and the hub's demo controller sliced out of projects.html and driven in a vm.
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { test, after } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
24
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
25
|
+
const demo = require('../modules/provisioning/demo.js');
|
|
26
|
+
const DAY = 86400000;
|
|
27
|
+
|
|
28
|
+
// ── the module ───────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
test('a demo asks for people and hours each, inside their limits, and nothing else', () => {
|
|
31
|
+
assert.deepEqual(demo.parseDemo(undefined), { ok: true, demo: null }, 'no demo field: an ordinary project');
|
|
32
|
+
assert.deepEqual(demo.parseDemo({ people: 3, hours_each: 4 }), { ok: true, demo: { people: 3, hours_each: 4 } });
|
|
33
|
+
assert.equal(demo.parseDemo({ people: 0, hours_each: 4 }).ok, false);
|
|
34
|
+
assert.equal(demo.parseDemo({ people: 13, hours_each: 4 }).ok, false);
|
|
35
|
+
assert.equal(demo.parseDemo({ people: 3, hours_each: 41 }).ok, false);
|
|
36
|
+
assert.equal(demo.parseDemo({ people: 3, hours_each: 4, ends_at: 'never' }).ok, false, 'a caller cannot name its own end');
|
|
37
|
+
assert.equal(demo.parseDemo([3, 4]).ok, false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the first read says what fits in the builder-hours', () => {
|
|
41
|
+
assert.equal(demo.fitCheck(1, 4).verdict, 'tight');
|
|
42
|
+
assert.equal(demo.fitCheck(3, 4).verdict, 'small');
|
|
43
|
+
assert.equal(demo.fitCheck(3, 4).total, 12);
|
|
44
|
+
assert.equal(demo.fitCheck(5, 4).verdict, 'plenty');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the demo picks no rewards and all managed, and an owner\'s own answer wins', () => {
|
|
48
|
+
assert.deepEqual(demo.withDemoDetail(undefined), { reward_intents: [], framing: 'managed' });
|
|
49
|
+
assert.deepEqual(demo.withDemoDetail({ framing: 'hybrid', team_shape: 'small-team' }), { reward_intents: [], framing: 'hybrid', team_shape: 'small-team' });
|
|
50
|
+
assert.deepEqual({ ...demo.DEMO_SETTINGS }, { visibility: 'private', joinability: 'invite_only' }, 'private to the team');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the time box is soft: it only counts down and then asks (D3)', () => {
|
|
54
|
+
const start = new Date('2026-10-01T09:00:00Z');
|
|
55
|
+
const row = { demo: demo.demoRecord({ people: 3, hours_each: 4 }, start) };
|
|
56
|
+
assert.equal(row.demo.ends_at, '2026-10-01T13:00:00.000Z');
|
|
57
|
+
const mid = demo.demoView(row, new Date('2026-10-01T11:30:00Z'));
|
|
58
|
+
assert.equal(mid.remaining_seconds, 5400);
|
|
59
|
+
assert.equal(mid.time_up, false);
|
|
60
|
+
assert.equal(mid.builder_hours, 12);
|
|
61
|
+
const late = demo.demoView(row, new Date('2026-10-01T15:00:00Z'));
|
|
62
|
+
assert.equal(late.remaining_seconds, 0);
|
|
63
|
+
assert.equal(late.time_up, true, 'it asks; nothing in the view stops anything');
|
|
64
|
+
assert.equal(late.offline_on, '2026-10-31T09:00:00.000Z', '30 days after it started (D9)');
|
|
65
|
+
assert.equal(late.offline_soon, false);
|
|
66
|
+
assert.equal(demo.demoView(row, new Date('2026-10-29T09:00:00Z')).offline_soon, true, 'the owner is told from three days before');
|
|
67
|
+
assert.equal(demo.demoView({ demo: null }), null, 'an ordinary project has no demo view');
|
|
68
|
+
assert.equal(demo.demoView({ demo: { ...row.demo, outcome: 'carried' } }), null, 'a carried-over demo is a real project');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function fakeDb(rows = []) {
|
|
72
|
+
const writes = [];
|
|
73
|
+
return { writes, query: async (sql, params) => { if (/^\s*UPDATE/.test(sql)) writes.push(params); return { rows }; } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test('keep your planet? is answered once', async () => {
|
|
77
|
+
const db = fakeDb();
|
|
78
|
+
const inst = { id: 9, demo: demo.demoRecord({ people: 2, hours_each: 3 }) };
|
|
79
|
+
const r = await demo.settleDemo(db, inst, 'carried');
|
|
80
|
+
assert.equal(r.ok, true);
|
|
81
|
+
assert.equal(JSON.parse(db.writes[0][1]).outcome, 'carried');
|
|
82
|
+
assert.equal((await demo.settleDemo(db, { ...inst, demo: r.demo }, 'kept')).reason, 'already_settled');
|
|
83
|
+
assert.equal((await demo.settleDemo(db, { id: 1, demo: null }, 'kept')).reason, 'not_a_demo');
|
|
84
|
+
assert.equal((await demo.settleDemo(db, inst, 'frozen')).reason, 'bad_outcome');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the sweep warns three days before and takes a demo nobody carried over offline at 30 days', async () => {
|
|
88
|
+
const started = new Date('2026-09-01T00:00:00Z');
|
|
89
|
+
const rows = [
|
|
90
|
+
{ id: 1, owner_builder_id: 7, status: 'active', demo: demo.demoRecord({ people: 1, hours_each: 2 }, started) },
|
|
91
|
+
{ id: 2, owner_builder_id: 7, status: 'active', demo: { ...demo.demoRecord({ people: 1, hours_each: 2 }, new Date('2026-09-04T00:00:00Z')), outcome: 'kept' } },
|
|
92
|
+
{ id: 3, owner_builder_id: 7, status: 'active', demo: demo.demoRecord({ people: 1, hours_each: 2 }, new Date('2026-09-20T00:00:00Z')) },
|
|
93
|
+
];
|
|
94
|
+
const torn = [];
|
|
95
|
+
const db = fakeDb(rows);
|
|
96
|
+
const out = await demo.sweepDemos(db, {
|
|
97
|
+
enqueueTeardown: async (_db, id) => { torn.push(id); },
|
|
98
|
+
recordEvent: async () => {},
|
|
99
|
+
now: new Date('2026-10-02T00:00:00Z'),
|
|
100
|
+
});
|
|
101
|
+
assert.deepEqual(torn, [1], 'only the one past 30 days goes offline, through the teardown path');
|
|
102
|
+
assert.equal(out.offline, 1);
|
|
103
|
+
assert.equal(out.warned, 1, 'the kept demo two days out is warned; the young one is left alone');
|
|
104
|
+
assert.match(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'demo.js'), 'utf8'), /COALESCE\(demo->>'outcome', ''\) <> 'carried'/, 'a carried-over demo is never swept');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── the routes ───────────────────────────────────────────────────────────────
|
|
108
|
+
const api = require('../src/module-api.js');
|
|
109
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
110
|
+
let created = null;
|
|
111
|
+
let started = null;
|
|
112
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
113
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
114
|
+
provisioning.createInstance = async (_db, args) => {
|
|
115
|
+
created = args;
|
|
116
|
+
return { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: args.detail || {} }, created: true };
|
|
117
|
+
};
|
|
118
|
+
provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
119
|
+
provisioning.getOpenIntent = async () => null;
|
|
120
|
+
provisioning.recordEvent = async () => {};
|
|
121
|
+
demo.startDemoProject = async (_db, _p, id, d) => { started = { id, d }; return null; };
|
|
122
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
123
|
+
let stored = null;
|
|
124
|
+
provisioning.getInstanceById = async (_db, id) => (id === 41 ? { id: 41, owner_builder_id: 7, demo: stored } : null);
|
|
125
|
+
api.pool.query = async (sql, params) => { if (/UPDATE provisioning_instances SET demo/.test(sql)) stored = JSON.parse(params[1]); return { rows: [] }; };
|
|
126
|
+
|
|
127
|
+
const express = require('express');
|
|
128
|
+
const app = express();
|
|
129
|
+
app.use(express.json());
|
|
130
|
+
app.use((_req, res, next) => {
|
|
131
|
+
res.fail = (code, opts) => {
|
|
132
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
133
|
+
res.status(status).json({ error: { code, message: opts && opts.message } });
|
|
134
|
+
};
|
|
135
|
+
next();
|
|
136
|
+
});
|
|
137
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
|
|
138
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/demo.js')());
|
|
139
|
+
const server = app.listen(0);
|
|
140
|
+
after(() => server.close());
|
|
141
|
+
async function post(p, body) {
|
|
142
|
+
await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
|
|
143
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos${p}`, {
|
|
144
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
145
|
+
});
|
|
146
|
+
return { status: res.status, body: await res.json() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test('the create files a normal project marked demo, with the demo\'s picked answers under the owner\'s', async () => {
|
|
150
|
+
const r = await post('/provisioning/instances', { slug: 'reading-list', hosting_shape: 'byo-host', demo: { people: 3, hours_each: 4 }, detail: { team_shape: 'small-team' } });
|
|
151
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
152
|
+
assert.deepEqual(created.detail, { reward_intents: [], framing: 'managed', team_shape: 'small-team' });
|
|
153
|
+
assert.deepEqual(started, { id: 41, d: { people: 3, hours_each: 4 } }, 'the row is marked and made private');
|
|
154
|
+
started = null;
|
|
155
|
+
const plain = await post('/provisioning/instances', { slug: 'real-one', hosting_shape: 'byo-host' });
|
|
156
|
+
assert.equal(plain.status, 201);
|
|
157
|
+
assert.equal(started, null, 'an ordinary project is never marked');
|
|
158
|
+
assert.equal((await post('/provisioning/instances', { slug: 'bad-demo', hosting_shape: 'byo-host', demo: { people: 99, hours_each: 4 } })).status, 400);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('keep your planet? over the route: the owner answers once', async () => {
|
|
162
|
+
stored = demo.demoRecord({ people: 3, hours_each: 4 }, new Date(Date.now() - 5 * 3600000));
|
|
163
|
+
const r = await post('/provisioning/instances/41/demo', { outcome: 'kept' });
|
|
164
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
165
|
+
assert.equal(stored.outcome, 'kept');
|
|
166
|
+
assert.equal(r.body.demo.outcome, 'kept');
|
|
167
|
+
assert.equal((await post('/provisioning/instances/41/demo', { outcome: 'carried' })).status, 409, 'answered once');
|
|
168
|
+
assert.equal((await post('/provisioning/instances/99/demo', { outcome: 'carried' })).status, 404, 'not yours, not found');
|
|
169
|
+
assert.equal((await post('/provisioning/instances/41/demo', { outcome: 'frozen' })).status, 400);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the module declares the route, the poller and its migration', () => {
|
|
173
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'module.json'), 'utf8'));
|
|
174
|
+
assert.ok(manifest.contributes.routes.includes('demo'));
|
|
175
|
+
assert.ok(manifest.contributes.pollers.includes('demo-archive'));
|
|
176
|
+
assert.match(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'migrations', 'provisioning_035_demo.sql'), 'utf8'), /ADD COLUMN IF NOT EXISTS demo jsonb/);
|
|
177
|
+
const poller = require('../modules/provisioning/pollers/demo-archive.js');
|
|
178
|
+
assert.equal(typeof poller.start, 'function');
|
|
179
|
+
assert.equal(poller.INTERVAL_MS, 3600000);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── the hub, RUN ─────────────────────────────────────────────────────────────
|
|
183
|
+
const SLICE = HUB.slice(HUB.indexOf(' /* ── the demo route (task 1004420, BV2.PS09) ──'), HUB.indexOf(' /* ── field wiring ── */'));
|
|
184
|
+
|
|
185
|
+
test('the hub carries the server\'s limits and fit sentences exactly', () => {
|
|
186
|
+
assert.ok(SLICE.length > 1000, 'the demo controller was found');
|
|
187
|
+
const box = {};
|
|
188
|
+
vm.createContext(box);
|
|
189
|
+
vm.runInContext(SLICE.slice(SLICE.indexOf(' var DEMO_LIMITS'), SLICE.indexOf(' function demoHandle')).replace('var demoDraft = state.demo', 'var state = {}; var demoDraft = state.demo'), box);
|
|
190
|
+
assert.deepEqual(JSON.parse(JSON.stringify(box.DEMO_LIMITS)), { people: [...demo.DEMO_LIMITS.people], hours: [...demo.DEMO_LIMITS.hours_each] });
|
|
191
|
+
for (const [p, h] of [[1, 2], [3, 4], [5, 4], [12, 40]]) {
|
|
192
|
+
assert.deepEqual(JSON.parse(JSON.stringify(box.demoFit(p, h))), demo.fitCheck(p, h), `${p} × ${h}`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
class El {
|
|
197
|
+
constructor(id) {
|
|
198
|
+
this.id = id; this.hidden = false; this.value = ''; this.textContent = ''; this.attrs = {}; this.listeners = {};
|
|
199
|
+
const self = this; this.classList = { toggle() {} };
|
|
200
|
+
this._label = { hidden: false }; this.closest = () => self._label;
|
|
201
|
+
}
|
|
202
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
203
|
+
getAttribute(k) { return this.attrs[k] ?? null; }
|
|
204
|
+
addEventListener(t, fn) { this.listeners[t] = fn; }
|
|
205
|
+
focus() {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function bootHub() {
|
|
209
|
+
const els = {};
|
|
210
|
+
const $ = (id) => (els[id] = els[id] || new El(id));
|
|
211
|
+
const steppers = [['people', '1'], ['people', '-1'], ['hours', '1'], ['hours', '-1']].map(([k, by]) => {
|
|
212
|
+
const b = new El(`step-${k}${by}`); b.attrs['data-demo-step'] = k; b.attrs['data-by'] = by; return b;
|
|
213
|
+
});
|
|
214
|
+
const shown = [];
|
|
215
|
+
const state = { demo: null, description: '', teamShape: null };
|
|
216
|
+
const sandbox = { $, state, saveDraft() {}, show: (n) => shown.push(n), root: { querySelectorAll: () => steppers } };
|
|
217
|
+
vm.createContext(sandbox);
|
|
218
|
+
vm.runInContext(SLICE, sandbox);
|
|
219
|
+
const click = (id) => $(id).listeners.click.call($(id));
|
|
220
|
+
return { $, state, shown, steppers, click, sandbox };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test('the door opens the setup, the steppers stay in their limits, and the first read follows them', () => {
|
|
224
|
+
const h = bootHub();
|
|
225
|
+
h.click('wizDemo');
|
|
226
|
+
assert.equal(h.$('forkIntro').hidden, true);
|
|
227
|
+
assert.equal(h.$('demoSetup').hidden, false);
|
|
228
|
+
assert.equal(h.$('demoRing').hidden, false, 'the time-box ring on the planet');
|
|
229
|
+
assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'true');
|
|
230
|
+
for (let i = 0; i < 20; i += 1) h.steppers[0].listeners.click.call(h.steppers[0]);
|
|
231
|
+
assert.equal(h.$('demoPeople').textContent, '12', 'people stop at twelve');
|
|
232
|
+
for (let i = 0; i < 20; i += 1) h.steppers[3].listeners.click.call(h.steppers[3]);
|
|
233
|
+
assert.equal(h.$('demoHours').textContent, '1', 'hours stop at one');
|
|
234
|
+
assert.equal(h.$('demoFitSum').textContent, '12 people × 1 hour = 12 builder-hours.');
|
|
235
|
+
assert.equal(h.$('demoFitVerdict').textContent, demo.fitCheck(12, 1).sentence);
|
|
236
|
+
h.click('demoBack1');
|
|
237
|
+
assert.equal(h.$('forkIntro').hidden, false, 'Back returns to the two doors');
|
|
238
|
+
assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'false');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('Name my demo planet carries the demo into the ordinary wizard: the idea, the team, the invites', () => {
|
|
242
|
+
const h = bootHub();
|
|
243
|
+
h.click('wizDemo');
|
|
244
|
+
h.$('demoIdea').value = 'A shared reading list.';
|
|
245
|
+
h.$('demoIdea').listeners.input.call(h.$('demoIdea'));
|
|
246
|
+
h.click('demoNext1');
|
|
247
|
+
assert.equal(h.$('demoReady').hidden, false);
|
|
248
|
+
h.$('demoInvite1').value = '@avery';
|
|
249
|
+
h.$('demoInvite2').value = 'not a handle!';
|
|
250
|
+
h.click('demoStart');
|
|
251
|
+
assert.deepEqual(JSON.parse(JSON.stringify(h.state.demo)), { people: 3, hours_each: 4, idea: 'A shared reading list.', invites: ['avery'] });
|
|
252
|
+
assert.equal(h.state.description, 'A shared reading list.', 'the idea is the description');
|
|
253
|
+
assert.equal(h.state.teamShape, 'small-team');
|
|
254
|
+
assert.deepEqual(h.shown, [3], 'on to the name step');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('the create sends the demo, the review names it, and the project page asks the question', () => {
|
|
258
|
+
assert.match(HUB, /demo: state\.demo \? \{ people: state\.demo\.people, hours_each: state\.demo\.hours_each \} : undefined,/);
|
|
259
|
+
assert.match(HUB, /if \(state\.demo\) \{\n\s*rows\.unshift\(\['Demo',/);
|
|
260
|
+
assert.match(HUB, /<p class="demoKeepH">Keep your planet\?<\/p>/);
|
|
261
|
+
assert.match(HUB, /data-demo-settle="carried">Carry it over</);
|
|
262
|
+
assert.match(HUB, /data-demo-settle="kept">Keep it as a demo</);
|
|
263
|
+
assert.match(HUB, /\$\('mDemoKeep'\)\.hidden = !\(d\.time_up && !d\.outcome\);/, 'the question waits for the time box (D3)');
|
|
264
|
+
});
|
|
@@ -113,24 +113,26 @@ test('Proceed is refused until the box is ticked, and the refusal says why in th
|
|
|
113
113
|
assert.equal(given.ok, true, 'a tick goes on');
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
test('the fork
|
|
116
|
+
test('the fork refuses nothing — its doors are the answer, and Begin clears a demo started earlier', () => {
|
|
117
117
|
assert.equal(runValidate(2, false).ok, true, 'the fork asks nothing a validator could refuse');
|
|
118
|
-
|
|
118
|
+
// task 1004420: "Begin planet creation" is a real project, so a demo the founder
|
|
119
|
+
// set up and then walked back from must not ride the create
|
|
120
|
+
assert.match(validateSlice, /if \(step === 2\) \{ state\.demo = null; return true; \}/);
|
|
119
121
|
});
|
|
120
122
|
|
|
121
123
|
// ── the wiring, executed ───────────────────────────────────────────────────────
|
|
122
124
|
const WIRE_START = " $('termsAck').checked = !!state.termsAck;";
|
|
123
|
-
const WIRE_END = ' /* ──
|
|
125
|
+
const WIRE_END = ' /* ── the demo route (task 1004420, BV2.PS09) ──';
|
|
124
126
|
const wireSlice = SCRIPT.includes(WIRE_START) ? SCRIPT.slice(SCRIPT.indexOf(WIRE_START), SCRIPT.indexOf(WIRE_END)) : null;
|
|
125
127
|
|
|
126
128
|
function runWiring(initialAck) {
|
|
127
129
|
const handlers = {};
|
|
128
130
|
const mk = (id) => ({
|
|
129
|
-
id, checked: false, hidden:
|
|
131
|
+
id, checked: false, hidden: false, textContent: '', className: '', attrs: {},
|
|
130
132
|
addEventListener(type, fn) { handlers[id + ':' + type] = fn; },
|
|
131
133
|
setAttribute(k, v) { this.attrs[k] = v; },
|
|
132
134
|
});
|
|
133
|
-
const els = { termsAck: mk('termsAck'),
|
|
135
|
+
const els = { termsAck: mk('termsAck'), 'h-terms': mk('h-terms') };
|
|
134
136
|
let saves = 0;
|
|
135
137
|
const sandbox = { $: (id) => els[id], state: { termsAck: initialAck }, saveDraft: () => { saves += 1; } };
|
|
136
138
|
sandbox.globalThis = sandbox;
|
|
@@ -141,7 +143,7 @@ function runWiring(initialAck) {
|
|
|
141
143
|
|
|
142
144
|
test('the front door\'s wiring is still findable', () => {
|
|
143
145
|
assert.ok(wireSlice && wireSlice.length > 100, 'the wiring block was not found in projects.html');
|
|
144
|
-
assert.match(wireSlice, /\$\('
|
|
146
|
+
assert.match(wireSlice, /\$\('termsAck'\)\.addEventListener\('change'/, 'the tick is part of the slice');
|
|
145
147
|
});
|
|
146
148
|
|
|
147
149
|
test('the tick mirrors into the draft both ways, and a resumed draft paints its own answer', () => {
|
|
@@ -156,17 +158,10 @@ test('the tick mirrors into the draft both ways, and a resumed draft paints its
|
|
|
156
158
|
assert.equal(saves(), 2, 'each change is saved');
|
|
157
159
|
});
|
|
158
160
|
|
|
159
|
-
test('the demo door
|
|
160
|
-
|
|
161
|
-
assert.
|
|
162
|
-
|
|
163
|
-
assert.equal(els.demoSoon.hidden, false, 'the placeholder opens');
|
|
164
|
-
assert.equal(els.wizDemo.attrs['aria-expanded'], 'true');
|
|
165
|
-
handlers['wizDemo:click'].call(els.wizDemo);
|
|
166
|
-
assert.equal(els.demoSoon.hidden, true, 'and closes again');
|
|
167
|
-
assert.equal(els.wizDemo.attrs['aria-expanded'], 'false');
|
|
168
|
-
assert.match(FORK, /id="wizDemo" aria-controls="demoSoon" aria-expanded="false"/, 'the door names what it opens');
|
|
169
|
-
assert.match(FORK, /<p class="demoSoon" id="demoSoon" hidden>The demo isn’t open yet\./, 'baked markup, never built by script');
|
|
161
|
+
test('the demo door names what it opens: the demo route\'s setup screen (task 1004420; run in tests/wizard_demo.mjs)', () => {
|
|
162
|
+
assert.match(FORK, /id="wizDemo" aria-controls="demoSetup" aria-expanded="false"/, 'the door names what it opens');
|
|
163
|
+
assert.match(FORK, /<div id="demoSetup" class="demoView" hidden>/, 'baked markup, hidden at rest, never built by script');
|
|
164
|
+
assert.doesNotMatch(FORK, /demoSoon|isn’t open yet/, 'the placeholder that said the demo was not open is gone');
|
|
170
165
|
});
|
|
171
166
|
|
|
172
167
|
// ── screen 2 · the fork ─────────────────────────────────────────────────────────
|
|
@@ -186,7 +181,7 @@ test('screen 2 is the door pill: two equal doors in one object, the accent only
|
|
|
186
181
|
});
|
|
187
182
|
|
|
188
183
|
test('the seed stands on the world\'s own field plate — nothing drawn but hairlines', () => {
|
|
189
|
-
assert.match(FORK, /<div class="forkStage" aria-hidden="true">/, 'decorative, so hidden from a screen reader');
|
|
184
|
+
assert.match(FORK, /<div class="forkStage" id="forkStage" aria-hidden="true">/, 'decorative, so hidden from a screen reader');
|
|
190
185
|
assert.match(FORK, /<img class="forkSeed" src="\/assets\/world\/r2-chrome-blob-one-cut\.webp" alt="">/, 'a real plate, root-absolute so it is stamped');
|
|
191
186
|
assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-chrome-blob-one-cut.webp')), 'and it exists on disk');
|
|
192
187
|
assert.match(STYLE, /\.forkStage\{[^}]*background:var\(--sky-bg\) var\(--field-plate\) center\/cover no-repeat;/, 'the field is the plate token, not a new image');
|
|
@@ -197,9 +192,10 @@ test('the seed stands on the world\'s own field plate — nothing drawn but hair
|
|
|
197
192
|
assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|box-shadow:(?!none)|linear-gradient/i, 'every colour a token, no shadow, no gradient');
|
|
198
193
|
});
|
|
199
194
|
|
|
200
|
-
test('the two routes are described honestly for today: the demo
|
|
195
|
+
test('the two routes are described honestly for today: the demo is open (task 1004420), creation promises no questions that do not exist yet', () => {
|
|
201
196
|
const routes = (FORK.match(/<div class="forkRoutes">[\s\S]*$/) || [''])[0];
|
|
202
|
-
assert.match(routes, /Route 1 · demo
|
|
197
|
+
assert.match(routes, /Route 1 · demo<\/p>/);
|
|
198
|
+
assert.doesNotMatch(routes, /coming soon/, 'the demo is built, so the route no longer says it is coming');
|
|
203
199
|
assert.match(routes, /Route 2 · planet creation/);
|
|
204
200
|
assert.doesNotMatch(routes, /seven questions/i, 'the seven planet questions are PS07\'s — the copy must not promise them before they ship');
|
|
205
201
|
assert.doesNotMatch(FORK, /\bBongos\b(?! pair)/, 'no product name baked where a child instance would ship it');
|