@bongos/core 1.21.108 → 1.21.110

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.
@@ -0,0 +1,274 @@
1
+ // tests/tweak_unattended.mjs — /tweak --page <id> --unattended halts by releasing,
2
+ // and the server, not the worker, holds its rules (task 1004678 / BV2.TW19b,
3
+ // ADR 0371 D8).
4
+ //
5
+ // 1. tweak-halt.js: every halt releases the round with fixed wording, and
6
+ // copy-apply's exits map onto those halts;
7
+ // 2. NO LAND WITHOUT THE ARTIST: every statement in the lifecycle that writes
8
+ // status 'confirmed' is enumerated from the source, and each one is either
9
+ // the artist's approval, the owner's override, or refuses a page tweak.
10
+ // confirmTask (the --skip-grade / grader-bypass path) is driven for real;
11
+ // 3. NO FILE OUTSIDE THE PAGE: autopickup-files.js over the server's own read of
12
+ // the pull request's files, and the real publish route refusing (409, PR
13
+ // closed, never armed) when an auto_pickup round strays.
14
+ // No DB, no network: fake pools, the real router, stubbed GitHub reads.
15
+ //
16
+ // Run: node tests/tweak_unattended.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import http from 'node:http';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
25
+
26
+ process.env.NODE_ENV = 'test';
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
29
+ const { test, summary } = makeRunner();
30
+
31
+ // ---- 1. halts ---------------------------------------------------------------
32
+
33
+ const halt = require('../scripts/gds/tweak-halt.js');
34
+
35
+ await test('each ADR 0371 D8 halt has fixed wording; an unknown code has none', () => {
36
+ for (const code of ['claim_refused', 'nothing_applied', 'ship_check_stale', 'grader_unavailable']) {
37
+ assert.equal(typeof halt.haltNote(code), 'string', code);
38
+ }
39
+ assert.equal(halt.haltNote('anything the worker says'), null);
40
+ assert.throws(() => halt.releaseWithNote(9, 'free text', { spawn: () => ({ status: 0 }) }), /unknown halt code/);
41
+ });
42
+
43
+ await test('copy-apply exits map to halts; a clean apply is not a halt', () => {
44
+ assert.equal(halt.haltForCopyApplyExit(0), null);
45
+ assert.equal(halt.haltForCopyApplyExit(2), 'nothing_applied');
46
+ assert.equal(halt.haltForCopyApplyExit(3), 'registry_mismatch');
47
+ assert.equal(halt.haltForCopyApplyExit(4), 'reread_failed');
48
+ assert.equal(halt.haltForCopyApplyExit(5), 'wrong_page');
49
+ assert.equal(halt.haltForCopyApplyExit(99), 'nothing_applied', 'an unknown failure still halts');
50
+ });
51
+
52
+ await test('a halt RELEASES the round through release.js, with the fixed note as its reason', () => {
53
+ const calls = [];
54
+ const exit = halt.releaseWithNote(4321, 'grader_unavailable', { spawn: (cmd, argv) => { calls.push(argv); return { status: 0 }; } });
55
+ assert.equal(exit, 0);
56
+ assert.equal(calls.length, 1);
57
+ const [script, task, flag, reason] = calls[0];
58
+ assert.match(script.replace(/\\/g, '/'), /scripts\/gds\/release\.js$/);
59
+ assert.equal(task, '4321');
60
+ assert.equal(flag, '--reason');
61
+ assert.equal(reason, `[tweak-unattended:grader_unavailable] ${halt.HALT_NOTES.grader_unavailable}`);
62
+ });
63
+
64
+ await test('copy-apply --unattended releases on every non-zero batch exit (wired, not just documented)', () => {
65
+ const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/copy-apply.js'), 'utf8');
66
+ const at = src.indexOf("hasFlag('--unattended') && code !== 0");
67
+ assert.ok(at > 0, 'the unattended branch tests every non-zero exit');
68
+ assert.ok(src.slice(at, at + 300).includes('halt.releaseWithNote(taskId, halt.haltForCopyApplyExit(code))'), 'and releases through tweak-halt');
69
+ assert.match(src, /arg\('--page'\)/);
70
+ });
71
+
72
+ await test('the skill documents the mode and never offers --skip-grade to it', () => {
73
+ const skill = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/skills/tweak/SKILL.md'), 'utf8');
74
+ const section = skill.slice(skill.indexOf('## Unattended mode'), skill.indexOf('## What happens next'));
75
+ assert.ok(section.length > 200, 'the unattended section exists');
76
+ assert.match(section, /--page <page-id> --unattended/);
77
+ assert.match(section, /tweak-halt\.js/);
78
+ assert.match(section, /Never `--skip-grade`/);
79
+ });
80
+
81
+ // ---- 2. no land without the artist -----------------------------------------
82
+
83
+ const achievements = require('../modules/economy/achievements.js');
84
+ achievements.evaluateAfterShip = async () => [];
85
+ achievements.evaluateAfterCreditAward = async () => [];
86
+ achievements.evaluateAfterClaim = async () => [];
87
+ achievements.reconcileBuilderAchievements = async () => [];
88
+ const seams = require('../src/module-seams.js');
89
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
90
+ const db = require('../modules/lifecycle/db.js');
91
+
92
+ // Every lifecycle statement that sets a TASK to confirmed, found from the source.
93
+ // A new one reds this test until it is listed below with why it holds.
94
+ const CONFIRM_WRITERS = {
95
+ 'db-grade.js': 'promoteToConfirmed: applyGrade withholds it for a page tweak (isPageTweak), approveHeldTweak is the artist',
96
+ 'db-overrides.js': "decideOverrideRequest: the owner's override request",
97
+ 'db-ship.js': 'confirmTask: refuses a page tweak (PAGE_TWEAK_NEEDS_ARTIST)',
98
+ };
99
+
100
+ await test('every confirm path in the lifecycle is listed, and each holds the artist gate', () => {
101
+ const dir = path.join(ROOT, 'modules/lifecycle');
102
+ const found = [];
103
+ for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.js'))) {
104
+ const src = fs.readFileSync(path.join(dir, f), 'utf8');
105
+ const n = (src.match(/UPDATE tasks\s+SET status = 'confirmed'/g) || []).length;
106
+ for (let i = 0; i < n; i++) found.push(f);
107
+ }
108
+ assert.deepEqual(found.sort(), Object.keys(CONFIRM_WRITERS).sort(), 'one confirm write per listed file, and no other');
109
+ const grade = fs.readFileSync(path.join(dir, 'db-grade.js'), 'utf8');
110
+ assert.match(grade, /held = tweakHold\.isPageTweak/);
111
+ assert.match(grade, /gradeResult\.passed && task\.status === 'completed' && !held/);
112
+ const approve = fs.readFileSync(path.join(dir, 'page-tweak-approve.js'), 'utf8');
113
+ assert.match(approve, /isHeldForArtist/, 'approve runs the promotion only on a held round');
114
+ });
115
+
116
+ function confirmPool(source) {
117
+ return makeSqlAwareClient((s) => {
118
+ if (/SELECT builder_id FROM claims/.test(s)) return { rows: [{ builder_id: 25 }] };
119
+ if (/^SELECT source FROM tasks/.test(s)) return { rows: [{ source }] };
120
+ if (/^UPDATE tasks/.test(s)) return { rows: [{ id: 7, title: 't', credits_reward: 10, kind: 'feature' }] };
121
+ return { rows: [] };
122
+ });
123
+ }
124
+
125
+ await test('confirmTask (the --skip-grade path) REFUSES a page tweak and writes nothing', async () => {
126
+ const pool = confirmPool('page-tweak');
127
+ await assert.rejects(() => db.confirmTask({ taskId: 7, builderId: 1 }, { pool }), (e) => e.code === 'PAGE_TWEAK_NEEDS_ARTIST');
128
+ assert.ok(!pool._client.queries.some((q) => /^\s*UPDATE tasks/.test(String(q.sql))), 'no status write');
129
+ });
130
+
131
+ await test('confirmTask on every other task confirms as before', async () => {
132
+ for (const source of ['planning-session', 'page-tweak-unplaced', null]) {
133
+ const out = await db.confirmTask({ taskId: 7, builderId: 1 }, { pool: confirmPool(source) });
134
+ assert.equal(out.taskStatus, 'confirmed', String(source));
135
+ }
136
+ });
137
+
138
+ await test('POST /tasks/:id/confirm maps the refusal to a 409 that names the artist', () => {
139
+ const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
140
+ assert.match(src, /err\.code === 'PAGE_TWEAK_NEEDS_ARTIST'\) return res\.fail\('page_tweak_needs_artist', \{ status: 409/);
141
+ });
142
+
143
+ // ---- 3. no file outside the page -------------------------------------------
144
+
145
+ const apf = require('../modules/lifecycle/autopickup-files.js');
146
+ const pcheck = require('../src/bongos/permission-path-check.js');
147
+ const INV = { pages: [{ id: 'landing:index', files: ['modules/public-landing/public/index.html', 'modules/public-landing/public/landing.css'], states_files: ['modules/public-landing/public/index.states.json'] }] };
148
+ const ROUND = { id: 7, source: 'page-tweak', source_ref: 'page-tweak/landing:index/r2', auto_pickup: true };
149
+ const files = (...p) => p.map((x) => ({ path: x }));
150
+ const run = (over = {}, deps = {}) => apf.autoPickupFileRefusal({ taskId: 7, prNumber: 77 }, {
151
+ readRound: async () => ({ ...ROUND, ...over }),
152
+ readInventory: () => INV,
153
+ getPullRequestFiles: async () => files('modules/public-landing/public/index.html', 'docs/copy-registry.json', 'docs/page-readings.json'),
154
+ matchProtected: pcheck.matchProtected,
155
+ ...deps,
156
+ });
157
+
158
+ await test('an auto_pickup round touching only its page and the regenerated files publishes', async () => {
159
+ assert.equal(await run(), null);
160
+ });
161
+
162
+ await test('a file outside the page is refused and named', async () => {
163
+ const r = await run({}, { getPullRequestFiles: async () => files('modules/public-landing/public/index.html', 'modules/hall-ui/public/goals.js') });
164
+ assert.equal(r.code, 'auto_pickup_outside_page');
165
+ assert.deepEqual(r.outside, ['modules/hall-ui/public/goals.js']);
166
+ assert.match(apf.refusalMessage(r), /goals\.js/);
167
+ });
168
+
169
+ await test('a protected path is refused even when it is on the regenerated list', async () => {
170
+ const r = await run({}, { getPullRequestFiles: async () => files('modules/public-landing/public/index.html', 'CLAUDE.md') });
171
+ assert.equal(r.code, 'auto_pickup_outside_page');
172
+ assert.deepEqual(r.protected, ['CLAUDE.md']);
173
+ });
174
+
175
+ await test('fail closed: unreadable files, a list at the cap, an unknown page, a bad ref', async () => {
176
+ assert.equal((await run({}, { getPullRequestFiles: async () => { throw new Error('502'); } })).code, 'auto_pickup_files_unreadable');
177
+ const cap = Array.from({ length: 300 }, () => ({ path: 'modules/public-landing/public/index.html' }));
178
+ assert.equal((await run({}, { getPullRequestFiles: async () => cap })).code, 'auto_pickup_files_unreadable');
179
+ assert.equal((await run({ source_ref: 'page-tweak/landing:gone/r1' })).code, 'auto_pickup_page_unknown');
180
+ assert.equal((await run({ source_ref: 'nonsense' })).code, 'auto_pickup_page_unknown');
181
+ assert.equal((await run({}, { readInventory: () => { throw new Error('ENOENT'); } })).code, 'auto_pickup_page_unknown');
182
+ });
183
+
184
+ await test('an UNMARKED claim, or any other task, is never checked (no GitHub read)', async () => {
185
+ let reads = 0;
186
+ const count = { getPullRequestFiles: async () => { reads++; return files('anything.js'); } };
187
+ assert.equal(await run({ auto_pickup: false }, count), null);
188
+ assert.equal(await run({ auto_pickup: null }, count), null);
189
+ assert.equal(await run({ source: 'planning-session' }, count), null);
190
+ assert.equal(await apf.autoPickupFileRefusal({ taskId: 7, prNumber: 77 }, { readRound: async () => null, ...count }), null);
191
+ assert.equal(reads, 0);
192
+ });
193
+
194
+ await test('the round read is one query naming the latest claim\'s mark', async () => {
195
+ const pool = makeSqlAwareClient(() => ({ rows: [ROUND] }));
196
+ const r = await apf.readRound(7, pool._client || pool);
197
+ assert.equal(r.auto_pickup, true);
198
+ const q = (pool._client || pool).queries.map((x) => String(x.sql).replace(/\s+/g, ' '));
199
+ assert.equal(q.length, 1);
200
+ assert.match(q[0], /SELECT c\.auto_pickup FROM claims c WHERE c\.task_id = t\.id ORDER BY c\.claimed_at DESC, c\.id DESC LIMIT 1/);
201
+ });
202
+
203
+ await test('the page files come from the real inventory for a real page', () => {
204
+ const inv = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/page-inventory.json'), 'utf8'));
205
+ const page = inv.pages.find((p) => (p.files || []).length);
206
+ assert.ok(apf.pageFiles(inv, page.id).includes(page.files[0]));
207
+ assert.equal(apf.pageFiles(inv, 'nope:nope'), null);
208
+ });
209
+
210
+ // The real publish route.
211
+ const express = require('express');
212
+ const api = require('../src/module-api.js');
213
+ const gp = require('../modules/lifecycle/github-push.js');
214
+ const prAssign = require('../modules/lifecycle/pr-assign.js');
215
+ const prClose = require('../modules/lifecycle/github-pr-close.js');
216
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
217
+
218
+ let round = ROUND;
219
+ let prFiles = files('modules/public-landing/public/index.html');
220
+ let armCalls = 0, closed = [];
221
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
222
+ db.getLastClaimHolder = async () => ({ builder_id: 5, github_login: 'some-builder' });
223
+ db.setClaimPublishedSha = async () => {};
224
+ api.pool.query = async () => ({ rows: [round] });
225
+ gp.isConfigured = () => true;
226
+ gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
227
+ gp.openOrFindPullRequest = async () => ({ number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' });
228
+ gp.enableAutoMergeDetailed = async () => { armCalls++; return { ok: true }; };
229
+ gp.getPullRequestFiles = async () => prFiles;
230
+ gp.setCommitStatus = async () => true;
231
+ prAssign.assignPullRequest = async () => ({ assigned: true });
232
+ prClose.closePullRequest = async ({ number }) => { closed.push(number); return { ok: true }; };
233
+ pageTweakReads.heldForArtistFailClosed = async () => true;
234
+
235
+ const app = express();
236
+ app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
237
+ app.use(require('../modules/lifecycle/routes/tasks.js')());
238
+ const server = http.createServer(app);
239
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
240
+ const base = `http://127.0.0.1:${server.address().port}`;
241
+ const publish = async () => {
242
+ const res = await fetch(`${base}/tasks/7/publish-branch`, {
243
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
244
+ body: JSON.stringify({ branch: 'task-7', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'Page tweak.' }),
245
+ });
246
+ return { status: res.status, body: await res.json() };
247
+ };
248
+
249
+ await test('publish: an auto_pickup round that strays is REFUSED 409, its PR closed, never armed', async () => {
250
+ round = ROUND; prFiles = files('modules/public-landing/public/index.html', 'src/server.js'); armCalls = 0; closed = [];
251
+ const r = await publish();
252
+ assert.equal(r.status, 409, JSON.stringify(r.body));
253
+ assert.equal(r.body.error, 'auto_pickup_outside_page');
254
+ assert.ok(r.body.outside.includes('src/server.js'));
255
+ assert.deepEqual(closed, [77]);
256
+ assert.equal(armCalls, 0);
257
+ });
258
+
259
+ await test('publish: an auto_pickup round inside its page publishes (held, so not armed)', async () => {
260
+ round = ROUND; prFiles = files('modules/public-landing/public/index.html'); closed = [];
261
+ const r = await publish();
262
+ assert.equal(r.status, 201, JSON.stringify(r.body));
263
+ assert.deepEqual(closed, []);
264
+ });
265
+
266
+ await test('publish: an ordinary task is not checked, whatever its files', async () => {
267
+ round = { id: 7, source: 'planning-session', source_ref: null, auto_pickup: null }; prFiles = files('src/server.js'); closed = [];
268
+ const r = await publish();
269
+ assert.equal(r.status, 201, JSON.stringify(r.body));
270
+ assert.deepEqual(closed, []);
271
+ });
272
+ server.close();
273
+
274
+ summary();