@bongos/core 1.21.166 → 1.21.168
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 +153 -73
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +306 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +21 -20
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +445 -445
- package/modules/autonomy/governor-docket.js +10 -5
- package/modules/builder-settings/builder-needs.js +29 -3
- package/modules/copy-desk/guarded-lines.js +89 -0
- package/modules/copy-desk/guarded-lines.json +14 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/page-status.js +77 -7
- package/modules/copy-desk/pages.js +4 -0
- package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +8 -0
- package/modules/copy-desk/stuck-tweaks.js +79 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
- package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
- package/modules/government/asks-db.js +38 -2
- package/modules/government/catalog.js +1 -1
- package/modules/government/docket.js +20 -2
- package/modules/government/migrations/government_032_ask_flags.sql +35 -0
- package/modules/government/routes/government.js +43 -1
- package/modules/hall-ui/public/approval-queue-lib.js +4 -0
- package/modules/hall-ui/public/approval-queue.js +1 -0
- package/modules/hall-ui/public/brand-apply.js +3 -0
- package/modules/hall-ui/public/city-decide.js +642 -0
- package/modules/hall-ui/public/city-draw.js +14 -3
- package/modules/hall-ui/public/city-switchboard.js +141 -0
- package/modules/hall-ui/public/city.css +176 -10
- package/modules/hall-ui/public/city.html +8 -3
- package/modules/hall-ui/public/city.js +185 -38
- package/modules/hall-ui/public/sky-data.js +4 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
- package/modules/hall-ui/public/tweak-editor.css +2 -0
- package/modules/hall-ui/public/tweak-editor.js +3 -1
- package/modules/lifecycle/db-claims.js +20 -5
- package/modules/lifecycle/rebase-gate.js +78 -0
- package/modules/lifecycle/routes/claims.js +10 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/gds/ask.js +68 -1
- package/scripts/gds/claim.js +6 -1
- package/scripts/gds/copy-apply.js +2 -0
- package/src/bongos/module-promotion.js +219 -0
- package/src/bongos/route-rank-check.js +7 -0
- package/src/bongos/routes/me.js +11 -0
- package/src/bongos/routes/modules.js +46 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -0
- package/tests/claim_gate_ci_unblock.mjs +4 -0
- package/tests/claim_gate_rebase.mjs +56 -3
- package/tests/copy_desk_page_editor.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +42 -0
- package/tests/copy_guarded_lines.mjs +54 -0
- package/tests/copy_stuck_page_tweaks.mjs +118 -0
- package/tests/government_ask_flags.mjs +80 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/government_treasury.mjs +1 -0
- package/tests/governor_docket.mjs +1 -1
- package/tests/hall_approval_queue.mjs +15 -0
- package/tests/hall_city.mjs +5 -4
- package/tests/hall_city_living.mjs +118 -12
- package/tests/hall_city_rewording.mjs +51 -0
- package/tests/hall_city_switchboard.mjs +63 -0
- package/tests/hall_gov_real_site.mjs +2 -2
- package/tests/hall_sky.mjs +18 -0
- package/tests/hall_street_deck.mjs +6 -4
- package/tests/hall_street_depth.mjs +1 -1
- package/tests/hall_street_desk.mjs +5 -3
- package/tests/hall_tweak_editor.mjs +26 -0
- package/tests/module_promotion.mjs +290 -0
- package/tests/rebase_gate_set_aside.mjs +73 -0
- package/tests/reward_mode_surfacing.mjs +12 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// tests/hall_city_rewording.mjs — a matter sent for a rewrite (the "?", owner
|
|
2
|
+
// 2026-10-23) leaves the to-do until its new words land, and is counted apart
|
|
3
|
+
// as "Rewording", building by building.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import vm from 'node:vm';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
13
|
+
|
|
14
|
+
function boot() {
|
|
15
|
+
const ctx = { console, document: {} };
|
|
16
|
+
ctx.window = ctx;
|
|
17
|
+
vm.createContext(ctx);
|
|
18
|
+
vm.runInContext(fs.readFileSync(path.join(PUB, 'city-decide.js'), 'utf8'), ctx);
|
|
19
|
+
return ctx.window.GovCityDecide;
|
|
20
|
+
}
|
|
21
|
+
const DOCKET = { matters: [
|
|
22
|
+
{ id: 'blockers:1', building: 'planning', unclear: { times: 1 } },
|
|
23
|
+
{ id: 'blockers:2', building: 'planning' },
|
|
24
|
+
{ id: 'redteam_reports:3', building: 'disputes', unclear: { times: 2 } },
|
|
25
|
+
] };
|
|
26
|
+
|
|
27
|
+
test('a flagged matter is left out of the queue as if answered, and the answered stay out too', () => {
|
|
28
|
+
const G = boot();
|
|
29
|
+
const done = JSON.parse(JSON.stringify(G.withoutReworded({ 'blockers:9': true }, DOCKET)));
|
|
30
|
+
assert.deepEqual(Object.keys(done).sort(), ['blockers:1', 'blockers:9', 'redteam_reports:3']);
|
|
31
|
+
assert.equal(done['blockers:2'], undefined, 'an unflagged matter stays in the queue');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('Rewording counts per building, and everywhere', () => {
|
|
35
|
+
const G = boot();
|
|
36
|
+
assert.equal(G.rewordingCount(DOCKET, 'planning'), 1);
|
|
37
|
+
assert.equal(G.rewordingCount(DOCKET, 'disputes'), 1);
|
|
38
|
+
assert.equal(G.rewordingCount(DOCKET, 'treasury'), 0);
|
|
39
|
+
assert.equal(G.rewordingCount(DOCKET, null), 2);
|
|
40
|
+
assert.equal(G.rewordingCount({ matters: [] }, null), 0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the "?" asks before it sends: the send lives behind a confirm button', () => {
|
|
44
|
+
const src = fs.readFileSync(path.join(PUB, 'city-decide.js'), 'utf8');
|
|
45
|
+
const fn = src.split('function unclearButton')[1].split('\n }\n')[0];
|
|
46
|
+
const send = fn.indexOf("on.send({ method: 'POST'");
|
|
47
|
+
const confirm = fn.indexOf("go.addEventListener('click'");
|
|
48
|
+
assert.ok(send > 0 && confirm > 0 && send > confirm, 'the request is sent only from the confirm button');
|
|
49
|
+
assert.match(fn, /Send this to Claude to rewrite\?/);
|
|
50
|
+
assert.match(fn, /Nothing is decided or changed/);
|
|
51
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// tests/hall_city_switchboard.mjs — Automation as a switchboard (owner, 2026-10-23).
|
|
2
|
+
// The board draws only the switches the docket hands THIS viewer, every flip
|
|
3
|
+
// sends the desk row's own request, and the fence is never a to-do.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import vm from 'node:vm';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const autoDocket = require(path.join(ROOT, 'modules/autonomy/governor-docket.js'));
|
|
16
|
+
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/government__docket.json'), 'utf8'));
|
|
17
|
+
const DOCKET = FIXTURE.body || FIXTURE;
|
|
18
|
+
|
|
19
|
+
function boot() {
|
|
20
|
+
const ctx = { console };
|
|
21
|
+
ctx.window = ctx;
|
|
22
|
+
vm.createContext(ctx);
|
|
23
|
+
for (const f of ['street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'city-switchboard.js']) vm.runInContext(fs.readFileSync(path.join(PUB, f), 'utf8'), ctx);
|
|
24
|
+
return ctx;
|
|
25
|
+
}
|
|
26
|
+
const j = (v) => JSON.parse(JSON.stringify(v));
|
|
27
|
+
const GOALS = [{ id: '1000089', title: 'Working area 3', status: 'open' }];
|
|
28
|
+
|
|
29
|
+
test('the fence is a standing state, never a to-do', () => {
|
|
30
|
+
for (const mayManage of [true, false]) {
|
|
31
|
+
for (const p of [{ enabled: false, goals: [] }, { enabled: true, goals: [] }]) {
|
|
32
|
+
assert.equal(autoDocket.fenceDocketMatters(p, mayManage)[0].state, 'settled');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
assert.deepEqual(autoDocket.switchDeskRows({ enabled: false, goals: [] })[0].acts, [{ key: 'switch_on' }], 'off: the master row switches it on');
|
|
36
|
+
assert.equal(autoDocket.switchDeskRows({ enabled: true, goals: [] })[0].acts[0].key, 'switch_off', 'on, even with no goal: the master row switches it off');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('the board draws the viewer\'s switches, and each flip is the desk row\'s request', () => {
|
|
40
|
+
const w = boot();
|
|
41
|
+
const m = j(w.GovCitySwitchboard.model(DOCKET, { now: Date.parse('2026-10-09T15:00:00Z'), goals: GOALS }));
|
|
42
|
+
assert.deepEqual(m.groups.map((g) => g.title), ['AI builds on its own (Autobongos)', 'Goals the AI may work on', 'Computers doing the building']);
|
|
43
|
+
const master = m.groups[0].switches[0];
|
|
44
|
+
assert.equal(master.on, false);
|
|
45
|
+
const live = w.GovCitySwitchboard.model(DOCKET, { now: Date.parse('2026-10-09T15:00:00Z'), goals: GOALS });
|
|
46
|
+
const flip = live.groups[0].switches[0].flip;
|
|
47
|
+
assert.deepEqual(j(w.GovMatterCard.request(flip.row.card, flip.key, flip.input)), { method: 'POST', url: '/api/bongos/autonomy/fence', body: { enabled: true } });
|
|
48
|
+
const goal = live.groups[1].switches[0];
|
|
49
|
+
assert.equal(goal.on, false, 'an open goal not yet allowed is an off switch');
|
|
50
|
+
assert.deepEqual(j(w.GovMatterCard.request(goal.flip.row.card, goal.flip.key, goal.flip.input)).body, { goal_id: 1000089 });
|
|
51
|
+
const runner = live.groups[2].switches[0];
|
|
52
|
+
assert.equal(runner.on, true);
|
|
53
|
+
assert.ok(runner.flip.reasons.length > 0, 'pausing a runner asks why, in one tap');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a viewer handed no Automation desks gets no switches', () => {
|
|
57
|
+
const w = boot();
|
|
58
|
+
const bare = j(DOCKET);
|
|
59
|
+
bare.desks = bare.desks.filter((d) => !/^automation\./.test(d.key));
|
|
60
|
+
const m = j(w.GovCitySwitchboard.model(bare, { goals: GOALS }));
|
|
61
|
+
assert.equal(m.any, false);
|
|
62
|
+
assert.deepEqual(m.groups, []);
|
|
63
|
+
});
|
|
@@ -555,9 +555,9 @@ test('a Lead the government appointed stands on their building on a real site; e
|
|
|
555
555
|
assert.match(hit('planning').attrs['aria-label'], new RegExp('led by ' + A_REAL_LEAD));
|
|
556
556
|
assert.match(hit('release').attrs['aria-label'], /held by the owner/);
|
|
557
557
|
hit('planning').fire('click');
|
|
558
|
-
assert.match(city.byId.get('cityCard').
|
|
558
|
+
assert.match(city.byId.get('cityCard').children[0].children[0].attrs.title, new RegExp('Led by ' + A_REAL_LEAD + '\\.'));
|
|
559
559
|
hit('treasury').fire('click');
|
|
560
|
-
assert.match(city.byId.get('cityCard').
|
|
560
|
+
assert.match(city.byId.get('cityCard').children[0].children[0].attrs.title, /hold(s)? it until a Lead is seated\./);
|
|
561
561
|
noPrototype(city, 'city card with real Leads', { seatsRead: true });
|
|
562
562
|
const street = await bootStreet('', false);
|
|
563
563
|
assert.ok(street.calls.includes('/api/bongos/government/leads'), 'the street reads it too');
|
package/tests/hall_sky.mjs
CHANGED
|
@@ -216,6 +216,24 @@ await test('the rings are the criteria, closing as each is satisfied and thick w
|
|
|
216
216
|
assert.equal(g.tasks.find((t) => t.id === '500').sourceRef, '10', 'and which thought it was born of');
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
await test('abandoned work is listed at zero and adds nothing to a well or its ring (task 1004782)', async () => {
|
|
220
|
+
const base = well();
|
|
221
|
+
const p = await boot({ sky: sky([well({
|
|
222
|
+
criteria: [{ ...base.criteria[0], task_ids: ['500', '504'] }, { ...base.criteria[1], task_ids: ['501', '502', '503'] }],
|
|
223
|
+
tasks: [
|
|
224
|
+
...base.tasks,
|
|
225
|
+
{ id: '503', title: 'A dropped task', status: 'abandoned', credits_reward: 500, created_on: '2026-08-03', idea_id: null },
|
|
226
|
+
// merged away: the DB only allows merged_into_task_id on an abandoned row, so this is its whole shape
|
|
227
|
+
{ id: '504', title: 'A task folded into 500', status: 'abandoned', merged_into_task_id: '500', credits_reward: 90, created_on: '2026-08-04', idea_id: null },
|
|
228
|
+
],
|
|
229
|
+
})]) });
|
|
230
|
+
const g = p.S().goalById.get('7');
|
|
231
|
+
assert.equal(g.tasks.find((t) => t.id === '503').credits, 0, 'still listed, at zero');
|
|
232
|
+
assert.equal(g.tasks.find((t) => t.id === '504').credits, 0, 'a merged-away task is listed at zero too');
|
|
233
|
+
assert.equal(g.credits, 130, 'the well\'s mass is the live work only (60 + 40 + 30)');
|
|
234
|
+
same(g.crit.map((c) => c.credits), [60, 70], 'and the ring it sat on is not thickened by it');
|
|
235
|
+
});
|
|
236
|
+
|
|
219
237
|
await test('a private goal keeps its structure and none of its prose', async () => {
|
|
220
238
|
const p = await boot({ sky: sky([well({ goal_id: '8', title: null, subtitle: null, criteria: [{ id: '80', criterion_id: 'c1', criterion_md: null, satisfied: false, pending_review: false, task_ids: [] }], tasks: [{ id: '800', title: null, status: 'ready', credits_reward: 10, created_on: '2026-08-01', idea_id: null }] })]) });
|
|
221
239
|
const g = p.S().goalById.get('8');
|
|
@@ -141,7 +141,6 @@ test('a real answer is the desk row\'s own request, to the route the desk uses',
|
|
|
141
141
|
'redteam_reports:confirm': 'POST /api/bongos/security/reports/N/confirm',
|
|
142
142
|
'redteam_reports:dispute': 'POST /api/bongos/security/reports/N/dispute',
|
|
143
143
|
'override_requests:release': 'POST /api/bongos/override-requests/N/decide',
|
|
144
|
-
'autobongos_fence:switch_on': 'POST /api/bongos/autonomy/fence',
|
|
145
144
|
'treasury_spending:answer': 'POST /api/bongos/government/treasury/borrows/N/answer',
|
|
146
145
|
});
|
|
147
146
|
// a blocker's option is its own chip, so its words are the resolution note
|
|
@@ -151,8 +150,11 @@ test('a real answer is the desk row\'s own request, to the route the desk uses',
|
|
|
151
150
|
const cr = K.cardView(lv.city.asks.find((x) => x.id === 'criteria_uat:1000184'), c);
|
|
152
151
|
assert.deepEqual(cr.options.map((o) => o.plan.mode), ['record', 'send', 'note']);
|
|
153
152
|
assert.equal(cr.options[0].plan.id, '1000184');
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
// the fence is a standing state on the Automation switchboard, never a to-do
|
|
154
|
+
// (task 1004784): its matter is settled and carries no act, so the switch is
|
|
155
|
+
// flipped from the switchboard's desk row, not from a card
|
|
156
|
+
const fence = lv.city.asks.find((x) => x.id === 'autobongos_fence:fence');
|
|
157
|
+
assert.ok(!fence || fence.state !== 'needs', 'the fence never waits on anyone');
|
|
156
158
|
});
|
|
157
159
|
|
|
158
160
|
test('a viewer the server hands no acts gets answers marked not from here, never a send', () => {
|
|
@@ -492,7 +494,7 @@ test('the page: #deck draws the top card over the street; a key answers it throu
|
|
|
492
494
|
assert.equal(next.getAttribute('data-ask'), 'blockers:1000143');
|
|
493
495
|
assert.match(next.className, /\bask--rise\b/, 'the next one rises');
|
|
494
496
|
assert.equal(p.cls(p.deck(), 'deck-dock__of')[0].textContent, ' of 8 to do');
|
|
495
|
-
assert.equal(p.byId.get('hudNeeds').textContent, '
|
|
497
|
+
assert.equal(p.byId.get('hudNeeds').textContent, '36', 'the street\'s header moves with the deck');
|
|
496
498
|
});
|
|
497
499
|
|
|
498
500
|
test('the page: a refusal keeps the card and counts nothing; S skips to the back', async () => {
|
|
@@ -276,7 +276,7 @@ test('what needs you lights salmon windows on the floor that holds it, with a ri
|
|
|
276
276
|
assert.ok(d.windows.some((w) => w.building === 'planning'), 'the viewer leads Planning');
|
|
277
277
|
assert.ok(!d.windows.some((w) => ['review', 'release', 'treasury'].includes(w.building)), 'what waits on another Lead lights nothing');
|
|
278
278
|
// the hub as it is (no Lead read, everything on the owner seat): every docket need is the viewer's own
|
|
279
|
-
assert.deepEqual(street({ leads: null, docket: fx('government__docket') }).depth.windows.map((w) => w.key), ['needs:
|
|
279
|
+
assert.deepEqual(street({ leads: null, docket: fx('government__docket') }).depth.windows.map((w) => w.key), ['needs:disputes:0', 'needs:planning:2', 'needs:release:0', 'needs:review:1', 'needs:treasury:4']);
|
|
280
280
|
// moving and settled light nothing; nor does a source no floor holds
|
|
281
281
|
const doc2 = clone(doc);
|
|
282
282
|
doc2.matters.forEach((m) => { m.state = 'moving'; });
|
|
@@ -404,7 +404,8 @@ function actedDocket({ longWords = false, force = true } = {}) {
|
|
|
404
404
|
desk('people.builders', 'people', buildersRoute.builderDeskRows([{ id: 7, display_name: t('Ana Ruiz'), github_login: 'ana', rank: 'thetes' }, { id: 95, display_name: 'Me', rank: 'archon' }], { viewerId: '95', mayRank: true, mayStop: true, ranks: LADDER })),
|
|
405
405
|
desk('people.ranks', 'people', govFloors.rankDeskRows([{ rank_key: 'tester', label: t('Tester'), is_system: false }, { rank_key: 'archon', label: 'Archon', is_system: true }], [{ builder_id: 7, display_name: 'Ana Ruiz', rank_key: 'tester' }, { builder_id: 8, github_login: 'kai', rank_key: 'metic' }])),
|
|
406
406
|
desk('disputes.confirmed', 'disputes', [reportsRoute.confirmedDeskRow({ id: 3, title: t('token leak'), severity: 'low', confirmed_at: '2026-09-28T10:00:00.000Z' })]),
|
|
407
|
-
|
|
407
|
+
// the switch is a standing row in either position (the fence matter is settled, never a to-do)
|
|
408
|
+
desk('automation.switch', 'automation', autoDocket.switchDeskRows({ enabled: false, paused_reason: 'payments touched', goals: [] }).map((r) => ({ ...r, id: 'switch-off' })).concat(autoDocket.switchDeskRows(project))),
|
|
408
409
|
desk('automation.goals', 'automation', autoDocket.goalDeskRows(project)),
|
|
409
410
|
desk('automation.runners', 'automation', autoDocket.runnerDeskRows([{ builder_id: 7, display_name: t('Ana Ruiz'), runners: [{ working_task_id: 12 }] }, { builder_id: 8, owner_paused: true, owner_paused_reason: 'spending too much', runners: [] }], NOW, healthy)),
|
|
410
411
|
];
|
|
@@ -469,10 +470,11 @@ test('Disputes: a report is confirmed or disputed, a confirmed one marked fixed,
|
|
|
469
470
|
test('Automation: switch on and off, allow a goal from the open goals, first in line, disallow, pause and resume a runner', () => {
|
|
470
471
|
const dk = actedDocket();
|
|
471
472
|
const sw = actedRows(dk, 'automation', 0);
|
|
472
|
-
|
|
473
|
+
assert.ok(!sw.some((r) => r.source === 'autobongos_fence'), 'the switch is not a to-do: its matter is settled');
|
|
474
|
+
const off = sw.find((r) => r.actions.some((a) => a.key === 'switch_on'));
|
|
473
475
|
assert.deepEqual(off.actions.map((a) => a.label), ['switch on']);
|
|
474
476
|
assert.deepEqual(j(C.request(off.card, 'switch_on')), { method: 'POST', url: '/api/bongos/autonomy/fence', body: { enabled: true } });
|
|
475
|
-
const on = sw.find((r) => r.
|
|
477
|
+
const on = sw.find((r) => r.actions.some((a) => a.key === 'switch_off'));
|
|
476
478
|
assert.deepEqual(j(C.request(on.card, 'switch_off', on.choices.switch_off.options[0].input)).body, { enabled: false, reason: 'pause for now' });
|
|
477
479
|
const goals = actedRows(dk, 'automation', 1);
|
|
478
480
|
const first = goals.find((r) => r.name === 'Governor city');
|
|
@@ -805,6 +805,32 @@ try {
|
|
|
805
805
|
assert.equal(b.el('tw-frame').hidden, true);
|
|
806
806
|
});
|
|
807
807
|
|
|
808
|
+
// task 1004776: a guarded line shows what it must still say under it, and the
|
|
809
|
+
// note turns to a warning while the artist types words that drop it.
|
|
810
|
+
await test('a guarded line shows its must-still-say note, and warns as the artist types it away', async () => {
|
|
811
|
+
const guarded = require('../modules/copy-desk/guarded-lines.js');
|
|
812
|
+
guarded._setList({ lines: [{ page_id: 'landing:index', file: 'modules/public-landing/public/index.html', must_contain: ['ship the code'], must_still_say: 'Agents ship the code.' }] });
|
|
813
|
+
try {
|
|
814
|
+
const ed = editorRead({ draftLines: null });
|
|
815
|
+
const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(ed) } });
|
|
816
|
+
await b.settle();
|
|
817
|
+
assert.equal(b.el('tw-L0004-note').textContent, 'must still say: Agents ship the code.');
|
|
818
|
+
assert.match(b.el('tw-L0004-note').className, /tw-note--guarded/);
|
|
819
|
+
b.type('L0004', 'Agents claim the tasks.');
|
|
820
|
+
assert.equal(b.el('tw-L0004-note').textContent, 'this no longer says what it must · must still say: Agents ship the code.');
|
|
821
|
+
b.type('L0004', 'Our agents ship the code.');
|
|
822
|
+
assert.equal(b.el('tw-L0004-note').textContent, 'must still say: Agents ship the code.');
|
|
823
|
+
} finally {
|
|
824
|
+
guarded._setList(null);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
await test('the submit warning is plain words the editor shows after sending', () => {
|
|
829
|
+
assert.deepEqual(lib.submitWarningHtml([{ key: 'L0004', must_still_say: 'Agents ship the code.' }]),
|
|
830
|
+
['A line you changed no longer says what it must: Agents ship the code. It was sent anyway; the person approving it will see this.']);
|
|
831
|
+
assert.deepEqual(lib.submitWarningHtml(undefined), []);
|
|
832
|
+
});
|
|
833
|
+
|
|
808
834
|
await test('the page writes only the copy-desk routes it names', () => {
|
|
809
835
|
const js = read('tweak-editor.js');
|
|
810
836
|
const writes = [...js.matchAll(/api\.request\('(POST|PUT|PATCH|DELETE)', ([^,]+),/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// tests/module_promotion.mjs — the promotion gate: tester to general, and what
|
|
2
|
+
// earns it (task 1003804, ADR 0344 D3; owner decisions 2026-10-23). The rule
|
|
3
|
+
// (decidePromotion) is driven directly; the sweep, the author's standing, the
|
|
4
|
+
// person's release and both routes run against an in-memory stand-in for the
|
|
5
|
+
// four tables they read and the one they write.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/module_promotion.mjs
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
|
|
19
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
20
|
+
const NOW = new Date('2026-10-23T12:00:00Z');
|
|
21
|
+
const ago = (days) => new Date(NOW.getTime() - days * DAY).toISOString();
|
|
22
|
+
|
|
23
|
+
// ---- an in-memory store ---------------------------------------------------
|
|
24
|
+
let state;
|
|
25
|
+
function fresh() {
|
|
26
|
+
state = {
|
|
27
|
+
modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed' } },
|
|
28
|
+
versions: [{ id: 7, module_key: 'weather', version: '1.3.0', tarball_sha256: 'x', artifact_path: 'a', published_at: ago(3) }],
|
|
29
|
+
channels: [{ id: 1, version_id: 7, channel: 'tester', reason: 'new_version', set_by: 9, set_at: ago(3) }],
|
|
30
|
+
installs: [],
|
|
31
|
+
errors: [],
|
|
32
|
+
signals: [
|
|
33
|
+
{ id: 1, version_id: 7, part: 'security', outcome: 'passed', score: null, measured_at: ago(3) },
|
|
34
|
+
{ id: 2, version_id: 7, part: 'tests', outcome: 'scored', score: 100, measured_at: ago(3) },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
39
|
+
fresh();
|
|
40
|
+
|
|
41
|
+
const newestRow = (vid) => state.channels.filter((c) => String(c.version_id) === String(vid))
|
|
42
|
+
.sort((a, b) => (b.set_at.localeCompare(a.set_at)) || (b.id - a.id))[0];
|
|
43
|
+
|
|
44
|
+
const query = async (sql, params = []) => {
|
|
45
|
+
if (/CROSS JOIN LATERAL/.test(sql)) {
|
|
46
|
+
const [vid, limit] = params;
|
|
47
|
+
const ids = [...new Set(state.channels.map((c) => c.version_id))]
|
|
48
|
+
.filter((id) => vid == null || String(id) === String(vid)).sort((a, b) => a - b);
|
|
49
|
+
const rows = [];
|
|
50
|
+
for (const id of ids) {
|
|
51
|
+
const cur = newestRow(id);
|
|
52
|
+
if (cur.channel !== 'tester') continue;
|
|
53
|
+
const v = state.versions.find((x) => x.id === id);
|
|
54
|
+
const mine = (l) => l.filter((r) => r.version_id === id);
|
|
55
|
+
rows.push({
|
|
56
|
+
version_id: id, module_key: v.module_key, version: v.version, current_reason: cur.reason,
|
|
57
|
+
on_tester_since: mine(state.channels).filter((c) => c.channel === 'tester').map((c) => c.set_at).sort()[0],
|
|
58
|
+
clean_installs: mine(state.installs).filter((r) => r.ok).length,
|
|
59
|
+
failed_installs: mine(state.installs).filter((r) => !r.ok).length,
|
|
60
|
+
runtime_errors: mine(state.errors).reduce((n, e) => n + e.error_count, 0),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return { rows: rows.slice(0, limit) };
|
|
64
|
+
}
|
|
65
|
+
if (/FROM module_assessment_signals/.test(sql)) {
|
|
66
|
+
const out = new Map();
|
|
67
|
+
for (const s of [...state.signals].sort((a, b) => b.measured_at.localeCompare(a.measured_at) || b.id - a.id)) {
|
|
68
|
+
if (!params[0].includes(s.version_id)) continue;
|
|
69
|
+
const k = `${s.version_id}:${s.part}`;
|
|
70
|
+
if (!out.has(k)) out.set(k, s);
|
|
71
|
+
}
|
|
72
|
+
return { rows: [...out.values()] };
|
|
73
|
+
}
|
|
74
|
+
if (/^\s*INSERT INTO store_module_version_channels/.test(sql)) {
|
|
75
|
+
const [vid, channel, reason, setBy, fromReason] = params;
|
|
76
|
+
const cur = newestRow(vid);
|
|
77
|
+
if (!cur || cur.reason !== fromReason) return { rows: [], rowCount: 0 };
|
|
78
|
+
const id = Math.max(...state.channels.map((c) => c.id)) + 1;
|
|
79
|
+
// A later set_at than anything already there, so this row is the newest.
|
|
80
|
+
state.channels.push({ id, version_id: vid, channel, reason, set_by: setBy, set_at: new Date(NOW.getTime() + id).toISOString() });
|
|
81
|
+
return { rows: [], rowCount: 1 };
|
|
82
|
+
}
|
|
83
|
+
if (/SELECT channel, reason, set_by, set_at FROM store_module_version_channels/.test(sql)) {
|
|
84
|
+
const cur = newestRow(params[0]); return { rows: cur ? [cur] : [] };
|
|
85
|
+
}
|
|
86
|
+
if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
|
|
87
|
+
const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
|
|
88
|
+
}
|
|
89
|
+
if (/FROM store_module_versions WHERE module_key = \$1 AND version = \$2/.test(sql)) {
|
|
90
|
+
return { rows: state.versions.filter((v) => v.module_key === params[0] && v.version === params[1]) };
|
|
91
|
+
}
|
|
92
|
+
return { rows: [] };
|
|
93
|
+
};
|
|
94
|
+
const pool = { connect: async () => ({ query, release() {} }), query };
|
|
95
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
96
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
|
|
97
|
+
|
|
98
|
+
const {
|
|
99
|
+
PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start,
|
|
100
|
+
} = require('../src/bongos/module-promotion.js');
|
|
101
|
+
const promo = { PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start };
|
|
102
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
103
|
+
|
|
104
|
+
const clean = (n) => { for (let i = 0; i < n; i++) state.installs.push({ version_id: 7, holder_ref: `p${i}`, ok: true }); };
|
|
105
|
+
const current = () => newestRow(7);
|
|
106
|
+
|
|
107
|
+
// ---- the rule -------------------------------------------------------------
|
|
108
|
+
const ev = (over = {}) => ({
|
|
109
|
+
onTesterSince: ago(3), held: false, cleanInstalls: 0, failedInstalls: 0, runtimeErrors: 0,
|
|
110
|
+
security: { outcome: 'passed' }, tests: { outcome: 'scored', score: 100 }, ...over,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('the owner numbers: 7 days, early from day 2 with 5 clean installs', () => {
|
|
114
|
+
assert.equal(promo.PROMOTE_AFTER_DAYS, 7);
|
|
115
|
+
assert.equal(promo.EARLY_AFTER_DAYS, 2);
|
|
116
|
+
assert.equal(promo.EARLY_CLEAN_INSTALLS, 5);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('seven days on tester promotes, even with no tester at all', () => {
|
|
120
|
+
const d = promo.decidePromotion(ev({ onTesterSince: ago(7) }), NOW);
|
|
121
|
+
assert.equal(d.action, 'promote');
|
|
122
|
+
assert.equal(d.reason, 'promoted_after_7_days');
|
|
123
|
+
assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(6.9) }), NOW).action, 'wait');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('early: day 2 and 5 clean installs; not before day 2, not with 4', () => {
|
|
127
|
+
assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(2), cleanInstalls: 5 }), NOW).reason, 'promoted_early_clean_installs');
|
|
128
|
+
assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(1.9), cleanInstalls: 50 }), NOW).action, 'wait');
|
|
129
|
+
assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(5), cleanInstalls: 4 }), NOW).action, 'wait');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('any failed tester install or runtime error holds, whatever the age', () => {
|
|
133
|
+
for (const over of [{ failedInstalls: 1 }, { runtimeErrors: 1 }, { failedInstalls: 1, onTesterSince: ago(30), cleanInstalls: 99 }]) {
|
|
134
|
+
const d = promo.decidePromotion(ev(over), NOW);
|
|
135
|
+
assert.equal(d.action, 'hold');
|
|
136
|
+
assert.equal(d.reason, 'held_tester_errors');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('a held version never moves on its own, even at day 30', () => {
|
|
141
|
+
const d = promo.decidePromotion(ev({ held: true, onTesterSince: ago(30) }), NOW);
|
|
142
|
+
assert.equal(d.action, 'wait');
|
|
143
|
+
assert.equal(d.reason, 'held_until_released');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('security and tests (ADR 0343): moves only when both pass; never held for them', () => {
|
|
147
|
+
const old = { onTesterSince: ago(10) };
|
|
148
|
+
assert.equal(promo.decidePromotion(ev({ ...old, security: { outcome: 'failed' } }), NOW).reason, 'checks_not_passed');
|
|
149
|
+
assert.equal(promo.decidePromotion(ev({ ...old, security: null }), NOW).reason, 'checks_not_passed');
|
|
150
|
+
assert.equal(promo.decidePromotion(ev({ ...old, tests: { outcome: 'scored', score: 80 } }), NOW).reason, 'checks_not_passed');
|
|
151
|
+
assert.equal(promo.decidePromotion(ev({ ...old, tests: { outcome: 'pending' } }), NOW).reason, 'checks_not_passed');
|
|
152
|
+
assert.equal(promo.decidePromotion(ev({ ...old, tests: { outcome: 'no_data' } }), NOW).action, 'promote', 'no tests is not a fail');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---- the sweep ------------------------------------------------------------
|
|
156
|
+
test('the sweep promotes early on clean installs, with a new system row', async () => {
|
|
157
|
+
fresh(); clean(5);
|
|
158
|
+
const out = await promo.sweepPromotions({ db: pool, now: NOW });
|
|
159
|
+
assert.deepEqual(out.promoted.map((p) => p.reason), ['promoted_early_clean_installs']);
|
|
160
|
+
assert.equal(current().channel, 'general');
|
|
161
|
+
assert.equal(current().set_by, null, 'the system moved it');
|
|
162
|
+
assert.equal(state.channels.length, 2, 'a move is a new row; the tester row stays');
|
|
163
|
+
const again = await promo.sweepPromotions({ db: pool, now: NOW });
|
|
164
|
+
assert.equal(again.promoted.length + again.held.length, 0, 'a general version is not swept again');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('the sweep holds on an error, once, and the hold outlives day 7', async () => {
|
|
168
|
+
fresh(); clean(9);
|
|
169
|
+
state.errors.push({ version_id: 7, error_count: 2 });
|
|
170
|
+
const out = await promo.sweepPromotions({ db: pool, now: NOW });
|
|
171
|
+
assert.equal(out.held.length, 1);
|
|
172
|
+
assert.equal(current().channel, 'tester');
|
|
173
|
+
assert.equal(current().reason, 'held_tester_errors');
|
|
174
|
+
assert.equal(current().set_by, null);
|
|
175
|
+
const later = await promo.sweepPromotions({ db: pool, now: new Date(NOW.getTime() + 30 * DAY) });
|
|
176
|
+
assert.equal(later.held.length + later.promoted.length, 0, 'no second hold row, no promotion');
|
|
177
|
+
assert.equal(state.channels.length, 2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('the sweep moves an untested version at day 7 and leaves a young one', async () => {
|
|
181
|
+
fresh();
|
|
182
|
+
assert.equal((await promo.sweepPromotions({ db: pool, now: NOW })).promoted.length, 0);
|
|
183
|
+
const out = await promo.sweepPromotions({ db: pool, now: new Date(NOW.getTime() + 4 * DAY) });
|
|
184
|
+
assert.deepEqual(out.promoted.map((p) => p.reason), ['promoted_after_7_days']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---- the author's standing ------------------------------------------------
|
|
188
|
+
test('an author reads the rule and where their version stands', async () => {
|
|
189
|
+
fresh(); clean(2);
|
|
190
|
+
const s = await promo.readStanding(7, { db: pool, now: NOW });
|
|
191
|
+
assert.equal(s.channel, 'tester');
|
|
192
|
+
assert.equal(s.moved_by, '9');
|
|
193
|
+
assert.match(s.rule, /7 days/);
|
|
194
|
+
assert.match(s.rule, /2 days once 5 or more testers/);
|
|
195
|
+
assert.equal(s.next.action, 'wait');
|
|
196
|
+
assert.equal(s.next.clean_installs, 2);
|
|
197
|
+
assert.equal(s.next.days_on_tester, 3);
|
|
198
|
+
assert.equal(s.next.general_on, new Date(new Date(ago(3)).getTime() + 7 * DAY).toISOString());
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---- the person's release -------------------------------------------------
|
|
202
|
+
test('only a held version can be released, and the person is recorded', async () => {
|
|
203
|
+
fresh();
|
|
204
|
+
const early = await promo.releaseHeld(7, 3, { db: pool });
|
|
205
|
+
assert.equal(early.ok, false);
|
|
206
|
+
assert.equal(early.code, 'not_held');
|
|
207
|
+
state.installs.push({ version_id: 7, holder_ref: 'p', ok: false });
|
|
208
|
+
await promo.sweepPromotions({ db: pool, now: NOW });
|
|
209
|
+
const r = await promo.releaseHeld(7, 3, { db: pool });
|
|
210
|
+
assert.equal(r.ok, true);
|
|
211
|
+
assert.equal(current().channel, 'general');
|
|
212
|
+
assert.equal(current().reason, 'released_by_person');
|
|
213
|
+
assert.equal(current().set_by, 3);
|
|
214
|
+
assert.equal((await promo.releaseHeld(7, 3, { db: pool })).code, 'not_held', 'a second release is refused');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---- the routes -----------------------------------------------------------
|
|
218
|
+
function handlerFor(router, method, p) {
|
|
219
|
+
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
220
|
+
assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
|
|
221
|
+
const stack = layer.route.stack;
|
|
222
|
+
return { handle: stack[stack.length - 1].handle, depth: stack.length };
|
|
223
|
+
}
|
|
224
|
+
async function invoke(handle, { key = 'weather', version = '1.3.0', body = {} } = {}) {
|
|
225
|
+
const res = {
|
|
226
|
+
statusCode: 200, body: undefined,
|
|
227
|
+
json(b) { this.body = b; return this; },
|
|
228
|
+
status(c) { this.statusCode = c; return this; },
|
|
229
|
+
set() { return this; },
|
|
230
|
+
fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
|
|
231
|
+
};
|
|
232
|
+
let nextErr = null;
|
|
233
|
+
await handle({ params: { key, version }, body, builder: { id: 3 } }, res, (err) => { nextErr = err; });
|
|
234
|
+
if (nextErr) throw nextErr;
|
|
235
|
+
return res;
|
|
236
|
+
}
|
|
237
|
+
const router = buildModulesRouter();
|
|
238
|
+
const release = handlerFor(router, 'post', '/store/modules/:key/versions/:version/release');
|
|
239
|
+
const channel = handlerFor(router, 'get', '/store/modules/:key/versions/:version/channel');
|
|
240
|
+
|
|
241
|
+
test('release is gated on module.delist (owner, no new permission) and pinned', () => {
|
|
242
|
+
assert.ok(release.depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
|
|
243
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
244
|
+
assert.match(src, /router\.post\('\/store\/modules\/:key\/versions\/:version\/release', auth\.requireBuilder, auth\.requirePermission\('module\.delist'\)/);
|
|
245
|
+
const pins = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
|
|
246
|
+
assert.match(pins, /'POST \/store\/modules\/:key\/versions\/:version\/release': 'perm:module\.delist'/);
|
|
247
|
+
const { byKey } = require('../modules/government/catalog.js');
|
|
248
|
+
assert.match(byKey('module.delist').guards, /POST \/store\/modules\/:key\/versions\/:version\/release/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('release needs a reason, refuses a version not held, and releases a held one', async () => {
|
|
252
|
+
fresh();
|
|
253
|
+
for (const body of [{}, { reason: ' ' }]) assert.equal((await invoke(release.handle, { body })).body.error, 'reason_required');
|
|
254
|
+
assert.equal((await invoke(release.handle, { body: { reason: 'r', channel: 'general' } })).statusCode, 400, 'unknown fields are refused');
|
|
255
|
+
assert.equal((await invoke(release.handle, { version: '9.9.9', body: { reason: 'r' } })).statusCode, 404);
|
|
256
|
+
const notHeld = await invoke(release.handle, { body: { reason: 'r' } });
|
|
257
|
+
assert.equal(notHeld.statusCode, 409);
|
|
258
|
+
assert.equal(notHeld.body.error, 'not_held');
|
|
259
|
+
state.errors.push({ version_id: 7, error_count: 1 });
|
|
260
|
+
await promo.sweepPromotions({ db: pool, now: NOW });
|
|
261
|
+
const ok = await invoke(release.handle, { body: { reason: 'the error was the tester\'s own config' } });
|
|
262
|
+
assert.equal(ok.statusCode, 200);
|
|
263
|
+
assert.equal(ok.body.channel, 'general');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the channel read: any signed-in builder, the standing, 404 for no version', async () => {
|
|
267
|
+
fresh();
|
|
268
|
+
assert.equal(channel.depth, 2, 'requireBuilder only — the author must be able to read it');
|
|
269
|
+
const res = await invoke(channel.handle);
|
|
270
|
+
assert.equal(res.statusCode, 200);
|
|
271
|
+
assert.equal(res.body.channel, 'tester');
|
|
272
|
+
assert.equal(res.body.module_key, 'weather');
|
|
273
|
+
assert.ok(res.body.next);
|
|
274
|
+
assert.equal((await invoke(channel.handle, { version: '2.0.0' })).statusCode, 404);
|
|
275
|
+
state.channels = [];
|
|
276
|
+
const none = await invoke(channel.handle);
|
|
277
|
+
assert.equal(none.statusCode, 404);
|
|
278
|
+
assert.equal(none.body.error, 'version_has_no_channel', 'a version with no channel row says so');
|
|
279
|
+
fresh();
|
|
280
|
+
assert.equal((await invoke(channel.handle, { key: 'Bad_Key' })).statusCode, 400);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('the sweep is started by the server after listen, never by a test, and can be switched off', () => {
|
|
284
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/platform-server.js'), 'utf8');
|
|
285
|
+
assert.match(src, /require\('\.\/bongos\/module-promotion'\); startPromotionSweep\(\)/);
|
|
286
|
+
const start = src.indexOf('function startBackgroundServices');
|
|
287
|
+
const body = src.slice(start, src.indexOf('}', start));
|
|
288
|
+
assert.doesNotMatch(body, /module-promotion/);
|
|
289
|
+
assert.equal(promo.start({ env: { MODULE_PROMOTION_DISABLED: '1' } }), false);
|
|
290
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// tests/rebase_gate_set_aside.mjs — one stuck land must not block every claim
|
|
2
|
+
// (task 1004788). DB-free half: the shared predicate (rebase-gate.js), the 409
|
|
3
|
+
// hint's wording, and that all three claim surfaces call the ONE predicate. The
|
|
4
|
+
// real-DB half (claimTask, claimTasksBatch, validateClaimBatch end to end) is
|
|
5
|
+
// cases 8-10 of tests/claim_gate_rebase.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/rebase_gate_set_aside.mjs
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { makeRunner } from './helpers.mjs';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const gate = require('../modules/lifecycle/rebase-gate.js');
|
|
19
|
+
const { rebaseRequiredHint } = require('../modules/lifecycle/routes/claims.js');
|
|
20
|
+
const { test, summary } = makeRunner();
|
|
21
|
+
|
|
22
|
+
const row = (id, reason) => ({ id, title: `t${id}`, needs_rebase_reason: reason, needs_rebase_at: null });
|
|
23
|
+
const GUARD = 'strand:branch_modifies_executed_code:.gitattributes,scripts/gds/git-merge-regen.js';
|
|
24
|
+
|
|
25
|
+
await test('a strand parked on a server-side guard is set aside: it never gates', () => {
|
|
26
|
+
const s = gate.splitRebaseGate([row(1, GUARD)], { limit: 1 });
|
|
27
|
+
assert.equal(s.gates, false);
|
|
28
|
+
assert.deepEqual(s.gating, []);
|
|
29
|
+
assert.equal(s.waiting.length, 1);
|
|
30
|
+
assert.match(s.waiting[0].waits_on, /server-side guard/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await test('an explicit waiting:<what> hold is set aside with what it waits on', () => {
|
|
34
|
+
assert.equal(gate.waitsOn('waiting:the owner'), 'the owner');
|
|
35
|
+
assert.equal(gate.waitsOn('strand:unresolvable_conflict:docs/x.json'), null);
|
|
36
|
+
assert.equal(gate.waitsOn('strand:pr_not_landed'), null);
|
|
37
|
+
assert.equal(gate.waitsOn(null), null, 'an unknown reason is the builder\'s, never silently waived');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await test('fixable strands gate only at the limit (default 3), not on the first', () => {
|
|
41
|
+
delete process.env.REBASE_GATE_LIMIT;
|
|
42
|
+
assert.equal(gate.REBASE_GATE_LIMIT, 3);
|
|
43
|
+
const one = [row(1, 'strand:pr_not_landed')];
|
|
44
|
+
const three = [...one, row(2, 'strand:pr_not_landed'), row(3, 'strand:unresolvable_conflict:a')];
|
|
45
|
+
assert.equal(gate.splitRebaseGate(one).gates, false);
|
|
46
|
+
assert.equal(gate.splitRebaseGate(three.slice(0, 2)).gates, false);
|
|
47
|
+
const s = gate.splitRebaseGate([...three, row(9, GUARD)]);
|
|
48
|
+
assert.equal(s.gates, true);
|
|
49
|
+
assert.deepEqual(s.gating.map((t) => t.id), [1, 2, 3], 'the waiting one never counts toward the limit');
|
|
50
|
+
const tasks = gate.rebaseRefusalTasks(s);
|
|
51
|
+
assert.deepEqual(tasks.map((t) => [t.id, !!t.waiting]), [[1, false], [2, false], [3, false], [9, true]]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await test('the refusal says which tasks gate and lists the rest as waiting, not on you', () => {
|
|
55
|
+
const s = gate.splitRebaseGate([row(1, 'strand:pr_not_landed'), row(2, 'strand:pr_not_landed'), row(3, 'strand:pr_not_landed'), row(9, GUARD)]);
|
|
56
|
+
const h = rebaseRequiredHint(gate.rebaseRefusalTasks(s));
|
|
57
|
+
assert.match(h, /3 confirmed task\(s\) of yours are waiting to land and need you \(task 1, task 2, task 3\)/);
|
|
58
|
+
assert.match(h, /Waiting, not on you .*task 9 \(a server-side guard/);
|
|
59
|
+
assert.match(h, /\/strand-fix N/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('all three claim surfaces call the ONE predicate', () => {
|
|
63
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-claims.js'), 'utf8');
|
|
64
|
+
const calls = src.match(/splitRebaseGate\(await gatingRebaseTasksForBuilder\(builderId, (client|pool)\)\)/g) || [];
|
|
65
|
+
assert.equal(calls.length, 3, 'claimTask, claimTasksBatch and validateClaimBatch');
|
|
66
|
+
for (const fn of ['async function claimTask(', 'async function claimTasksBatch(', 'async function validateClaimBatch(']) {
|
|
67
|
+
const start = src.indexOf(fn);
|
|
68
|
+
const next = src.indexOf('\nasync function ', start + 10);
|
|
69
|
+
assert.ok(src.slice(start, next < 0 ? undefined : next).includes('splitRebaseGate('), `${fn} uses the shared predicate`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
summary();
|
|
@@ -164,5 +164,17 @@ test('hall: the tooltip names the mode and what actually pays', () => {
|
|
|
164
164
|
assert.match(note, /guide, not a payout/i);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
// task 1004782 — abandoned and merged-away tasks are worth 0 wherever a total
|
|
168
|
+
// adds them up, on every reward mode.
|
|
169
|
+
test('hall: an abandoned or merged-away task is worth 0, whatever its estimate', () => {
|
|
170
|
+
for (const mode of [CPO, PAYS, undefined]) {
|
|
171
|
+
const B = loadBrandApply(mode);
|
|
172
|
+
assert.equal(B.taskCreditValue({ status: 'abandoned', credits_reward: 60 }), 0);
|
|
173
|
+
assert.equal(B.taskCreditValue({ status: 'abandoned', credits_reward: 60, credits_awarded: 12 }), 0);
|
|
174
|
+
assert.equal(B.taskCreditValue({ status: 'ready', credits_reward: 60, merged_into_task_id: 9 }), 0);
|
|
175
|
+
assert.equal(B.taskCreditValue(READY_TASK), 60, 'live work keeps its value');
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
167
179
|
console.log(`\nreward_mode_surfacing: ${passed} passed, ${failed} failed`);
|
|
168
180
|
process.exit(failed ? 1 : 0);
|