@bongos/core 1.20.83 → 1.21.1
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 +151 -61
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +153 -4
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +12 -12
- package/docs/design/project-startup-direction.md +63 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +6 -0
- package/docs/page-inventory.json +8 -3
- package/docs/page-readings.json +382 -373
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- package/modules/hall-ui/public/genesis-home.js +17 -3
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- package/modules/lifecycle/workflow-dispatch.js +21 -11
- package/modules/npm-release/release.js +10 -1
- package/modules/onboarding/founding-birth.js +116 -0
- package/modules/onboarding/genesis-home.js +72 -5
- package/modules/onboarding/port.js +9 -2
- package/modules/onboarding/routes/onboarding.js +8 -1
- package/modules/provisioning/birth.js +64 -0
- package/modules/provisioning/dns-resolves.js +54 -0
- package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
- package/modules/provisioning/pollers/liveness-sweep.js +5 -0
- package/modules/provisioning/provisioning.js +7 -6
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +48 -0
- package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
- package/modules/public-landing/public/projects.html +40 -15
- package/modules/sessions/db.js +58 -0
- package/modules/sessions/routes/sessions.js +97 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +35 -0
- package/scripts/gds/provision.js +77 -53
- package/scripts/gds/session-digest.js +11 -3
- package/scripts/gds/session-transcript-build.js +155 -0
- package/scripts/gds/session-transcript-upload.js +29 -0
- package/scripts/gds/ship-session-upload.js +6 -1
- package/scripts/gds/upgrade-commit-identity.js +32 -0
- package/scripts/gds/upgrade.js +1 -1
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/hub_map_orb_states.mjs +5 -1
- package/tests/npm_release_release.mjs +37 -2
- package/tests/projects_hub.mjs +5 -3
- package/tests/projects_hub_dns_ready.mjs +153 -0
- package/tests/projects_hub_pre_uat.mjs +9 -5
- package/tests/provision_dns_first.mjs +171 -0
- package/tests/provision_settings_apply.mjs +6 -1
- package/tests/provisioning_settings_env.mjs +49 -0
- package/tests/session_transcript.mjs +273 -0
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/upgrade_commit_identity.mjs +89 -0
- package/tests/wizard_demo.mjs +29 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// scripts/gds/session-transcript-build.js — the PURE transcript→full-transcript builder.
|
|
2
|
+
//
|
|
3
|
+
// ADR 0051 / task 1001004: every session is stored as its FULL per-turn content
|
|
4
|
+
// (user prompts, assistant text, tool calls AND their results), not just the
|
|
5
|
+
// metadata + 160-char snippets buildSessionDigest keeps. This is the sibling of
|
|
6
|
+
// session-digest-build.js and shares its scrubber: EVERY string leaves this file
|
|
7
|
+
// through scrubSecrets (modules/security/secret-scrub.js, the task-1003 scrubber),
|
|
8
|
+
// on the builder's box, before anything is uploaded. A string that reaches the
|
|
9
|
+
// output unscrubbed is a defect — keep that single choke point (`clean`).
|
|
10
|
+
//
|
|
11
|
+
// Bounded on purpose: each text field is capped and the whole transcript has a
|
|
12
|
+
// byte ceiling (head + tail kept, like buildSessionDigest's turn cap), so one
|
|
13
|
+
// runaway tool result cannot make an upload that the server must reject.
|
|
14
|
+
// Extended-thinking blocks are NOT captured: they are model-internal, often
|
|
15
|
+
// opaque, and ADR 0051 asks for what the builder asked and what they were told.
|
|
16
|
+
const { scrubSecrets } = require('../../modules/security/secret-scrub');
|
|
17
|
+
|
|
18
|
+
const MAX_FIELD_CHARS = 20000; // one prompt / reply / tool result / tool input
|
|
19
|
+
const MAX_TRANSCRIPT_BYTES = 12 * 1024 * 1024; // whole upload ceiling (the server enforces the same number)
|
|
20
|
+
const MAX_TRANSCRIPT_TURNS = 20000; // the route's maxItems; the builder trims to it so the server never has to refuse
|
|
21
|
+
const MAX_TURN_BYTES = 200 * 1024; // under the route's 256 KB per-turn cap, so a many-tool-call turn is shrunk here rather than refused there
|
|
22
|
+
const MAX_RAW_CHARS = 200000; // bound on what the scrubber is ever handed (entropy-sweep cost)
|
|
23
|
+
|
|
24
|
+
// clean — the one exit for every string: drop NULs (Postgres rejects them in
|
|
25
|
+
// jsonb), SCRUB, then cap. Scrub BEFORE the final cap so a credential straddling
|
|
26
|
+
// the cap boundary is redacted whole rather than sliced into a fragment too short
|
|
27
|
+
// for any scrub shape to recognise. The scrubber is only ever handed MAX_RAW_CHARS
|
|
28
|
+
// so a multi-MB tool result cannot make the entropy sweep unbounded.
|
|
29
|
+
function clean(text, extraSecrets) {
|
|
30
|
+
if (typeof text !== 'string' || text.length === 0) return undefined;
|
|
31
|
+
const flat = text.replace(/\0/g, '');
|
|
32
|
+
if (!flat) return undefined;
|
|
33
|
+
const bounded = flat.length > MAX_RAW_CHARS ? flat.slice(0, MAX_RAW_CHARS) : flat;
|
|
34
|
+
const scrubbed = scrubSecrets(bounded, extraSecrets);
|
|
35
|
+
if (!scrubbed) return undefined;
|
|
36
|
+
if (scrubbed.length <= MAX_FIELD_CHARS && flat.length === bounded.length) return scrubbed;
|
|
37
|
+
const omitted = (flat.length - bounded.length) + Math.max(0, scrubbed.length - MAX_FIELD_CHARS);
|
|
38
|
+
return `${scrubbed.slice(0, MAX_FIELD_CHARS)}…[+${omitted} chars]`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resultText(content) {
|
|
42
|
+
if (typeof content === 'string') return content;
|
|
43
|
+
if (Array.isArray(content)) {
|
|
44
|
+
return content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).filter(Boolean).join('\n');
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// shrinkTurn — cut every string field of an oversize turn to an even share of a
|
|
50
|
+
// fixed character budget (in place), marking the cut. Already scrubbed, so this
|
|
51
|
+
// only ever removes text.
|
|
52
|
+
function shrinkTurn(t) {
|
|
53
|
+
const fields = [];
|
|
54
|
+
if (typeof t.text === 'string') fields.push([t, 'text']);
|
|
55
|
+
for (const c of t.tool_calls || []) if (typeof c.input === 'string') fields.push([c, 'input']);
|
|
56
|
+
for (const r of t.tool_results || []) if (typeof r.text === 'string') fields.push([r, 'text']);
|
|
57
|
+
const cap = Math.max(200, Math.floor(40000 / Math.max(1, fields.length)));
|
|
58
|
+
for (const [o, k] of fields) {
|
|
59
|
+
if (o[k].length > cap) o[k] = `${o[k].slice(0, cap)}…[+${o[k].length - cap} chars]`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// buildSessionTranscript — parsed Claude Code transcript entries →
|
|
64
|
+
// { turns, turnCount, byteSize, truncated }. Pure; exported for tests.
|
|
65
|
+
function buildSessionTranscript(entries, opts = {}) {
|
|
66
|
+
const extra = opts.extraSecrets || [];
|
|
67
|
+
const maxBytes = opts.maxBytes || MAX_TRANSCRIPT_BYTES;
|
|
68
|
+
const maxTurns = opts.maxTurns || MAX_TRANSCRIPT_TURNS;
|
|
69
|
+
const turns = [];
|
|
70
|
+
let i = 0;
|
|
71
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
72
|
+
if (!e || typeof e !== 'object' || !e.message) continue;
|
|
73
|
+
const ts = typeof e.timestamp === 'string' ? e.timestamp : undefined;
|
|
74
|
+
const msg = e.message;
|
|
75
|
+
if (e.type === 'assistant') {
|
|
76
|
+
const row = { i, role: 'assistant', ts };
|
|
77
|
+
const texts = [];
|
|
78
|
+
const calls = [];
|
|
79
|
+
for (const c of Array.isArray(msg.content) ? msg.content : []) {
|
|
80
|
+
if (c && c.type === 'text' && typeof c.text === 'string') texts.push(c.text);
|
|
81
|
+
else if (c && c.type === 'tool_use') {
|
|
82
|
+
let input;
|
|
83
|
+
try { input = clean(JSON.stringify(c.input), extra); } catch (_) { input = undefined; }
|
|
84
|
+
calls.push({ name: String(c.name || '?'), input });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const text = clean(texts.join('\n'), extra);
|
|
88
|
+
if (text) row.text = text;
|
|
89
|
+
if (calls.length) row.tool_calls = calls;
|
|
90
|
+
if (!text && !calls.length) continue; // pure-thinking line: nothing readable
|
|
91
|
+
turns.push(row);
|
|
92
|
+
i++;
|
|
93
|
+
} else if (e.type === 'user') {
|
|
94
|
+
const row = { i, role: 'user', ts };
|
|
95
|
+
if (typeof msg.content === 'string') {
|
|
96
|
+
const text = clean(msg.content, extra);
|
|
97
|
+
if (text) row.text = text;
|
|
98
|
+
} else if (Array.isArray(msg.content)) {
|
|
99
|
+
const texts = [];
|
|
100
|
+
const results = [];
|
|
101
|
+
for (const c of msg.content) {
|
|
102
|
+
if (c && c.type === 'text' && typeof c.text === 'string') texts.push(c.text);
|
|
103
|
+
else if (c && c.type === 'tool_result') {
|
|
104
|
+
const r = { text: clean(resultText(c.content), extra) };
|
|
105
|
+
if (c.is_error) r.is_error = true;
|
|
106
|
+
results.push(r);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const text = clean(texts.join('\n'), extra);
|
|
110
|
+
if (text) row.text = text;
|
|
111
|
+
if (results.length) row.tool_results = results;
|
|
112
|
+
}
|
|
113
|
+
if (!row.text && !row.tool_results) continue;
|
|
114
|
+
turns.push(row);
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Byte ceiling: keep the head (what was asked) and the tail (how it ended),
|
|
120
|
+
// drop the middle, and SAY so — a silently shortened record reads as complete.
|
|
121
|
+
// Serialize each turn once; an oversize turn (many tool calls, each at the field
|
|
122
|
+
// cap) is shrunk field-by-field to fit, so the route's per-turn cap never refuses
|
|
123
|
+
// a transcript the builder could have trimmed. Sizes include the comma each turn
|
|
124
|
+
// costs in the array, and the total the 2 bytes of brackets, so this total is the
|
|
125
|
+
// SAME number the route measures against its ceiling.
|
|
126
|
+
const sizes = turns.map((t, idx) => {
|
|
127
|
+
let json = JSON.stringify(t);
|
|
128
|
+
let bytes = Buffer.byteLength(json, 'utf8');
|
|
129
|
+
if (bytes > MAX_TURN_BYTES) {
|
|
130
|
+
shrinkTurn(t);
|
|
131
|
+
json = JSON.stringify(t);
|
|
132
|
+
bytes = Buffer.byteLength(json, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
turns[idx] = t;
|
|
135
|
+
return bytes + 1;
|
|
136
|
+
});
|
|
137
|
+
let total = sizes.reduce((a, b) => a + b, 2);
|
|
138
|
+
let out = turns;
|
|
139
|
+
let truncated = false;
|
|
140
|
+
if (total > maxBytes || turns.length > maxTurns) {
|
|
141
|
+
truncated = true;
|
|
142
|
+
const headBudget = Math.floor(maxBytes * 0.6);
|
|
143
|
+
const tailBudget = maxBytes - headBudget;
|
|
144
|
+
let h = 0; let headEnd = 0;
|
|
145
|
+
const headCount = Math.floor(maxTurns * 0.6);
|
|
146
|
+
while (headEnd < turns.length && headEnd < headCount && h + sizes[headEnd] <= headBudget) h += sizes[headEnd++];
|
|
147
|
+
let t = 0; let tailStart = turns.length;
|
|
148
|
+
while (tailStart > headEnd && (turns.length - tailStart) < maxTurns - headEnd && t + sizes[tailStart - 1] <= tailBudget) t += sizes[--tailStart];
|
|
149
|
+
out = turns.slice(0, headEnd).concat(turns.slice(tailStart));
|
|
150
|
+
total = h + t + 2;
|
|
151
|
+
}
|
|
152
|
+
return { turns: out, turnCount: turns.length, byteSize: total, truncated };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { buildSessionTranscript, MAX_FIELD_CHARS, MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_TURNS };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// scripts/gds/session-transcript-upload.js — send a session's FULL scrubbed
|
|
2
|
+
// transcript to the corpus (ADR 0051, task 1001004).
|
|
3
|
+
//
|
|
4
|
+
// Shared by the SessionEnd hook (session-digest.js — fires for EVERY session) and
|
|
5
|
+
// the on-ship upload (ship-session-upload.js — covers the mid-session tail), so
|
|
6
|
+
// both build and send the transcript the same way. BEST-EFFORT and NON-FATAL like
|
|
7
|
+
// the digest upload beside it: returns a one-line outcome string, never throws.
|
|
8
|
+
// It must run AFTER the digest POST — the server creates the transcript row only
|
|
9
|
+
// from the caller's own session_records row.
|
|
10
|
+
const { buildSessionTranscript } = require('./session-transcript-build');
|
|
11
|
+
|
|
12
|
+
async function uploadSessionTranscript(api, sessionId, entries, extraSecrets) {
|
|
13
|
+
try {
|
|
14
|
+
const t = buildSessionTranscript(entries, { extraSecrets });
|
|
15
|
+
if (!t.turns.length) return 'transcript: nothing to store.';
|
|
16
|
+
const r = await api.sessions.putSessionsSidTranscript({
|
|
17
|
+
sid: sessionId,
|
|
18
|
+
body: { turns: t.turns, truncated: t.truncated },
|
|
19
|
+
});
|
|
20
|
+
if (r && r.ok) {
|
|
21
|
+
return `transcript: stored ${t.turns.length}/${t.turnCount} turns (${Math.round(t.byteSize / 1024)} KB${t.truncated ? ', truncated to the size ceiling' : ''}).`;
|
|
22
|
+
}
|
|
23
|
+
return `transcript: skipped (PUT ${r && r.status}${r && r.data && r.data.error ? ' ' + JSON.stringify(r.data.error) : ''}).`;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return `transcript: skipped — ${e && e.message ? e.message : e}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { uploadSessionTranscript };
|
|
@@ -13,6 +13,7 @@ const path = require('node:path');
|
|
|
13
13
|
const { cliClient, loadSession } = require('./cli-lib');
|
|
14
14
|
const { resolveTranscriptPath } = require('./ship-memory-sync.js');
|
|
15
15
|
const { apiErrorLine } = require('./ship-io.js');
|
|
16
|
+
const { uploadSessionTranscript } = require('./session-transcript-upload.js');
|
|
16
17
|
const { REPO_ROOT } = require('./ship-state.js');
|
|
17
18
|
|
|
18
19
|
// ---------- Session upload-on-ship (6D.1 / ADR 0027) ----------
|
|
@@ -268,11 +269,15 @@ async function uploadSessionOnShip(taskId) {
|
|
|
268
269
|
metrics: digest.metrics,
|
|
269
270
|
model_usage: digest.metrics.model_usage || {}, // per-model tokens → cost-plus reward (task 1005)
|
|
270
271
|
};
|
|
271
|
-
const
|
|
272
|
+
const api = await cliClient({ allowUnauthenticated: true });
|
|
273
|
+
const r = await api.sessions.postSessions({ body: payload });
|
|
272
274
|
if (r.ok) {
|
|
273
275
|
console.log(
|
|
274
276
|
` session upload: stored digest (${digest.metrics.assistant_turns} assistant turns, ${digest.totalTokens} tokens, ${digest.metrics.tool_error_count} tool errors, ${digest.metrics.harness_retry_count} harness retries).`
|
|
275
277
|
);
|
|
278
|
+
// ADR 0051: the full scrubbed transcript rides after the digest (the server
|
|
279
|
+
// creates it from the digest's row). Non-fatal — its outcome is a log line.
|
|
280
|
+
console.log(` session upload: ${await uploadSessionTranscript(api, sessionId, entries, extraSecrets)}`);
|
|
276
281
|
} else if (r.status === 401) {
|
|
277
282
|
recordUploadSkip(taskId, {
|
|
278
283
|
code: 'session_invalid',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/upgrade-commit-identity.js — WHO the pin commit is signed by when the project's
|
|
4
|
+
// git names nobody (task 1004514). Kept out of upgrade.js, which sits at the 1500-line cap.
|
|
5
|
+
//
|
|
6
|
+
// THE BUG. persistPin staged the new pin and ran `git commit`. In a repo with no
|
|
7
|
+
// user.name/user.email — none in the repo, none global for the account the upgrade runs as —
|
|
8
|
+
// git refuses ("Please tell me who you are"), so the move reported success with the pin
|
|
9
|
+
// STAGED but never committed. The next move then read its own leftovers as a dirty tree and
|
|
10
|
+
// spent its one automatic fix (wedge-remedy.js) committing them. Seen live on 2026-10-01: a
|
|
11
|
+
// throwaway hosted project on cloudbongos.com, and hermeslines-marketing, whose repo carries no
|
|
12
|
+
// identity while every scaffolded one does (provision-repo.js sets it at scaffold time).
|
|
13
|
+
//
|
|
14
|
+
// THE FIX IS A FALLBACK, NEVER AN OVERRIDE. A repo or account that names someone keeps
|
|
15
|
+
// signing as them. Only an empty answer gets the provisioner's own identity, the one the
|
|
16
|
+
// scaffold commits as, through git's AUTHOR/COMMITTER env — so nothing is written into the
|
|
17
|
+
// owner's git config, and the argv stays exactly what it was.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Env for `git commit` in `instanceDir`: null when git already names a committer there, else
|
|
21
|
+
* the provisioner's identity. Asks git itself, so repo, global and system config all count.
|
|
22
|
+
*/
|
|
23
|
+
function commitIdentityEnv(instanceDir, run, env = process.env) {
|
|
24
|
+
const r = run('git', ['config', 'user.email'], { cwd: instanceDir, encoding: 'utf8' });
|
|
25
|
+
if (r && !r.error && r.status === 0 && String(r.stdout || '').trim()) return null;
|
|
26
|
+
if (env.GIT_AUTHOR_EMAIL || env.GIT_COMMITTER_EMAIL) return null; // the caller already chose
|
|
27
|
+
const { provisionerBotName, provisionerBotEmail } = require('./instance-apex.js');
|
|
28
|
+
const name = provisionerBotName(), email = provisionerBotEmail(env);
|
|
29
|
+
return { ...env, GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { commitIdentityEnv };
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -352,7 +352,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
|
|
|
352
352
|
err(' ! could not stage the pin files — commit + push them by hand or the next deploy reverts this bump.');
|
|
353
353
|
return { applicable: true, committed: false, pushed: false, dirty, error: 'git add failed' };
|
|
354
354
|
}
|
|
355
|
-
const commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8' });
|
|
355
|
+
const idEnv = require('./upgrade-commit-identity').commitIdentityEnv(instanceDir, run), commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8', ...(idEnv ? { env: idEnv } : {}) }); // a repo naming nobody still commits (task 1004514)
|
|
356
356
|
if (!commit || commit.error || commit.status !== 0) {
|
|
357
357
|
err(' ! could not commit the pin — commit + push it by hand or the next deploy reverts this bump.');
|
|
358
358
|
return { applicable: true, committed: false, pushed: false, dirty, error: 'git commit failed' };
|
package/src/bongos/routes.js
CHANGED
|
@@ -93,6 +93,9 @@ function buildGdsRouter() {
|
|
|
93
93
|
if (
|
|
94
94
|
req.path === '/memory/sync' ||
|
|
95
95
|
req.path === '/sessions' ||
|
|
96
|
+
// ADR 0051 / task 1001004: the full-transcript upload (MB-scale), which
|
|
97
|
+
// mounts its own 16 MB parser in modules/sessions/routes/sessions.js.
|
|
98
|
+
/^\/sessions\/[A-Za-z0-9_-]+\/transcript$/.test(req.path) ||
|
|
96
99
|
req.path === '/llm-cache/store' ||
|
|
97
100
|
/^\/tasks\/\d+\/publish-branch$/.test(req.path) ||
|
|
98
101
|
// task 1003109: the ship-time task visual — a base64 image up to ~5.3 MB
|
package/src/branding.js
CHANGED
|
@@ -94,6 +94,15 @@ const ENV_OVERRIDES = [
|
|
|
94
94
|
['NIGHT_TINT', ['project', 'nightTint']],
|
|
95
95
|
['LOOK_ACCENT', ['project', 'lookAccent']],
|
|
96
96
|
['LOGO_URL', ['project', 'logoUrl']],
|
|
97
|
+
// The birth marker (task 1004506): set by the platform only on a project created
|
|
98
|
+
// through the startup flow, so a project made before that never has it (spec D8).
|
|
99
|
+
// FOUNDING_BIRTH is when the platform made the row; DEMO_PEOPLE / DEMO_HOURS are the
|
|
100
|
+
// demo's time box, empty for a real project. The onboarding module reads them once to
|
|
101
|
+
// start genesis (modules/onboarding/founding-birth.js). SERVER-ONLY: clientBranding
|
|
102
|
+
// publishes no `founding` block.
|
|
103
|
+
['FOUNDING_BIRTH', ['founding', 'birth']],
|
|
104
|
+
['DEMO_PEOPLE', ['founding', 'demoPeople']],
|
|
105
|
+
['DEMO_HOURS', ['founding', 'demoHours']],
|
|
97
106
|
];
|
|
98
107
|
|
|
99
108
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
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.
|
|
78
|
+
const CORE_VERSION = '1.21.1'; // 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');
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/founding_birth.mjs — A NEW PROJECT IS BORN INTO GENESIS, and a demo's genesis is
|
|
2
|
+
// expedited (task 1004506 / BV2.PS18, goal 1000121; spec step 5, D4, D8, D9).
|
|
3
|
+
//
|
|
4
|
+
// Done-when: a new real project and a new demo each open their hall already in genesis;
|
|
5
|
+
// the demo's decide stage needs no choices and its stages fit its time box; a
|
|
6
|
+
// pre-existing hall is unchanged. These tests RUN the instance half
|
|
7
|
+
// (modules/onboarding/founding-birth.js) over an in-memory project_settings store and
|
|
8
|
+
// fake lifecycle port: the birth hook runs once, is idempotent, never runs without the
|
|
9
|
+
// marker, and never after the project came alive. The hub half (the marker on the row
|
|
10
|
+
// and in web.env) is pinned in tests/provisioning_settings_env.mjs and
|
|
11
|
+
// tests/wizard_demo.mjs.
|
|
12
|
+
//
|
|
13
|
+
// Run: node --test tests/founding_birth.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const birth = require('../modules/onboarding/founding-birth.js');
|
|
21
|
+
const gh = require('../modules/onboarding/genesis-home.js');
|
|
22
|
+
const fm = require('../modules/onboarding/founding-mode.js');
|
|
23
|
+
|
|
24
|
+
const BORN = { born: true, demo: null };
|
|
25
|
+
const BORN_DEMO = { born: true, demo: { people: 3, hours_each: 4 } };
|
|
26
|
+
|
|
27
|
+
function world({ marker = BORN, settings = {}, withLifecycle = true } = {}) {
|
|
28
|
+
const store = new Map(Object.entries(settings));
|
|
29
|
+
const writes = [];
|
|
30
|
+
let seeds = 0;
|
|
31
|
+
const lifecycle = withLifecycle ? {
|
|
32
|
+
seedGenesis: async () => { seeds += 1; return { seeded: 5 }; },
|
|
33
|
+
genesisTasks: async () => ({}),
|
|
34
|
+
} : null;
|
|
35
|
+
const deps = () => ({
|
|
36
|
+
marker,
|
|
37
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
38
|
+
setSetting: async (k, v) => { writes.push(k); store.set(k, String(v)); return { key: k, value: String(v) }; },
|
|
39
|
+
lifecycle, government: null, now: new Date('2026-10-01T09:00:00Z'),
|
|
40
|
+
});
|
|
41
|
+
return { store, writes, deps, seeds: () => seeds };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── the marker ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
test('the marker: born only with a readable birth stamp; a demo box only inside its limits', () => {
|
|
47
|
+
assert.deepEqual(birth.birthMarker(undefined), { born: false, demo: null });
|
|
48
|
+
assert.deepEqual(birth.birthMarker({}), { born: false, demo: null });
|
|
49
|
+
assert.deepEqual(birth.birthMarker({ birth: 'whenever' }), { born: false, demo: null });
|
|
50
|
+
assert.deepEqual(birth.birthMarker({ birth: '2026-10-01T00:00:00.000Z' }), { born: true, demo: null });
|
|
51
|
+
assert.deepEqual(birth.birthMarker({ birth: '2026-10-01T00:00:00.000Z', demoPeople: '3', demoHours: '4' }),
|
|
52
|
+
{ born: true, demo: { people: 3, hours_each: 4 } });
|
|
53
|
+
assert.deepEqual(birth.birthMarker({ birth: '2026-10-01T00:00:00.000Z', demoPeople: '99', demoHours: '4' }),
|
|
54
|
+
{ born: true, demo: null }, 'a malformed box still founds, with the ordinary plan');
|
|
55
|
+
assert.deepEqual(birth.birthMarker({ demoPeople: '3', demoHours: '4' }), { born: false, demo: null },
|
|
56
|
+
'a demo box without a birth is not a birth');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── the birth hook ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
test('no marker: nothing runs, and the database is never read (a pre-existing hall is unchanged)', async () => {
|
|
62
|
+
const r = await birth.bornFounding({
|
|
63
|
+
marker: { born: false, demo: null },
|
|
64
|
+
getSetting: async () => { throw new Error('read without a marker'); },
|
|
65
|
+
setSetting: async () => { throw new Error('write without a marker'); },
|
|
66
|
+
});
|
|
67
|
+
assert.deepEqual(r, { ran: false, reason: 'no_marker' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a new real project is born deciding: founding on, stage decide, no plan, nothing filed', async () => {
|
|
71
|
+
const w = world();
|
|
72
|
+
const r = await birth.bornFounding(w.deps());
|
|
73
|
+
assert.deepEqual(r, { ran: true, started: true, plan: false, demo: false });
|
|
74
|
+
const mode = await fm.foundingModeFor({ getSetting: w.deps().getSetting, government: null });
|
|
75
|
+
assert.equal(mode.active, true);
|
|
76
|
+
assert.equal(mode.stage, 'decide');
|
|
77
|
+
assert.equal(w.store.get(gh.PLAN_KEY), undefined, 'a real founder settles their own plan');
|
|
78
|
+
assert.equal(w.seeds(), 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('idempotent: a second run (a restart, a settings push) changes nothing', async () => {
|
|
82
|
+
const w = world();
|
|
83
|
+
await birth.bornFounding(w.deps());
|
|
84
|
+
const before = new Map(w.store);
|
|
85
|
+
w.writes.length = 0;
|
|
86
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_started' });
|
|
87
|
+
assert.deepEqual(w.writes, [], 'no write at all');
|
|
88
|
+
assert.deepEqual([...w.store], [...before]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('never after the project came alive: genesis happens once', async () => {
|
|
92
|
+
const w = world({ settings: {
|
|
93
|
+
[fm.KEYS.ACTIVE]: 'false', [fm.KEYS.STARTED_AT]: '2026-09-01T00:00:00.000Z', [fm.KEYS.ENDED_AT]: '2026-09-10T00:00:00.000Z',
|
|
94
|
+
} });
|
|
95
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_alive' });
|
|
96
|
+
assert.deepEqual(w.writes, []);
|
|
97
|
+
assert.equal(await fm.foundingModeFor({ getSetting: w.deps().getSetting, government: null }), null, 'the hall stays alive');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a founding already under way (started by hand, mid-stage) is left exactly where it is', async () => {
|
|
101
|
+
const w = world({ marker: BORN_DEMO, settings: {
|
|
102
|
+
[fm.KEYS.ACTIVE]: 'true', [fm.KEYS.STAGE]: 'strategize', [fm.KEYS.STARTED_AT]: '2026-09-28T00:00:00.000Z',
|
|
103
|
+
} });
|
|
104
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_started' });
|
|
105
|
+
assert.deepEqual(w.writes, []);
|
|
106
|
+
assert.equal(w.seeds(), 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a new demo is born past decide: the plan is settled from the demo, and its stages are filed', async () => {
|
|
110
|
+
const w = world({ marker: BORN_DEMO });
|
|
111
|
+
const r = await birth.bornFounding(w.deps());
|
|
112
|
+
assert.deepEqual(r, { ran: true, started: true, plan: true, demo: true });
|
|
113
|
+
assert.equal(w.seeds(), 1, 'the five stages filed as tasks, once');
|
|
114
|
+
const plan = JSON.parse(w.store.get(gh.PLAN_KEY));
|
|
115
|
+
assert.equal(plan.government, 'monarchy', 'Monarchy / BDFL: the founder\'s one yes closes each stage (D4)');
|
|
116
|
+
assert.equal(plan.passes, 1);
|
|
117
|
+
assert.equal(plan.advisors, 'none');
|
|
118
|
+
assert.equal(plan.expected_builders, 3, 'the demo\'s people');
|
|
119
|
+
assert.deepEqual(plan.demo, { people: 3, hours_each: 4 });
|
|
120
|
+
const mode = await fm.foundingModeFor({ getSetting: w.deps().getSetting, government: null });
|
|
121
|
+
assert.equal(mode.stage, 'scope', 'the decide stage needs no choices: it is already behind');
|
|
122
|
+
// Again: nothing more.
|
|
123
|
+
w.writes.length = 0;
|
|
124
|
+
assert.deepEqual(await birth.bornFounding(w.deps()), { ran: false, reason: 'already_started' });
|
|
125
|
+
assert.deepEqual(w.writes, []);
|
|
126
|
+
assert.equal(w.seeds(), 1, 'never filed twice');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a demo whose plan could not be saved yet (no board) asks for a retry, and the retry settles it', async () => {
|
|
130
|
+
birth.resetBirthForTests();
|
|
131
|
+
const store = new Map();
|
|
132
|
+
let seeds = 0;
|
|
133
|
+
const base = {
|
|
134
|
+
marker: BORN_DEMO,
|
|
135
|
+
getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
|
|
136
|
+
setSetting: async (k, v) => { store.set(k, String(v)); },
|
|
137
|
+
government: null,
|
|
138
|
+
};
|
|
139
|
+
const first = await birth.ensureBirth({ ...base, lifecycle: null });
|
|
140
|
+
assert.equal(first.retry, true);
|
|
141
|
+
assert.equal(first.reason, 'no_board');
|
|
142
|
+
assert.equal(store.get(fm.KEYS.STAGE) || 'decide', 'decide');
|
|
143
|
+
const second = await birth.ensureBirth({ ...base, lifecycle: { seedGenesis: async () => { seeds += 1; return { seeded: 5 }; } } });
|
|
144
|
+
assert.deepEqual(second, { ran: true, started: false, plan: true, demo: true }, 'the retry saves the plan without restarting founding');
|
|
145
|
+
assert.equal(seeds, 1);
|
|
146
|
+
assert.equal(store.get(fm.KEYS.STAGE), 'scope');
|
|
147
|
+
birth.resetBirthForTests();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('once per process: concurrent first reads share ONE run', async () => {
|
|
151
|
+
birth.resetBirthForTests();
|
|
152
|
+
const w = world({ marker: BORN_DEMO });
|
|
153
|
+
const results = await Promise.all([birth.ensureBirth(w.deps()), birth.ensureBirth(w.deps()), birth.ensureBirth(w.deps())]);
|
|
154
|
+
assert.equal(results[0], results[1]);
|
|
155
|
+
assert.equal(results[1], results[2]);
|
|
156
|
+
assert.equal(w.seeds(), 1);
|
|
157
|
+
assert.equal(w.writes.filter((k) => k === fm.KEYS.STARTED_AT).length, 1, 'started once');
|
|
158
|
+
assert.equal(w.writes.filter((k) => k === gh.PLAN_KEY).length, 1, 'planned once');
|
|
159
|
+
// A later read reuses the settled run; it does not touch the store again.
|
|
160
|
+
w.writes.length = 0;
|
|
161
|
+
await birth.ensureBirth(w.deps());
|
|
162
|
+
assert.deepEqual(w.writes, []);
|
|
163
|
+
birth.resetBirthForTests();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a run that throws is forgotten, so a later read tries again; the read itself never fails', async () => {
|
|
167
|
+
birth.resetBirthForTests();
|
|
168
|
+
let fail = true;
|
|
169
|
+
const store = new Map();
|
|
170
|
+
const deps = {
|
|
171
|
+
marker: BORN,
|
|
172
|
+
getSetting: async (k) => { if (fail) throw new Error('db down'); return store.has(k) ? { value: store.get(k) } : null; },
|
|
173
|
+
setSetting: async (k, v) => { store.set(k, String(v)); },
|
|
174
|
+
};
|
|
175
|
+
assert.deepEqual(await birth.ensureBirth(deps), { ran: false, reason: 'error' });
|
|
176
|
+
fail = false;
|
|
177
|
+
assert.equal((await birth.ensureBirth(deps)).ran, true);
|
|
178
|
+
assert.equal(store.get(fm.KEYS.ACTIVE), 'true');
|
|
179
|
+
birth.resetBirthForTests();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── the wiring ───────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
test('the /me read (the port, no arguments) runs the birth first; a caller with its own deps does not', async () => {
|
|
185
|
+
const port = require('../modules/onboarding/port.js');
|
|
186
|
+
const orig = { ensure: birth.ensureBirth, read: fm.foundingModeFor };
|
|
187
|
+
const calls = [];
|
|
188
|
+
birth.ensureBirth = async () => { calls.push('birth'); };
|
|
189
|
+
fm.foundingModeFor = async (d) => { calls.push(d ? 'read:deps' : 'read'); return null; };
|
|
190
|
+
try {
|
|
191
|
+
await port.foundingModeFor();
|
|
192
|
+
assert.deepEqual(calls, ['birth', 'read'], 'the birth lands before the read, so the first hall paint is already founding');
|
|
193
|
+
calls.length = 0;
|
|
194
|
+
await port.foundingModeFor({ getSetting: async () => null });
|
|
195
|
+
assert.deepEqual(calls, ['read:deps']);
|
|
196
|
+
} finally {
|
|
197
|
+
birth.ensureBirth = orig.ensure;
|
|
198
|
+
fm.foundingModeFor = orig.read;
|
|
199
|
+
}
|
|
200
|
+
});
|
package/tests/founding_mode.mjs
CHANGED
|
@@ -278,7 +278,9 @@ test('/me carries founding_mode from the onboarding port, fail-open to null', ()
|
|
|
278
278
|
assert.match(ME, /onboarding\.foundingModeFor\(\)/);
|
|
279
279
|
const at = ME.indexOf('onboarding.foundingModeFor()');
|
|
280
280
|
assert.match(ME.slice(at, at + 200), /catch \(_\) \{ return null; \}/);
|
|
281
|
-
|
|
281
|
+
// The port delegates at call time; a no-argument read (me.js) first runs the guarded
|
|
282
|
+
// birth (task 1004506, tests/founding_birth.mjs).
|
|
283
|
+
assert.match(PORT, /foundingModeFor: async \(\.\.\.a\) => \{\s*if \(!a\.length\) await foundingBirth\.ensureBirth\(\);\s*return foundingMode\.foundingModeFor\(\.\.\.a\);/);
|
|
282
284
|
});
|
|
283
285
|
|
|
284
286
|
test('BOTH applyAccess callers pass it, or the index would never show the band', () => {
|
package/tests/genesis_home.mjs
CHANGED
|
@@ -91,6 +91,58 @@ test('a plan out of range is refused with the reason', () => {
|
|
|
91
91
|
assert.equal(gh.parsePlan({ expected_builders: 0 }).reason, 'bad_expected_builders');
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
// ── the demo's expedited plan (task 1004506) ────────────────────────────────
|
|
95
|
+
|
|
96
|
+
test('a demo\'s stages are minutes, fitted to a fifth of its time box (10 min to 2 h)', () => {
|
|
97
|
+
const sum = (m) => Object.values(m).reduce((a, b) => a + b, 0);
|
|
98
|
+
assert.deepEqual(gh.demoMinutes(4), { scope: 14, strategize: 10, charter: 10, legalize: 5, legislation: 10 },
|
|
99
|
+
'3 people × 4 hours: about 48 minutes of genesis, then building');
|
|
100
|
+
assert.deepEqual(Object.keys(gh.demoMinutes(1)), STAGES5);
|
|
101
|
+
for (const h of [1, 2, 4, 8, 20, 40]) {
|
|
102
|
+
const total = sum(gh.demoMinutes(h));
|
|
103
|
+
assert.ok(total <= h * 60 * 0.25, `${h}h: genesis (${total} min) leaves most of the time box for building`);
|
|
104
|
+
assert.ok(total >= 8 && total <= 125, `${h}h: ${total} min is inside the bounds`);
|
|
105
|
+
assert.ok(Object.values(gh.demoMinutes(h)).every((m) => Number.isInteger(m) && m >= 1));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the demo plan is the settled one: Monarchy / BDFL, one pass, no advisors, the demo\'s people', () => {
|
|
110
|
+
const r = gh.parsePlan(gh.demoPlan({ people: 3, hours_each: 4 }));
|
|
111
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
112
|
+
assert.equal(r.plan.government, 'monarchy');
|
|
113
|
+
assert.equal(r.plan.passes, 1);
|
|
114
|
+
assert.equal(r.plan.advisors, 'none');
|
|
115
|
+
assert.equal(r.plan.expected_builders, 3);
|
|
116
|
+
assert.equal(r.plan.existing, false);
|
|
117
|
+
assert.deepEqual(r.plan.demo, { people: 3, hours_each: 4 });
|
|
118
|
+
assert.deepEqual(r.plan.minutes, gh.demoMinutes(4));
|
|
119
|
+
assert.equal(gh.parsePlan({ demo: { people: 0, hours_each: 4 } }).reason, 'bad_demo');
|
|
120
|
+
assert.equal(gh.parsePlan({ minutes: { scope: 5 } }).reason, 'bad_minutes', 'every stage needs its minutes');
|
|
121
|
+
assert.equal(gh.parsePlan({}).plan.minutes, undefined, 'a real project\'s plan stays in days');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the plan route does not name the demo fields, so only the birth can write a demo plan', () => {
|
|
125
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
|
|
126
|
+
const schema = src.slice(src.indexOf("router.post('/founding/plan'"), src.indexOf("router.post('/founding/stages"));
|
|
127
|
+
assert.ok(schema.includes('validateOrRespond'));
|
|
128
|
+
assert.doesNotMatch(schema, /\bdemo\b|\bminutes\b/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a demo genesis reads in minutes: each stage due minutes after it began', () => {
|
|
132
|
+
const plan = gh.parsePlan(gh.demoPlan({ people: 2, hours_each: 4 })).plan;
|
|
133
|
+
const g = gh.shapeGenesis({
|
|
134
|
+
mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 },
|
|
135
|
+
plan, stageAt: { scope: '2026-10-01T09:00:00.000Z' },
|
|
136
|
+
});
|
|
137
|
+
assert.deepEqual(g.demo, { people: 2, hours_each: 4 });
|
|
138
|
+
assert.equal(g.stages[0].minutes, 14);
|
|
139
|
+
assert.equal(g.stages[0].due, '2026-10-01T09:14:00.000Z');
|
|
140
|
+
const real = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan, stageAt: { scope: '2026-10-01T09:00:00.000Z' } });
|
|
141
|
+
assert.equal(real.demo, null);
|
|
142
|
+
assert.equal(real.stages[0].minutes, null);
|
|
143
|
+
assert.equal(real.stages[0].due, '2026-10-08T09:00:00.000Z', 'a real project is unchanged: days');
|
|
144
|
+
});
|
|
145
|
+
|
|
94
146
|
// ── the flow, end to end over fakes ──────────────────────────────────────────
|
|
95
147
|
|
|
96
148
|
function world({ withGovernment = true } = {}) {
|
|
@@ -232,7 +284,7 @@ test('every genesis write is the project owner\'s; the read is any signed-in bui
|
|
|
232
284
|
|
|
233
285
|
const CARD = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis-home.js'), 'utf8');
|
|
234
286
|
|
|
235
|
-
function bootCard({ founding }) {
|
|
287
|
+
function bootCard({ founding, data = { genesis: null } }) {
|
|
236
288
|
const calls = [];
|
|
237
289
|
let widget = null;
|
|
238
290
|
let observer = null;
|
|
@@ -240,7 +292,7 @@ function bootCard({ founding }) {
|
|
|
240
292
|
const sandbox = {
|
|
241
293
|
window: {
|
|
242
294
|
OTB: { escapeHtml: (s) => String(s), contributeHallWidget: (w) => { widget = w; } },
|
|
243
|
-
BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data
|
|
295
|
+
BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data }; } }) },
|
|
244
296
|
},
|
|
245
297
|
document: { documentElement: { getAttribute: (k) => attrs[k] || null } },
|
|
246
298
|
MutationObserver: class { constructor(fn) { observer = fn; } observe() {} disconnect() {} },
|
|
@@ -277,6 +329,23 @@ test('founding mode stamped after mount still starts the card, once', async () =
|
|
|
277
329
|
assert.deepEqual(b.calls, ['GET /api/bongos/founding/genesis']);
|
|
278
330
|
});
|
|
279
331
|
|
|
332
|
+
test('a demo\'s genesis card: no decide form, the settled plan in one line, each stage in minutes (task 1004506)', async () => {
|
|
333
|
+
const genesis = gh.shapeGenesis({
|
|
334
|
+
mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 },
|
|
335
|
+
plan: gh.parsePlan(gh.demoPlan({ people: 3, hours_each: 4 })).plan,
|
|
336
|
+
stageAt: { scope: '2026-10-01T09:00:00.000Z' },
|
|
337
|
+
});
|
|
338
|
+
const { host } = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
|
|
339
|
+
await new Promise((r) => setImmediate(r));
|
|
340
|
+
await new Promise((r) => setImmediate(r));
|
|
341
|
+
assert.equal(host.hidden, false);
|
|
342
|
+
assert.match(host.innerHTML, /Monarchy \/ BDFL, 3 people, 4 hours each/);
|
|
343
|
+
assert.match(host.innerHTML, /about 49 minutes/);
|
|
344
|
+
assert.match(host.innerHTML, /14 min, due /, 'the stage in progress is due at a time, not a date');
|
|
345
|
+
assert.match(host.innerHTML, /Stage 1 of 5 · scope · demo/);
|
|
346
|
+
assert.doesNotMatch(host.innerHTML, /data-genesis-plan/, 'a demo never shows the decide form');
|
|
347
|
+
});
|
|
348
|
+
|
|
280
349
|
test('the hall carries the five life plates byte-identical to the world\'s', () => {
|
|
281
350
|
const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
|
|
282
351
|
const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
|
|
@@ -66,6 +66,10 @@ const PA_START = SCRIPT.indexOf('function planetAttrs(');
|
|
|
66
66
|
const PA_END = SCRIPT.indexOf('var PLANET_SHAPE_WORDS');
|
|
67
67
|
assert.ok(PA_START > 0 && PA_END > PA_START, 'planetAttrs must still be sliceable');
|
|
68
68
|
const PLANET_ATTRS = SCRIPT.slice(PA_START, PA_END);
|
|
69
|
+
// The row's site door (task 1004505) and the rule it asks, sliced the same way.
|
|
70
|
+
const ADDRESS_READY = (SCRIPT.match(/function addressReady\(inst\) \{[^\n]*\}/) || [''])[0];
|
|
71
|
+
const SITE_HREF = (SCRIPT.match(/function siteHref\(p\) \{[^\n]*\}/) || [''])[0];
|
|
72
|
+
assert.ok(ADDRESS_READY && SITE_HREF, 'addressReady and siteHref must still be sliceable');
|
|
69
73
|
|
|
70
74
|
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
71
75
|
// Narrow on purpose: it supports exactly what the region touches, so it stays
|
|
@@ -111,7 +115,7 @@ function boot() {
|
|
|
111
115
|
listedDate: (iso) => String(iso),
|
|
112
116
|
};
|
|
113
117
|
vm.createContext(sandbox);
|
|
114
|
-
vm.runInContext(PLANET_ATTRS + '\n' + REGION, sandbox);
|
|
118
|
+
vm.runInContext(PLANET_ATTRS + '\n' + ADDRESS_READY + '\n' + SITE_HREF + '\n' + REGION, sandbox);
|
|
115
119
|
return {
|
|
116
120
|
sandbox,
|
|
117
121
|
sky,
|