@bongos/core 1.21.105 → 1.21.107

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.
Files changed (55) hide show
  1. package/.bongos-core.json +88 -58
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +91 -2
  8. package/docs/api-reference.md +5 -3
  9. package/docs/copy-inventory.md +368 -366
  10. package/docs/copy-registry.json +475 -457
  11. package/docs/design/project-startup-direction.md +14 -0
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +10 -3
  14. package/docs/page-readings.json +1023 -977
  15. package/migrations/core_275_claims_auto_pickup.sql +24 -0
  16. package/modules/government/decision-rules.js +1 -0
  17. package/modules/lifecycle/autopickup.js +94 -0
  18. package/modules/lifecycle/db-claims.js +18 -4
  19. package/modules/lifecycle/routes/claims.js +12 -1
  20. package/modules/lifecycle/routes/project-settings.js +59 -0
  21. package/modules/public-landing/public/projects-hall-live.states.json +3 -2
  22. package/modules/public-landing/public/projects-setup.states.json +91 -0
  23. package/modules/public-landing/public/projects.html +535 -804
  24. package/modules/public-landing/public/projects.probes.json +3 -3
  25. package/modules/public-landing/public/projects.states.json +1 -1
  26. package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +22 -2
  27. package/modules/ui-design/kit/fixtures/provisioning-instances-setup.json +195 -0
  28. package/modules/ui-design/kit/serve.js +22 -4
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +12 -0
  32. package/scripts/gds/government-walk.js +1 -0
  33. package/scripts/gds/run-unit-tests.js +3 -0
  34. package/src/bongos/project-settings.js +79 -0
  35. package/src/module-api.js +8 -1
  36. package/tests/autopickup_cap.mjs +236 -0
  37. package/tests/autopickup_cap_db.mjs +115 -0
  38. package/tests/cosmos_token_contrast.mjs +10 -18
  39. package/tests/github_handback_any_device.mjs +4 -3
  40. package/tests/github_one_trip.mjs +45 -47
  41. package/tests/github_org_account.mjs +2 -2
  42. package/tests/github_pick_up.mjs +40 -29
  43. package/tests/government_act_alone_buildings.mjs +1 -0
  44. package/tests/onboard.mjs +13 -7
  45. package/tests/project_invite_ui.mjs +6 -3
  46. package/tests/project_modules_ui.mjs +3 -3
  47. package/tests/projects_hub.mjs +8 -9
  48. package/tests/projects_hub_cloud_hop.mjs +77 -125
  49. package/tests/projects_hub_dns_ready.mjs +42 -51
  50. package/tests/projects_hub_pre_uat.mjs +68 -69
  51. package/tests/public_landing_projects.mjs +45 -102
  52. package/tests/setup_status_facts.mjs +400 -0
  53. package/tests/wizard_github_feedback.mjs +90 -87
  54. package/tests/wizard_one_page.mjs +11 -9
  55. package/tests/wizard_auto_open.mjs +0 -94
