@bongos/core 1.20.49 → 1.20.51
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 +104 -79
- package/.claude/skills/new-project/SKILL.md +5 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +3 -0
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +367 -345
- package/docs/copy-registry.json +663 -463
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -1
- package/docs/page-readings.json +986 -947
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +29 -14
- package/modules/hall-ui/public/goal-map.states.json +12 -0
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +145 -15
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +61 -5
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +15 -11
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.css +6 -0
- package/modules/hall-ui/public/tweak-editor.html +2 -2
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/hall-navigation.md +22 -0
- package/modules/hall-ui/records/sky.md +2 -0
- package/modules/platform-identity/routes/sso.js +5 -0
- package/modules/provisioning/migrations/provisioning_033_terms_ack.sql +33 -0
- package/modules/provisioning/onboard-plan.js +5 -0
- package/modules/provisioning/provisioning.js +7 -4
- package/modules/provisioning/routes/provisioning.js +3 -3
- package/modules/provisioning/terms-ack.js +36 -0
- package/modules/public-landing/public/projects.html +226 -77
- package/modules/public-landing/public/projects.probes.json +8 -2
- package/modules/public-landing/public/projects.states.json +16 -12
- package/modules/ui-design/kit/serve.js +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/goal_map_page.mjs +10 -7
- package/tests/hall_audit.mjs +3 -4
- package/tests/hall_fullscreen.mjs +99 -14
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/projects_hub.mjs +4 -2
- package/tests/projects_hub_app_status.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +9 -9
- package/tests/projects_hub_module_picker.mjs +7 -7
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provisioning_terms_ack.mjs +216 -0
- package/tests/wizard_draft_resume.mjs +4 -4
- package/tests/wizard_front_door.mjs +271 -0
- package/tests/wizard_intent_resume.mjs +31 -19
|
@@ -41,7 +41,7 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
41
41
|
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
|
|
42
42
|
|
|
43
43
|
const START = '/* ── the module picker (task 1002339, BV1.R20) ──';
|
|
44
|
-
const END = "$('skipModules').addEventListener('click', function () { show(
|
|
44
|
+
const END = "$('skipModules').addEventListener('click', function () { show(7, true); });";
|
|
45
45
|
const from = HUB.indexOf(START);
|
|
46
46
|
const to = HUB.indexOf(END);
|
|
47
47
|
assert.ok(from > -1, 'the picker block opens with its sentinel comment');
|
|
@@ -160,7 +160,7 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
|
|
|
160
160
|
const els = new Map();
|
|
161
161
|
const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
|
162
162
|
const form = makeEl('panel-modules-form');
|
|
163
|
-
const state = { step:
|
|
163
|
+
const state = { step: 6, projType: null, teamShape: null, modules: null, ...seed };
|
|
164
164
|
const shown = [];
|
|
165
165
|
let saves = 0;
|
|
166
166
|
let fetches = 0;
|
|
@@ -312,7 +312,7 @@ test('Skip walks past WITHOUT answering, so the server applies the bundle the pa
|
|
|
312
312
|
|
|
313
313
|
// Assert — skipping is the one path that must leave nothing said, because the
|
|
314
314
|
// preselect and the server's fallback are the same declaration (ADR 0237)
|
|
315
|
-
assert.deepEqual(h.shown, [
|
|
315
|
+
assert.deepEqual(h.shown, [7], 'it advances to the Home step (7 since task 1004417)');
|
|
316
316
|
assert.equal(h.state.modules, null, 'and answers nothing');
|
|
317
317
|
assert.equal(h.forCreate(), undefined);
|
|
318
318
|
assert.deepEqual(h.picked(), ['npm-release', 'discord'], 'while the set they were shown is still the set they get');
|
|
@@ -377,7 +377,7 @@ test('a catalog that will not load degrades to a clause — it never blocks a pr
|
|
|
377
377
|
assert.match(h.coreNote(), /couldn’t load/, 'the panel says so in the owner\'s words');
|
|
378
378
|
assert.equal(h.state.modules, null, 'nothing is answered on their behalf');
|
|
379
379
|
h.skip();
|
|
380
|
-
assert.deepEqual(h.shown, [
|
|
380
|
+
assert.deepEqual(h.shown, [7], 'and the wizard still walks on — the server applies the bundle');
|
|
381
381
|
});
|
|
382
382
|
|
|
383
383
|
test('every rendered value goes through the page escaper', async () => {
|
|
@@ -415,7 +415,7 @@ test('the review names the set even when the picker was never opened (task 10023
|
|
|
415
415
|
// and the module NAMES are not known. "None — just the core" would be a false
|
|
416
416
|
// promise on the last screen before Create: the server is about to apply the
|
|
417
417
|
// type's bundle. The row says that instead, then repaints when the read lands.
|
|
418
|
-
const h = boot({ state: { projType: 'game', step:
|
|
418
|
+
const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
|
|
419
419
|
|
|
420
420
|
// Arrange + Act — the review asks for the value before the read can answer
|
|
421
421
|
const first = h.extrasRow();
|
|
@@ -510,7 +510,7 @@ test('Skip after an answered About step still yields the set the panel showed',
|
|
|
510
510
|
|
|
511
511
|
h.skip();
|
|
512
512
|
|
|
513
|
-
assert.deepEqual(h.shown, [
|
|
513
|
+
assert.deepEqual(h.shown, [7]);
|
|
514
514
|
assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
|
|
515
515
|
assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
|
|
516
516
|
assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
|
|
@@ -821,7 +821,7 @@ test('with nothing switched on the count says only what is shown', async () => {
|
|
|
821
821
|
});
|
|
822
822
|
|
|
823
823
|
test('the review names the hidden picks too — a filter never reaches the create request', async () => {
|
|
824
|
-
const h = boot({ state: { projType: 'game', step:
|
|
824
|
+
const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
|
|
825
825
|
await h.open();
|
|
826
826
|
h.click('agents');
|
|
827
827
|
assert.deepEqual(h.forCreate(), ['agents', 'npm-release', 'discord']);
|
|
@@ -550,7 +550,10 @@ test('the rail at the done panel keeps its marker on Create until the project an
|
|
|
550
550
|
assert.doesNotMatch(fn('show'), /classList\.toggle\('done'/, 'and no longer marks the rail on its own');
|
|
551
551
|
assert.ok(SCRIPT.includes(`if (state.step === ${DONE}) paintRail(${DONE});`),
|
|
552
552
|
'the done-panel poll repaints when the status moves');
|
|
553
|
-
|
|
553
|
+
// the first NUMBERED beat carries it — since task 1004417 the front door's two screens come
|
|
554
|
+
// before the rail, and the card statically wears wizFront so a no-script visitor sees no rail there
|
|
555
|
+
assert.match(HUB, new RegExp(`<li class="now" data-step="${STEP['panel-name']}" aria-current="step"><span class="snum">1</span>`), 'the static rail starts with the same marker');
|
|
556
|
+
assert.match(HUB, /<section id="view-new" hidden>\s*<div class="wrap viewWrap">\s*<div class="card wizFront">/, 'and steps aside on the static first screen');
|
|
554
557
|
});
|
|
555
558
|
|
|
556
559
|
test('every action result announces, nothing under the pointer is deleted, and focus stays where it was', () => {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// tests/provisioning_terms_ack.mjs
|
|
2
|
+
//
|
|
3
|
+
// The create wizard's terms screen, as the server records it (task 1004417, BV2.PS05;
|
|
4
|
+
// migration provisioning_033). The founder ticks one box; the project row keeps WHEN and
|
|
5
|
+
// WHICH terms version. The owner's call (2026-09-30): the record lives on the PROJECT,
|
|
6
|
+
// not on the person's account — ADR 0213's account stamp is a different record, written
|
|
7
|
+
// only at a witnessed sign-in.
|
|
8
|
+
//
|
|
9
|
+
// The one property every case below protects: the VERSION is the server's answer. The
|
|
10
|
+
// request can say only "the box was ticked"; the version comes from platform-identity's
|
|
11
|
+
// TERMS_VERSION through the platform-identity.hub port, so a body cannot record agreement
|
|
12
|
+
// to a text it names itself.
|
|
13
|
+
//
|
|
14
|
+
// Four layers:
|
|
15
|
+
// • the port — platform-identity's hub provider answers TERMS_VERSION
|
|
16
|
+
// • createInstance — what reaches the INSERT
|
|
17
|
+
// • POST /instances — validation, and that no body field reaches the version
|
|
18
|
+
// • the migration — the columns exist and do not collide with the account's
|
|
19
|
+
//
|
|
20
|
+
// DB-free: a recording pool, stubbed data-access functions, a loopback listener.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/provisioning_terms_ack.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
|
|
31
|
+
let passed = 0, failed = 0;
|
|
32
|
+
async function ta(name, fn) {
|
|
33
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
34
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const api = require('../src/module-api.js');
|
|
38
|
+
const { TERMS_VERSION } = require('../modules/platform-identity/terms-acceptance.js');
|
|
39
|
+
|
|
40
|
+
// ── the port: the hub answers the version /terms states ───────────────────────
|
|
41
|
+
await ta('the platform-identity.hub port answers the current TERMS_VERSION, and it is a read', async () => {
|
|
42
|
+
// Capture what the SSO route factory registers, without touching the real registry.
|
|
43
|
+
const realHas = api.hasProvider, realReg = api.registerProvider;
|
|
44
|
+
const registered = {};
|
|
45
|
+
api.hasProvider = () => false;
|
|
46
|
+
api.registerProvider = (name, provider) => { registered[name] = provider; };
|
|
47
|
+
try {
|
|
48
|
+
require('../modules/platform-identity/routes/sso.js')();
|
|
49
|
+
} finally {
|
|
50
|
+
api.hasProvider = realHas; api.registerProvider = realReg;
|
|
51
|
+
}
|
|
52
|
+
const hub = registered['platform-identity.hub'];
|
|
53
|
+
assert.ok(hub, 'the SSO factory registers the hub port');
|
|
54
|
+
assert.equal(typeof hub.termsVersion, 'function', 'the port carries termsVersion');
|
|
55
|
+
assert.equal(hub.termsVersion(), TERMS_VERSION, 'and it is the version /terms states');
|
|
56
|
+
assert.match(TERMS_VERSION, /^\d{4}-\d{2}-\d{2}$/, 'a date-stamped version, as the page prints it');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── createInstance: what reaches the INSERT ───────────────────────────────────
|
|
60
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
61
|
+
const realCreate = provisioning.createInstance;
|
|
62
|
+
|
|
63
|
+
function recordingDb() {
|
|
64
|
+
const calls = [];
|
|
65
|
+
return {
|
|
66
|
+
calls,
|
|
67
|
+
query: async (text, params) => { calls.push({ text, params }); return { rows: [{ id: 1, owner_builder_id: 7, slug: 'demo' }] }; },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function insertOf(db) {
|
|
71
|
+
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
72
|
+
assert.ok(insert, 'issued an INSERT');
|
|
73
|
+
return insert;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await ta('createInstance: an acknowledgement stores the time and the server-resolved version', async () => {
|
|
77
|
+
const db = recordingDb();
|
|
78
|
+
await realCreate(db, { ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', termsAck: { version: '2026-08-29' } });
|
|
79
|
+
const { text, params } = insertOf(db);
|
|
80
|
+
assert.match(text, /terms_ack_at, terms_ack_version\)/, 'both columns are in the column list');
|
|
81
|
+
assert.match(text, /CASE WHEN \$15::boolean THEN now\(\) END, \$16\)/, 'the time is the database clock, never a caller value');
|
|
82
|
+
assert.equal(params[14], true);
|
|
83
|
+
assert.equal(params[15], '2026-08-29');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await ta('createInstance: no acknowledgement leaves both columns NULL — a row that never saw the screen says so', async () => {
|
|
87
|
+
for (const termsAck of [undefined, null]) {
|
|
88
|
+
const db = recordingDb();
|
|
89
|
+
await realCreate(db, { ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', termsAck });
|
|
90
|
+
const { params } = insertOf(db);
|
|
91
|
+
assert.equal(params[14], false, `termsAck=${termsAck} records no time`);
|
|
92
|
+
assert.equal(params[15], null, 'and no version');
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await ta('createInstance: an acknowledgement with no hub to ask keeps the time and a NULL version', async () => {
|
|
97
|
+
const db = recordingDb();
|
|
98
|
+
await realCreate(db, { ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', termsAck: { version: null } });
|
|
99
|
+
const { params } = insertOf(db);
|
|
100
|
+
assert.equal(params[14], true, 'the founder did tick the box');
|
|
101
|
+
assert.equal(params[15], null, 'but nothing names a version this instance cannot vouch for');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── POST /instances over a mounted router ─────────────────────────────────────
|
|
105
|
+
let lastCreateArgs = null;
|
|
106
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
107
|
+
provisioning.getInstanceByDomain = async () => null;
|
|
108
|
+
provisioning.createInstance = async (_db, args) => {
|
|
109
|
+
lastCreateArgs = args;
|
|
110
|
+
return { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: {} }, created: true };
|
|
111
|
+
};
|
|
112
|
+
provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
113
|
+
provisioning.getOpenIntent = async () => null;
|
|
114
|
+
provisioning.recordEvent = async () => {};
|
|
115
|
+
|
|
116
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
117
|
+
// The hub port, as the route will see it. `hubVersion === undefined` = no hub at all.
|
|
118
|
+
let hubVersion;
|
|
119
|
+
const realResolveOptional = api.resolveOptional;
|
|
120
|
+
api.resolveOptional = (name, fallback = null) => {
|
|
121
|
+
if (name !== 'platform-identity.hub') return realResolveOptional(name, fallback);
|
|
122
|
+
return hubVersion === undefined ? fallback : { termsVersion: () => hubVersion };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const express = require('express');
|
|
126
|
+
const app = express();
|
|
127
|
+
app.use(express.json());
|
|
128
|
+
app.use((_req, res, next) => {
|
|
129
|
+
res.fail = (code, opts, details) => {
|
|
130
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
131
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
132
|
+
};
|
|
133
|
+
next();
|
|
134
|
+
});
|
|
135
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
|
|
136
|
+
const server = app.listen(0);
|
|
137
|
+
await new Promise((r) => server.once('listening', r));
|
|
138
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
139
|
+
|
|
140
|
+
function post(path, body) {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const payload = JSON.stringify(body);
|
|
143
|
+
const req = http.request(`${base}${path}`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), Connection: 'close' },
|
|
146
|
+
}, (res) => {
|
|
147
|
+
let d = '';
|
|
148
|
+
res.on('data', (c) => { d += c; });
|
|
149
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
150
|
+
});
|
|
151
|
+
req.on('error', reject);
|
|
152
|
+
req.end(payload);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await ta('POST /instances: a ticked box records the version the SERVER names', async () => {
|
|
157
|
+
lastCreateArgs = null; hubVersion = '2026-08-29';
|
|
158
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', terms_acknowledged: true });
|
|
159
|
+
assert.equal(res.status, 201, res.raw);
|
|
160
|
+
assert.deepEqual(lastCreateArgs.termsAck, { version: '2026-08-29' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await ta('POST /instances: a caller-named version is refused outright, never recorded', async () => {
|
|
164
|
+
// Every spelling a forger might try, beside a real tick. The schema is closed, so each
|
|
165
|
+
// one is named back as an unknown field and nothing is created at all.
|
|
166
|
+
for (const forged of ['terms_version', 'terms_ack_version', 'termsVersion']) {
|
|
167
|
+
lastCreateArgs = null; hubVersion = '2026-08-29';
|
|
168
|
+
const res = await post('/provisioning/instances', {
|
|
169
|
+
slug: 'mercury', hosting_shape: 'byo-host', terms_acknowledged: true, [forged]: '1999-01-01',
|
|
170
|
+
});
|
|
171
|
+
assert.equal(res.status, 400, res.raw);
|
|
172
|
+
assert.equal(res.body.error.code, 'validation_failed');
|
|
173
|
+
assert.match(res.raw, new RegExp(`"field":"${forged}","reason":"unknown_field"`), `${forged} is named as unknown`);
|
|
174
|
+
assert.equal(lastCreateArgs, null, `${forged}: nothing was created`);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await ta('POST /instances: no tick is still a valid create — the CLI and older clients never showed the screen', async () => {
|
|
179
|
+
lastCreateArgs = null; hubVersion = '2026-08-29';
|
|
180
|
+
for (const body of [{}, { terms_acknowledged: false }]) {
|
|
181
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', ...body });
|
|
182
|
+
assert.equal(res.status, 201, res.raw);
|
|
183
|
+
assert.equal(lastCreateArgs.termsAck, null, `${JSON.stringify(body)} records nothing`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await ta('POST /instances: an instance with no hub keeps the tick with a NULL version', async () => {
|
|
188
|
+
lastCreateArgs = null; hubVersion = undefined;
|
|
189
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', terms_acknowledged: true });
|
|
190
|
+
assert.equal(res.status, 201, res.raw);
|
|
191
|
+
assert.deepEqual(lastCreateArgs.termsAck, { version: null });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await ta('POST /instances: terms_acknowledged must be a boolean — "yes" is refused, not read as a tick', async () => {
|
|
195
|
+
lastCreateArgs = null; hubVersion = '2026-08-29';
|
|
196
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', terms_acknowledged: 'yes' });
|
|
197
|
+
assert.equal(res.status, 400, res.raw);
|
|
198
|
+
assert.equal(res.body.error.code, 'validation_failed');
|
|
199
|
+
assert.equal(lastCreateArgs, null, 'nothing was created');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
server.close();
|
|
203
|
+
api.resolveOptional = realResolveOptional;
|
|
204
|
+
|
|
205
|
+
// ── the migration ─────────────────────────────────────────────────────────────
|
|
206
|
+
await ta('provisioning_033 adds both columns, nullable, and never names the account\'s acceptance columns', async () => {
|
|
207
|
+
const sql = fs.readFileSync(new URL('../modules/provisioning/migrations/provisioning_033_terms_ack.sql', import.meta.url), 'utf8');
|
|
208
|
+
const code = sql.replace(/--.*$/gm, '');
|
|
209
|
+
assert.match(code, /ADD COLUMN IF NOT EXISTS terms_ack_at timestamptz;/);
|
|
210
|
+
assert.match(code, /ADD COLUMN IF NOT EXISTS terms_ack_version text;/);
|
|
211
|
+
assert.doesNotMatch(code, /NOT NULL|DEFAULT|UPDATE /i, 'no backfill: a project that never saw the screen stays NULL');
|
|
212
|
+
assert.doesNotMatch(code, /\bterms_(?:accepted_at|version|accepted_source)\b/, 'the account\'s columns belong to terms-acceptance.js alone');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
216
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -71,7 +71,7 @@ function runStep({ authKnown, draftStep }) {
|
|
|
71
71
|
|
|
72
72
|
test('pre-auth: the placeholder renders step 1 but the draft KEEPS the real step', () => {
|
|
73
73
|
const { rendered, persisted } = runStep({ authKnown: false, draftStep: 5 });
|
|
74
|
-
assert.deepEqual(rendered, [1], 'still renders the
|
|
74
|
+
assert.deepEqual(rendered, [1], 'still renders step 1 (the terms, since task 1004417) before identity settles');
|
|
75
75
|
assert.equal(persisted.step, 5, 'the draft must not be overwritten with the placeholder');
|
|
76
76
|
});
|
|
77
77
|
|
|
@@ -90,9 +90,9 @@ test('post-auth: the real step is rendered', () => {
|
|
|
90
90
|
|
|
91
91
|
test('a draft already at step 1 is unchanged, and an out-of-range step is not resurrected', () => {
|
|
92
92
|
assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
|
|
93
|
-
//
|
|
94
|
-
// is a resumable wizard position, so the placeholder is allowed to stand.
|
|
95
|
-
for (const bad of [0,
|
|
93
|
+
// 11 is the done panel (9 before task 1004417's front door, 8 before task 1004351), reached by its own branch
|
|
94
|
+
// above this slice; 0 is the gate. Neither is a resumable wizard position, so the placeholder is allowed to stand.
|
|
95
|
+
for (const bad of [0, 11, 99]) {
|
|
96
96
|
assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
|
|
97
97
|
}
|
|
98
98
|
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// tests/wizard_front_door.mjs — the create wizard's front door (task 1004417, BV2.PS05;
|
|
2
|
+
// goal 1000121, docs/design/project-startup-direction.md "Front door").
|
|
3
|
+
//
|
|
4
|
+
// Two screens now stand in front of the wizard's numbered steps:
|
|
5
|
+
// 1 · terms — "anything you want to make": one tick, recorded on the project against
|
|
6
|
+
// the terms version the SERVER stamps (tests/provisioning_terms_ack.mjs holds that half);
|
|
7
|
+
// 2 · fork — "you are now creating a digital planet": the world's door pill, two equal
|
|
8
|
+
// doors, try the demo / begin planet creation. The demo is its own task (BV2.PS09), so
|
|
9
|
+
// its door says the demo is not open yet rather than leading anywhere.
|
|
10
|
+
// Sign-in stays where it was: the gate (step 0) comes first.
|
|
11
|
+
//
|
|
12
|
+
// Most of this RUNS the page's own script, the vm-slice idiom of tests/wizard_draft_resume.mjs:
|
|
13
|
+
// validateStep(), the front door's wiring, and show()'s class toggles are sliced out of the
|
|
14
|
+
// inline script and executed under a minimal DOM stub. Every slice boundary is asserted, so a
|
|
15
|
+
// rename fails loudly instead of leaving this file quietly testing nothing.
|
|
16
|
+
//
|
|
17
|
+
// projects.html is CRLF on a Windows checkout; this file normalises at read time.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/wizard_front_door.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
30
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
31
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
32
|
+
const STYLE = HUB.slice(HUB.indexOf('<style>'), HUB.indexOf('</style>'));
|
|
33
|
+
|
|
34
|
+
const panel = (id) => (HUB.match(new RegExp(`<section class="panel[^"]*" id="${id}"[\\s\\S]*?\\n {10}</section>`)) || [''])[0];
|
|
35
|
+
const TERMS = panel('panel-terms');
|
|
36
|
+
const FORK = panel('panel-fork');
|
|
37
|
+
const MAP = {};
|
|
38
|
+
for (const m of ((SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1]).matchAll(/(\d+):\s*'([a-z-]+)'/g)) MAP[m[2]] = Number(m[1]);
|
|
39
|
+
|
|
40
|
+
// ── the order ───────────────────────────────────────────────────────────────────
|
|
41
|
+
test('the front door stands between sign-in and the name step: gate 0, terms 1, fork 2, name 3', () => {
|
|
42
|
+
assert.equal(MAP['panel-gate'], 0, 'sign-in stays where it is today, first');
|
|
43
|
+
assert.equal(MAP['panel-terms'], 1);
|
|
44
|
+
assert.equal(MAP['panel-fork'], 2);
|
|
45
|
+
assert.equal(MAP['panel-name'], 3, 'and today\'s wizard continues unchanged after it');
|
|
46
|
+
assert.ok(HUB.indexOf('id="panel-gate"') < HUB.indexOf('id="panel-terms"')
|
|
47
|
+
&& HUB.indexOf('id="panel-terms"') < HUB.indexOf('id="panel-fork"')
|
|
48
|
+
&& HUB.indexOf('id="panel-fork"') < HUB.indexOf('id="panel-name"'), 'the markup reads in the same order');
|
|
49
|
+
assert.match(TERMS, /<form data-next="2">/, 'the terms go on to the fork');
|
|
50
|
+
assert.match(FORK, /<form data-next="3">/, 'the fork\'s primary door goes on to the name step');
|
|
51
|
+
assert.match(FORK, /data-back="1"/, 'and the way back is the terms');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the rail is the numbered steps\' — it steps aside on both front-door screens, statically too', () => {
|
|
55
|
+
const beats = [...HUB.matchAll(/<li[^>]*data-step="(\d+)"/g)].map((m) => Number(m[1]));
|
|
56
|
+
assert.deepEqual(beats, [3, 4, 5, 6, 7, 8, 9, 10], 'the rail numbers name…create, never the front door');
|
|
57
|
+
assert.match(HUB, /<div class="card wizFront">/, 'a no-script visitor on the static first screen sees no rail');
|
|
58
|
+
assert.match(STYLE, /#view-new \.card\.wizFront \.steps\{display:none;\}/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── screen 1 · the terms ────────────────────────────────────────────────────────
|
|
62
|
+
test('screen 1 is the canvas\'s "anything you want to make": two ledes, one tick, a read-the-terms link, one door', () => {
|
|
63
|
+
assert.ok(TERMS, '#panel-terms ships as real markup (C7)');
|
|
64
|
+
assert.match(TERMS, /^<section class="panel on" id="panel-terms"/, 'it carries the static `on` — step 1 for a no-script visitor');
|
|
65
|
+
assert.match(TERMS, /<h1 id="terms-h">Anything you want to make<\/h1>/);
|
|
66
|
+
assert.equal((TERMS.match(/<p class="lede">/g) || []).length, 2, 'two lede paragraphs');
|
|
67
|
+
assert.match(TERMS, /<input type="checkbox" id="termsAck" aria-describedby="h-terms">/, 'one checkbox, named by its hint');
|
|
68
|
+
assert.match(TERMS, /<label class="termsAck">\s*<input[^>]*>\s*<span>I can sign legally, or can get a signature, for the use of this platform, and I acknowledge the terms and conditions\.<\/span>/,
|
|
69
|
+
'the whole row is its label, so the target is the sentence, not a 20px box');
|
|
70
|
+
assert.match(TERMS, /<a class="termsRead" href="\/terms" target="_blank" rel="noopener">Read the terms<\/a>/,
|
|
71
|
+
'the platform\'s own /terms, in a new tab so the draft is not left behind');
|
|
72
|
+
assert.equal((TERMS.match(/type="submit"/g) || []).length, 1, 'one primary door');
|
|
73
|
+
assert.match(TERMS, /<span data-brand-product-inline>Cloud Bongos<\/span>/, 'the product name is the pack\'s, re-branded on a child');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('the page never names a terms version — the server stamps which text was acknowledged', () => {
|
|
77
|
+
// A version baked here would be a second copy of TERMS_VERSION that drifts, and worse, a
|
|
78
|
+
// value a caller could send. The create request carries only the tick.
|
|
79
|
+
assert.doesNotMatch(HUB, /terms_version|termsVersion|terms_ack_version/, 'no version travels from the page');
|
|
80
|
+
assert.doesNotMatch(TERMS, /\b20\d\d-\d\d-\d\d\b/, 'and none is printed on the screen');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the resume offer moved with step 1 — it lives on the terms screen, once', () => {
|
|
84
|
+
assert.equal((HUB.match(/id="wizResume"/g) || []).length, 1);
|
|
85
|
+
assert.ok(TERMS.includes('id="wizResume"'), 'a deliberate "New project" lands here, so the offer does too');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── the validator, executed ────────────────────────────────────────────────────
|
|
89
|
+
const VALIDATE_RE = /function validateStep\(step\) \{[\s\S]*?\n {4}\}/;
|
|
90
|
+
const validateSlice = (SCRIPT.match(VALIDATE_RE) || [null])[0];
|
|
91
|
+
|
|
92
|
+
function runValidate(step, termsAck) {
|
|
93
|
+
const els = {};
|
|
94
|
+
const $ = (id) => (els[id] = els[id] || { textContent: '', className: '', classList: { add() {} }, focus() {} });
|
|
95
|
+
const sandbox = { $, state: { termsAck, name: 'Mercury', projType: 'game' }, validateRender: () => true, repoAccess: true };
|
|
96
|
+
sandbox.globalThis = sandbox;
|
|
97
|
+
vm.createContext(sandbox);
|
|
98
|
+
vm.runInContext(`${validateSlice}\nvar ok = validateStep(${step});`, sandbox);
|
|
99
|
+
return { ok: sandbox.ok, hint: els['h-terms'] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test('validateStep is still findable, with the terms clause inside it', () => {
|
|
103
|
+
assert.ok(validateSlice, 'validateStep() was not found in projects.html');
|
|
104
|
+
assert.match(validateSlice, /if \(step === 1\) \{\s*if \(!state\.termsAck\)/, 'the clause is part of the slice');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('Proceed is refused until the box is ticked, and the refusal says why in the hint', () => {
|
|
108
|
+
const refused = runValidate(1, false);
|
|
109
|
+
assert.equal(refused.ok, false, 'no tick, no fork');
|
|
110
|
+
assert.equal(refused.hint.className, 'hint err');
|
|
111
|
+
assert.match(refused.hint.textContent, /Tick the box to go on/);
|
|
112
|
+
const given = runValidate(1, true);
|
|
113
|
+
assert.equal(given.ok, true, 'a tick goes on');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('the fork has no clause of its own — its doors are the answer', () => {
|
|
117
|
+
assert.equal(runValidate(2, false).ok, true, 'the fork asks nothing a validator could refuse');
|
|
118
|
+
assert.ok(!validateSlice.includes('if (step === 2) {'), 'and no clause for step 2 exists');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── the wiring, executed ───────────────────────────────────────────────────────
|
|
122
|
+
const WIRE_START = " $('termsAck').checked = !!state.termsAck;";
|
|
123
|
+
const WIRE_END = ' /* ── field wiring ── */';
|
|
124
|
+
const wireSlice = SCRIPT.includes(WIRE_START) ? SCRIPT.slice(SCRIPT.indexOf(WIRE_START), SCRIPT.indexOf(WIRE_END)) : null;
|
|
125
|
+
|
|
126
|
+
function runWiring(initialAck) {
|
|
127
|
+
const handlers = {};
|
|
128
|
+
const mk = (id) => ({
|
|
129
|
+
id, checked: false, hidden: id === 'demoSoon', textContent: '', className: '', attrs: {},
|
|
130
|
+
addEventListener(type, fn) { handlers[id + ':' + type] = fn; },
|
|
131
|
+
setAttribute(k, v) { this.attrs[k] = v; },
|
|
132
|
+
});
|
|
133
|
+
const els = { termsAck: mk('termsAck'), wizDemo: mk('wizDemo'), demoSoon: mk('demoSoon'), 'h-terms': mk('h-terms') };
|
|
134
|
+
let saves = 0;
|
|
135
|
+
const sandbox = { $: (id) => els[id], state: { termsAck: initialAck }, saveDraft: () => { saves += 1; } };
|
|
136
|
+
sandbox.globalThis = sandbox;
|
|
137
|
+
vm.createContext(sandbox);
|
|
138
|
+
vm.runInContext(wireSlice, sandbox);
|
|
139
|
+
return { els, handlers, sandbox, saves: () => saves };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test('the front door\'s wiring is still findable', () => {
|
|
143
|
+
assert.ok(wireSlice && wireSlice.length > 100, 'the wiring block was not found in projects.html');
|
|
144
|
+
assert.match(wireSlice, /\$\('wizDemo'\)\.addEventListener\('click'/, 'the demo door is part of the slice');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the tick mirrors into the draft both ways, and a resumed draft paints its own answer', () => {
|
|
148
|
+
const { els, handlers, sandbox, saves } = runWiring(true);
|
|
149
|
+
assert.equal(els.termsAck.checked, true, 'a draft that ticked comes back ticked (the GitHub round-trip keeps it)');
|
|
150
|
+
els.termsAck.checked = false; handlers['termsAck:change'].call(els.termsAck);
|
|
151
|
+
assert.equal(sandbox.state.termsAck, false, 'unticking is an answer too');
|
|
152
|
+
els['h-terms'].className = 'hint err';
|
|
153
|
+
els.termsAck.checked = true; handlers['termsAck:change'].call(els.termsAck);
|
|
154
|
+
assert.equal(sandbox.state.termsAck, true);
|
|
155
|
+
assert.equal(els['h-terms'].className, 'hint', 'ticking clears the refusal');
|
|
156
|
+
assert.equal(saves(), 2, 'each change is saved');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the demo door opens a line saying the demo is not open yet, and says so to a screen reader', () => {
|
|
160
|
+
const { els, handlers } = runWiring(false);
|
|
161
|
+
assert.equal(els.demoSoon.hidden, true, 'hidden at rest');
|
|
162
|
+
handlers['wizDemo:click'].call(els.wizDemo);
|
|
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');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── screen 2 · the fork ─────────────────────────────────────────────────────────
|
|
173
|
+
test('screen 2 is the door pill: two equal doors in one object, the accent only on the one that goes on', () => {
|
|
174
|
+
assert.match(FORK, /<h1 id="fork-h">You are now creating a digital planet<\/h1>/);
|
|
175
|
+
const pill = (FORK.match(/<div class="doorPill" role="group" aria-label="How to begin">[\s\S]*?<\/div>/) || [''])[0];
|
|
176
|
+
assert.ok(pill, 'one grouped pill');
|
|
177
|
+
const doors = [...pill.matchAll(/<button type="(button|submit)" class="([^"]+)"[^>]*>([^<]+)<\/button>/g)];
|
|
178
|
+
assert.equal(doors.length, 2, 'exactly two doors');
|
|
179
|
+
assert.deepEqual(doors.map((d) => d[3]), ['Try the demo', 'Begin planet creation']);
|
|
180
|
+
assert.equal(doors[0][2], 'btn big ghost', 'the demo door is the secondary: transparent at rest');
|
|
181
|
+
assert.equal(doors[1][2], 'btn big', 'begin carries the accent');
|
|
182
|
+
assert.equal(doors[1][1], 'submit', 'and is the form\'s one submit, so Enter goes on');
|
|
183
|
+
assert.match(STYLE, /\.doorPill\{display:inline-grid;grid-template-columns:1fr 1fr;/, 'two EQUAL tracks — the two routes are equal ways in');
|
|
184
|
+
assert.match(STYLE, /\.doorPill\{[^}]*border-radius:999px;/, 'pill or nothing');
|
|
185
|
+
assert.match(STYLE, /@media \(max-width:520px\)\{\s*\.doorPill\{display:grid;grid-template-columns:1fr;/, 'at a phone width the two stack, still one object');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
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');
|
|
190
|
+
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
|
+
assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-chrome-blob-one-cut.webp')), 'and it exists on disk');
|
|
192
|
+
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');
|
|
193
|
+
assert.match(STYLE, /\.forkOrbit\{[^}]*border:1px solid var\(--rule\);border-radius:50%;/, 'the orbits are hairline circles');
|
|
194
|
+
const at = STYLE.indexOf('the front door (task 1004417');
|
|
195
|
+
const block = STYLE.slice(at, STYLE.indexOf('/* a field:', at));
|
|
196
|
+
assert.ok(at > -1 && block.includes('.forkStage{'), 'the front door\'s CSS block is findable');
|
|
197
|
+
assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|box-shadow:(?!none)|linear-gradient/i, 'every colour a token, no shadow, no gradient');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the two routes are described honestly for today: the demo says coming soon, creation promises no questions that do not exist yet', () => {
|
|
201
|
+
const routes = (FORK.match(/<div class="forkRoutes">[\s\S]*$/) || [''])[0];
|
|
202
|
+
assert.match(routes, /Route 1 · demo · coming soon/);
|
|
203
|
+
assert.match(routes, /Route 2 · planet creation/);
|
|
204
|
+
assert.doesNotMatch(routes, /seven questions/i, 'the seven planet questions are PS07\'s — the copy must not promise them before they ship');
|
|
205
|
+
assert.doesNotMatch(FORK, /\bBongos\b(?! pair)/, 'no product name baked where a child instance would ship it');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── show(): the class toggles, executed ────────────────────────────────────────
|
|
209
|
+
const SHOW_RE = /function show\(step, moveFocus\) \{[\s\S]*?\n {4}\}/;
|
|
210
|
+
const showSlice = (SCRIPT.match(SHOW_RE) || [null])[0];
|
|
211
|
+
|
|
212
|
+
function runShow(step) {
|
|
213
|
+
const cls = (name) => { const set = new Set(); return { name, set, toggle(c, on) { if (on) set.add(c); else set.delete(c); } }; };
|
|
214
|
+
const card = cls('card'), split = cls('split');
|
|
215
|
+
const panels = {};
|
|
216
|
+
const $ = (id) => (panels[id] = panels[id] || { classList: cls(id), querySelector: () => null });
|
|
217
|
+
const sandbox = {
|
|
218
|
+
$, state: {}, panels: Object.fromEntries(Object.entries(MAP).map(([id, n]) => [n, id])),
|
|
219
|
+
root: { querySelector: (sel) => (sel === '.card' ? { classList: card } : { classList: split }) },
|
|
220
|
+
paintRail() {}, saveDraft() {}, authKnown: false, repoAccess: null,
|
|
221
|
+
renderModules() {}, syncHome() {}, ensureRepoAccess() {}, checkSlugAvailable() {}, renderAddrPanel() {},
|
|
222
|
+
syncAppStep() {}, syncRenderSetup() {}, renderReview() {},
|
|
223
|
+
};
|
|
224
|
+
sandbox.globalThis = sandbox;
|
|
225
|
+
vm.createContext(sandbox);
|
|
226
|
+
vm.runInContext(`${showSlice}\nshow(${step}, false);`, sandbox);
|
|
227
|
+
return { card: card.set, split: split.set, on: Object.keys(panels).filter((id) => panels[id].classList.set.has('on')) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
test('show() takes the rail away on the two front-door screens only, and gives the fork the whole card', () => {
|
|
231
|
+
assert.ok(showSlice, 'show() was not found in projects.html');
|
|
232
|
+
const terms = runShow(1), fork = runShow(2), name = runShow(3), done = runShow(MAP['panel-done']);
|
|
233
|
+
assert.deepEqual(terms.on, ['panel-terms']);
|
|
234
|
+
assert.ok(terms.card.has('wizFront') && !terms.split.has('wizFork'), 'terms: no rail, the bongo pair stays');
|
|
235
|
+
assert.ok(fork.card.has('wizFront') && fork.split.has('wizFork'), 'fork: no rail, and the seed takes the pair\'s place');
|
|
236
|
+
assert.ok(!name.card.has('wizFront') && !name.split.has('wizFork'), 'the name step has its rail back');
|
|
237
|
+
assert.ok(done.split.has('wizDone') && !done.card.has('wizFront'), 'the done panel is untouched by the front door');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── the create ──────────────────────────────────────────────────────────────────
|
|
241
|
+
test('the create carries the tick, and is never filed without it', () => {
|
|
242
|
+
const create = SCRIPT.slice(SCRIPT.indexOf("$('doCreate').addEventListener('click'"));
|
|
243
|
+
assert.match(create, /terms_acknowledged: state\.termsAck \? true : undefined,/,
|
|
244
|
+
'a real boolean when ticked, absent otherwise — the route refuses anything but a boolean');
|
|
245
|
+
const guard = create.indexOf('if (!state.termsAck) { show(1, true); validateStep(1); return; }');
|
|
246
|
+
assert.ok(guard > -1, 'an unticked draft is sent back to the terms');
|
|
247
|
+
assert.ok(guard < create.indexOf('ensureFreshRepo()'), 'BEFORE anything is created — the repo included');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('the tick is part of the draft and of the reset, and the draft key moved with the renumbering', () => {
|
|
251
|
+
assert.match(SCRIPT, /var state = \{ step: 0,[^}]*termsAck: false \};/, 'the initial state declares it');
|
|
252
|
+
assert.match(SCRIPT, /state = \{ step: 1,[^}]*termsAck: false \};/, 'and "start another project" clears it — each project records its own');
|
|
253
|
+
assert.match(SCRIPT, /var DRAFT_KEY = 'cb-create-draft-v6';/, 'a draft saved under the old numbering is ignored, not replayed onto the wrong screen');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── the look-before-you-ship kit walks it ─────────────────────────────────────
|
|
257
|
+
test('the kit has a state for each front-door screen, and every walk past it ticks and chooses first', () => {
|
|
258
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
259
|
+
for (const s of ['in-new', 'in-new-terms-refused', 'in-new-fork', 'in-new-fork-demo', 'in-new-name']) assert.ok(states[s], `the ${s} state exists`);
|
|
260
|
+
assert.deepEqual(states['in-new'].expect.visible, ['#panel-terms', '#termsAck'], 'arriving at ?view=new pictures the terms');
|
|
261
|
+
const FRONT = ['#termsAck', '#panel-terms button[type=submit]', '#panel-fork button[type=submit]'];
|
|
262
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
|
|
263
|
+
const walks = [...Object.entries(states).map(([k, v]) => [k, v.actions || []]), ...probes.map((p) => [p.name, p.steps])]
|
|
264
|
+
.filter(([, steps]) => steps.some((st) => st[1] === '#newName'));
|
|
265
|
+
assert.ok(walks.length >= 12, `every wizard walk is found (${walks.length})`);
|
|
266
|
+
for (const [name, steps] of walks) {
|
|
267
|
+
const clicks = steps.filter((st) => st[0] === 'click').map((st) => st[1]);
|
|
268
|
+
assert.deepEqual(clicks.slice(0, 3), FRONT, `${name}: the front door comes first`);
|
|
269
|
+
}
|
|
270
|
+
assert.ok(probes.some((p) => /the front door \(task 1004417\)/.test(p.name)), 'a probe drives the refusal, the demo door and the way on');
|
|
271
|
+
});
|