@bongos/core 1.21.127 → 1.21.129
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 +66 -26
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +147 -2
- package/docs/api-reference.md +5 -2
- package/docs/architecture.md +12 -0
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_276_module_release_channels.sql +73 -0
- package/modules/onboarding/howto-moments.js +91 -0
- package/modules/onboarding/howto-state.js +108 -0
- package/modules/onboarding/migrations/onboarding_001_howto_reads.sql +33 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/onboarding.js +48 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/claude-md-template.js +1 -0
- package/scripts/gds/howto.js +65 -0
- package/src/module-api.js +1 -1
- package/tests/howto_moments.mjs +136 -0
- package/tests/howto_state.mjs +178 -0
- package/tests/module_release_channels.mjs +58 -0
- package/tests/own_scope_guard.mjs +6 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// tests/howto_moments.mjs — the right unread how-to step at the right moment, never twice (task 1004635)
|
|
2
|
+
//
|
|
3
|
+
// The done-when: on a fresh project the first session is walked through the starting
|
|
4
|
+
// step, the first ship triggers the shipping walkthrough, and neither repeats once read.
|
|
5
|
+
// Held here at three layers: the pure picker (modules/onboarding/howto-moments.js), the
|
|
6
|
+
// Conductor advisory built from it (.claude/hooks/conductor.js howtoAdvisory), and the
|
|
7
|
+
// non-Claude path (the `npx bongos howto` verb + the instance CLAUDE.md lines naming it).
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/howto_moments.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const guide = require('../modules/onboarding/howto-guide.js');
|
|
16
|
+
const m = require('../modules/onboarding/howto-moments.js');
|
|
17
|
+
const { howtoAdvisory, evaluateTriggers } = require('../.claude/hooks/conductor.js');
|
|
18
|
+
const { VERBS } = require('../bin/bongos.js');
|
|
19
|
+
const { renderInstanceClaudeMd } = require('../scripts/gds/claude-md-template.js');
|
|
20
|
+
const { readIds } = require('../scripts/gds/howto.js');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
function test(name, fn) {
|
|
25
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
26
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
27
|
+
}
|
|
28
|
+
const ids = (pick) => (pick ? pick.steps.map((s) => s.id) : []);
|
|
29
|
+
|
|
30
|
+
console.log('the picker:');
|
|
31
|
+
|
|
32
|
+
test('a fresh project: the first prompt walks through what Bongos is and where to start', () => {
|
|
33
|
+
const pick = m.pickSteps({ firstPrompt: true, prompt: 'hello', claims: [], read: [] });
|
|
34
|
+
assert.equal(pick.moment.id, 'first_open');
|
|
35
|
+
assert.deepEqual(ids(pick), ['welcome', 'where_to_start']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('once read, the starting step never comes back — the next session moves on', () => {
|
|
39
|
+
const pick = m.pickSteps({ firstPrompt: true, prompt: 'hello', claims: [], read: ['welcome', 'where_to_start'] });
|
|
40
|
+
assert.ok(!ids(pick).includes('where_to_start'));
|
|
41
|
+
assert.ok(!ids(pick).includes('welcome'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an ordinary later prompt with nothing claimed says nothing', () => {
|
|
45
|
+
assert.equal(m.pickSteps({ firstPrompt: false, prompt: 'rename the button', claims: [], read: [] }), null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the first ship triggers the shipping walkthrough, ahead of the claim steps', () => {
|
|
49
|
+
const pick = m.pickSteps({ prompt: 'ship it', claims: [{ task_id: 1 }], read: [] });
|
|
50
|
+
assert.equal(pick.moment.id, 'first_ship');
|
|
51
|
+
assert.deepEqual(ids(pick), ['what_ship_does', 'review_findings']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the shipping walkthrough does not repeat once read', () => {
|
|
55
|
+
const read = ['what_ship_does', 'review_findings', 'claim_a_task', 'give_a_task_back', 'preview_before_ship'];
|
|
56
|
+
assert.equal(m.pickSteps({ prompt: 'ship it', claims: [{ task_id: 1 }], read }), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('holding a claim opens the first-claim steps', () => {
|
|
60
|
+
assert.equal(m.pickSteps({ prompt: 'go on', claims: [{ task_id: 1 }], read: [] }).moment.id, 'first_claim');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('after a ship (no claim, shipping walkthrough read) comes what happens next', () => {
|
|
64
|
+
const pick = m.pickSteps({ prompt: 'ok', claims: [], read: ['what_ship_does', 'review_findings'] });
|
|
65
|
+
assert.equal(pick.moment.id, 'after_ship');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('unknown claim state never guesses a claim moment', () => {
|
|
69
|
+
assert.equal(m.pickSteps({ prompt: 'ship it', claims: null, read: [] }), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('short: never more than two steps, all from one moment', () => {
|
|
73
|
+
for (const firstPrompt of [true, false]) {
|
|
74
|
+
for (const prompt of ['ship it', 'I am stuck', 'make a goal', 'invite my teammate', 'what can I work on?']) {
|
|
75
|
+
const pick = m.pickSteps({ firstPrompt, prompt, claims: [{ task_id: 1 }], read: [] });
|
|
76
|
+
if (!pick) continue;
|
|
77
|
+
assert.ok(pick.steps.length <= m.MAX_STEPS);
|
|
78
|
+
assert.ok(pick.steps.every((s) => s.moment === pick.moment.id));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('every moment the picker can open is a moment the guide defines', () => {
|
|
84
|
+
const opened = new Set();
|
|
85
|
+
for (const prompt of ['ship it', 'stuck', 'set up', 'goal', 'make a task', "what's next", 'invite', 'settings']) {
|
|
86
|
+
for (const claims of [[], [{}], null]) {
|
|
87
|
+
for (const id of m.openMoments({ firstPrompt: true, prompt, claims, read: [] })) opened.add(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const id of opened) assert.ok(guide.MOMENT_IDS.has(id), id);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('the AI is told to say it once, briefly, and that it is marked read', () => {
|
|
94
|
+
const text = m.renderForAi(m.pickSteps({ firstPrompt: true, claims: [], read: [] }));
|
|
95
|
+
assert.match(text, /Where to start/);
|
|
96
|
+
assert.match(text, /once/);
|
|
97
|
+
assert.match(text, /marked read/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
console.log('\nthe Conductor:');
|
|
101
|
+
|
|
102
|
+
test('no read state (offline, signed out, no onboarding module) → no how-to advisory', () => {
|
|
103
|
+
assert.equal(howtoAdvisory({ howto: null, firstPrompt: true, activeClaims: [] }), null);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the advisory names the steps it shows, so main() can mark exactly those read', () => {
|
|
107
|
+
const a = howtoAdvisory({ howto: { read: [] }, firstPrompt: true, activeClaims: [] });
|
|
108
|
+
assert.deepEqual(a.howtoSteps, ['welcome', 'where_to_start']);
|
|
109
|
+
assert.equal(a.id, 'howto:welcome,where_to_start');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('evaluateTriggers carries the how-to advisory on a first prompt, and not once read', () => {
|
|
113
|
+
const first = evaluateTriggers({ prompt: 'hi', activeClaims: [], howto: { read: [] }, firstPrompt: true });
|
|
114
|
+
assert.ok(first.some((a) => a.howtoSteps));
|
|
115
|
+
const all = guide.STEPS.map((s) => s.id);
|
|
116
|
+
const later = evaluateTriggers({ prompt: 'ship it', activeClaims: [{ task_id: 1 }], howto: { read: all }, firstPrompt: true });
|
|
117
|
+
assert.ok(!later.some((a) => a.howtoSteps));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log('\nother AI tools:');
|
|
121
|
+
|
|
122
|
+
test('`bongos howto` is a verb, run by scripts/gds/howto.js', () => {
|
|
123
|
+
assert.equal(VERBS.howto.script, 'howto.js');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the instance CLAUDE.md names the three moment commands', () => {
|
|
127
|
+
const md = renderInstanceClaudeMd({});
|
|
128
|
+
for (const id of ['first_open', 'first_claim', 'first_ship']) assert.ok(md.includes(`npx bongos howto moment ${id}`), id);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the CLI counts read and skipped steps as done', () => {
|
|
132
|
+
assert.deepEqual(readIds({ steps: [{ id: 'a', state: 'read' }, { id: 'b', state: 'skipped' }, { id: 'c', state: 'unread' }] }), ['a', 'b']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
136
|
+
if (failed) process.exitCode = 1;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// tests/howto_state.mjs — per-person read/unread state for the how-to steps (task 1004634)
|
|
2
|
+
//
|
|
3
|
+
// Two halves. The DB-free half drives howto-state.js against an in-memory pool that
|
|
4
|
+
// honours the three statements it issues, so the contract runs everywhere: unread by
|
|
5
|
+
// default, mark read, skip all, a read upgrades a skip (never the reverse), the first
|
|
6
|
+
// read time is kept, reset some or all, and an id the guide does not name is refused.
|
|
7
|
+
// The DB half applies the real migration and runs the same calls against Postgres; it
|
|
8
|
+
// skips when no Postgres is reachable (DATABASE_URL), like copy_desk_flags_db.mjs.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/howto_state.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import url from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const hs = require('../modules/onboarding/howto-state.js');
|
|
20
|
+
const guide = require('../modules/onboarding/howto-guide.js');
|
|
21
|
+
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
|
|
23
|
+
let passed = 0;
|
|
24
|
+
let failed = 0;
|
|
25
|
+
async function test(name, fn) {
|
|
26
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
27
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// An in-memory stand-in for the three statements howto-state.js runs.
|
|
31
|
+
function fakePool() {
|
|
32
|
+
const rows = new Map(); // `${b}|${step}` → { builder_id, step_id, how, read_at }
|
|
33
|
+
let clock = 0;
|
|
34
|
+
return {
|
|
35
|
+
rows,
|
|
36
|
+
async query(sql, params) {
|
|
37
|
+
if (/^SELECT step_id/.test(sql)) {
|
|
38
|
+
return { rows: [...rows.values()].filter((r) => r.builder_id === params[0]) };
|
|
39
|
+
}
|
|
40
|
+
if (/^INSERT INTO onboarding_howto_reads/.test(sql)) {
|
|
41
|
+
const [b, ids, how] = params;
|
|
42
|
+
for (const s of ids) {
|
|
43
|
+
const k = `${b}|${s}`;
|
|
44
|
+
const cur = rows.get(k);
|
|
45
|
+
if (!cur) rows.set(k, { builder_id: b, step_id: s, how, read_at: new Date(1e12 + ++clock * 1000) });
|
|
46
|
+
else if (cur.how === 'skipped' && how === 'read') cur.how = 'read';
|
|
47
|
+
}
|
|
48
|
+
return { rows: [] };
|
|
49
|
+
}
|
|
50
|
+
if (/^DELETE FROM onboarding_howto_reads WHERE builder_id = \$1 AND/.test(sql)) {
|
|
51
|
+
for (const s of params[1]) rows.delete(`${params[0]}|${s}`);
|
|
52
|
+
return { rows: [] };
|
|
53
|
+
}
|
|
54
|
+
if (/^DELETE FROM onboarding_howto_reads WHERE builder_id = \$1$/.test(sql)) {
|
|
55
|
+
for (const [k, r] of rows) if (r.builder_id === params[0]) rows.delete(k);
|
|
56
|
+
return { rows: [] };
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function contract(label, deps, A, B) {
|
|
64
|
+
const first = guide.STEPS[0].id;
|
|
65
|
+
const second = guide.STEPS[1].id;
|
|
66
|
+
|
|
67
|
+
await test(`${label}: a new person has every step unread, next is the first`, async () => {
|
|
68
|
+
const s = await hs.readState(A, deps);
|
|
69
|
+
assert.equal(s.total, guide.STEPS.length);
|
|
70
|
+
assert.equal(s.unread, s.total);
|
|
71
|
+
assert.equal(s.next_unread, first);
|
|
72
|
+
assert.ok(s.steps.every((x) => x.state === 'unread' && x.read_at === null));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await test(`${label}: mark one read → it is read, next moves on, other people untouched`, async () => {
|
|
76
|
+
const r = await hs.markRead(A, { steps: [first] }, deps);
|
|
77
|
+
assert.equal(r.ok, true);
|
|
78
|
+
assert.equal(r.state.read, 1);
|
|
79
|
+
assert.equal(r.state.next_unread, second);
|
|
80
|
+
assert.equal(r.state.steps[0].state, 'read');
|
|
81
|
+
assert.ok(r.state.steps[0].read_at);
|
|
82
|
+
const other = await hs.readState(B, deps);
|
|
83
|
+
assert.equal(other.read, 0, 'per person');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test(`${label}: re-marking keeps the first read time (idempotent)`, async () => {
|
|
87
|
+
const before = (await hs.readState(A, deps)).steps[0].read_at;
|
|
88
|
+
await hs.markRead(A, { steps: [first] }, deps);
|
|
89
|
+
assert.equal((await hs.readState(A, deps)).steps[0].read_at, before);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test(`${label}: an unknown step id is refused and nothing is stored`, async () => {
|
|
93
|
+
const r = await hs.markRead(A, { steps: [second, 'not_a_step'] }, deps);
|
|
94
|
+
assert.equal(r.ok, false);
|
|
95
|
+
assert.equal(r.reason, 'unknown_step');
|
|
96
|
+
assert.deepEqual(r.unknown, ['not_a_step']);
|
|
97
|
+
assert.equal((await hs.readState(A, deps)).read, 1);
|
|
98
|
+
assert.equal((await hs.markRead(A, { steps: [] }, deps)).reason, 'no_steps');
|
|
99
|
+
assert.equal((await hs.markRead(A, { steps: [second], how: 'seen' }, deps)).reason, 'bad_how');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test(`${label}: skip all marks every other step skipped; nothing left unread`, async () => {
|
|
103
|
+
const r = await hs.markRead(A, { all: true, how: 'skipped' }, deps);
|
|
104
|
+
assert.equal(r.state.unread, 0);
|
|
105
|
+
assert.equal(r.state.next_unread, null);
|
|
106
|
+
assert.equal(r.state.read, 1, 'the step already read stays read');
|
|
107
|
+
assert.equal(r.state.skipped, guide.STEPS.length - 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test(`${label}: reading a skipped step upgrades it; skipping a read one does not downgrade`, async () => {
|
|
111
|
+
await hs.markRead(A, { steps: [second] }, deps);
|
|
112
|
+
let s = await hs.readState(A, deps);
|
|
113
|
+
assert.equal(s.steps[1].state, 'read');
|
|
114
|
+
await hs.markRead(A, { steps: [second], how: 'skipped' }, deps);
|
|
115
|
+
s = await hs.readState(A, deps);
|
|
116
|
+
assert.equal(s.steps[1].state, 'read');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test(`${label}: reset some steps, then reset everything`, async () => {
|
|
120
|
+
let r = await hs.reset(A, { steps: [first] }, deps);
|
|
121
|
+
assert.equal(r.state.steps[0].state, 'unread');
|
|
122
|
+
assert.equal(r.state.next_unread, first);
|
|
123
|
+
assert.equal((await hs.reset(A, { steps: ['nope_nope'] }, deps)).reason, 'unknown_step');
|
|
124
|
+
r = await hs.reset(A, {}, deps);
|
|
125
|
+
assert.equal(r.state.unread, guide.STEPS.length);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await test('shapeState ignores a stored mark for a step the guide no longer has', async () => {
|
|
130
|
+
const s = hs.shapeState([{ step_id: 'retired_step', how: 'read', read_at: new Date() }]);
|
|
131
|
+
assert.equal(s.read, 0);
|
|
132
|
+
assert.equal(s.total, guide.STEPS.length);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('the module declares its migration and the routes are mounted', async () => {
|
|
136
|
+
const mod = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/onboarding/module.json'), 'utf8'));
|
|
137
|
+
assert.equal(mod.contributes.migrations, true);
|
|
138
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_001_howto_reads.sql')));
|
|
139
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/onboarding/routes/onboarding.js'), 'utf8');
|
|
140
|
+
for (const r of ["router.get('/me/howto'", "router.post('/me/howto/read'", "router.post('/me/howto/reset'"]) assert.ok(src.includes(r), r);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await contract('in-memory', { pool: fakePool() }, 1, 2);
|
|
144
|
+
|
|
145
|
+
// ── the real database ────────────────────────────────────────────────────────
|
|
146
|
+
const { Pool } = require('pg');
|
|
147
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
148
|
+
let dbUp = true;
|
|
149
|
+
try { await pool.query('SELECT 1'); } catch (e) {
|
|
150
|
+
dbUp = false;
|
|
151
|
+
console.log(` (db half SKIPPED — no reachable Postgres: ${e.code || e.message}. Set DATABASE_URL to run.)`);
|
|
152
|
+
}
|
|
153
|
+
if (dbUp) {
|
|
154
|
+
await pool.query(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
|
|
156
|
+
CREATE TABLE IF NOT EXISTS builders (id bigserial PRIMARY KEY, github_login text);
|
|
157
|
+
`);
|
|
158
|
+
await pool.query(fs.readFileSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_001_howto_reads.sql'), 'utf8'));
|
|
159
|
+
await pool.query(fs.readFileSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_001_howto_reads.sql'), 'utf8')); // idempotent
|
|
160
|
+
const A = 910000001;
|
|
161
|
+
const B = 910000002;
|
|
162
|
+
await pool.query('INSERT INTO builders (id, github_login) VALUES ($1, $2), ($3, $4) ON CONFLICT (id) DO NOTHING', [A, 'howto-a', B, 'howto-b']);
|
|
163
|
+
await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id IN ($1, $2)', [A, B]);
|
|
164
|
+
try {
|
|
165
|
+
await contract('postgres', { pool }, A, B);
|
|
166
|
+
await test('postgres: the CHECK refuses a malformed step id and a bad how', async () => {
|
|
167
|
+
await assert.rejects(pool.query("INSERT INTO onboarding_howto_reads (builder_id, step_id) VALUES ($1, 'Bad Id')", [A]));
|
|
168
|
+
await assert.rejects(pool.query("INSERT INTO onboarding_howto_reads (builder_id, step_id, how) VALUES ($1, 'welcome', 'seen')", [A]));
|
|
169
|
+
});
|
|
170
|
+
} finally {
|
|
171
|
+
await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id IN ($1, $2)', [A, B]).catch(() => {});
|
|
172
|
+
await pool.query('DELETE FROM builders WHERE id IN ($1, $2)', [A, B]).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await pool.end().catch(() => {});
|
|
176
|
+
|
|
177
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
178
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// tests/module_release_channels.mjs — the release-channel schema a module version
|
|
2
|
+
// sits on (task 1003815, ADR 0344; migration core_276). No database: the migration
|
|
3
|
+
// is read as text and its shape pinned. The integration lane applies it to a real
|
|
4
|
+
// Postgres with every other migration.
|
|
5
|
+
//
|
|
6
|
+
// Run: node tests/module_release_channels.mjs
|
|
7
|
+
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const SQL = fs.readFileSync(path.join(ROOT, 'migrations', 'core_276_module_release_channels.sql'), 'utf8');
|
|
19
|
+
const DDL = SQL.replace(/--.*$/gm, '');
|
|
20
|
+
|
|
21
|
+
test('core_276 is additive and idempotent', () => {
|
|
22
|
+
// the project's own migration linter, so this tracks its unsafe-pattern list as it grows
|
|
23
|
+
const lint = require('../scripts/gds/lint-migrations.js');
|
|
24
|
+
assert.deepEqual(lint.scanMigrationSql(SQL).unsafe || [], [], 'additive only');
|
|
25
|
+
for (const m of DDL.matchAll(/CREATE (TABLE|INDEX)\s+(?!IF NOT EXISTS\b)(\w+)/g)) {
|
|
26
|
+
assert.fail(`CREATE ${m[1]} ${m[2]} without IF NOT EXISTS is not safe to re-run`);
|
|
27
|
+
}
|
|
28
|
+
assert.match(DDL, /ON CONFLICT \(channel\) DO NOTHING/, 're-running does not duplicate the channel rows');
|
|
29
|
+
assert.match(DDL, /WHERE NOT EXISTS \(SELECT 1 FROM store_module_version_channels c WHERE c\.version_id = v\.id\)/,
|
|
30
|
+
're-running does not add a second backfill row');
|
|
31
|
+
assert.match(SQL, /INSERT INTO schema_migrations \(version\) VALUES \('core_276_module_release_channels'\)/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('channels are rows, so a fourth needs no schema change; tester, general and withdrawn ship', () => {
|
|
35
|
+
assert.match(DDL, /CREATE TABLE IF NOT EXISTS store_release_channels \(\s*channel\s+text PRIMARY KEY/);
|
|
36
|
+
const seeded = [...DDL.matchAll(/\('(\w+)',\s+'[^']*',\s+(true|false),\s+\d+\)/g)].map((m) => [m[1], m[2]]);
|
|
37
|
+
assert.deepEqual(seeded, [['tester', 'true'], ['general', 'true'], ['withdrawn', 'false']],
|
|
38
|
+
'two channels serve installs; withdrawn never does');
|
|
39
|
+
assert.doesNotMatch(DDL, /channel\s+text[^,\n]*CHECK \(channel IN/, 'no closed list of channels in a CHECK');
|
|
40
|
+
assert.doesNotMatch(DDL, /\bis_tester\b|\btester\s+boolean/, 'not a boolean');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a channel belongs to a version, as history: the newest row is current', () => {
|
|
44
|
+
const t = DDL.match(/CREATE TABLE IF NOT EXISTS store_module_version_channels \(([\s\S]*?)\n\);/);
|
|
45
|
+
assert.ok(t, 'the history table exists');
|
|
46
|
+
const body = t[1];
|
|
47
|
+
assert.match(body, /version_id\s+bigint NOT NULL REFERENCES store_module_versions\(id\)/, 'keyed by version, not module');
|
|
48
|
+
assert.doesNotMatch(body, /\bmodule_key\b/, 'never by module');
|
|
49
|
+
assert.match(body, /channel\s+text NOT NULL REFERENCES store_release_channels\(channel\)/);
|
|
50
|
+
assert.match(body, /reason\s+text NOT NULL CHECK \(reason ~ '\^\[a-z\]\[a-z0-9_\]\{0,39\}\$'\)/, 'a short code, never free text');
|
|
51
|
+
assert.match(body, /set_by\s+bigint REFERENCES builders\(id\),/, 'nullable: the system can move a version');
|
|
52
|
+
assert.doesNotMatch(body, /UNIQUE \(version_id\)/, 'a version can move more than once');
|
|
53
|
+
assert.match(DDL, /ON store_module_version_channels \(version_id, set_at DESC, id DESC\)/, 'current-channel lookup is indexed');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('versions published before staged rollout stay offered to everyone', () => {
|
|
57
|
+
assert.match(DDL, /SELECT v\.id, 'general', 'predates_staged_rollout'\s+FROM store_module_versions v/);
|
|
58
|
+
});
|
|
@@ -164,6 +164,12 @@ const OWN_SCOPED_SURFACES = [
|
|
|
164
164
|
},
|
|
165
165
|
],
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
file: 'modules/onboarding/routes/onboarding.js',
|
|
169
|
+
why: "the how-to read state (task 1004634). GET /me/howto and POST /me/howto/read|reset read and write the CALLER's own marks, keyed on req.builder.id; the body names step ids, never a builder. The rest of the router is /founding/*, whose one path param names a founding STAGE, not a builder.",
|
|
170
|
+
minOwnScoped: 1,
|
|
171
|
+
crossOwner: [],
|
|
172
|
+
},
|
|
167
173
|
];
|
|
168
174
|
|
|
169
175
|
// Floors that keep this guard from going quietly vacuous (a regex that stops
|