@@ -0,0 +1,236 @@
1
+ // tests/autopickup_cap.mjs — tweak auto-pickup's setting, monthly cap and claim mark
2
+ // (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
3
+ //
4
+ // Pinned here (DB-free; the race lives in tests/autopickup_cap_db.mjs):
5
+ // 1. the two parsers and the resolver: off by default, cap 20 by default, an
6
+ // unusable stored value reported and never thrown, a dead DB answers defaults;
7
+ // 2. the refusal ladder: not a tweak, switch off, round limit, monthly cap, and the
8
+ // lock is taken BEFORE either count;
9
+ // 3. claimTask: a marked claim runs the gate before the insert and names the
10
+ // column; an unmarked claim's insert is unchanged;
11
+ // 4. the routes: any builder reads (with this month's count), only
12
+ // project.settings.manage writes, and a half-valid body writes nothing.
13
+ //
14
+ // Run: node --test tests/autopickup_cap.mjs
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const poolMod = require(path.join(ROOT, 'src', 'bongos', 'pool.js'));
25
+ const {
26
+ DEFAULT_AUTOPICKUP, DEFAULT_AUTOPICKUP_CAP, parseAutopickup, parseAutopickupCap,
27
+ resolveTweakAutopickup, describeTweakAutopickup, SETTING_KEYS, AUTOPICKUP_MODES, MAX_AUTOPICKUP_CAP,
28
+ } = require('../src/bongos/project-settings.js');
29
+ const { autoPickupRefusal, LOCK_KEY, MAX_AUTO_APPLIES_PER_ROUND } = require('../modules/lifecycle/autopickup.js');
30
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
31
+
32
+ async function withPool(query, fn) {
33
+ const pool = poolMod.pool;
34
+ const saved = pool.query;
35
+ pool.query = query;
36
+ try { return await fn(); } finally { pool.query = saved; }
37
+ }
38
+ const settingsRows = (map) => async (_sql, [key]) => ({ rows: key in map ? [{ key, value: map[key] }] : [] });
39
+
40
+ // ---- 1. the setting ---------------------------------------------------------
41
+
42
+ test('the parsers accept exactly off|on and a whole positive cap', () => {
43
+ assert.deepEqual([...AUTOPICKUP_MODES], ['off', 'on']);
44
+ assert.equal(api.projectSettings.MAX_AUTOPICKUP_CAP, MAX_AUTOPICKUP_CAP, 'the doorway hands out the same bound');
45
+ assert.deepEqual(parseAutopickup(' On '), { mode: 'on' });
46
+ assert.match(parseAutopickup('maybe').reason, /off \| on/);
47
+ assert.deepEqual(parseAutopickupCap('5'), { cap: 5 });
48
+ assert.match(parseAutopickupCap('0').reason, /positive/);
49
+ assert.match(parseAutopickupCap('2.5').reason, /whole/);
50
+ assert.match(parseAutopickupCap('100000').reason, /out of range/);
51
+ });
52
+
53
+ test('an untouched project is off with a cap of 20', async () => {
54
+ assert.equal(DEFAULT_AUTOPICKUP, 'off');
55
+ assert.equal(DEFAULT_AUTOPICKUP_CAP, 20);
56
+ const r = await withPool(settingsRows({}), () => resolveTweakAutopickup());
57
+ assert.deepEqual(r, { mode: 'off', cap: 20, source: { mode: 'default', cap: 'default' }, invalid: [] });
58
+ assert.match(describeTweakAutopickup(r), /waits until a person/);
59
+ });
60
+
61
+ test('stored values are read; an unusable one is reported and falls back', async () => {
62
+ const r = await withPool(settingsRows({ [SETTING_KEYS.TWEAK_AUTOPICKUP]: 'on', [SETTING_KEYS.TWEAK_AUTOPICKUP_CAP]: 'lots' }),
63
+ () => resolveTweakAutopickup());
64
+ assert.equal(r.mode, 'on');
65
+ assert.equal(r.source.mode, 'project_settings');
66
+ assert.equal(r.cap, 20);
67
+ assert.equal(r.invalid.length, 1);
68
+ assert.equal(r.invalid[0].raw, 'lots');
69
+ assert.match(describeTweakAutopickup({ mode: 'on', cap: 7 }), /up to 7 a month/);
70
+ });
71
+
72
+ test('a database that cannot be read answers the defaults', async () => {
73
+ const r = await withPool(async () => { throw new Error('db down'); }, () => resolveTweakAutopickup());
74
+ assert.equal(r.mode, 'off');
75
+ assert.equal(r.cap, 20);
76
+ });
77
+
78
+ // ---- 2. the refusal ladder --------------------------------------------------
79
+
80
+ function fakeClient({ round = 0, month = 0 } = {}) {
81
+ const calls = [];
82
+ return {
83
+ calls,
84
+ async query(sql, params) {
85
+ calls.push(sql.includes('pg_advisory_xact_lock') ? `lock:${params[0]}` : sql.includes('date_trunc') ? 'month' : 'round');
86
+ if (sql.includes('pg_advisory_xact_lock')) return { rows: [] };
87
+ return { rows: [{ n: sql.includes('date_trunc') ? month : round }] };
88
+ },
89
+ };
90
+ }
91
+ const tweak = { id: 9, source: 'page-tweak' };
92
+ const on = { mode: 'on', cap: 3 };
93
+
94
+ test('a marked claim on anything but a page tweak is refused, before any lock', async () => {
95
+ const c = fakeClient();
96
+ assert.equal((await autoPickupRefusal(c, { id: 9, source: 'manual' }, on)).code, 'AUTO_PICKUP_NOT_A_TWEAK');
97
+ assert.deepEqual(c.calls, []);
98
+ });
99
+
100
+ test('the switch off refuses a marked claim', async () => {
101
+ assert.equal((await autoPickupRefusal(fakeClient(), tweak, { mode: 'off', cap: 3 })).code, 'AUTO_PICKUP_OFF');
102
+ });
103
+
104
+ test('the lock is taken before either count, on the one project-wide key', async () => {
105
+ const c = fakeClient();
106
+ assert.equal(await autoPickupRefusal(c, tweak, on), null);
107
+ assert.equal(LOCK_KEY, 'page-tweak/autopickup');
108
+ assert.deepEqual(c.calls, ['lock:page-tweak/autopickup', 'round', 'month']);
109
+ });
110
+
111
+ test('a third automatic apply of one round is refused', async () => {
112
+ assert.equal(MAX_AUTO_APPLIES_PER_ROUND, 2);
113
+ assert.equal(await autoPickupRefusal(fakeClient({ round: 1 }), tweak, on), null);
114
+ const r = await autoPickupRefusal(fakeClient({ round: 2 }), tweak, on);
115
+ assert.equal(r.code, 'AUTO_PICKUP_ROUND_LIMIT');
116
+ assert.match(r.message, /waits for a person/);
117
+ });
118
+
119
+ test('the monthly cap refuses at the cap and admits below it', async () => {
120
+ assert.equal(await autoPickupRefusal(fakeClient({ month: 2 }), tweak, on), null);
121
+ const r = await autoPickupRefusal(fakeClient({ month: 3 }), tweak, on);
122
+ assert.equal(r.code, 'AUTO_PICKUP_CAP_REACHED');
123
+ assert.equal(r.cap, 3);
124
+ });
125
+
126
+ // ---- 3. claimTask wiring ----------------------------------------------------
127
+
128
+ test('claimTask runs the gate before the insert, and names the column only on a marked claim', () => {
129
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
130
+ const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
131
+ const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings)');
132
+ const insert = body.indexOf('INSERT INTO claims');
133
+ assert.ok(gate > 0 && insert > gate, 'the gate runs inside the transaction, before the insert');
134
+ assert.match(body.slice(gate, insert), /if \(refusal\) throw refusal;/, 'a refusal stops the claim');
135
+ assert.match(body, /\$\{autoPickup \? ', auto_pickup' : ''\}/);
136
+ assert.match(body, /goal_id, source FROM tasks WHERE id = \$1 FOR UPDATE/, 'the task row carries its source');
137
+ });
138
+
139
+ test('POST /claims accepts auto_pickup and maps every refusal to a 409', () => {
140
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'claims.js'), 'utf8');
141
+ assert.match(src, /auto_pickup: \{ type: 'boolean' \}/);
142
+ assert.match(src, /autoPickup: autoPickup === true/);
143
+ for (const code of ['AUTO_PICKUP_NOT_A_TWEAK', 'AUTO_PICKUP_OFF', 'AUTO_PICKUP_CAP_REACHED', 'AUTO_PICKUP_ROUND_LIMIT']) {
144
+ assert.match(src, new RegExp(`err.code === '${code}' \\? 409`));
145
+ }
146
+ });
147
+
148
+ // ---- 4. the routes ----------------------------------------------------------
149
+
150
+ const fakeRes = () => ({
151
+ code: null, statusCode: 200, payload: null, headers: {},
152
+ fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code, ...(opts || {}) }; return this; },
153
+ status(c) { this.statusCode = c; return this; },
154
+ set(k, v) { this.headers[k] = v; return this; },
155
+ json(p) { this.payload = p; return this; },
156
+ });
157
+
158
+ function buildRouter({ used = 4 } = {}) {
159
+ const ps = api.projectSettings;
160
+ const saved = { resolve: ps.resolveTweakAutopickup, set: ps.set };
161
+ const writes = [];
162
+ const state = { mode: 'off', cap: 20 };
163
+ ps.resolveTweakAutopickup = async () => ({ ...state, source: { mode: 'default', cap: 'default' }, invalid: [] });
164
+ ps.set = async (key, value, by) => {
165
+ writes.push({ key, value, by });
166
+ if (key === 'tweak.autopickup') state.mode = value; else state.cap = value;
167
+ };
168
+ const autopickup = require('../modules/lifecycle/autopickup.js');
169
+ const savedCount = autopickup.countThisMonth;
170
+ autopickup.countThisMonth = async () => used;
171
+ const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
172
+ delete require.cache[require.resolve(file)];
173
+ const router = require(file)();
174
+ const route = (method, p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]).route;
175
+ const restore = () => { ps.resolveTweakAutopickup = saved.resolve; ps.set = saved.set; autopickup.countThisMonth = savedCount; };
176
+ return { route, writes, restore };
177
+ }
178
+ const handler = (r) => r.stack[r.stack.length - 1].handle;
179
+
180
+ test('the read is open to any builder and reports this month\'s count against the cap', async () => {
181
+ const { route, restore } = buildRouter({ used: 4 });
182
+ try {
183
+ const get = route('get', '/project-settings/tweak-autopickup');
184
+ assert.equal(get.stack.length, 2, 'requireBuilder + the handler');
185
+ const res = fakeRes();
186
+ await handler(get)({}, res);
187
+ assert.equal(res.payload.mode, 'off');
188
+ assert.equal(res.payload.used_this_month, 4);
189
+ assert.equal(res.payload.remaining_this_month, 16);
190
+ assert.equal(res.payload.per_round_limit, 2);
191
+ assert.equal(res.headers['Cache-Control'], 'no-store');
192
+ } finally { restore(); }
193
+ });
194
+
195
+ test('the write is gated on project.settings.manage and the Rules decision rule', () => {
196
+ const { route, restore } = buildRouter();
197
+ try {
198
+ const write = route('patch', '/project-settings/tweak-autopickup').stack;
199
+ assert.equal(write.length, 4);
200
+ assert.equal(write[2].handle.name, 'actAloneGate');
201
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'), 'utf8');
202
+ assert.match(src, /router\.patch\('\/project-settings\/tweak-autopickup', api\.requireBuilder, api\.requirePermission\('project\.settings\.manage'\)/);
203
+ } finally { restore(); }
204
+ });
205
+
206
+ test('a half-valid body writes nothing; a good one writes both and echoes the new read', async () => {
207
+ const { route, writes, restore } = buildRouter({ used: 0 });
208
+ try {
209
+ const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
210
+ const bad = fakeRes();
211
+ await patch({ body: { mode: 'on', cap: 0 }, builder: { id: 7 } }, bad);
212
+ assert.equal(bad.code, 'bad_tweak_autopickup');
213
+ assert.equal(writes.length, 0);
214
+
215
+ const empty = fakeRes();
216
+ await patch({ body: {}, builder: { id: 7 } }, empty);
217
+ assert.equal(empty.code, 'bad_tweak_autopickup');
218
+
219
+ const good = fakeRes();
220
+ await patch({ body: { mode: 'ON', cap: 5 }, builder: { id: 7 } }, good);
221
+ assert.equal(good.code, null);
222
+ assert.deepEqual(writes, [
223
+ { key: 'tweak.autopickup', value: 'on', by: 7 },
224
+ { key: 'tweak.autopickup_monthly_cap', value: 5, by: 7 },
225
+ ]);
226
+ assert.equal(good.payload.mode, 'on');
227
+ assert.equal(good.payload.cap, 5);
228
+ } finally { restore(); }
229
+ });
230
+
231
+ test('core_275 is additive: one nullable column, recorded in schema_migrations', () => {
232
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8');
233
+ assert.match(sql, /ALTER TABLE claims ADD COLUMN IF NOT EXISTS auto_pickup boolean;/);
234
+ assert.doesNotMatch(sql, /NOT NULL|DROP /);
235
+ assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_275_claims_auto_pickup'\)/);
236
+ });
@@ -0,0 +1,115 @@
1
+ // tests/autopickup_cap_db.mjs — the auto-pickup cap holds under a race, against a
2
+ // REAL Postgres (task 1004677 / BV2.TW19a, ADR 0371 D5).
3
+ //
4
+ // tests/autopickup_cap.mjs proves the refusal ladder DB-free. What it cannot prove
5
+ // is the claim this file exists for: two runners racing for the LAST slot cannot
6
+ // both get it. That is a property of pg_advisory_xact_lock plus the count running
7
+ // inside the claim's transaction, so it needs a real database and two real
8
+ // connections.
9
+ //
10
+ // It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
11
+ // run-unit-tests.js's INTEGRATION set so the DB-free lane never runs it. It builds
12
+ // throwaway `tasks` and `claims` tables, so point it at a scratch database.
13
+ //
14
+ // Run: DATABASE_URL=postgres://... node tests/autopickup_cap_db.mjs
15
+
16
+ import pg from 'pg';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import url from 'node:url';
20
+ import { createRequire } from 'node:module';
21
+ import { strict as assert } from 'node:assert';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
25
+
26
+ try {
27
+ await pool.query('SELECT 1');
28
+ } catch (e) {
29
+ console.log(`autopickup_cap_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
30
+ await pool.end().catch(() => {});
31
+ process.exit(0);
32
+ }
33
+ const { rows: realRows } = await pool.query(`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
34
+ if (realRows[0].has_builders) {
35
+ console.log('autopickup_cap_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB.');
36
+ await pool.end().catch(() => {});
37
+ process.exit(0);
38
+ }
39
+
40
+ let passed = 0, failed = 0;
41
+ async function t(name, fn) {
42
+ try { await fn(); passed++; console.log(` ok ${name}`); }
43
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
44
+ }
45
+
46
+ await pool.query(`
47
+ CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
48
+ CREATE TABLE IF NOT EXISTS tasks (id bigint PRIMARY KEY, source text);
49
+ CREATE TABLE IF NOT EXISTS claims (
50
+ id bigserial PRIMARY KEY, task_id bigint, builder_id bigint,
51
+ claimed_at timestamptz DEFAULT now(), released_at timestamptz);
52
+ `);
53
+ const HERE = path.dirname(url.fileURLToPath(import.meta.url));
54
+ await pool.query(fs.readFileSync(path.join(HERE, '..', 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8'));
55
+
56
+ const { autoPickupRefusal, countThisMonth } = require('../modules/lifecycle/autopickup.js');
57
+
58
+ async function reset() {
59
+ await pool.query('TRUNCATE claims, tasks');
60
+ await pool.query(`INSERT INTO tasks (id, source) VALUES (1,'page-tweak'),(2,'page-tweak'),(3,'page-tweak'),(4,'manual')`);
61
+ }
62
+
63
+ // One marked claim the way claimTask makes it: BEGIN, the gate (which takes the
64
+ // lock and counts), the insert, COMMIT. `pause` holds the transaction open after
65
+ // the count so the other racer's count is forced to happen while it is in flight.
66
+ async function markedClaim(taskId, settings, pause = 0) {
67
+ const client = await pool.connect();
68
+ try {
69
+ await client.query('BEGIN');
70
+ const refusal = await autoPickupRefusal(client, { id: taskId, source: 'page-tweak' }, settings);
71
+ if (refusal) { await client.query('ROLLBACK'); return refusal.code; }
72
+ if (pause) await new Promise((r) => setTimeout(r, pause));
73
+ await client.query('INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES ($1, 1, TRUE)', [taskId]);
74
+ await client.query('COMMIT');
75
+ return 'claimed';
76
+ } catch (e) {
77
+ await client.query('ROLLBACK').catch(() => {});
78
+ throw e;
79
+ } finally { client.release(); }
80
+ }
81
+
82
+ await t('core_275 adds a nullable auto_pickup column', async () => {
83
+ const { rows } = await pool.query(`SELECT is_nullable, data_type FROM information_schema.columns WHERE table_name='claims' AND column_name='auto_pickup'`);
84
+ assert.equal(rows[0].data_type, 'boolean');
85
+ assert.equal(rows[0].is_nullable, 'YES');
86
+ });
87
+
88
+ await t('two runners racing for the last slot: exactly one wins', async () => {
89
+ await reset();
90
+ await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES (3, 1, TRUE)`); // cap 2, one used
91
+ const settings = { mode: 'on', cap: 2 };
92
+ const results = await Promise.all([markedClaim(1, settings, 300), markedClaim(2, settings, 300)]);
93
+ assert.deepEqual(results.sort(), ['AUTO_PICKUP_CAP_REACHED', 'claimed'], JSON.stringify(results));
94
+ assert.equal(await countThisMonth(pool), 2);
95
+ });
96
+
97
+ await t('a round applied twice automatically refuses a third marked claim', async () => {
98
+ await reset();
99
+ const settings = { mode: 'on', cap: 20 };
100
+ assert.equal(await markedClaim(1, settings), 'claimed');
101
+ assert.equal(await markedClaim(1, settings), 'claimed');
102
+ assert.equal(await markedClaim(1, settings), 'AUTO_PICKUP_ROUND_LIMIT');
103
+ });
104
+
105
+ await t('unmarked claims and last month\'s marked claims do not count', async () => {
106
+ await reset();
107
+ await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup, claimed_at) VALUES
108
+ (1, 1, NULL, now()), (2, 1, FALSE, now()), (3, 1, TRUE, date_trunc('month', now()) - interval '1 day'),
109
+ (4, 1, TRUE, now())`); // task 4 is not a page tweak
110
+ assert.equal(await countThisMonth(pool), 0);
111
+ });
112
+
113
+ await pool.end();
114
+ console.log(`\n${passed} passed, ${failed} failed`);
115
+ process.exit(failed ? 1 : 0); // the doorway's own pool would otherwise hold the process open
@@ -515,24 +515,16 @@ test('text is never dimmed by subtree opacity', () => {
515
515
  assert.deepEqual(offenders, [], `\n ${offenders.join('\n ')}\n`);
516
516
  });
517
517
 
518
- test('the .prog strip mirrors .strip: state marks are --sea-ink, ticks are --on-ok', () => {
519
- // /create's done panel renders the page-local .prog, NOT cosmos's .strip —
520
- // a re-point of one and not the other is exactly how the 1.74:1 dark tick
521
- // shipped the first time. State-mark rules (done fill/connector, now
522
- // ring/pulse, the tick) must use the derived tokens; raw --sea is only legal
523
- // as a tint ingredient (color-mix) or the idle -soft rail.
524
- const progRules = [...pageSrc.matchAll(/\.prog[^{}]*\{([^}]*)\}/g)];
525
- assert.ok(progRules.length >= 5, 'the .prog rules moved out of projects.html — update this test');
526
- const bad = [];
527
- for (const m of progRules) {
528
- const body = m[1];
529
- if (/(?:border(?:-\w+)?|background)\s*:\s*var\(\s*--sea\s*\)/.test(body)) {
530
- bad.push(`raw --sea state mark: ${m[0].slice(0, 80)}`);
531
- }
532
- if (/border-(?:left|bottom)\s*:[^;]*#fff\b/i.test(body)) {
533
- bad.push(`hardcoded #fff tick: ${m[0].slice(0, 80)}`);
534
- }
535
- }
518
+ test('part 03 draws no stage strip, and its fact marks use the status inks, never raw --sea or #fff (task 1004683)', () => {
519
+ // The done panel's page-local .prog strip (a mirror of cosmos's .strip) left with the
520
+ // stage dots: part 03 is one list of checked facts now. The mine and manage views keep
521
+ // cosmos's own .strip. A fact's mark carries its state in the --ok / --err / --accent
522
+ // inks (which the day room re-points through --wz-*); raw --sea or a literal white
523
+ // would be the 1.74:1 dark tick this test was first written for.
524
+ assert.equal([...pageSrc.matchAll(/\.prog[^{}]*\{([^}]*)\}/g)].length, 0, 'no .prog rules are left in projects.html');
525
+ const marks = [...pageSrc.matchAll(/\.setFact\.(done|bad|now|you) \.d\{([^}]*)\}/g)];
526
+ assert.ok(marks.length >= 4, 'the four marked fact states are found');
527
+ const bad = marks.filter((m) => /var\(\s*--sea\s*\)|#fff\b/i.test(m[2])).map((m) => m[0].slice(0, 80));
536
528
  assert.deepEqual(bad, [], `\n ${bad.join('\n ')}\n`);
537
529
  });
538
530
 
@@ -82,8 +82,8 @@ function region(startMark, endMark) {
82
82
  const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
83
83
  return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
84
84
  }
85
- const POLLS = region(" /* ── C8: the wizard's polls run only while the create view is on stage", ' /* ── live build progress (task 2083)');
86
- const WATCH_VARS = region(' var pollTimer = null;', " /* The done panel's own flow vocabularies");
85
+ const POLLS = region(" /* ── C8: the wizard's polls run only while the create view is on stage", " /* ── the owner's instance read (task 2083)");
86
+ const WATCH_VARS = region(' var pollTimer = null;', ' function startProgress(id) {');
87
87
  const START_PROGRESS = (SCRIPT.match(/ {4}function startProgress\(id\) \{[\s\S]*?\n {4}\}/) || [null])[0];
88
88
  const DOORS = region(" /* ── the hub's three doors ── */", ' return { enter: enter, leave: leave, onAuth: onAuth };');
89
89
 
@@ -136,7 +136,8 @@ function page({ url, who, storage = 'empty', name = '', hidden = false, instance
136
136
  renderDone() { log.rendered += 1; }, renderSteps() { log.rendered += 1; },
137
137
  resetForNewProject() { log.resets += 1; },
138
138
  saveDraft() {},
139
- setProgress() {}, progressMarkup: () => '', setMoment() {}, renderProgress: () => false, addressReady: () => true,
139
+ // part 03's facts (task 1004683) are tests/setup_status_facts.mjs; here they only have to exist
140
+ paintFacts() {}, homeFacts: () => [], ghFacts: () => [], setupReady: () => false, hostOk: () => true, addressReady: () => true,
140
141
  };
141
142
  sandbox.globalThis = sandbox;
142
143
  vm.createContext(sandbox);
@@ -8,8 +8,8 @@
8
8
  // app's OWN key: the creds read back out of a REAL webEnvBody, a JWT that verifies,
9
9
  // installed / not installed / cannot tell, device flow on / off / cannot tell, and the
10
10
  // tick recording only what GitHub confirmed — never a secret in a log line.
11
- // 3. the REAL projection (githubSetupRead) run into the REAL page renderers (projects.html,
12
- // the vm-slice idiom): each server state draws the step the founder should see.
11
+ // 3. the REAL projection (githubSetupRead) run into the REAL page facts (projects.html,
12
+ // ghFacts, the vm-slice idiom; task 1004683): each server state draws the fact the founder should see.
13
13
  // 4. the device-flow finding is written down where the next builder will look.
14
14
  //
15
15
  // Run: node --test tests/github_one_trip.mjs
@@ -185,38 +185,35 @@ test('the window query takes each project\'s LATEST attempt, past placement, sti
185
185
  assert.match(fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'provision.js'), 'utf8'), /require\('\.\/provision-github-setup\.js'\)\.verifyGithubAppSetups\(deps\)/, 'it runs on every runner tick, after the conversions');
186
186
  });
187
187
 
188
- // ── 3. the real projection into the real renderers ──────────────────────────
188
+ // ── 3. the real projection into the real page facts ─────────────────────────
189
+ // Since task 1004683 part 03 draws FACTS, not checklist markup: ghFacts() is pure over
190
+ // what the reads said, and paintFacts draws it from nodes (tests/setup_status_facts.mjs
191
+ // runs the whole route-to-DOM path). Here: the GitHub half, fed by the real projection.
189
192
 
190
- const RS = SCRIPT.indexOf(' /* The four GitHub steps of part 03');
191
- const RE = SCRIPT.indexOf(' var STEP_RENDERERS = {', RS);
193
+ const FS = SCRIPT.indexOf(' function ghStep(key) {');
194
+ const FE = SCRIPT.indexOf(' /* ── drawing it: nodes and textContent only', FS);
192
195
  const SS = SCRIPT.indexOf(' /* the checklist the server derived (task 1004649)');
193
196
  const SE = SCRIPT.indexOf(' function pollManifest(stateToken) {', SS);
194
- const PS = SCRIPT.indexOf(' function stepsPaint() {');
195
- const PE = SCRIPT.indexOf(' (function hopInput()', PS);
196
197
 
197
198
  test('the regions are findable (a rename must fail loudly)', () => {
198
- assert.ok(RS > 0 && RE > RS && SS > 0 && SE > SS && PS > 0 && PE > PS);
199
+ assert.ok(FS > 0 && FE > FS && SS > 0 && SE > SS);
200
+ assert.match(SCRIPT.slice(FS, FE), /function ghFacts\(now\)/);
199
201
  });
200
202
 
201
203
  function page({ manifest, appSlug = null }) {
202
204
  const sandbox = {
203
- live: { manifest, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug, setup: null, installUrl: null, instStatus: 'active', addrReady: true },
205
+ live: { manifest, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug, appOrg: null, setup: null, installUrl: null, pickUp: null, verifying: false, askedPlatform: false, instStatus: 'active', addrReady: true, inst: null, readAt: 0, gone: '', repo: null },
204
206
  state: { name: 'Mer<cury', slug: 'mercury', owner: 'acme', repo: 'mercury', step: 2 },
205
- autoOpenArmed: false,
206
- resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com',
207
- esc: (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
208
- GH_ICON: '', manualOauthDetails: () => '', projectPageLink: (t) => t, autoOpenTick: () => '',
209
- progressMarkup: (a, b, note) => `<prog>${note}</prog>`, encodeURIComponent, JSON, Array,
207
+ resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com', addressReady: () => true,
208
+ document: { hidden: false, hasFocus: () => true },
210
209
  };
211
210
  vm.createContext(sandbox);
212
- vm.runInContext(`${SCRIPT.slice(RS, RE)}\n${SCRIPT.slice(SS, SE)}\n${SCRIPT.slice(PS, PE)}`, sandbox);
211
+ vm.runInContext(`${SCRIPT.slice(FS, FE)}\n${SCRIPT.slice(SS, SE)}`, sandbox);
213
212
  return sandbox;
214
213
  }
215
- const ROWS = ['renderStepOauthApp', 'renderStepAppInstall', 'renderStepFirstSignIn', 'renderStepDeviceFlow'];
216
- function draw(sb) {
217
- return ROWS.map((f) => vm.runInContext(`${f}()`, sb));
218
- }
219
- function cls(li) { return ((li.match(/^<li(?: class="([^"]*)")?/) || [])[1] || '').split(/\s+/).filter(Boolean); }
214
+ // vm-born values compared through JSON
215
+ const draw = (sb) => JSON.parse(JSON.stringify(vm.runInContext('ghFacts(Date.now())', sb)));
216
+ const states = (rows) => rows.map((r) => r.state);
220
217
 
221
218
  async function serverSays(status, appSlug, eventRows) {
222
219
  const db = { query: async () => ({ rows: eventRows }) };
@@ -224,55 +221,50 @@ async function serverSays(status, appSlug, eventRows) {
224
221
  return GS.githubSetupRead(db, { target_ref: 'acme/mercury' }, row, status);
225
222
  }
226
223
 
227
- test('the mockup\'s four steps, in its words and order, the typed name escaped', () => {
228
- const sb = page({ manifest: null });
229
- const rows = draw(sb);
230
- const titles = rows.map((li) => (li.match(/<b>([^<]*)<\/b>/) || [])[1]);
231
- assert.deepEqual(titles, ['Approve the Mer&lt;cury app on GitHub', 'Install it on your repo', 'Builders can sign in', 'Command-line sign-in is on']);
232
- assert.match(rows[0], /data-action="manifest-start"[^>]*>Connect GitHub ↗/, 'one click starts the one trip');
233
- assert.equal(SCRIPT.match(/var STEP_ORDER = \[([^\]]*)\]/)[1], "'github-repo', 'oauth-app', 'app-install', 'first-sign-in', 'device-flow', 'enter-hall'", 'drawn in the mockup\'s order');
224
+ test('the mockup\'s four facts, in its words and order; the typed name is text, never markup', () => {
225
+ const rows = draw(page({ manifest: null }));
226
+ assert.deepEqual(rows.map((r) => r.title), ['Approve the Mer<cury app on GitHub', 'Install it on your repo', 'Builders can sign in', 'Command-line sign-in']);
227
+ assert.deepEqual(rows.map((r) => r.key), ['approve', 'install', 'sign_in', 'device_flow']);
228
+ assert.equal(rows[0].act, 'connect', 'one click starts the one trip, from inside the Approve fact');
229
+ assert.equal(rows[0].state, 'you', 'it is the founder\'s turn');
230
+ assert.equal(rows[3].opt, true, 'command-line sign-in is optional');
231
+ assert.doesNotMatch(SCRIPT.slice(FS, FE), /esc\(|innerHTML/, 'the facts carry text; the painter writes nodes');
234
232
  });
235
233
 
236
- test('each server state draws the step the founder should see — from the REAL projection', async () => {
234
+ test('each server state draws the facts the founder should see — from the REAL projection', async () => {
237
235
  // keys placed, nothing verified on GitHub yet
238
236
  let sb = page({ manifest: 'converting', appSlug: 'mercury-app' });
239
237
  let d = await serverSays('converting', 'mercury-app', []);
240
- vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
238
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
241
239
  let rows = draw(sb);
242
- assert.deepEqual(rows.map(cls), [['sdone'], [], ['slocked'], ['slocked', 'soptional']]);
243
- assert.match(rows[1], /href="https:\/\/github\.com\/apps\/mercury-app\/installations\/new\?state=S1"/, 'the way back to the install, with the attempt');
244
- assert.equal(vm.runInContext('stepsPaint.toString().length > 0', sb), true);
240
+ assert.deepEqual(states(rows), ['done', 'you', 'now', 'todo']);
241
+ assert.equal(rows[1].href, 'https://github.com/apps/mercury-app/installations/new?state=S1', 'the way back to the install, with the attempt');
242
+ assert.equal(rows[1].sub, 'acme/mercury', 'the repo the server says it installs on');
243
+ assert.equal(rows[1].right, 'not yet seen');
245
244
 
246
245
  // sign-in verified, install still unconfirmed: NOT connected
247
246
  sb = page({ manifest: 'done', appSlug: 'mercury-app' });
248
247
  d = await serverSays('done', 'mercury-app', []);
249
- vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
248
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
250
249
  rows = draw(sb);
251
- assert.deepEqual(rows.map(cls), [['sdone'], [], ['sdone'], ['soptional']]);
252
- assert.match(rows[3], /Optional — turn it on later\./);
250
+ assert.deepEqual(states(rows), ['done', 'you', 'done', 'todo']);
251
+ assert.equal(JSON.stringify(rows[3].sub), JSON.stringify(['Turn it on later in ', { t: 'the app settings', href: 'https://github.com/settings/apps/mercury-app' }, '.']));
253
252
  assert.equal(vm.runInContext('setupOpen()', sb), true, 'the page keeps watching for the install');
254
253
 
255
254
  // GitHub confirmed the install to the runner: connected, device flow still optional
256
255
  sb = page({ manifest: 'done', appSlug: 'mercury-app' });
257
256
  d = await serverSays('done', 'mercury-app', [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }]);
258
- vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
257
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
259
258
  rows = draw(sb);
260
- assert.deepEqual(rows.map(cls), [['sdone'], ['sdone'], ['sdone'], ['soptional']]);
261
- assert.match(rows[1], /The app is on acme\/mercury/);
259
+ assert.deepEqual(states(rows), ['done', 'done', 'done', 'todo']);
262
260
  assert.equal(vm.runInContext('setupOpen()', sb), false);
263
- // the part's state line, from these very rows: the optional step does not hold it back
264
- const items = rows.map((li) => ({ classList: { contains: (c) => cls(li).includes(c) } }));
265
- const ghState = { textContent: '' };
266
- sb.$ = (id) => (id === 'humanSteps' ? { children: items } : id === 'ghState' ? ghState : null);
267
- sb.manifestInFlight = () => false;
268
- vm.runInContext('stepsPaint()', sb);
269
- assert.equal(ghState.textContent, 'connected ✓');
261
+ assert.equal(vm.runInContext('setupCount([], ghFacts(Date.now()))', sb), 'ready ✓', 'the optional fact does not hold the count back');
270
262
 
271
263
  // and once GitHub has device flow on, the fourth ticks too
272
264
  sb = page({ manifest: 'done', appSlug: 'mercury-app' });
273
265
  d = await serverSays('done', 'mercury-app', [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }, { event: 'github-app-device-flow', at: '2026-10-08T10:05:00Z' }]);
274
- vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
275
- assert.deepEqual(cls(draw(sb)[3]), ['sdone', 'soptional']);
266
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
267
+ assert.equal(draw(sb)[3].state, 'done');
276
268
  });
277
269
 
278
270
  test('seedSetup is change-only, and an install link must be GitHub\'s', () => {
@@ -286,6 +278,12 @@ test('seedSetup is change-only, and an install link must be GitHub\'s', () => {
286
278
  vm.runInContext(`seedSetup(${c}, 'javascript:alert(1)')`, sb2);
287
279
  assert.equal(vm.runInContext('live.installUrl', sb2), null);
288
280
  assert.equal(vm.runInContext('seedSetup(null, null)', sb2), false);
281
+ // the repo it installs on rides the read, strict
282
+ const sb3 = page({ manifest: 'done', appSlug: 'a' });
283
+ vm.runInContext(`seedSetup(${c}, null, { repo: 'acme/mer cury' })`, sb3);
284
+ assert.equal(vm.runInContext('live.repo', sb3), null, 'a malformed repo never reaches the page');
285
+ vm.runInContext(`seedSetup(${c}, null, { repo: 'acme/mercury' })`, sb3);
286
+ assert.equal(vm.runInContext('live.repo', sb3), 'acme/mercury');
289
287
  });
290
288
 
291
289
  // ── 4. the finding is written down ──────────────────────────────────────────
@@ -113,7 +113,7 @@ const PICKER = region(" /* ── whose GitHub account: mine or an organisati
113
113
  const ENSURE_REPO = region(' function ensureFreshRepo() {', " $('doCreate').addEventListener('click'");
114
114
  const NOTE_ORG = region(" /* the organisation the server says this attempt's app is on: change-only */", ' function startManifest(btn) {');
115
115
  const START = region(' function startManifest(btn) {', ' /* the "start again" offer once a wait has clearly gone wrong');
116
- const SETTINGS = region(' /* the organisation that owns the app, when the founder made it on one (task 1004651) */', ' /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow');
116
+ const SETTINGS = region(' /* the organisation that owns the app, when the founder made it on one (task 1004651) */', ' /* When a fact was seen, said plainly');
117
117
 
118
118
  test('the page regions are still findable (a rename must fail loudly, not test nothing)', () => {
119
119
  for (const [name, r] of Object.entries({ PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS })) assert.ok(r, `${name} region not found`);
@@ -165,7 +165,7 @@ function page({ state = {}, live = {} } = {}) {
165
165
  live: { manifest: null, manifestMsg: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, ...live },
166
166
  repoAccess: true, instanceId: 42, me: { github_login: 'founder' },
167
167
  hopForRepoAccess: () => false, syncHome() {}, saveDraft() {},
168
- rememberManifest() {}, renderSteps() {}, pollManifest() {}, watchGhTab() {},
168
+ rememberManifest() {}, paintFacts() {}, repaintFacts() {}, pollManifest() {}, watchGhTab() {},
169
169
  };
170
170
  sandbox.manifestStartedAt = 0;
171
171
  vm.createContext(sandbox);