@bongos/core 1.25.14 → 1.25.16
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 +101 -41
- package/.claude/skills/goal-create/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +5 -4
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +20 -0
- package/clients/bongos-client/index.cjs +20 -0
- package/clients/bongos-client/index.d.ts +24 -0
- package/clients/bongos-client/index.mjs +20 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +61 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +544 -2
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +120 -109
- package/docs/copy-registry.json +275 -165
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +107 -107
- package/docs/project-context.template.md +1 -0
- package/modules/hall-ui/public/dom-utils.js +5 -0
- package/modules/hall-ui/public/genesis-home.js +23 -8
- package/modules/hall-ui/public/setup-gate.js +1 -1
- package/modules/hall-ui/public/style.css +21 -1
- package/modules/lifecycle/kickoff-checklist.js +18 -4
- package/modules/lifecycle/kickoff-seed.js +27 -4
- package/modules/onboarding/genesis-home.js +11 -2
- package/modules/onboarding/genesis-work.js +387 -0
- package/modules/onboarding/migrations/onboarding_002_genesis_work.sql +76 -0
- package/modules/onboarding/routes/genesis-work.js +85 -0
- package/modules/onboarding/routes/onboarding.js +3 -0
- package/modules/public-landing/public/assets/guide-scene.css +245 -0
- package/modules/public-landing/public/assets/guide-scene.js +566 -0
- package/modules/public-landing/public/index.html +62 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/genesis.js +180 -0
- package/scripts/bongos/run-unit-tests.js +3 -0
- package/scripts/gds/genesis.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/genesis_home.mjs +70 -2
- package/tests/genesis_work.mjs +304 -0
- package/tests/genesis_work_db.mjs +73 -0
- package/tests/genesis_work_routes.mjs +130 -0
- package/tests/landing_guide_scene.mjs +504 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// tests/genesis_work.mjs — the founder's own work on a genesis stage (task 1005116)
|
|
2
|
+
//
|
|
3
|
+
// The DB-free half. It drives modules/onboarding/genesis-work.js against an in-memory
|
|
4
|
+
// store with the same four-method contract as the Postgres one, so the rules run
|
|
5
|
+
// everywhere: three doors onto ONE record (switching loses nothing), guided is the
|
|
6
|
+
// default, an answer is stored as an answer, "not sure yet" / skipped / empty are three
|
|
7
|
+
// different things, un-answering goes back to empty, a revision keeps its history, a
|
|
8
|
+
// carried-over answer never overwrites the founder, and the read path a planning
|
|
9
|
+
// session uses says what the founder said. Plus the assisted door's brief and the CLI's
|
|
10
|
+
// argument parsing.
|
|
11
|
+
//
|
|
12
|
+
// The SAME contract runs against Postgres in tests/genesis_work_db.mjs (integration lane),
|
|
13
|
+
// which imports `contract` from here. The tests below run only when this file is the
|
|
14
|
+
// entry point.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/genesis_work.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import url from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const gw = require('../modules/onboarding/genesis-work.js');
|
|
24
|
+
const { parseAnswer, formatStage } = require('../scripts/bongos/genesis.js');
|
|
25
|
+
|
|
26
|
+
// An in-memory store honouring the contract pgStore implements.
|
|
27
|
+
export function memStore() {
|
|
28
|
+
const work = new Map();
|
|
29
|
+
const answers = new Map();
|
|
30
|
+
const history = [];
|
|
31
|
+
let clock = 0;
|
|
32
|
+
const tick = () => new Date(1.7e12 + ++clock * 1000);
|
|
33
|
+
return {
|
|
34
|
+
work, answers, history,
|
|
35
|
+
async loadAll() { return { work: [...work.values()], answers: [...answers.values()] }; },
|
|
36
|
+
async upsertWork(stage, patch, by) {
|
|
37
|
+
const cur = work.get(stage) || { stage, door: 'guided', page_md: null, link_url: null };
|
|
38
|
+
work.set(stage, { ...cur, ...patch, updated_by: by, updated_at: tick() });
|
|
39
|
+
},
|
|
40
|
+
async writeAnswer(stage, q, v, by, { ifEmpty = false } = {}) {
|
|
41
|
+
const k = `${stage}|${q}`;
|
|
42
|
+
const cur = answers.get(k);
|
|
43
|
+
if (cur && ifEmpty) return { changed: false };
|
|
44
|
+
if (cur && cur.state === v.state && cur.answer_md === v.answer_md && cur.origin === v.origin) return { changed: false };
|
|
45
|
+
answers.set(k, { stage, question_id: q, ...v, updated_by: by, updated_at: tick() });
|
|
46
|
+
history.push({ stage, question_id: q, state: v.state, answer_md: v.answer_md, origin: v.origin, changed_by: by, changed_at: tick() });
|
|
47
|
+
return { changed: true };
|
|
48
|
+
},
|
|
49
|
+
async clearAnswer(stage, q, by) {
|
|
50
|
+
const k = `${stage}|${q}`;
|
|
51
|
+
if (!answers.has(k)) return { cleared: false };
|
|
52
|
+
answers.delete(k);
|
|
53
|
+
history.push({ stage, question_id: q, state: null, answer_md: null, origin: null, changed_by: by, changed_at: tick() });
|
|
54
|
+
return { cleared: true };
|
|
55
|
+
},
|
|
56
|
+
async history(stage, q) {
|
|
57
|
+
return history.filter((h) => h.stage === stage && h.question_id === q).reverse();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const qOf = (r, id) => r.work.questions.find((q) => q.id === id);
|
|
63
|
+
|
|
64
|
+
// The contract both stores must satisfy. `deps` carries the store; `A` is a builder id.
|
|
65
|
+
export async function contract(test, label, deps, A) {
|
|
66
|
+
await test(`${label}: an untouched stage reads guided, every question empty`, async () => {
|
|
67
|
+
const r = await gw.readStage('strategize', deps);
|
|
68
|
+
assert.equal(r.ok, true);
|
|
69
|
+
assert.equal(r.work.door, 'guided', 'guided is the default door');
|
|
70
|
+
assert.equal(r.work.adopted, true);
|
|
71
|
+
assert.deepEqual(r.work.counts, { total: 4, answered: 0, not_sure: 0, skipped: 0, empty: 4 });
|
|
72
|
+
assert.ok(r.work.questions.every((q) => q.answer === null));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await test(`${label}: an answer is stored as an answer, with its words and origin`, async () => {
|
|
76
|
+
const r = await gw.setAnswer('strategize', 'big_pieces', { text: ' Ledger first, then the importer, then the view. ' }, A, deps);
|
|
77
|
+
assert.equal(r.changed, true);
|
|
78
|
+
const a = qOf(r, 'big_pieces').answer;
|
|
79
|
+
assert.equal(a.state, 'answered');
|
|
80
|
+
assert.equal(a.text, 'Ledger first, then the importer, then the view.');
|
|
81
|
+
assert.equal(a.origin, 'founder');
|
|
82
|
+
assert.equal(a.carried_over, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await test(`${label}: "not sure yet", skipped and empty are three different states`, async () => {
|
|
86
|
+
await gw.setAnswer('strategize', 'risks', { state: 'not_sure', text: 'depends on the bank API' }, A, deps);
|
|
87
|
+
await gw.setAnswer('strategize', 'try_first', { state: 'skipped' }, A, deps);
|
|
88
|
+
const r = await gw.readStage('strategize', deps);
|
|
89
|
+
assert.equal(qOf(r, 'risks').answer.state, 'not_sure');
|
|
90
|
+
assert.equal(qOf(r, 'risks').answer.text, 'depends on the bank API');
|
|
91
|
+
assert.equal(qOf(r, 'try_first').answer.state, 'skipped');
|
|
92
|
+
assert.equal(qOf(r, 'change_course').answer, null, 'never touched is empty, not skipped');
|
|
93
|
+
assert.deepEqual(r.work.counts, { total: 4, answered: 1, not_sure: 1, skipped: 1, empty: 1 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test(`${label}: revising replaces the answer and keeps what it used to say`, async () => {
|
|
97
|
+
const r = await gw.setAnswer('strategize', 'risks', { text: 'The bank API may not allow read-only access.' }, A, deps);
|
|
98
|
+
assert.equal(qOf(r, 'risks').answer.state, 'answered');
|
|
99
|
+
const h = await gw.answerHistory('strategize', 'risks', deps);
|
|
100
|
+
assert.equal(h.ok, true);
|
|
101
|
+
assert.deepEqual(h.history.map((x) => x.state), ['answered', 'not_sure'], 'newest first');
|
|
102
|
+
assert.equal(h.history[1].text, 'depends on the bank API');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await test(`${label}: a write that changes nothing writes nothing`, async () => {
|
|
106
|
+
const r = await gw.setAnswer('strategize', 'risks', { text: 'The bank API may not allow read-only access.' }, A, deps);
|
|
107
|
+
assert.equal(r.changed, false);
|
|
108
|
+
assert.equal((await gw.answerHistory('strategize', 'risks', deps)).history.length, 2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test(`${label}: un-answering goes back to EMPTY (not skipped) and is recorded`, async () => {
|
|
112
|
+
const r = await gw.clearAnswer('strategize', 'try_first', A, deps);
|
|
113
|
+
assert.equal(r.cleared, true);
|
|
114
|
+
assert.equal(qOf(r, 'try_first').answer, null);
|
|
115
|
+
assert.equal(r.work.counts.skipped, 0);
|
|
116
|
+
assert.equal(r.work.counts.empty, 2);
|
|
117
|
+
const h = await gw.answerHistory('strategize', 'try_first', deps);
|
|
118
|
+
assert.equal(h.history[0].state, 'cleared');
|
|
119
|
+
const again = await gw.clearAnswer('strategize', 'try_first', A, deps);
|
|
120
|
+
assert.equal(again.cleared, false, 'clearing an empty question is a no-op');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test(`${label}: switching doors keeps the answers, the page and the link`, async () => {
|
|
124
|
+
await gw.setDoor('strategize', 'open', A, deps);
|
|
125
|
+
await gw.setPage('strategize', 'We bet on the ledger. Everything else waits.', A, deps);
|
|
126
|
+
await gw.setLink('strategize', 'https://example.com/strategy', A, deps);
|
|
127
|
+
let r = await gw.setDoor('strategize', 'assisted', A, deps);
|
|
128
|
+
assert.equal(r.work.door, 'assisted');
|
|
129
|
+
r = await gw.setDoor('strategize', 'guided', A, deps);
|
|
130
|
+
assert.equal(r.work.page, 'We bet on the ledger. Everything else waits.');
|
|
131
|
+
assert.equal(r.work.link, 'https://example.com/strategy');
|
|
132
|
+
assert.equal(r.work.counts.answered, 2, 'answers survive every switch');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test(`${label}: a carried-over answer fills only what is empty, and the founder's edit makes it theirs`, async () => {
|
|
136
|
+
const kept = await gw.setAnswer('strategize', 'big_pieces', { text: 'from the pre-sign-up chat', origin: 'carried_over' }, null, deps, { ifEmpty: true });
|
|
137
|
+
assert.equal(kept.changed, false);
|
|
138
|
+
assert.equal(qOf(kept, 'big_pieces').answer.origin, 'founder', 'never overwrites the founder');
|
|
139
|
+
const filled = await gw.setAnswer('strategize', 'change_course', { text: 'If nobody connects a bank in a month.', origin: 'carried_over' }, null, deps, { ifEmpty: true });
|
|
140
|
+
assert.equal(qOf(filled, 'change_course').answer.carried_over, true);
|
|
141
|
+
const mine = await gw.setAnswer('strategize', 'change_course', { text: 'If nobody connects a bank in two months.' }, A, deps);
|
|
142
|
+
assert.equal(qOf(mine, 'change_course').answer.origin, 'founder');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test(`${label}: the read path a planning session uses says what the founder said`, async () => {
|
|
146
|
+
const { markdown, stages } = await gw.readContext(deps);
|
|
147
|
+
assert.equal(stages.length, 5);
|
|
148
|
+
assert.match(markdown, /^# What the founder has said about this project/);
|
|
149
|
+
assert.match(markdown, /## Strategize/);
|
|
150
|
+
assert.match(markdown, /Ledger first, then the importer, then the view\./);
|
|
151
|
+
assert.match(markdown, /We bet on the ledger/);
|
|
152
|
+
assert.match(markdown, /https:\/\/example\.com\/strategy/);
|
|
153
|
+
assert.doesNotMatch(markdown, /## Scope/, 'a stage with nothing in it is left out');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── the scope interview (task 1005114) ──
|
|
157
|
+
await test(`${label}: scope's interview is its guided door: six questions, all empty, guided first`, async () => {
|
|
158
|
+
const r = await gw.readStage('scope', deps);
|
|
159
|
+
assert.equal(r.work.door, 'guided');
|
|
160
|
+
assert.deepEqual(r.work.questions.map((q) => q.id),
|
|
161
|
+
['what_creating', 'goal_impact', 'success_two_years', 'reach_who', 'work_with_who', 'others_wrong']);
|
|
162
|
+
assert.deepEqual([...new Set(r.work.questions.map((q) => q.group))], ['What and why', 'Who', 'The bet']);
|
|
163
|
+
assert.equal(r.work.counts.empty, 6);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test(`${label}: scope stores answers, "not sure yet", a skip, an un-answer and a revision`, async () => {
|
|
167
|
+
await gw.setAnswer('scope', 'what_creating', { text: 'A live picture of each business you run.' }, A, deps);
|
|
168
|
+
await gw.setAnswer('scope', 'success_two_years', { state: 'not_sure', text: 'depends on whether others want it' }, A, deps);
|
|
169
|
+
await gw.setAnswer('scope', 'work_with_who', { state: 'skipped' }, A, deps);
|
|
170
|
+
await gw.setAnswer('scope', 'others_wrong', { text: 'They value a business from the outside.' }, A, deps);
|
|
171
|
+
await gw.clearAnswer('scope', 'work_with_who', A, deps);
|
|
172
|
+
let r = await gw.setAnswer('scope', 'what_creating', { text: 'A live picture of each business you run, and your worth across them.' }, A, deps);
|
|
173
|
+
assert.deepEqual(r.work.counts, { total: 6, answered: 2, not_sure: 1, skipped: 0, empty: 3 });
|
|
174
|
+
assert.equal(qOf(r, 'work_with_who').answer, null, 'un-answered is empty, not skipped');
|
|
175
|
+
const h = await gw.answerHistory('scope', 'what_creating', deps);
|
|
176
|
+
assert.equal(h.history.length, 2, 'the founder can see what the answer used to say');
|
|
177
|
+
assert.equal(h.history[1].text, 'A live picture of each business you run.');
|
|
178
|
+
r = await gw.setAnswer('scope', 'success_two_years', { text: 'Three other founders run their estate on it.' }, A, deps);
|
|
179
|
+
assert.equal(qOf(r, 'success_two_years').answer.state, 'answered', '"not sure yet" is easy to come back to');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test(`${label}: a session planning a goal reads the scope answers, scope first`, async () => {
|
|
183
|
+
const { markdown } = await gw.readContext(deps);
|
|
184
|
+
assert.ok(markdown.indexOf('## Scope') < markdown.indexOf('## Strategize'), 'genesis order');
|
|
185
|
+
assert.match(markdown, /In a few sentences, what are you creating with this project\?\*\*\n A live picture of each business you run, and your worth across them\./);
|
|
186
|
+
assert.match(markdown, /Three other founders run their estate on it\./);
|
|
187
|
+
assert.match(markdown, /They value a business from the outside\./);
|
|
188
|
+
assert.doesNotMatch(markdown, /Who do you want working on it with you/, 'an empty question is not printed');
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
|
|
193
|
+
let passed = 0;
|
|
194
|
+
let failed = 0;
|
|
195
|
+
const test = async (name, fn) => {
|
|
196
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
197
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
await contract(test, 'memory', { store: memStore() }, 7);
|
|
201
|
+
|
|
202
|
+
await test('refusals: unknown stage / question, bad state / origin / door / link, empty answer', async () => {
|
|
203
|
+
const deps = { store: memStore() };
|
|
204
|
+
assert.equal((await gw.readStage('decide', deps)).reason, 'unknown_stage');
|
|
205
|
+
assert.equal((await gw.setAnswer('strategize', 'nope', { text: 'x' }, 1, deps)).reason, 'unknown_question');
|
|
206
|
+
assert.equal((await gw.setAnswer('strategize', 'risks', { state: 'maybe' }, 1, deps)).reason, 'bad_state');
|
|
207
|
+
assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'x', origin: 'model' }, 1, deps)).reason, 'bad_origin');
|
|
208
|
+
assert.equal((await gw.setAnswer('strategize', 'risks', { text: ' ' }, 1, deps)).reason, 'empty_answer');
|
|
209
|
+
assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'x'.repeat(4001) }, 1, deps)).reason, 'answer_too_long');
|
|
210
|
+
assert.equal((await gw.setDoor('strategize', 'autopilot', 1, deps)).reason, 'bad_door');
|
|
211
|
+
assert.equal((await gw.setLink('strategize', 'javascript:alert(1)', 1, deps)).reason, 'bad_link');
|
|
212
|
+
assert.equal((await gw.clearAnswer('strategize', 'nope', 1, deps)).reason, 'unknown_question');
|
|
213
|
+
assert.equal(deps.store.answers.size, 0, 'no refusal wrote anything');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await test('"not sure yet" needs no words; a skip may carry a note', async () => {
|
|
217
|
+
const deps = { store: memStore() };
|
|
218
|
+
const r = await gw.setAnswer('strategize', 'risks', { state: 'not_sure' }, 1, deps);
|
|
219
|
+
assert.equal(qOf(r, 'risks').answer.state, 'not_sure');
|
|
220
|
+
assert.equal(qOf(r, 'risks').answer.text, null);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('a stage that has not adopted questions still takes a page and a link', async () => {
|
|
224
|
+
const deps = { store: memStore() };
|
|
225
|
+
const r = await gw.setPage('charter', 'Monarchy to start.', 1, deps);
|
|
226
|
+
assert.equal(r.work.adopted, false);
|
|
227
|
+
assert.equal(r.work.page, 'Monarchy to start.');
|
|
228
|
+
assert.equal((await gw.setAnswer('charter', 'anything', { text: 'x' }, 1, deps)).reason, 'unknown_question');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('an empty project gives the planning read path nothing to print', async () => {
|
|
232
|
+
assert.equal((await gw.readContext({ store: memStore() })).markdown, '');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await test('the read path labels not-sure, skipped, assisted and carried-over answers', async () => {
|
|
236
|
+
const deps = { store: memStore() };
|
|
237
|
+
await gw.setAnswer('strategize', 'risks', { state: 'not_sure', text: 'waiting on the bank' }, 1, deps);
|
|
238
|
+
await gw.setAnswer('strategize', 'try_first', { state: 'skipped' }, 1, deps);
|
|
239
|
+
await gw.setAnswer('strategize', 'big_pieces', { text: 'Ledger, then view.', origin: 'assisted' }, 1, deps);
|
|
240
|
+
await gw.setAnswer('strategize', 'change_course', { text: 'No users.', origin: 'carried_over' }, null, deps);
|
|
241
|
+
const md = (await gw.readContext(deps)).markdown;
|
|
242
|
+
assert.match(md, /Not sure yet — waiting on the bank/);
|
|
243
|
+
assert.match(md, /Skipped by the founder/);
|
|
244
|
+
assert.match(md, /worked out with an AI session, accepted by the founder/);
|
|
245
|
+
assert.match(md, /carried over from before sign-up, not yet confirmed/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('the assisted door works the stage\'s own questions and leaves the founder the author', async () => {
|
|
249
|
+
const p = gw.assistPrompt('strategize');
|
|
250
|
+
assert.match(p, /I am the author; you are my assistant/);
|
|
251
|
+
for (const q of gw.QUESTIONS.strategize) assert.ok(p.includes(q.ask), `asks: ${q.ask}`);
|
|
252
|
+
assert.match(p, /Push back/);
|
|
253
|
+
assert.match(p, /Not sure yet/i);
|
|
254
|
+
assert.match(p, /npx bongos genesis answer strategize <question-id> "<my words>" --origin assisted/);
|
|
255
|
+
assert.equal(gw.assistPrompt('decide'), null);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await test('the assisted door for scope walks the same interview, ids included, and drafts only from the answers', async () => {
|
|
259
|
+
const p = gw.assistPrompt('scope');
|
|
260
|
+
for (const q of gw.QUESTIONS.scope) assert.ok(p.includes(`[${q.id}] ${q.ask}`), `asks: ${q.id}`);
|
|
261
|
+
assert.match(p, /draft page built only from my answers/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await test('CLI: the scope interview prints under its group headings', async () => {
|
|
265
|
+
const deps = { store: memStore() };
|
|
266
|
+
const r = await gw.readStage('scope', deps);
|
|
267
|
+
const out = formatStage(r.work);
|
|
268
|
+
assert.ok(out.indexOf('— What and why —') < out.indexOf('— Who —'));
|
|
269
|
+
assert.ok(out.indexOf('— Who —') < out.indexOf('— The bet —'));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await test('question ids are unique and fit the stored shape', async () => {
|
|
273
|
+
for (const [stage, qs] of Object.entries(gw.QUESTIONS)) {
|
|
274
|
+
assert.ok(gw.STAGES.includes(stage));
|
|
275
|
+
const ids = qs.map((q) => q.id);
|
|
276
|
+
assert.equal(new Set(ids).size, ids.length);
|
|
277
|
+
for (const id of ids) assert.match(id, /^[a-z][a-z0-9_]{1,39}$/);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await test('CLI: answer arguments become the right body', async () => {
|
|
282
|
+
assert.deepEqual(parseAnswer(['Ledger', 'first']).body, { state: 'answered', text: 'Ledger first' });
|
|
283
|
+
assert.deepEqual(parseAnswer(['--not-sure']).body, { state: 'not_sure' });
|
|
284
|
+
assert.deepEqual(parseAnswer(['--not-sure', 'bank API']).body, { state: 'not_sure', text: 'bank API' });
|
|
285
|
+
assert.deepEqual(parseAnswer(['--skip']).body, { state: 'skipped' });
|
|
286
|
+
assert.deepEqual(parseAnswer(['Ledger', '--origin', 'assisted']).body, { state: 'answered', text: 'Ledger', origin: 'assisted' });
|
|
287
|
+
assert.ok(parseAnswer([]).error);
|
|
288
|
+
assert.ok(parseAnswer(['--skip', '--not-sure']).error);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await test('CLI: a stage prints each answer with its state and origin', async () => {
|
|
292
|
+
const deps = { store: memStore() };
|
|
293
|
+
await gw.setAnswer('strategize', 'risks', { state: 'not_sure' }, 1, deps);
|
|
294
|
+
const r = await gw.setAnswer('strategize', 'big_pieces', { text: 'Ledger', origin: 'assisted' }, 1, deps);
|
|
295
|
+
const out = formatStage(r.work);
|
|
296
|
+
assert.match(out, /Strategize — working guided · 1 answered, 1 not sure, 0 skipped, 2 empty/);
|
|
297
|
+
assert.match(out, /\[not sure yet\]/);
|
|
298
|
+
assert.match(out, /Ledger \(with AI help\)/);
|
|
299
|
+
assert.match(out, /\(empty\)/);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
console.log(`\ngenesis_work: ${passed} passed, ${failed} failed`);
|
|
303
|
+
if (failed) process.exit(1);
|
|
304
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// tests/genesis_work_db.mjs — the genesis stage-work contract against a REAL Postgres
|
|
2
|
+
// (task 1005116).
|
|
3
|
+
//
|
|
4
|
+
// tests/genesis_work.mjs runs the contract against an in-memory store in the DB-free
|
|
5
|
+
// unit lane. This runs the SAME contract (imported, not copied) through the real
|
|
6
|
+
// Postgres store (genesis-work.js pgStore) over the real migration, so the one-statement
|
|
7
|
+
// answer-plus-history writes, the no-op guard, the if-empty carry-over and the CHECK
|
|
8
|
+
// constraints are proven where they live. Integration lane (run-unit-tests.js).
|
|
9
|
+
//
|
|
10
|
+
// Needs a MIGRATED database (scripts/migrate.sh). SKIPS (exit 0) with no reachable
|
|
11
|
+
// Postgres, like every *_db test.
|
|
12
|
+
//
|
|
13
|
+
// Run: DATABASE_URL=postgres://... node tests/genesis_work_db.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import url from 'node:url';
|
|
20
|
+
import { contract } from './genesis_work.mjs';
|
|
21
|
+
import { insertTestBuilders } from './helpers.mjs';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const { Pool } = require('pg');
|
|
26
|
+
const gw = require('../modules/onboarding/genesis-work.js');
|
|
27
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await pool.query('SELECT 1');
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.log(`genesis_work_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
33
|
+
await pool.end().catch(() => {});
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
let failed = 0;
|
|
39
|
+
async function test(name, fn) {
|
|
40
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
41
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_002_genesis_work.sql'), 'utf8');
|
|
45
|
+
const wipe = () => pool.query('DELETE FROM onboarding_genesis_answers; DELETE FROM onboarding_genesis_answer_history; DELETE FROM onboarding_genesis_work;');
|
|
46
|
+
|
|
47
|
+
await test('postgres: the migration re-applies cleanly over the migrated schema (idempotent)', async () => {
|
|
48
|
+
await pool.query(MIGRATION);
|
|
49
|
+
await pool.query(MIGRATION);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const A = 910000201;
|
|
53
|
+
await insertTestBuilders(pool, [{ id: A, login: 'genesis-a' }]);
|
|
54
|
+
await wipe();
|
|
55
|
+
try {
|
|
56
|
+
await contract(test, 'postgres', { store: gw.pgStore(pool) }, A);
|
|
57
|
+
await test('postgres: the CHECKs refuse a wordless answer, a bad state, origin, door and link', async () => {
|
|
58
|
+
const bad = (sql, params) => assert.rejects(pool.query(sql, params), (e) => e.code === '23514');
|
|
59
|
+
await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state, answer_md) VALUES ('scope', 'what', 'answered', ' ')");
|
|
60
|
+
await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state) VALUES ('scope', 'what', 'maybe')");
|
|
61
|
+
await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state, origin) VALUES ('scope', 'what', 'skipped', 'model')");
|
|
62
|
+
await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state) VALUES ('decide', 'what', 'skipped')");
|
|
63
|
+
await bad("INSERT INTO onboarding_genesis_work (stage, door) VALUES ('charter', 'autopilot')");
|
|
64
|
+
await bad("INSERT INTO onboarding_genesis_work (stage, link_url) VALUES ('charter', 'javascript:alert(1)')");
|
|
65
|
+
});
|
|
66
|
+
} finally {
|
|
67
|
+
await wipe().catch(() => {});
|
|
68
|
+
await pool.query('DELETE FROM builders WHERE id = $1', [A]).catch(() => {});
|
|
69
|
+
await pool.end().catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`\ngenesis_work_db: ${passed} passed, ${failed} failed`);
|
|
73
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// tests/genesis_work_routes.mjs — the genesis stage-work HTTP surface (task 1005116)
|
|
2
|
+
//
|
|
3
|
+
// Drives the REAL routes (modules/onboarding/routes/genesis-work.js) over a real express
|
|
4
|
+
// server and an in-memory store. Auth is stubbed at the doorway the way other route
|
|
5
|
+
// tests do it: a request names its builder in `x-test-builder`, and only the builder
|
|
6
|
+
// named in `x-test-owner` holds `project.settings.manage`. What it proves: any builder
|
|
7
|
+
// reads, only the owner writes, a refusal is a 4xx with its code, un-answer is DELETE,
|
|
8
|
+
// and the planning read path comes back over the wire.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/genesis_work_routes.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { memStore } from './genesis_work.mjs';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const express = require('express');
|
|
18
|
+
const api = require('../src/module-api.js');
|
|
19
|
+
|
|
20
|
+
const OWNER = 1;
|
|
21
|
+
const OTHER = 2;
|
|
22
|
+
api.requireBuilder = (req, res, next) => {
|
|
23
|
+
const id = Number(req.get('x-test-builder'));
|
|
24
|
+
if (!id) return res.status(401).json({ error: { code: 'unauthenticated' } });
|
|
25
|
+
req.builder = { id };
|
|
26
|
+
next();
|
|
27
|
+
};
|
|
28
|
+
api.requirePermission = () => (req, res, next) => (req.builder.id === OWNER ? next() : res.status(403).json({ error: { code: 'forbidden' } }));
|
|
29
|
+
|
|
30
|
+
const mount = require('../modules/onboarding/routes/genesis-work.js');
|
|
31
|
+
const store = memStore();
|
|
32
|
+
const app = express();
|
|
33
|
+
app.use(express.json());
|
|
34
|
+
const router = express.Router();
|
|
35
|
+
mount(router, { store });
|
|
36
|
+
app.use('/api/bongos', router);
|
|
37
|
+
const server = app.listen(0);
|
|
38
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
39
|
+
|
|
40
|
+
async function call(method, path, { as = OWNER, body } = {}) {
|
|
41
|
+
const r = await fetch(base + path, {
|
|
42
|
+
method,
|
|
43
|
+
headers: { 'content-type': 'application/json', ...(as ? { 'x-test-builder': String(as) } : {}) },
|
|
44
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
45
|
+
});
|
|
46
|
+
return { status: r.status, data: await r.json() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let passed = 0;
|
|
50
|
+
let failed = 0;
|
|
51
|
+
async function test(name, fn) {
|
|
52
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
53
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await test('reads need a signed-in builder', async () => {
|
|
58
|
+
assert.equal((await call('GET', '/founding/work', { as: null })).status, 401);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await test('the owner answers, and any builder reads the answer back', async () => {
|
|
62
|
+
const w = await call('PUT', '/founding/work/strategize/answers/big_pieces', { body: { text: 'Ledger first.' } });
|
|
63
|
+
assert.equal(w.status, 200);
|
|
64
|
+
assert.equal(w.data.changed, true);
|
|
65
|
+
const r = await call('GET', '/founding/work/strategize', { as: OTHER });
|
|
66
|
+
assert.equal(r.status, 200);
|
|
67
|
+
assert.equal(r.data.work.questions.find((q) => q.id === 'big_pieces').answer.text, 'Ledger first.');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('a builder who is not the owner cannot write', async () => {
|
|
71
|
+
for (const [m, p, body] of [
|
|
72
|
+
['PUT', '/founding/work/strategize/answers/risks', { text: 'x' }],
|
|
73
|
+
['DELETE', '/founding/work/strategize/answers/big_pieces'],
|
|
74
|
+
['PUT', '/founding/work/strategize/door', { door: 'open' }],
|
|
75
|
+
['PUT', '/founding/work/strategize/page', { page: 'x' }],
|
|
76
|
+
['PUT', '/founding/work/strategize/link', { url: 'https://example.com' }],
|
|
77
|
+
]) assert.equal((await call(m, p, { as: OTHER, body })).status, 403, `${m} ${p}`);
|
|
78
|
+
assert.equal(store.answers.size, 1, 'nothing was written');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await test('"not sure yet" over the wire, then un-answer back to empty', async () => {
|
|
82
|
+
const ns = await call('PUT', '/founding/work/strategize/answers/risks', { body: { state: 'not_sure' } });
|
|
83
|
+
assert.equal(ns.data.work.questions.find((q) => q.id === 'risks').answer.state, 'not_sure');
|
|
84
|
+
const del = await call('DELETE', '/founding/work/strategize/answers/risks');
|
|
85
|
+
assert.equal(del.status, 200);
|
|
86
|
+
assert.equal(del.data.cleared, true);
|
|
87
|
+
assert.equal(del.data.work.questions.find((q) => q.id === 'risks').answer, null);
|
|
88
|
+
const h = await call('GET', '/founding/work/strategize/answers/risks/history', { as: OTHER });
|
|
89
|
+
assert.deepEqual(h.data.history.map((x) => x.state), ['cleared', 'not_sure']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test('refusals come back as 4xx with their code', async () => {
|
|
93
|
+
const cases = [
|
|
94
|
+
['PUT', '/founding/work/strategize/answers/nope', { text: 'x' }, 404, 'unknown_question'],
|
|
95
|
+
['GET', '/founding/work/decide', undefined, 404, 'unknown_stage'],
|
|
96
|
+
['PUT', '/founding/work/strategize/answers/risks', { text: ' ' }, 400, 'empty_answer'],
|
|
97
|
+
['PUT', '/founding/work/strategize/door', { door: 'autopilot' }, 400, 'bad_door'],
|
|
98
|
+
['PUT', '/founding/work/strategize/link', { url: 'ftp://x' }, 400, 'bad_link'],
|
|
99
|
+
];
|
|
100
|
+
for (const [m, p, body, status, code] of cases) {
|
|
101
|
+
const r = await call(m, p, { body });
|
|
102
|
+
assert.equal(r.status, status, `${m} ${p}`);
|
|
103
|
+
assert.equal(r.data.error.code, code);
|
|
104
|
+
}
|
|
105
|
+
const unknownField = await call('PUT', '/founding/work/strategize/door', { body: { door: 'open', extra: 1 } });
|
|
106
|
+
assert.equal(unknownField.status, 400, 'an unknown field is refused');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('switching door over the wire keeps the answers', async () => {
|
|
110
|
+
await call('PUT', '/founding/work/strategize/page', { body: { page: 'Bet on the ledger.' } });
|
|
111
|
+
const r = await call('PUT', '/founding/work/strategize/door', { body: { door: 'open' } });
|
|
112
|
+
assert.equal(r.data.work.door, 'open');
|
|
113
|
+
assert.equal(r.data.work.page, 'Bet on the ledger.');
|
|
114
|
+
assert.equal(r.data.work.counts.answered, 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('the planning read path and the assisted brief are readable by any builder', async () => {
|
|
118
|
+
const c = await call('GET', '/founding/context', { as: OTHER });
|
|
119
|
+
assert.equal(c.status, 200);
|
|
120
|
+
assert.match(c.data.markdown, /Ledger first\./);
|
|
121
|
+
assert.match(c.data.markdown, /Bet on the ledger\./);
|
|
122
|
+
const a = await call('GET', '/founding/work/strategize/assist', { as: OTHER });
|
|
123
|
+
assert.match(a.data.prompt, /I am the author/);
|
|
124
|
+
});
|
|
125
|
+
} finally {
|
|
126
|
+
server.close();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`\ngenesis_work_routes: ${passed} passed, ${failed} failed`);
|
|
130
|
+
if (failed) process.exit(1);
|