@bongos/core 1.19.1033 → 1.19.1034
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 +186 -86
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +4 -0
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +1 -1
- package/docs/architecture.md +3 -2
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/module-api-changelog.md +3 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/grading/grade-engine.js +267 -0
- package/modules/grading/grader-subagent.js +4 -3
- package/modules/grading/grader-workers/index.js +67 -11
- package/modules/grading/grader-workers/worker-verdict.js +32 -2
- package/modules/grading/grader.js +109 -88
- package/modules/hall-ui/public/deploy.js +35 -0
- package/modules/hall-ui/public/project-settings.css +7 -0
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/provisioning/control-plane-guard.js +14 -13
- package/modules/provisioning/core-upgrade.js +30 -17
- package/modules/provisioning/public-refusal.js +44 -0
- package/modules/provisioning/routes/disconnect.js +2 -1
- package/modules/provisioning/routes/env-manifest.js +5 -4
- package/modules/provisioning/routes/provisioning.js +16 -15
- package/modules/provisioning/routes/repo-private.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +70 -0
- package/scripts/gds/autobongos-run.js +10 -2
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/deploy-main-audit-gate.sh +11 -4
- package/scripts/gds/dev-lib.js +212 -5
- package/scripts/gds/dev.js +102 -50
- package/scripts/gds/doctor.js +3 -2
- package/scripts/gds/grade-correlation-audit.js +280 -0
- package/scripts/gds/grade-replay.js +237 -0
- package/scripts/gds/hooks/pre-push.js +7 -3
- package/scripts/gds/main-audit.js +19 -10
- package/scripts/gds/memory-map.js +3 -1
- package/scripts/gds/onboarding-config.js +3 -2
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +155 -4
- package/scripts/gds/provision-repo-auth.js +15 -0
- package/scripts/gds/provision.js +1 -1
- package/scripts/gds/publish-credential-check.js +3 -0
- package/scripts/gds/push-main.js +14 -3
- package/scripts/gds/push-path-brief.js +3 -2
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +157 -11
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +30 -8
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/upgrade.js +3 -3
- package/scripts/hall-preview/README.md +20 -3
- package/scripts/hall-preview/live-identity.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +71 -11
- package/scripts/hall-preview/scrub-identity.js +296 -0
- package/src/bongos/routes/security.js +3 -3
- package/src/build-info.js +4 -2
- package/src/module-api.js +1 -1
- package/tests/adr_0089_scope.mjs +70 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +240 -4
- package/tests/credential_presence.mjs +153 -0
- package/tests/deploy_audit_gate.mjs +5 -2
- package/tests/deploy_page_projects.mjs +63 -2
- package/tests/dev_lib.mjs +2 -1
- package/tests/dev_windows.mjs +324 -0
- package/tests/env_knobs_neutral_prefix.mjs +203 -0
- package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
- package/tests/government_protected_surfaces.mjs +10 -0
- package/tests/grade_correlation_audit.mjs +159 -0
- package/tests/grade_engine.mjs +249 -0
- package/tests/grade_engine_replay.mjs +99 -0
- package/tests/grader_prompt_window_bound.mjs +132 -0
- package/tests/grader_unbacked_score.mjs +194 -0
- package/tests/hall_preview_scrub.mjs +214 -0
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +67 -1
- package/tests/start_env_guard.mjs +16 -0
- package/tests/upgrade.mjs +20 -1
|
@@ -41,7 +41,18 @@ const { INTEGRATION, INTEGRATION_PARKED, KNOWN_FAILING } = require('../scripts/g
|
|
|
41
41
|
// Two later readings — 2/2 locally on a clean database, and the first CI run after
|
|
42
42
|
// the parking landed — showed the pass was the anomaly. No new breakage was parked
|
|
43
43
|
// here; one straggler joined the cohort it always belonged to.
|
|
44
|
-
|
|
44
|
+
//
|
|
45
|
+
// LOWERED 24 → 23 by task 1002863: consumer_layout_boot was fixed and un-parked, and the
|
|
46
|
+
// ceiling follows it down so the freed slot cannot quietly be spent on a new failure.
|
|
47
|
+
const MAX_PARKED = 23;
|
|
48
|
+
|
|
49
|
+
// The one test that proves the PUBLISHED package works end to end: packaged, installed into
|
|
50
|
+
// a consumer, installed again from its own shipped lockfile, migrated onto EMPTY databases,
|
|
51
|
+
// and serving a database-backed route (tasks 2142 + 1002863 — "PROVE PUBLISH"). Every other
|
|
52
|
+
// test runs the source tree; none installs the package and runs it. So while this one is
|
|
53
|
+
// parked or unselected that guarantee silently does not exist — which is exactly how it sat,
|
|
54
|
+
// parked, from task 1004034 until task 1002863 found out why it failed.
|
|
55
|
+
const PUBLISH_PROOF = 'consumer_layout_boot';
|
|
45
56
|
|
|
46
57
|
test('every parked entry names a test file that exists', () => {
|
|
47
58
|
const ghosts = [...INTEGRATION_PARKED]
|
|
@@ -71,6 +82,18 @@ test('no test is parked in both INTEGRATION_PARKED and KNOWN_FAILING', () => {
|
|
|
71
82
|
'parked in two places at once — pick the mechanism that owns each test.');
|
|
72
83
|
});
|
|
73
84
|
|
|
85
|
+
test('the packaged-core proof is selected by the integration lane and parked nowhere', () => {
|
|
86
|
+
assert.ok(fs.existsSync(path.join(TESTS_DIR, `${PUBLISH_PROOF}.mjs`)),
|
|
87
|
+
`tests/${PUBLISH_PROOF}.mjs is gone — nothing proves the published package installs, migrates and serves any more.`);
|
|
88
|
+
assert.ok(INTEGRATION.has(PUBLISH_PROOF),
|
|
89
|
+
`${PUBLISH_PROOF} is not in the INTEGRATION set, so either the DB-free unit gate would try to run it `
|
|
90
|
+
+ '(it needs npm, a network and a Postgres) or no lane selects it at all.');
|
|
91
|
+
assert.ok(!INTEGRATION_PARKED.has(PUBLISH_PROOF) && !KNOWN_FAILING.has(PUBLISH_PROOF),
|
|
92
|
+
`${PUBLISH_PROOF} is parked. It is the only proof that the PUBLISHED core installs, migrates an `
|
|
93
|
+
+ 'empty database and serves from it; parking it re-opens that gap without a single red check. '
|
|
94
|
+
+ 'Fix the failure instead — or, if parking is truly the call, make it deliberately by editing this guard.');
|
|
95
|
+
});
|
|
96
|
+
|
|
74
97
|
test('the parked count is ratcheted and cannot grow silently', () => {
|
|
75
98
|
const n = INTEGRATION_PARKED.size;
|
|
76
99
|
assert.ok(n <= MAX_PARKED,
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// tests/provisioning_public_refusal.mjs — a refusal reaches the person as its own sentence,
|
|
2
|
+
// and nothing else ever does (task 1004126).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. The control-plane guard refuses a destructive action on the platform's own row
|
|
5
|
+
// with a code, a 409 and a sentence written for the owner. Every catch in the provisioning
|
|
6
|
+
// module answered a blanket 500 "internal error" instead, so an owner who pressed Take
|
|
7
|
+
// offline or Disconnect on that row read "internal error" while the real sentence went only
|
|
8
|
+
// to the journal. Task 1004125 fixed ONE catch by duck-typing three fields; twenty-three more
|
|
9
|
+
// had the same shape.
|
|
10
|
+
//
|
|
11
|
+
// WHAT THESE PIN.
|
|
12
|
+
// • failFrom forwards a declared PublicRefusal, and ONLY that: a look-alike carrying the
|
|
13
|
+
// same three fields and a Postgres error naming a table both get the opaque 500.
|
|
14
|
+
// • the guard throws a PublicRefusal, so there is one definition of "public", not one per catch.
|
|
15
|
+
// • the three routes that can reach the guard today (owner teardown, force-teardown,
|
|
16
|
+
// disconnect) answer 409 with the guard's sentence, driven through the REAL router, the
|
|
17
|
+
// REAL enqueue helper and the REAL guard; an unexpected error on the same routes still
|
|
18
|
+
// answers 500 and leaks nothing.
|
|
19
|
+
// • the ratchet: no catch in the module answers a 5xx except through failFrom.
|
|
20
|
+
//
|
|
21
|
+
// DB-free: the shared pool's query is stubbed and auth is faked. No network.
|
|
22
|
+
//
|
|
23
|
+
// Run: node --test tests/provisioning_public_refusal.mjs
|
|
24
|
+
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { test, after } from 'node:test';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const { PublicRefusal, isPublicRefusal, failFrom } = require('../modules/provisioning/public-refusal.js');
|
|
36
|
+
const guard = require('../modules/provisioning/control-plane-guard.js');
|
|
37
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
38
|
+
|
|
39
|
+
// A res that records what failFrom answered, through the same envelope a route uses.
|
|
40
|
+
function recorder() {
|
|
41
|
+
const out = {};
|
|
42
|
+
return { out, fail: (code, opts) => { Object.assign(out, { code, ...opts }); } };
|
|
43
|
+
}
|
|
44
|
+
const selfRowDb = { query: async () => ({ rows: [{ hosting_shape: 'control-plane' }] }) };
|
|
45
|
+
const guardRefusal = () => guard.assertIntentAllowed(selfRowDb, 1, 'teardown').then(() => null, (e) => e);
|
|
46
|
+
|
|
47
|
+
// ---- the helper -----------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
test('failFrom forwards a PublicRefusal as its own code, status and PUBLIC sentence', () => {
|
|
50
|
+
const r = recorder();
|
|
51
|
+
failFrom(r, new PublicRefusal({ code: 'nope', status: 409, publicMessage: 'You cannot do that here.', message: 'journal: row 7 is special' }), 'x_failed');
|
|
52
|
+
assert.deepEqual(r.out, { code: 'nope', status: 409, message: 'You cannot do that here.' }, 'the journal sentence stays in the journal');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a look-alike with the same three fields is NOT public — only a declared refusal is', () => {
|
|
56
|
+
// Task 1004125's catch duck-typed on code && status && publicMessage. Anything can carry
|
|
57
|
+
// those; only the class says it was written for a person.
|
|
58
|
+
const r = recorder();
|
|
59
|
+
failFrom(r, Object.assign(new Error('x'), { code: 'lookalike', status: 409, publicMessage: 'leaked' }), 'x_failed');
|
|
60
|
+
assert.deepEqual(r.out, { code: 'x_failed', status: 500, message: 'internal error' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('an unexpected error is opaque — a Postgres error naming a table never reaches the client', () => {
|
|
64
|
+
const r = recorder();
|
|
65
|
+
const pg = Object.assign(new Error('relation "provisioning_intents" does not exist'), { code: '42P01', status: 500 });
|
|
66
|
+
failFrom(r, pg, 'teardown_failed');
|
|
67
|
+
assert.deepEqual(r.out, { code: 'teardown_failed', status: 500, message: 'internal error' });
|
|
68
|
+
assert.equal(isPublicRefusal(pg), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a refusal is never an outage: PublicRefusal will not carry a non-4xx status', () => {
|
|
72
|
+
assert.throws(() => new PublicRefusal({ code: 'boom', status: 500, publicMessage: 'x' }), /4xx/);
|
|
73
|
+
assert.throws(() => new PublicRefusal({ code: 'boom', status: 409 }), /publicMessage/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('the control-plane guard throws a PublicRefusal: the owner sentence public, the journal one not', async () => {
|
|
77
|
+
const err = await guardRefusal();
|
|
78
|
+
assert.ok(isPublicRefusal(err), 'the guard declares its refusal public');
|
|
79
|
+
assert.equal(err.code, 'control_plane_protected');
|
|
80
|
+
assert.equal(err.status, 409);
|
|
81
|
+
assert.match(err.publicMessage, /cannot be aimed at the machine the platform itself runs on/);
|
|
82
|
+
assert.match(err.message, /'teardown' cannot be aimed at the control plane/, 'the journal keeps the action it refused');
|
|
83
|
+
assert.equal(await guard.assertIntentAllowed(selfRowDb, 1, 'restart'), undefined, 'an allowed action is still allowed');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---- the routes, through the real router, enqueue helper and guard --------------
|
|
87
|
+
|
|
88
|
+
const api = require('../src/module-api.js');
|
|
89
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner', rank: 'archon' }; next(); };
|
|
90
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
91
|
+
const selfRow = { id: 1, slug: 'platform', owner_builder_id: 7, status: 'active', hosting_shape: 'control-plane' };
|
|
92
|
+
provisioning.getInstanceById = async () => ({ ...selfRow });
|
|
93
|
+
provisioning.recordEvent = async () => {};
|
|
94
|
+
|
|
95
|
+
// The shared pool: the guard's shape lookup answers 'control-plane'; every other read and
|
|
96
|
+
// the teardown's supersede UPDATE answer nothing. `fault` makes every query throw instead.
|
|
97
|
+
let fault = null;
|
|
98
|
+
api.pool.query = async (sql) => {
|
|
99
|
+
if (fault) throw fault;
|
|
100
|
+
if (/SELECT hosting_shape FROM provisioning_instances/.test(sql)) return { rows: [{ hosting_shape: 'control-plane' }] };
|
|
101
|
+
return { rows: [] };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const express = require('express');
|
|
105
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
106
|
+
const app = express();
|
|
107
|
+
app.use(express.json());
|
|
108
|
+
app.use(attachFail);
|
|
109
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
|
|
110
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/disconnect.js')());
|
|
111
|
+
const server = app.listen(0);
|
|
112
|
+
await new Promise((r) => server.once('listening', r));
|
|
113
|
+
after(() => server.close());
|
|
114
|
+
|
|
115
|
+
function post(p, body = {}) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const payload = JSON.stringify(body);
|
|
118
|
+
const req = http.request(`http://127.0.0.1:${server.address().port}/api/bongos${p}`, {
|
|
119
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
120
|
+
}, (res) => {
|
|
121
|
+
let d = '';
|
|
122
|
+
res.on('data', (c) => { d += c; });
|
|
123
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d), raw: d }));
|
|
124
|
+
});
|
|
125
|
+
req.on('error', reject);
|
|
126
|
+
req.end(payload);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const REACHABLE = [
|
|
131
|
+
['owner teardown', '/provisioning/instances/1/teardown', {}, 'teardown_failed'],
|
|
132
|
+
['force-teardown', '/provisioning/instances/1/force-teardown', {}, 'teardown_failed'],
|
|
133
|
+
['disconnect', '/provisioning/instances/1/disconnect', { confirm_slug: 'platform' }, 'disconnect_failed'],
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const [name, p, body, fallback] of REACHABLE) {
|
|
137
|
+
test(`${name} on the platform's own row answers 409 with the guard's sentence, not "internal error"`, async () => {
|
|
138
|
+
fault = null;
|
|
139
|
+
const r = await post(p, body);
|
|
140
|
+
assert.equal(r.status, 409, r.raw);
|
|
141
|
+
assert.equal(r.body.error.code, 'control_plane_protected');
|
|
142
|
+
assert.equal(r.body.error.message, (await guardRefusal()).publicMessage);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test(`${name}: an unexpected failure is still an opaque 500 (${fallback}) that leaks nothing`, async () => {
|
|
146
|
+
fault = new Error('relation "provisioning_intents" does not exist');
|
|
147
|
+
const r = await post(p, body);
|
|
148
|
+
fault = null;
|
|
149
|
+
assert.equal(r.status, 500, r.raw);
|
|
150
|
+
assert.deepEqual(r.body.error, { code: fallback, message: 'internal error' });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- the ratchet ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
// Every `catch (e) { … }` / `catch { … }` body in a file, with the line it opens on. A
|
|
157
|
+
// promise's `.catch(() => …)` is not a catch block and is not matched.
|
|
158
|
+
function catchBodies(src) {
|
|
159
|
+
const out = [];
|
|
160
|
+
const re = /\bcatch\s*(?:\(\s*\w*\s*\))?\s*\{/g;
|
|
161
|
+
for (let m = re.exec(src); m; m = re.exec(src)) {
|
|
162
|
+
let depth = 1;
|
|
163
|
+
let i = re.lastIndex;
|
|
164
|
+
for (; i < src.length && depth; i++) depth += src[i] === '{' ? 1 : src[i] === '}' ? -1 : 0;
|
|
165
|
+
out.push({ line: src.slice(0, m.index).split('\n').length, body: src.slice(re.lastIndex, i - 1) });
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test('no catch in the provisioning module answers a 5xx except through failFrom', () => {
|
|
171
|
+
const dir = path.join(ROOT, 'modules', 'provisioning');
|
|
172
|
+
const files = fs.readdirSync(dir, { recursive: true }).map(String)
|
|
173
|
+
.filter((f) => f.endsWith('.js') && !f.split(path.sep).includes('tests'));
|
|
174
|
+
const HAND_ROLLED_5XX = /res\.fail\([^;]*?(status:\s*5\d\d|,\s*5\d\d\s*[,)])/;
|
|
175
|
+
const offenders = [];
|
|
176
|
+
let viaHelper = 0;
|
|
177
|
+
for (const f of files) {
|
|
178
|
+
for (const c of catchBodies(fs.readFileSync(path.join(dir, f), 'utf8'))) {
|
|
179
|
+
if (HAND_ROLLED_5XX.test(c.body)) offenders.push(`${f}:${c.line}`);
|
|
180
|
+
if (/\bfailFrom\(res, /.test(c.body)) viaHelper++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
assert.deepEqual(offenders, [], 'answer with failFrom(res, err, <code>) so a declared refusal can reach the person');
|
|
184
|
+
assert.ok(viaHelper >= 20, `the scan must reach the converted catches (24 at task 1004126), saw ${viaHelper}`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the ratchet reads a hand-rolled 500 in a catch, and not one outside a catch', () => {
|
|
188
|
+
const src = "try { a(); } catch (err) {\n log.error('x', err);\n res.fail('x_failed', { status: 500, message: 'internal error' });\n}\nres.fail('config_unset', { status: 500, message: 'no origin' });\n"
|
|
189
|
+
+ "p.catch(() => {});\ntry { b(); } catch { res.fail('y_failed', 500); }";
|
|
190
|
+
const bodies = catchBodies(src);
|
|
191
|
+
assert.equal(bodies.length, 2, 'a catch with no binding counts; a promise .catch does not');
|
|
192
|
+
assert.match(bodies[1].body, /y_failed/);
|
|
193
|
+
assert.match(bodies[0].body, /x_failed/);
|
|
194
|
+
assert.doesNotMatch(bodies[0].body, /config_unset/, 'a config refusal outside a catch is a real sentence, not a swallowed error');
|
|
195
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// tests/publish_status_branch.mjs — GET /tasks/:id/publish-status finds the task's
|
|
2
|
+
// branch itself when the caller names none (task 1003764, gap 1).
|
|
3
|
+
//
|
|
4
|
+
// WHY. This route is the diagnostic every agent is told to use INSTEAD of raw git
|
|
5
|
+
// probes (push-path-brief.js, the stuck-strike nudge), and it required ?branch=. Called
|
|
6
|
+
// the way those briefs spell it — no query — it answered 400 bad_branch for task
|
|
7
|
+
// 1003747 while origin carried its live, server-pushed branch and PR 673 sat open. The
|
|
8
|
+
// sanctioned diagnostic was the one thing that could not diagnose a live publish.
|
|
9
|
+
//
|
|
10
|
+
// Drives the real route handler with fake req/res and stubbed github-push reads: no
|
|
11
|
+
// DB, no server, no network. Run: node tests/publish_status_branch.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
process.env.NODE_ENV = 'test';
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
const gp = require('../modules/lifecycle/github-push.js');
|
|
20
|
+
const buildTasksRouter = require('../modules/lifecycle/routes/tasks.js');
|
|
21
|
+
|
|
22
|
+
let pass = 0;
|
|
23
|
+
let fail = 0;
|
|
24
|
+
const t = async (name, fn) => {
|
|
25
|
+
try { await fn(); pass++; console.log(`PASS ${name}`); }
|
|
26
|
+
catch (e) { fail++; console.log(`FAIL ${name}\n ${e.stack || e.message}`); }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const CRED_KEYS = ['GITHUB_PUSH_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_INSTALLATION_ID', 'GITHUB_APP_PRIVATE_KEY'];
|
|
30
|
+
|
|
31
|
+
// Stub the github-push reads the route makes, run the handler once, restore everything.
|
|
32
|
+
// `calls` records what the route asked GitHub for, so a test can prove what it did NOT do.
|
|
33
|
+
async function callRoute(query, stubs = {}) {
|
|
34
|
+
const saved = {};
|
|
35
|
+
for (const k of ['findTaskPullRequest', 'getPublishStatus', 'mergeIfGreen']) saved[k] = gp[k];
|
|
36
|
+
const savedEnv = CRED_KEYS.map((k) => [k, process.env[k]]);
|
|
37
|
+
const calls = { findTaskPullRequest: [], getPublishStatus: [], mergeIfGreen: [] };
|
|
38
|
+
gp.findTaskPullRequest = async (a) => { calls.findTaskPullRequest.push(a); return stubs.taskPr === undefined ? null : stubs.taskPr; };
|
|
39
|
+
gp.getPublishStatus = async (a) => {
|
|
40
|
+
calls.getPublishStatus.push(a);
|
|
41
|
+
return stubs.status || { pr_state: 'open', merged: false, merge_sha: null, deploy_status: 'pending', pr_url: 'https://github.test/pr/673', pr_number: 673 };
|
|
42
|
+
};
|
|
43
|
+
gp.mergeIfGreen = async (a) => { calls.mergeIfGreen.push(a); return stubs.mergeDriver || { reason: 'failing_check:unit', failing_tests: '1 failed: fitness' }; };
|
|
44
|
+
for (const k of CRED_KEYS) delete process.env[k];
|
|
45
|
+
process.env.GITHUB_PUSH_TOKEN = 'ghp_fake_for_isConfigured';
|
|
46
|
+
try {
|
|
47
|
+
const router = buildTasksRouter();
|
|
48
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/tasks/:id/publish-status' && l.route.methods.get);
|
|
49
|
+
const handler = layer.route.stack[layer.route.stack.length - 1].handle;
|
|
50
|
+
const out = { failCode: null, failOpts: null, body: null };
|
|
51
|
+
const res = {
|
|
52
|
+
status() { return this; },
|
|
53
|
+
json(b) { out.body = b; return this; },
|
|
54
|
+
fail(code, opts) { out.failCode = code; out.failOpts = opts; return this; },
|
|
55
|
+
};
|
|
56
|
+
// rank archon → gateTaskOwnership passes without a DB lookup, keeping this hermetic.
|
|
57
|
+
await handler({ params: { id: '1003747' }, query, builder: { id: 25, rank: 'archon' } }, res);
|
|
58
|
+
return { ...out, calls };
|
|
59
|
+
} finally {
|
|
60
|
+
Object.assign(gp, saved);
|
|
61
|
+
for (const [k, v] of savedEnv) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await t('no ?branch= → the route finds the task\'s own PR branch instead of answering 400 (the task 1003747 repro)', async () => {
|
|
66
|
+
const r = await callRoute({}, { taskPr: { branch: 'task-1003747', pr_number: 673, pr_state: 'open', merged: false } });
|
|
67
|
+
assert.equal(r.failCode, null, `must not refuse — got ${r.failCode}`);
|
|
68
|
+
assert.equal(r.body.ok, true);
|
|
69
|
+
assert.equal(r.body.branch, 'task-1003747', 'reports which branch it answered about');
|
|
70
|
+
assert.equal(r.body.branch_source, 'task_pr', 'and how it found it');
|
|
71
|
+
assert.deepEqual(r.calls.findTaskPullRequest, [{ taskId: 1003747 }], 'looked the PR up by THIS task id');
|
|
72
|
+
assert.deepEqual(r.calls.getPublishStatus, [{ branch: 'task-1003747' }]);
|
|
73
|
+
assert.equal(r.body.pr_number, 673);
|
|
74
|
+
// The whole point: the red check is now one call away, not twenty minutes of git.
|
|
75
|
+
assert.equal(r.body.merge_driver.failing_tests, '1 failed: fitness');
|
|
76
|
+
assert.deepEqual(r.calls.mergeIfGreen, [{ branch: 'task-1003747' }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('no ?branch= and no PR for the task → 200 "none" with a hint, never a 400, and nothing else is probed', async () => {
|
|
80
|
+
const r = await callRoute({}, { taskPr: null });
|
|
81
|
+
assert.equal(r.failCode, null);
|
|
82
|
+
assert.equal(r.body.ok, true);
|
|
83
|
+
assert.equal(r.body.pr_state, 'none');
|
|
84
|
+
assert.equal(r.body.merged, false);
|
|
85
|
+
assert.equal(r.body.branch, null);
|
|
86
|
+
assert.equal(r.body.branch_source, null);
|
|
87
|
+
assert.match(r.body.branch_hint, /\?branch=/, 'says how to ask about a specific branch');
|
|
88
|
+
assert.equal(r.body.reconciled, false);
|
|
89
|
+
assert.equal(r.body.merge_driver, null);
|
|
90
|
+
assert.deepEqual(r.calls.getPublishStatus, [], 'no branch → no branch-status read');
|
|
91
|
+
assert.deepEqual(r.calls.mergeIfGreen, [], 'no PR → the merge driver never runs');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t('an empty ?branch= is treated as absent, not as a bad branch', async () => {
|
|
95
|
+
const r = await callRoute({ branch: '' }, { taskPr: { branch: 'task-1003747', pr_number: 673 } });
|
|
96
|
+
assert.equal(r.failCode, null);
|
|
97
|
+
assert.equal(r.body.branch_source, 'task_pr');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await t('an explicit ?branch= still wins, and the task-PR lookup is skipped', async () => {
|
|
101
|
+
const r = await callRoute({ branch: 'claude/some-worktree' });
|
|
102
|
+
assert.equal(r.failCode, null);
|
|
103
|
+
assert.equal(r.body.branch, 'claude/some-worktree');
|
|
104
|
+
assert.equal(r.body.branch_source, 'query');
|
|
105
|
+
assert.deepEqual(r.calls.findTaskPullRequest, [], 'the caller named the branch; nothing to look up');
|
|
106
|
+
assert.deepEqual(r.calls.getPublishStatus, [{ branch: 'claude/some-worktree' }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await t('a fully-qualified refs/heads/<branch> names the same branch and is accepted', async () => {
|
|
110
|
+
const r = await callRoute({ branch: 'refs/heads/task-1003747' });
|
|
111
|
+
assert.equal(r.failCode, null);
|
|
112
|
+
assert.equal(r.body.branch, 'task-1003747');
|
|
113
|
+
assert.deepEqual(r.calls.getPublishStatus, [{ branch: 'task-1003747' }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('a genuinely unsafe branch is still refused 400 — in a sentence, before any GitHub call', async () => {
|
|
117
|
+
for (const branch of ['-rf', 'a/../../etc', 'refs/tags/x', ['a', 'b']]) {
|
|
118
|
+
const r = await callRoute({ branch });
|
|
119
|
+
assert.equal(r.failCode, 'bad_branch', `${JSON.stringify(branch)} must be refused`);
|
|
120
|
+
assert.equal(r.failOpts.status, 400);
|
|
121
|
+
assert.match(r.failOpts.message, /left out/, 'the refusal says the branch can simply be omitted');
|
|
122
|
+
assert.deepEqual(r.calls.findTaskPullRequest, []);
|
|
123
|
+
assert.deepEqual(r.calls.getPublishStatus, []);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
console.log(`\npublish-status branch resolution (task 1003764): ${pass} passed, ${fail} failed`);
|
|
128
|
+
process.exitCode = fail > 0 ? 1 : 0;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// tests/ship_land_red_pr.mjs — a stalled land names its red check (task 1003764, gap 2).
|
|
2
|
+
//
|
|
3
|
+
// WHY. Task 1003747's ship sat at "waiting for auto-merge + prod deploy" while a
|
|
4
|
+
// required check was red, and ended on "This is NOT a failure". The cause — CI tests
|
|
5
|
+
// the branch MERGED with main, and main had moved six commits, staling the copy
|
|
6
|
+
// registry in the merged tree only — took ~20 minutes of git archaeology to find,
|
|
7
|
+
// though it was one API call away. The laptop poll (pollCiDeploy) never looked at
|
|
8
|
+
// checks at all; the server poll named them but not the merge trap, and not how a
|
|
9
|
+
// freshness gate heals. These drive both polls with fake gh / server reads (no
|
|
10
|
+
// network, no sleeping) and pin the pure pieces they are built from.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/ship_land_red_pr.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import {
|
|
18
|
+
FRESHNESS_TESTS, failingTestNames, landPendingLines, localMergeDriver, mergeTrapLines,
|
|
19
|
+
pollCiDeploy, pollServerPublish, redBlocksMerge, redPrLines, unitReportFor,
|
|
20
|
+
} from '../scripts/gds/ship-land.js';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const githubPush = require('../modules/lifecycle/github-push.js');
|
|
24
|
+
const { CARDS } = require('../scripts/gds/plain-cards.js');
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
async function test(name, fn) {
|
|
29
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
30
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Run fn with console.log captured; returns { result, out }.
|
|
34
|
+
async function capture(fn) {
|
|
35
|
+
const lines = [];
|
|
36
|
+
const orig = console.log;
|
|
37
|
+
console.log = (...a) => { lines.push(a.join(' ')); };
|
|
38
|
+
try { return { result: await fn(), out: lines.join('\n'), lines }; }
|
|
39
|
+
finally { console.log = orig; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SHA = 'a'.repeat(40);
|
|
43
|
+
// What `gh pr view --json state,mergeCommit,mergeStateStatus,headRefOid,statusCheckRollup`
|
|
44
|
+
// really returns (shape taken from a live PR on this repo): check runs, then statuses.
|
|
45
|
+
const run = (name, conclusion, status = 'COMPLETED') => ({ __typename: 'CheckRun', name, conclusion, status, workflowName: 'unit' });
|
|
46
|
+
const ctx = (context, state) => ({ __typename: 'StatusContext', context, state, targetUrl: '' });
|
|
47
|
+
const openPr = (rollup, mergeStateStatus = 'BLOCKED') => ({ state: 'OPEN', mergeCommit: null, mergeStateStatus, headRefOid: SHA, statusCheckRollup: rollup });
|
|
48
|
+
const RED_ROLLUP = [run('unit', 'FAILURE'), run('report', 'SUCCESS'), ctx('gate-author-trust', 'SUCCESS'), ctx('unit-report', 'FAILURE')];
|
|
49
|
+
const COMBINED_STATUS = { state: 'failure', statuses: [
|
|
50
|
+
{ context: 'gate-author-trust', state: 'success', description: 'Metic+ author' },
|
|
51
|
+
{ context: 'unit-report', state: 'failure', description: '1 failed: fitness' },
|
|
52
|
+
] };
|
|
53
|
+
|
|
54
|
+
// ---- localMergeDriver: the laptop's view of a red PR, in publish-status's shape ----
|
|
55
|
+
|
|
56
|
+
await test('localMergeDriver names the failing required check and asks for the tests once the report is posted', async () => {
|
|
57
|
+
const asked = [];
|
|
58
|
+
const md = localMergeDriver(openPr(RED_ROLLUP), (sha) => { asked.push(sha); return '1 failed: fitness'; });
|
|
59
|
+
assert.deepEqual(md, { reason: 'failing_check:unit', failing_tests: '1 failed: fitness' });
|
|
60
|
+
assert.deepEqual(asked, [SHA], 'the unit-report is read for the PR head sha');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await test('localMergeDriver does not ask for tests before the unit-report status exists', async () => {
|
|
64
|
+
let asked = 0;
|
|
65
|
+
const md = localMergeDriver(openPr([run('unit', 'FAILURE')]), () => { asked++; return 'x'; });
|
|
66
|
+
assert.deepEqual(md, { reason: 'failing_check:unit', failing_tests: null });
|
|
67
|
+
assert.equal(asked, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('localMergeDriver: pending, expected and in-progress are waiting, not red', async () => {
|
|
71
|
+
for (const rollup of [
|
|
72
|
+
[run('unit', '', 'IN_PROGRESS')],
|
|
73
|
+
[ctx('unit-report', 'PENDING')],
|
|
74
|
+
[ctx('some-required-status', 'EXPECTED')],
|
|
75
|
+
[run('unit', 'SUCCESS'), run('lint', 'NEUTRAL'), run('docs', 'SKIPPED'), ctx('unit-report', 'SUCCESS')],
|
|
76
|
+
]) assert.equal(localMergeDriver(openPr(rollup)), null, JSON.stringify(rollup));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await test('localMergeDriver: an UNSTABLE PR means the failing check is optional', async () => {
|
|
80
|
+
assert.equal(localMergeDriver(openPr([run('lint', 'FAILURE')], 'UNSTABLE')).reason, 'non_required_check_failing:lint');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('localMergeDriver: nothing to say for a merged/closed PR, or a gh that returned no rollup', async () => {
|
|
84
|
+
assert.equal(localMergeDriver(null), null);
|
|
85
|
+
assert.equal(localMergeDriver({ ...openPr(RED_ROLLUP), state: 'MERGED' }), null);
|
|
86
|
+
assert.equal(localMergeDriver({ state: 'OPEN', mergeCommit: null }), null, 'the legacy two-field read has no rollup');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('localMergeDriver agrees with the server\'s mergeBlockReason on a red required check', async () => {
|
|
90
|
+
const server = githubPush.mergeBlockReason({
|
|
91
|
+
merged: false, pr_state: 'open', mergeable: 'MERGEABLE', merge_state: 'BLOCKED', review_decision: null,
|
|
92
|
+
rollup_state: 'FAILURE', checks: [{ name: 'unit', conclusion: 'FAILURE', status: 'COMPLETED' }], checks_unreadable: false,
|
|
93
|
+
});
|
|
94
|
+
assert.equal(localMergeDriver(openPr([run('unit', 'FAILURE')])).reason, server);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- unitReportFor: the failing-test line, via the same parser the server uses ----
|
|
98
|
+
|
|
99
|
+
await test('unitReportFor reads the unit-report status for the sha', async () => {
|
|
100
|
+
const calls = [];
|
|
101
|
+
const text = unitReportFor(SHA, (args) => { calls.push(args); return COMBINED_STATUS; });
|
|
102
|
+
assert.equal(text, '1 failed: fitness');
|
|
103
|
+
assert.deepEqual(calls, [['api', `repos/{owner}/{repo}/commits/${SHA}/status`]]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('unitReportFor: a green report, an unreadable one, or a bad sha → null (and a bad sha never reaches gh)', async () => {
|
|
107
|
+
assert.equal(unitReportFor(SHA, () => ({ statuses: [{ context: 'unit-report', state: 'success', description: 'all unit tests passed' }] })), null);
|
|
108
|
+
assert.equal(unitReportFor(SHA, () => null), null);
|
|
109
|
+
assert.equal(unitReportFor('HEAD; rm -rf /', () => { throw new Error('must not call gh'); }), null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---- the merge trap + the freshness heal ----
|
|
113
|
+
|
|
114
|
+
await test('failingTestNames parses the unit-report line and drops a name the status limit cut', async () => {
|
|
115
|
+
assert.deepEqual(failingTestNames('1 failed: fitness'), ['fitness']);
|
|
116
|
+
assert.deepEqual(failingTestNames('3 failed: api_docs, fitness, security'), ['api_docs', 'fitness', 'security']);
|
|
117
|
+
assert.deepEqual(failingTestNames('40 failed: a_test, b_test, some_lo…'), ['a_test', 'b_test']);
|
|
118
|
+
assert.deepEqual(failingTestNames(null), []);
|
|
119
|
+
assert.deepEqual(failingTestNames('all unit tests passed'), []);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('every name in FRESHNESS_TESTS is a real test file', async () => {
|
|
123
|
+
for (const name of FRESHNESS_TESTS) {
|
|
124
|
+
assert.ok(fs.existsSync(new URL(`./${name}.mjs`, import.meta.url)), `tests/${name}.mjs does not exist`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('every red PR names the trap: CI tests the MERGE, not the branch', async () => {
|
|
129
|
+
const local = mergeTrapLines({ reason: 'failing_check:unit' }, { taskId: 42, via: 'local' }).join('\n');
|
|
130
|
+
assert.match(local, /MERGED with the current main, not the branch alone/);
|
|
131
|
+
assert.match(local, /git fetch origin && git merge origin\/main/, 'the laptop can reproduce on the merge');
|
|
132
|
+
const server = mergeTrapLines({ reason: 'failing_check:unit' }, { taskId: 42, via: 'server' }).join('\n');
|
|
133
|
+
assert.match(server, /MERGED with the current main/);
|
|
134
|
+
assert.ok(!/git merge origin\/main/.test(server), 'a credential-less machine cannot merge main locally — never tell it to');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('a failing freshness gate gets its healing command, per push path', async () => {
|
|
138
|
+
const md = { reason: 'failing_check:unit-report', failing_tests: '1 failed: fitness' };
|
|
139
|
+
const local = mergeTrapLines(md, { taskId: 42, via: 'local' }).join('\n');
|
|
140
|
+
assert.match(local, /`fitness` is a freshness gate/);
|
|
141
|
+
assert.match(local, /stale in the MERGED tree/);
|
|
142
|
+
assert.match(local, /node scripts\/gds\/ship\.js 42/, 'names the re-ship for THIS task');
|
|
143
|
+
assert.match(local, /ship-check\.js/);
|
|
144
|
+
const server = mergeTrapLines(md, { taskId: 42, via: 'server' }).join('\n');
|
|
145
|
+
assert.match(server, /The server regenerates stale generated files/);
|
|
146
|
+
const plain = mergeTrapLines({ reason: 'failing_check:unit-report', failing_tests: '1 failed: security' }, { taskId: 42 }).join('\n');
|
|
147
|
+
assert.ok(!/freshness gate/.test(plain), 'an ordinary failing test gets no regenerate advice');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('redPrLines: the red line first, then the trap, then the plain-words card', async () => {
|
|
151
|
+
const lines = redPrLines({ reason: 'failing_check:unit-report', failing_tests: '1 failed: fitness' }, { taskId: 42, via: 'local' });
|
|
152
|
+
assert.match(lines[0], /the PR is RED — 1 failed: fitness/);
|
|
153
|
+
assert.match(lines[1], /MERGED with the current main/);
|
|
154
|
+
const at = lines.findIndex((l) => l.includes('[otb-plain]'));
|
|
155
|
+
assert.ok(at > 2, 'the card still follows');
|
|
156
|
+
assert.ok(lines.slice(at).join('\n').includes(CARDS.failing_check.happened));
|
|
157
|
+
assert.deepEqual(redPrLines({ reason: 'review_required' }), [], 'nothing red → nothing printed');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('redBlocksMerge: only a failing REQUIRED check stops the merge', async () => {
|
|
161
|
+
assert.equal(redBlocksMerge({ reason: 'failing_check:unit' }), true);
|
|
162
|
+
assert.equal(redBlocksMerge({ reason: 'failing_check_unreadable' }), true);
|
|
163
|
+
assert.equal(redBlocksMerge({ reason: 'non_required_check_failing:lint' }), false, 'GitHub still merges an UNSTABLE PR');
|
|
164
|
+
assert.equal(redBlocksMerge(null), false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await test('landPendingLines: a RED PR never gets "NOT a failure"; a green-but-slow one keeps it', async () => {
|
|
168
|
+
const red = landPendingLines(42, { red: true }).join('\n');
|
|
169
|
+
assert.ok(!/NOT a failure/.test(red), 'a failing required check does not pass by waiting');
|
|
170
|
+
assert.match(red, /RED/);
|
|
171
|
+
assert.match(red, /node scripts\/gds\/ship\.js 42/);
|
|
172
|
+
assert.match(landPendingLines(42).join('\n'), /This is NOT a failure/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---- the two poll loops, end to end with fakes ----
|
|
176
|
+
|
|
177
|
+
await test('pollCiDeploy (laptop): a red PR is named once, the trap and heal are printed, and the banner is red', async () => {
|
|
178
|
+
const seen = { prView: 0, status: 0 };
|
|
179
|
+
const gh = (args) => {
|
|
180
|
+
if (args[0] === 'pr' && args[1] === 'view') { seen.prView++; return openPr(RED_ROLLUP); }
|
|
181
|
+
if (args[0] === 'api') { seen.status++; return COMBINED_STATUS; }
|
|
182
|
+
throw new Error(`unexpected gh call: ${args.join(' ')}`);
|
|
183
|
+
};
|
|
184
|
+
const { result, out } = await capture(() => pollCiDeploy(42, 'task-42', SHA, { ghJson: gh, pause: () => {} }));
|
|
185
|
+
assert.equal(result.pending, true, 'still a pending land — the check can yet pass');
|
|
186
|
+
assert.equal(seen.prView, 30, 'polled the whole window');
|
|
187
|
+
assert.equal(seen.status, 1, 'the unit-report is read once per head sha, not once per poll');
|
|
188
|
+
assert.equal(out.split('the PR is RED — 1 failed: fitness').length - 1, 1, 'named once, not thirty times');
|
|
189
|
+
assert.match(out, /MERGED with the current main/);
|
|
190
|
+
assert.match(out, /`fitness` is a freshness gate/);
|
|
191
|
+
assert.match(out, /node scripts\/gds\/ship\.js 42/);
|
|
192
|
+
assert.ok(!/This is NOT a failure/.test(out), 'the closing banner must not contradict the red line');
|
|
193
|
+
assert.ok(!out.includes(CARDS.land_pending.happened), 'nor the "nothing to do" card');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await test('pollCiDeploy: a gh that rejects the new fields falls back to the old read and still sees the merge', async () => {
|
|
197
|
+
const gh = (args) => {
|
|
198
|
+
if (args[0] === 'pr' && args.includes('state,mergeCommit')) return { state: 'MERGED', mergeCommit: { oid: 'b'.repeat(40) } };
|
|
199
|
+
if (args[0] === 'pr') return null; // unknown JSON field → gh exits non-zero → ghJson null
|
|
200
|
+
if (args[0] === 'run') return [];
|
|
201
|
+
throw new Error(`unexpected gh call: ${args.join(' ')}`);
|
|
202
|
+
};
|
|
203
|
+
const { result, out } = await capture(() => pollCiDeploy(42, 'task-42', SHA, { ghJson: gh, pause: () => {} }));
|
|
204
|
+
assert.match(out, /merged to main \(bbbbbbbb\)/);
|
|
205
|
+
assert.equal(result.pending, true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await test('pollCiDeploy: a green PR that is merely slow keeps the reassuring banner', async () => {
|
|
209
|
+
const gh = (args) => (args[0] === 'pr' ? openPr([run('unit', '', 'IN_PROGRESS')]) : null);
|
|
210
|
+
const { out } = await capture(() => pollCiDeploy(42, 'task-42', SHA, { ghJson: gh, pause: () => {} }));
|
|
211
|
+
assert.ok(!/RED/.test(out));
|
|
212
|
+
assert.match(out, /This is NOT a failure/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test('pollServerPublish (credential-less): a failing status read is said once; a red PR gets the server-path heal', async () => {
|
|
216
|
+
let n = 0;
|
|
217
|
+
const readStatus = async () => {
|
|
218
|
+
n++;
|
|
219
|
+
if (n <= 3) return { ok: false, status: 502, data: { error: { code: 'status_failed', message: 'x' } } };
|
|
220
|
+
return { ok: true, status: 200, data: {
|
|
221
|
+
pr_state: 'open', merged: false, deploy_status: 'pending', reconcile_blocked: null,
|
|
222
|
+
merge_driver: { reason: 'failing_check:unit-report', failing_tests: '1 failed: fitness' },
|
|
223
|
+
} };
|
|
224
|
+
};
|
|
225
|
+
const { result, out } = await capture(() => pollServerPublish(42, 'task-42', { readStatus, pause: () => {} }));
|
|
226
|
+
assert.equal(result.pending, true);
|
|
227
|
+
assert.equal(out.split('could not read the land status from the server (HTTP 502 status_failed)').length - 1, 1,
|
|
228
|
+
'three identical failures are reported once');
|
|
229
|
+
assert.equal(out.split('the PR is RED — 1 failed: fitness').length - 1, 1);
|
|
230
|
+
assert.match(out, /The server regenerates stale generated files/);
|
|
231
|
+
assert.ok(!/git merge origin\/main/.test(out), 'never tells a credential-less machine to merge main');
|
|
232
|
+
assert.ok(!/This is NOT a failure/.test(out));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
console.log(`\nship-land red PR (task 1003764) tests: ${passed} passed, ${failed} failed`);
|
|
236
|
+
process.exitCode = failed > 0 ? 1 : 0;
|