@bongos/core 1.24.76 → 1.24.78
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 +129 -49
- 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 +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +45 -0
- package/docs/api/openapi.json +209 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +109 -92
- package/docs/copy-registry.json +291 -108
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +111 -97
- package/docs/recipes/autobongos-runner-setup.md +148 -0
- package/docs/recipes/autobongos-windows-host.md +10 -7
- package/modules/autonomy/cadence.js +3 -0
- package/modules/autonomy/db.js +86 -7
- package/modules/autonomy/fence.js +20 -5
- package/modules/autonomy/gauge.js +25 -1
- package/modules/autonomy/migrations/autonomy_006_runner_device.sql +45 -0
- package/modules/autonomy/module.json +2 -1
- package/modules/autonomy/routes/autonomy.js +165 -3
- package/modules/autonomy/runner-health.js +27 -0
- package/modules/autonomy/skills/autobongos-setup/SKILL.md +86 -0
- package/modules/hall-ui/public/commands-lib.js +1 -1
- package/modules/hall-ui/public/gate.js +12 -5
- package/modules/hall-ui/public/runner-button.css +55 -0
- package/modules/hall-ui/public/runner-button.js +187 -0
- package/modules/hall-ui/public/settings-autobongos.js +349 -63
- package/modules/hall-ui/public/settings.html +23 -10
- package/modules/hall-ui/records/runner-button.md +16 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/autobongos-bootstrap.ps1 +169 -0
- package/scripts/bongos/autobongos-bootstrap.sh +151 -0
- package/scripts/bongos/autobongos-host.js +883 -0
- package/scripts/bongos/autobongos-run.js +25 -4
- package/scripts/bongos/autonomy-gauge.js +11 -3
- package/scripts/bongos/cli-lib.js +5 -1
- package/scripts/bongos/stranded-rows.js +39 -2
- package/scripts/gds/autobongos-bootstrap.ps1 +8 -0
- package/scripts/gds/autobongos-bootstrap.sh +8 -0
- package/scripts/gds/autobongos-host.js +7 -0
- package/scripts/hall-preview/server.js +7 -0
- package/src/instance-config.js +22 -3
- package/src/module-api.js +1 -1
- package/tests/autobongos_host.mjs +647 -0
- package/tests/autobongos_loop.mjs +11 -0
- package/tests/autonomy_config_idle.mjs +9 -4
- package/tests/autonomy_gauge.mjs +43 -0
- package/tests/autonomy_runner_device.mjs +444 -0
- package/tests/autonomy_runner_identity.mjs +3 -1
- package/tests/cli_sessions.mjs +16 -0
- package/tests/hall_runner_button.mjs +467 -0
- package/tests/hall_runner_panel_connect.mjs +73 -0
- package/tests/stranded_rows.mjs +50 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// tests/hall_runner_button.mjs — a device's Run button on Settings > Your
|
|
2
|
+
// Autobongos runner (task 1005094).
|
|
3
|
+
//
|
|
4
|
+
// Owner direction, 2026-11-09: running Autobongos on a device is ONE button, and
|
|
5
|
+
// any cooldown is built into the button itself. Two files carry that, and both
|
|
6
|
+
// are RUN here, never read:
|
|
7
|
+
//
|
|
8
|
+
// · runner-button.js decides what the button's face says. It is pure, so it is
|
|
9
|
+
// required and asked directly, one case per face.
|
|
10
|
+
// · settings-autobongos.js draws the panel. It is loaded in a vm against
|
|
11
|
+
// stand-in elements and the hall-preview fixtures, and its own click handler
|
|
12
|
+
// is pressed: the requests it sends, in the order it sends them, are the
|
|
13
|
+
// assertions.
|
|
14
|
+
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import vm from 'node:vm';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { realOTB } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
27
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
28
|
+
const fixture = (name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', `${name}.json`), 'utf8'));
|
|
29
|
+
const RB = require(path.join(HALL, 'runner-button.js'));
|
|
30
|
+
|
|
31
|
+
const NOW = Date.parse('2026-11-09T12:00:00.000Z');
|
|
32
|
+
const iso = (offsetMs) => new Date(NOW + offsetMs).toISOString();
|
|
33
|
+
const MIN = 60 * 1000;
|
|
34
|
+
// A fence that lets the runner work: the project is on and a goal is picked.
|
|
35
|
+
const OPEN = { enabled: true, scope: { goals: [1000118] }, project: { enabled: true, goals: [{ goal_id: 1000118 }] } };
|
|
36
|
+
const device = (over = {}, healthOver = {}) => ({
|
|
37
|
+
host: 'ada-laptop', run_requested: true, run_changed_at: iso(-60 * MIN), last_seen_at: iso(-30 * 1000),
|
|
38
|
+
health: { state: 'alive', host: 'ada-laptop', workingTaskId: null, holdUntil: null, age: '30s', ...healthOver },
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
const face = (runner, fence = OPEN) => RB.buttonState({ runner, fence, nowMs: NOW });
|
|
42
|
+
|
|
43
|
+
// ── what the button says ─────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
test('a stopped device shows Run, and a press starts it', () => {
|
|
46
|
+
const b = face(device({ run_requested: false }, { state: 'stopped' }));
|
|
47
|
+
assert.deepEqual([b.status, b.label, b.act, b.busy], ['', 'Run', 'run', false]);
|
|
48
|
+
assert.equal(b.sub, 'Stopped on this device.');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a press of Run reads Starting until the device has checked in after it, and cannot be pressed again', () => {
|
|
52
|
+
const pressed = device({ run_changed_at: iso(-20 * 1000), last_seen_at: iso(-40 * 1000) }, { state: 'stopped' });
|
|
53
|
+
const b = face(pressed);
|
|
54
|
+
assert.deepEqual([b.status, b.act, b.busy], ['Starting…', null, true], 'the cooldown is the button: it has no action while it starts');
|
|
55
|
+
assert.match(b.sub, /under a minute/);
|
|
56
|
+
// The device picked it up: it checked in after the press.
|
|
57
|
+
assert.equal(face(device({ run_changed_at: iso(-20 * 1000), last_seen_at: iso(-5 * 1000) })).status, 'Running');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('Starting does not last forever: a device that never picks the press up reads as off', () => {
|
|
61
|
+
const stale = device({ run_changed_at: iso(-RB.STARTING_MS - 1000), last_seen_at: iso(-3 * 3600 * 1000) }, { state: 'dead', age: '3h' });
|
|
62
|
+
const b = face(stale);
|
|
63
|
+
assert.equal(b.status, 'Device is off or asleep');
|
|
64
|
+
assert.equal(b.act, 'stop', 'and the press that is left turns the request off again');
|
|
65
|
+
assert.match(b.sub, /No check-in for 3h\. Turn it on and sign in/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the stay-on note goes with a device that is switched on, and with one finishing its last task', () => {
|
|
69
|
+
assert.equal(face(device()).stayOn, true);
|
|
70
|
+
assert.equal(face(device({}, { state: 'dead', age: '4h' })).stayOn, true, 'most of all when it has gone quiet');
|
|
71
|
+
assert.equal(face(device({ run_requested: false }, { state: 'stopped' })).stayOn, false, 'a stopped device has nothing to stay awake for');
|
|
72
|
+
assert.equal(face(device({ run_requested: false }, { workingTaskId: '7' })).stayOn, true, 'still building: it must stay up to finish');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a running device shows what it is doing, and a press stops it', () => {
|
|
76
|
+
const working = face(device({}, { workingTaskId: '1004711' }));
|
|
77
|
+
assert.deepEqual([working.status, working.label, working.act, working.tone], ['Running', 'Stop', 'stop', 'ok']);
|
|
78
|
+
assert.equal(working.sub, 'Working on task 1004711.');
|
|
79
|
+
assert.equal(face(device()).sub, 'On — waiting for work.');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('THE COOLDOWN IS ON THE BUTTON: a resting device shows when it resumes and how long is left', () => {
|
|
83
|
+
const b = face(device({}, { holdUntil: iso(144 * MIN) }));
|
|
84
|
+
assert.match(b.status, /^Resting until /);
|
|
85
|
+
assert.equal(b.status, `Resting until ${RB.clock(NOW + 144 * MIN, NOW)}`);
|
|
86
|
+
assert.equal(b.countdown, '2 h 24 min');
|
|
87
|
+
assert.equal(b.act, 'stop', 'it can still be stopped while it rests');
|
|
88
|
+
assert.match(b.sub, /starts again by itself/);
|
|
89
|
+
// A rest that is already over is not shown.
|
|
90
|
+
assert.equal(face(device({}, { holdUntil: iso(-MIN) })).status, 'Running');
|
|
91
|
+
// The countdown's own words.
|
|
92
|
+
assert.equal(RB.countdown(30 * 1000), '1 min');
|
|
93
|
+
assert.equal(RB.countdown(59 * MIN), '59 min');
|
|
94
|
+
assert.equal(RB.countdown(120 * MIN), '2 h');
|
|
95
|
+
assert.equal(RB.countdown(0), '');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the resume time is a clock time today, a weekday within the week, a date beyond', () => {
|
|
99
|
+
const at = (ms) => RB.clock(NOW + ms, NOW);
|
|
100
|
+
const time = new Date(NOW + 30 * MIN).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
101
|
+
// Thirty minutes ahead is the same day unless this machine's midnight falls between.
|
|
102
|
+
if (new Date(NOW).toDateString() === new Date(NOW + 30 * MIN).toDateString()) assert.equal(at(30 * MIN), time);
|
|
103
|
+
assert.match(at(3 * 24 * 3600 * 1000), /\D+ .*\d/, 'a weekday and a time');
|
|
104
|
+
assert.match(at(40 * 24 * 3600 * 1000), /\d{4}/, 'a date with its year');
|
|
105
|
+
// A rest longer than any usage window is not counted down in hours.
|
|
106
|
+
assert.equal(face(device({}, { holdUntil: iso(40 * 24 * 3600 * 1000) })).countdown, '');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('Stop on a device that is mid-task reads Stopping, with a way to change your mind', () => {
|
|
110
|
+
const b = face(device({ run_requested: false }, { workingTaskId: '1004711' }));
|
|
111
|
+
assert.deepEqual([b.status, b.act, b.busy], ['Stopping…', null, true]);
|
|
112
|
+
assert.equal(b.sub, 'Finishing task 1004711 first, then it stops.');
|
|
113
|
+
assert.deepEqual(b.alt, { act: 'run', label: 'Keep it running' });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a device that has gone quiet says so on the button, switched on or not', () => {
|
|
117
|
+
const on = face(device({}, { state: 'dead', age: '4h' }));
|
|
118
|
+
assert.deepEqual([on.status, on.tone], ['Device is off or asleep', 'warn']);
|
|
119
|
+
const off = face(device({ run_requested: false }, { state: 'dead', age: '4h' }));
|
|
120
|
+
assert.deepEqual([off.label, off.act], ['Run', 'run']);
|
|
121
|
+
assert.match(off.sub, /off or asleep.*starts when it is back on/);
|
|
122
|
+
// Quiet longer than a loop but with a task in hand is a worker building, not a device that is off.
|
|
123
|
+
assert.equal(face(device({}, { state: 'late', workingTaskId: '1004711' })).status, 'Running');
|
|
124
|
+
assert.equal(face(device({}, { state: 'late' })).status, 'Device is off or asleep');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a device that is on but held says why, in the order someone asks', () => {
|
|
128
|
+
const sub = (fence, healthOver) => face(device({}, healthOver), fence).sub;
|
|
129
|
+
assert.equal(face(device(), { enabled: false, stop_code: 'kill_switch', scope: {}, project: { enabled: false, paused_reason: 'night off' } }).status, 'Ready');
|
|
130
|
+
assert.equal(sub({ enabled: false, stop_code: 'kill_switch', scope: {}, project: { enabled: false, paused_reason: 'night off' } }), 'It is on, but the project’s runner switch is off (night off).');
|
|
131
|
+
assert.equal(sub({ enabled: false, stop_code: 'builder_paused', scope: { owner_paused_reason: 'too many PRs' }, project: { enabled: true } }), 'It is on, but the owner paused your runner (too many PRs).');
|
|
132
|
+
assert.equal(sub({ enabled: false, stop_code: 'not_permitted', scope: {}, project: { enabled: true } }), 'It is on, but your rank cannot run a runner on this project.');
|
|
133
|
+
assert.equal(sub({ enabled: true, scope: { goals: [] }, project: { enabled: true, goals: [{ goal_id: 1 }] } }), 'It is on, but no goals are chosen for it yet — pick at least one below.');
|
|
134
|
+
assert.equal(sub({ enabled: true, scope: { goals: [] }, project: { enabled: true, goals: [] } }), 'It is on, but the project has not allowed any goals for runners yet.');
|
|
135
|
+
assert.match(sub(OPEN, { state: 'idle_config', why: 'its usage gauge has no fresh reading', remedy: 'Open one Claude session there.' }), /idle because its usage gauge has no fresh reading\. Open one Claude session there\./);
|
|
136
|
+
// A task in hand outranks a setting: the device says what it is doing this minute.
|
|
137
|
+
assert.equal(face(device({}, { workingTaskId: '7' }), { enabled: false, stop_code: 'kill_switch', scope: {}, project: { enabled: false } }).status, 'Running');
|
|
138
|
+
// No fence read yet is not a hold.
|
|
139
|
+
assert.equal(face(device(), null).status, 'Running');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a runner restarting on new code says so', () => {
|
|
143
|
+
const b = face(device({}, { state: 'restarting' }));
|
|
144
|
+
assert.deepEqual([b.status, b.busy], ['Updating…', true]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── the setup command ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const CODE = `abr_${'a1'.repeat(32)}`;
|
|
150
|
+
|
|
151
|
+
test('the setup command is one line per platform, carrying the code, the project and the repository', () => {
|
|
152
|
+
const c = RB.setupCommands({ apiBase: 'https://hub.example/', code: CODE, repoUrl: 'https://github.com/owner/name' });
|
|
153
|
+
assert.equal(c.windows, `& ([scriptblock]::Create((irm 'https://hub.example/api/bongos/autonomy/runner-setup.ps1'))) -Code '${CODE}' -ApiBase 'https://hub.example' -Repo 'https://github.com/owner/name'`);
|
|
154
|
+
assert.equal(c.mac, `curl -fsSL 'https://hub.example/api/bongos/autonomy/runner-setup.sh' | bash -s -- --code '${CODE}' --api-base 'https://hub.example' --repo 'https://github.com/owner/name'`);
|
|
155
|
+
assert.equal(c.windows.includes('\n') || c.mac.includes('\n'), false, 'one line each');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('the setup command downloads and runs code, so it is only ever built for an https project', () => {
|
|
159
|
+
const good = { code: CODE, repoUrl: 'https://github.com/owner/name' };
|
|
160
|
+
for (const base of ['http://hub.example', 'http://hub.example:8080', 'http://10.0.0.5:3000', 'http://evil.localhost.example', 'ftp://hub.example']) {
|
|
161
|
+
assert.equal(RB.setupCommands({ ...good, apiBase: base }), null, `${base} must not get a command: anyone on the network could swap the script`);
|
|
162
|
+
}
|
|
163
|
+
// A project on this very machine never leaves the device.
|
|
164
|
+
for (const base of ['http://localhost:3000', 'http://127.0.0.1:4600', 'http://builders.localhost:4642']) {
|
|
165
|
+
assert.ok(RB.setupCommands({ ...good, apiBase: base }), base);
|
|
166
|
+
}
|
|
167
|
+
assert.ok(RB.setupCommands({ ...good, apiBase: 'https://hub.example:8443' }));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('no command is built with a hole in it, or from text that could end the quoting', () => {
|
|
171
|
+
const good = { apiBase: 'https://hub.example', code: CODE, repoUrl: 'https://github.com/owner/name' };
|
|
172
|
+
for (const bad of [
|
|
173
|
+
{ repoUrl: null }, { repoUrl: 'git@github.com:owner/name.git' }, { repoUrl: "https://github.com/owner/name'; rm -rf ~; '" },
|
|
174
|
+
{ code: '' }, { code: "abr_x'; whoami; '" }, { code: 'not-a-code' },
|
|
175
|
+
{ apiBase: '' }, { apiBase: "https://hub.example'; calc; '" }, { apiBase: 'javascript:alert(1)' },
|
|
176
|
+
]) {
|
|
177
|
+
assert.equal(RB.setupCommands({ ...good, ...bad }), null, JSON.stringify(bad));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('the platform shown first follows the browser', () => {
|
|
182
|
+
assert.equal(RB.guessPlatform('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'), 'mac');
|
|
183
|
+
assert.equal(RB.guessPlatform('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'), 'windows');
|
|
184
|
+
assert.equal(RB.guessPlatform(''), 'windows');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── the panel, run for real ──────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const FENCE = fixture('autonomy__fence');
|
|
190
|
+
const RUNNERS = fixture('autonomy__runners');
|
|
191
|
+
|
|
192
|
+
// settings-autobongos.js in a vm. `respond(method, url, body)` answers each
|
|
193
|
+
// request ({ status, data }); anything it returns nothing for is a 404.
|
|
194
|
+
async function loadPanel({ respond, confirm = async () => true, userAgent = 'Mozilla/5.0 (Windows NT 10.0)', hidden = false } = {}) {
|
|
195
|
+
const els = {};
|
|
196
|
+
const el = (id) => {
|
|
197
|
+
let html = '';
|
|
198
|
+
const node = {
|
|
199
|
+
id, hidden: true, textContent: '', disabled: false, writes: 0, handlers: {},
|
|
200
|
+
get innerHTML() { return html; },
|
|
201
|
+
set innerHTML(v) { html = String(v); node.writes += 1; },
|
|
202
|
+
addEventListener(type, fn) { node.handlers[type] = fn; },
|
|
203
|
+
contains: () => true,
|
|
204
|
+
};
|
|
205
|
+
return node;
|
|
206
|
+
};
|
|
207
|
+
for (const id of ['autobongos-scroll', 'autobongos-body', 'ab-notices', 'ab-devices', 'ab-add', 'ab-goals', 'autobongos-status', 'autobongos-save']) els[`#${id}`] = el(id);
|
|
208
|
+
const requests = [];
|
|
209
|
+
const toasts = [];
|
|
210
|
+
const timers = [];
|
|
211
|
+
const dialogs = [];
|
|
212
|
+
const copied = [];
|
|
213
|
+
const kit = await import(`file://${path.join(HALL, 'hall-kit.js').replace(/\\/g, '/')}`).then((m) => m.default);
|
|
214
|
+
const window = {
|
|
215
|
+
OTB: { ...realOTB(), $: (sel) => els[sel] || null, toast: (m) => toasts.push(m) },
|
|
216
|
+
OTBKit: kit,
|
|
217
|
+
OTBDialog: { confirm: async (opts) => { dialogs.push(opts); return confirm(opts); } },
|
|
218
|
+
BongosClient: {
|
|
219
|
+
createClient: () => ({
|
|
220
|
+
request: async (method, url, opts = {}) => {
|
|
221
|
+
// Through JSON: the page runs in its own realm, and an object born
|
|
222
|
+
// there is never deepEqual to one written here.
|
|
223
|
+
const body = opts.body === undefined ? undefined : JSON.parse(JSON.stringify(opts.body));
|
|
224
|
+
requests.push([method, url.replace('/api/bongos', ''), ...(body === undefined ? [] : [body])]);
|
|
225
|
+
const r = await respond(method, url.replace('/api/bongos', ''), body);
|
|
226
|
+
if (!r) return { ok: false, status: 404, data: null };
|
|
227
|
+
const status = r.status || 200;
|
|
228
|
+
// A copy, every time: the page keeps what it is handed and writes a press
|
|
229
|
+
// into it, and a fixture shared by reference would carry one test's press
|
|
230
|
+
// into the next.
|
|
231
|
+
return { ok: status >= 200 && status < 300, status, data: r.data === undefined ? null : JSON.parse(JSON.stringify(r.data)) };
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
location: { assign() {}, origin: 'https://hub.example' },
|
|
236
|
+
navigator: { userAgent, clipboard: { writeText: async (t) => { copied.push(t); } } },
|
|
237
|
+
setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
|
|
238
|
+
clearTimeout: () => {},
|
|
239
|
+
};
|
|
240
|
+
const sandbox = { window, document: { hidden, activeElement: null, addEventListener() {} }, console: { error() {}, warn() {} }, Date, Promise, Set, Map, Number, String, Array, Object, JSON, Error, encodeURIComponent };
|
|
241
|
+
vm.createContext(sandbox);
|
|
242
|
+
vm.runInContext(read('runner-button.js'), sandbox, { filename: 'runner-button.js' });
|
|
243
|
+
vm.runInContext(read('settings-autobongos.js'), sandbox, { filename: 'settings-autobongos.js' });
|
|
244
|
+
const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); };
|
|
245
|
+
await settle();
|
|
246
|
+
const body = els['#autobongos-body'];
|
|
247
|
+
// Press a button the way the page's one listener sees it.
|
|
248
|
+
const click = async (attrs) => {
|
|
249
|
+
const btn = { disabled: false, id: attrs.id || '', dataset: { ...attrs } };
|
|
250
|
+
await body.handlers.click({ target: { closest: () => btn } });
|
|
251
|
+
await settle();
|
|
252
|
+
};
|
|
253
|
+
const html = (id) => els[`#${id}`].innerHTML;
|
|
254
|
+
return { els, html, click, requests, toasts, timers, dialogs, copied, settle };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The harness's own answers, with the runners list a test can change.
|
|
258
|
+
function fixtureResponder({ fence = FENCE, runners = () => RUNNERS.runners, extra = () => null } = {}) {
|
|
259
|
+
return async (method, url, body) => {
|
|
260
|
+
const mine = await extra(method, url, body);
|
|
261
|
+
if (mine) return mine;
|
|
262
|
+
if (method === 'GET' && url === '/autonomy/fence') return { data: fence };
|
|
263
|
+
if (method === 'GET' && url === '/autonomy/runners') return { data: { runners: runners() } };
|
|
264
|
+
if (method === 'GET' && url === '/goals') return { data: { goals: [{ id: 1000118, title: 'Bug squash' }] } };
|
|
265
|
+
if (method === 'GET' && url === '/public/repo-info') return { data: fixture('public__repo-info') };
|
|
266
|
+
if (method === 'POST' && url === '/auth/runner-code/issue') return { status: 201, data: { ...fixture('auth__runner-code__issue.post'), api_base: 'https://hub.example' } };
|
|
267
|
+
return null;
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const count = (haystack, needle) => haystack.split(needle).length - 1;
|
|
272
|
+
|
|
273
|
+
test('the panel draws one card per device, each with its one button, and the stay-on note once', async () => {
|
|
274
|
+
const p = await loadPanel({ respond: fixtureResponder() });
|
|
275
|
+
assert.equal(p.els['#autobongos-scroll'].hidden, false);
|
|
276
|
+
const devices = p.html('ab-devices');
|
|
277
|
+
assert.equal(count(devices, 'class="rb-card"'), 4);
|
|
278
|
+
for (const host of ['ada-laptop', 'studio-mac', 'gmk-always-on', 'winbox']) assert.ok(devices.includes(`data-host="${host}"`), host);
|
|
279
|
+
assert.equal(count(devices, 'rb-button"'), 4, 'one Run button per device');
|
|
280
|
+
// studio-mac is stopped: its button is the accent Run, and a press starts it.
|
|
281
|
+
assert.match(devices, /<button type="button" class="btn rb-button" data-focus="press:studio-mac"\s+data-press="run" data-host="studio-mac">/);
|
|
282
|
+
// ada-laptop is building: the face says so and the press stops it.
|
|
283
|
+
assert.match(devices, /data-press="stop" data-host="ada-laptop">[\s\S]*?Running[\s\S]*?Stop/);
|
|
284
|
+
assert.ok(devices.includes('Working on task 1004711.'));
|
|
285
|
+
assert.ok(devices.includes('Device is off or asleep'));
|
|
286
|
+
// The stay-on note is on the card of every device that is switched on (three
|
|
287
|
+
// of the four here), and not on the one its builder stopped.
|
|
288
|
+
const cards = devices.split('<li class="rb-card"').slice(1);
|
|
289
|
+
const noted = cards.filter((c) => c.includes('Keep this device on and plugged in while it runs.')).map((c) => /data-host="([a-z-]+)"/.exec(c)[1]);
|
|
290
|
+
assert.deepEqual(noted.sort(), ['ada-laptop', 'gmk-always-on', 'winbox']);
|
|
291
|
+
assert.ok(p.html('ab-add').includes('Add a device'));
|
|
292
|
+
assert.ok(p.html('ab-notices').includes('runner switch is off'), 'the fixture project has its switch off, and the panel says so first');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('a press of Run sends that device’s switch, shows Starting, and reads the devices again', async () => {
|
|
296
|
+
let pressed = false;
|
|
297
|
+
const afterPress = () => RUNNERS.runners.map((r) => (r.host === 'studio-mac' && pressed
|
|
298
|
+
? { ...r, run_requested: true, run_changed_at: new Date().toISOString(), health: { ...r.health, state: 'stopped' } } : r));
|
|
299
|
+
const p = await loadPanel({ respond: fixtureResponder({
|
|
300
|
+
runners: afterPress,
|
|
301
|
+
extra: (method, url, body) => {
|
|
302
|
+
if (method === 'POST' && url === '/autonomy/runners/studio-mac/run') { pressed = body.run === true; return { data: { host: 'studio-mac', run_requested: true, run_changed_at: new Date().toISOString() } }; }
|
|
303
|
+
return null;
|
|
304
|
+
},
|
|
305
|
+
}) });
|
|
306
|
+
p.requests.length = 0;
|
|
307
|
+
await p.click({ press: 'run', host: 'studio-mac' });
|
|
308
|
+
assert.deepEqual(p.requests[0], ['POST', '/autonomy/runners/studio-mac/run', { run: true }]);
|
|
309
|
+
assert.ok(p.requests.some(([m, u]) => m === 'GET' && u === '/autonomy/runners'), 'the devices are read again');
|
|
310
|
+
assert.deepEqual(p.toasts, ['Starting it on that device.']);
|
|
311
|
+
const card = p.html('ab-devices').split('<li class="rb-card"').find((c) => c.includes('data-host="studio-mac"'));
|
|
312
|
+
assert.ok(card.includes('Starting…'), 'the button is the cooldown');
|
|
313
|
+
assert.match(card, /rb-button--busy"[^>]*data-host="studio-mac" disabled>/, 'and cannot be pressed while it starts');
|
|
314
|
+
// While a card is starting the panel reads again quickly.
|
|
315
|
+
assert.equal(p.timers.at(-1).ms, 3000);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('a press of Stop sends run:false, and a refusal is shown as the server’s own sentence', async () => {
|
|
319
|
+
const sent = [];
|
|
320
|
+
const p = await loadPanel({ respond: fixtureResponder({ extra: (method, url, body) => {
|
|
321
|
+
if (method === 'POST' && url === '/autonomy/runners/ada-laptop/run') { sent.push(body); return { data: { host: 'ada-laptop', run_requested: false, run_changed_at: new Date().toISOString() } }; }
|
|
322
|
+
if (method === 'POST' && url === '/autonomy/runners/winbox/run') return { status: 404, data: { error: { code: 'device_not_found', message: 'You have no device by that name.' } } };
|
|
323
|
+
return null;
|
|
324
|
+
} }) });
|
|
325
|
+
await p.click({ press: 'stop', host: 'ada-laptop' });
|
|
326
|
+
assert.deepEqual(sent, [{ run: false }]);
|
|
327
|
+
assert.equal(p.toasts.at(-1), 'Stopping it on that device.');
|
|
328
|
+
await p.click({ press: 'stop', host: 'winbox' });
|
|
329
|
+
assert.equal(p.toasts.at(-1), 'You have no device by that name.');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('a failure with no sentence from the server is said in the page’s one wording, as a whole sentence', async () => {
|
|
333
|
+
const p = await loadPanel({ respond: fixtureResponder({ extra: (method, url) => {
|
|
334
|
+
if (method === 'POST' && url.endsWith('/run')) return { status: 502, data: null };
|
|
335
|
+
if (method === 'POST' && url === '/me/sessions/revoke') return { status: 502, data: null };
|
|
336
|
+
if (method === 'POST' && url === '/auth/runner-code/issue') return { status: 502, data: null };
|
|
337
|
+
return null;
|
|
338
|
+
} }) });
|
|
339
|
+
await p.click({ press: 'run', host: 'studio-mac' });
|
|
340
|
+
assert.equal(p.toasts.at(-1), 'Could not start it on that device. Try again.');
|
|
341
|
+
await p.click({ press: 'stop', host: 'ada-laptop' });
|
|
342
|
+
assert.equal(p.toasts.at(-1), 'Could not stop it on that device. Try again.');
|
|
343
|
+
await p.click({ remove: 'studio-mac' });
|
|
344
|
+
assert.equal(p.toasts.at(-1), 'Could not sign that device out. Try again.');
|
|
345
|
+
await p.click({ id: 'ab-add-open' });
|
|
346
|
+
assert.ok(p.html('ab-add').includes('Could not make a setup code. Try again.'));
|
|
347
|
+
for (const t of p.toasts) assert.match(t, /^[A-Z]/, `"${t}" starts with a capital`);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('Remove signs the device’s own login out FIRST, then clears its card', async () => {
|
|
351
|
+
const p = await loadPanel({ respond: fixtureResponder({ extra: (method, url) => {
|
|
352
|
+
if (method === 'POST' && url === '/me/sessions/revoke') return { data: { ok: true, revoked: 1 } };
|
|
353
|
+
if (method === 'DELETE' && url === '/autonomy/runners/studio-mac') return { data: { removed: true } };
|
|
354
|
+
return null;
|
|
355
|
+
} }) });
|
|
356
|
+
p.requests.length = 0;
|
|
357
|
+
await p.click({ remove: 'studio-mac' });
|
|
358
|
+
assert.equal(p.dialogs.length, 1);
|
|
359
|
+
assert.equal(p.dialogs[0].danger, true);
|
|
360
|
+
assert.match(p.dialogs[0].body, /signed out at once/);
|
|
361
|
+
const writes = p.requests.filter(([m]) => m !== 'GET');
|
|
362
|
+
assert.deepEqual(writes, [
|
|
363
|
+
['POST', '/me/sessions/revoke', { session_id: '8802' }],
|
|
364
|
+
['DELETE', '/autonomy/runners/studio-mac'],
|
|
365
|
+
], 'a card that is gone while its login still works would be a runner nobody can see');
|
|
366
|
+
assert.equal(p.toasts.at(-1), 'studio-mac removed.');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('Remove on a device with no login of its own says what it cannot do, and revokes nothing', async () => {
|
|
370
|
+
const p = await loadPanel({ respond: fixtureResponder({ extra: (method, url) => (method === 'DELETE' && url === '/autonomy/runners/winbox' ? { data: { removed: true } } : null) }) });
|
|
371
|
+
p.requests.length = 0;
|
|
372
|
+
await p.click({ remove: 'winbox' });
|
|
373
|
+
assert.match(p.dialogs[0].body, /only clears its card/);
|
|
374
|
+
assert.deepEqual(p.requests.filter(([m]) => m !== 'GET'), [['DELETE', '/autonomy/runners/winbox']]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('a cancelled Remove, or a login that would not sign out, leaves the card where it is', async () => {
|
|
378
|
+
const cancelled = await loadPanel({ respond: fixtureResponder(), confirm: async () => false });
|
|
379
|
+
cancelled.requests.length = 0;
|
|
380
|
+
await cancelled.click({ remove: 'studio-mac' });
|
|
381
|
+
assert.deepEqual(cancelled.requests, []);
|
|
382
|
+
|
|
383
|
+
const stuck = await loadPanel({ respond: fixtureResponder({ extra: (method, url) => (method === 'POST' && url === '/me/sessions/revoke' ? { status: 500, data: { error: { code: 'x', message: 'internal error' } } } : null) }) });
|
|
384
|
+
stuck.requests.length = 0;
|
|
385
|
+
await stuck.click({ remove: 'studio-mac' });
|
|
386
|
+
assert.equal(stuck.requests.some(([m]) => m === 'DELETE'), false, 'the card is not cleared over a login that is still live');
|
|
387
|
+
assert.equal(stuck.toasts.at(-1), 'internal error');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('Add a device shows one command for the browser’s platform, the other on a press, and copies it', async () => {
|
|
391
|
+
const p = await loadPanel({ respond: fixtureResponder(), userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' });
|
|
392
|
+
await p.click({ id: 'ab-add-open' });
|
|
393
|
+
const code = fixture('auth__runner-code__issue.post').code;
|
|
394
|
+
const repo = fixture('public__repo-info').blob_base.split('/blob/')[0];
|
|
395
|
+
let add = p.html('ab-add');
|
|
396
|
+
assert.ok(add.includes(`curl -fsSL 'https://hub.example/api/bongos/autonomy/runner-setup.sh' | bash -s -- --code '${code}' --api-base 'https://hub.example' --repo '${repo}'`) || add.includes(`--code '${code}'`), 'the Mac line, for a Mac browser');
|
|
397
|
+
assert.ok(add.includes('open Terminal'));
|
|
398
|
+
assert.ok(add.includes('works once, for 15 minutes'));
|
|
399
|
+
await p.click({ platform: 'windows' });
|
|
400
|
+
add = p.html('ab-add');
|
|
401
|
+
assert.ok(add.includes('runner-setup.ps1'));
|
|
402
|
+
assert.ok(add.includes('PowerShell (not as administrator)'));
|
|
403
|
+
await p.click({ id: 'ab-add-copy' });
|
|
404
|
+
assert.equal(p.copied.length, 1);
|
|
405
|
+
assert.equal(p.copied[0], RB.setupCommands({ apiBase: 'https://hub.example', code, repoUrl: repo }).windows, 'what is copied is the line shown, unescaped');
|
|
406
|
+
assert.equal(p.toasts.at(-1), 'Setup command copied.');
|
|
407
|
+
assert.equal(p.timers.at(-1).ms, 3000, 'and the panel watches closely for the new device');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('when the new device checks in, the command goes away and its card is there', async () => {
|
|
411
|
+
let arrived = false;
|
|
412
|
+
const newcomer = { host: 'new-pc', run_requested: false, run_changed_at: null, last_seen_at: new Date().toISOString(), session_id: '9001', health: { state: 'stopped', host: 'new-pc', workingTaskId: null, holdUntil: null, age: '2s' } };
|
|
413
|
+
const p = await loadPanel({ respond: fixtureResponder({ runners: () => (arrived ? [newcomer, ...RUNNERS.runners] : RUNNERS.runners) }) });
|
|
414
|
+
await p.click({ id: 'ab-add-open' });
|
|
415
|
+
assert.ok(p.html('ab-add').includes('Waiting for the device to check in'));
|
|
416
|
+
arrived = true;
|
|
417
|
+
await p.timers.at(-1).fn(); // the next read of the devices
|
|
418
|
+
await p.settle();
|
|
419
|
+
assert.ok(p.html('ab-devices').includes('data-host="new-pc"'));
|
|
420
|
+
assert.equal(p.html('ab-add').includes('setup command'), false, 'the command is gone');
|
|
421
|
+
assert.equal(p.toasts.at(-1), 'Device connected. Press Run on its card.');
|
|
422
|
+
assert.match(p.html('ab-devices'), /data-press="run" data-host="new-pc"/, 'a new device starts stopped, so its button is Run');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('a project with no repository address gets a sentence instead of a command with a hole in it', async () => {
|
|
426
|
+
const p = await loadPanel({ respond: fixtureResponder({ extra: (method, url) => (url === '/public/repo-info' ? { status: 503, data: null } : null) }) });
|
|
427
|
+
await p.click({ id: 'ab-add-open' });
|
|
428
|
+
const add = p.html('ab-add');
|
|
429
|
+
assert.ok(add.includes('no repository address'));
|
|
430
|
+
assert.equal(add.includes('runner-setup'), false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('a builder whose rank cannot run a runner sees one line: no devices, no Add a device, no picks', async () => {
|
|
434
|
+
const p = await loadPanel({ respond: fixtureResponder({ fence: { ...FENCE, scope: { ...FENCE.scope, may_run: false } } }) });
|
|
435
|
+
assert.ok(p.html('ab-devices').includes('Your rank cannot run an Autobongos runner'));
|
|
436
|
+
assert.equal(p.html('ab-add'), '');
|
|
437
|
+
assert.equal(p.html('ab-goals'), '');
|
|
438
|
+
assert.equal(p.els['#autobongos-body'].handlers.click, undefined, 'and nothing on the panel is wired');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('the goal picks are drawn once: the timer redraws the devices and never the form', async () => {
|
|
442
|
+
const withGoals = { ...FENCE, enabled: true, stop_code: null, scope: { may_run: true, goals: [1000118] }, project: { enabled: true, goals: [{ goal_id: 1000118, note: 'bug squash' }] } };
|
|
443
|
+
// Without the device that is restarting: that one is busy, and a busy card is
|
|
444
|
+
// read again quickly, which is the next test's subject, not this one's.
|
|
445
|
+
const steady = RUNNERS.runners.filter((r) => r.health.state !== 'restarting');
|
|
446
|
+
const p = await loadPanel({ respond: fixtureResponder({ fence: withGoals, runners: () => steady }) });
|
|
447
|
+
assert.ok(p.html('ab-goals').includes('Bug squash'), 'the goal is named, not numbered');
|
|
448
|
+
const formWrites = p.els['#ab-goals'].writes;
|
|
449
|
+
const deviceWrites = p.els['#ab-devices'].writes;
|
|
450
|
+
await p.timers.at(-1).fn();
|
|
451
|
+
await p.settle();
|
|
452
|
+
assert.ok(p.els['#ab-devices'].writes > deviceWrites, 'the devices were redrawn');
|
|
453
|
+
assert.equal(p.els['#ab-goals'].writes, formWrites, 'a form redrawn under someone ticking boxes would untick them');
|
|
454
|
+
assert.equal(p.timers.at(-1).ms, 15000, 'nothing is starting, so the read is the slow one');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('a hidden tab reads nothing, and a failed read keeps the cards that were there', async () => {
|
|
458
|
+
const hiddenTab = await loadPanel({ respond: fixtureResponder(), hidden: true });
|
|
459
|
+
assert.equal(hiddenTab.timers.length, 0, 'no timer while the tab is hidden');
|
|
460
|
+
|
|
461
|
+
let fail = false;
|
|
462
|
+
const p = await loadPanel({ respond: fixtureResponder({ extra: (method, url) => (fail && url === '/autonomy/runners' ? { status: 503, data: null } : null) }) });
|
|
463
|
+
fail = true;
|
|
464
|
+
await p.timers.at(-1).fn();
|
|
465
|
+
await p.settle();
|
|
466
|
+
assert.equal(count(p.html('ab-devices'), 'class="rb-card"'), 4, 'one bad read does not empty the page');
|
|
467
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// tests/hall_runner_panel_connect.mjs — the owner's runner panel sends "Connect
|
|
2
|
+
// a PC" to the Settings flow (task 1005097).
|
|
3
|
+
//
|
|
4
|
+
// The panel on Watch > Autobongos (gate.js) is a checklist while anything the
|
|
5
|
+
// runner needs is missing. Its first line, "Connect a PC", used to say that a
|
|
6
|
+
// runner had to be installed and nothing about how. It now opens Settings > Your
|
|
7
|
+
// Autobongos runner, where Add a device hands out the one setup command.
|
|
8
|
+
//
|
|
9
|
+
// gate.js is RUN here in a vm against stand-in elements: the assertions are on
|
|
10
|
+
// what it draws for an owner with no device, and for one whose device checks in.
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import vm from 'node:vm';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { realOTB } from './helpers.mjs';
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
22
|
+
|
|
23
|
+
// The panel as an Archon sees it. `runners` is what GET /autonomy/runners answers.
|
|
24
|
+
async function drawPanel({ runners = [], fence = { enabled: false, goals: [], project: { enabled: false, goals: [] }, scope: { goals: [] } } } = {}) {
|
|
25
|
+
const els = new Map();
|
|
26
|
+
const el = (id) => {
|
|
27
|
+
if (!els.has(id)) {
|
|
28
|
+
els.set(id, {
|
|
29
|
+
id, hidden: true, textContent: '', innerHTML: '', open: false,
|
|
30
|
+
addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], focus() {},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return els.get(id);
|
|
34
|
+
};
|
|
35
|
+
const answers = {
|
|
36
|
+
'/api/bongos/me': { builder: { rank: 'archon' } },
|
|
37
|
+
'/api/bongos/autonomy/runners': { runners },
|
|
38
|
+
'/api/bongos/autonomy/fence': fence,
|
|
39
|
+
'/api/bongos/autonomy/runners/all': { builders: [] },
|
|
40
|
+
};
|
|
41
|
+
const kit = await import(`file://${path.join(HALL, 'hall-kit.js').replace(/\\/g, '/')}`).then((m) => m.default);
|
|
42
|
+
const window = {
|
|
43
|
+
OTB: { ...realOTB(), rankLabel: (r) => r, toast() {} },
|
|
44
|
+
OTBKit: kit,
|
|
45
|
+
OTBDialog: { prompt: async () => null },
|
|
46
|
+
BongosClient: { createClient: () => ({ request: async (_m, url) => (url in answers ? { ok: true, status: 200, data: answers[url] } : { ok: false, status: 404, data: null }) }) },
|
|
47
|
+
location: { assign() {} },
|
|
48
|
+
};
|
|
49
|
+
const sandbox = { window, document: { getElementById: el }, console: { error() {} }, Promise, Object, Array, Number, String, JSON, Error, encodeURIComponent };
|
|
50
|
+
vm.createContext(sandbox);
|
|
51
|
+
vm.runInContext(fs.readFileSync(path.join(HALL, 'gate.js'), 'utf8'), sandbox, { filename: 'gate.js' });
|
|
52
|
+
window.OTBWatchTabs.autobongos(); // the tab opens
|
|
53
|
+
for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r));
|
|
54
|
+
return el('fence-body').innerHTML;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('with no device, Connect a PC says how and opens the Settings flow', async () => {
|
|
58
|
+
const html = await drawPanel();
|
|
59
|
+
const line = html.split('<li class="rn-check__item').find((c) => c.includes('Connect a PC'));
|
|
60
|
+
assert.ok(line, 'the checklist has the line');
|
|
61
|
+
assert.match(line, /rn-check__item--todo/);
|
|
62
|
+
assert.match(line, /Add a device in Settings: one copied command on any Windows PC or Mac/);
|
|
63
|
+
assert.match(line, /<a class="btn btn--sm" href="\/builders\/settings#autobongos">Add a device<\/a>/, 'a link to the flow, not a sentence about one');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('once a device is checking in, the line is ticked and the link is gone', async () => {
|
|
67
|
+
const html = await drawPanel({ runners: [{ host: 'ada-laptop', health: { state: 'alive', host: 'ada-laptop', age: '30s', account: 'ada@example.com' } }] });
|
|
68
|
+
const line = html.split('<li class="rn-check__item').find((c) => c.includes('PC connected'));
|
|
69
|
+
assert.ok(line);
|
|
70
|
+
assert.match(line, /rn-check__item--ok/);
|
|
71
|
+
assert.equal(html.includes('Connect a PC'), false);
|
|
72
|
+
assert.equal(html.includes('>Add a device</a>'), false, 'nothing to add when one is connected');
|
|
73
|
+
});
|
package/tests/stranded_rows.mjs
CHANGED
|
@@ -44,6 +44,56 @@ test('the claim is read from ANYWHERE in the message, not just the subject', ()
|
|
|
44
44
|
assert.deepEqual([...authoredIds(mergeBody)], ['1002200']);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
// ---- the second convention: the task leads the subject (task 1005187) ------
|
|
48
|
+
//
|
|
49
|
+
// Main went red on 2026-11-10 because builders, and ship.js's own regeneration
|
|
50
|
+
// commits, had moved to writing the task at the START of the subject, and the only
|
|
51
|
+
// form this file knew was the `(task N)` suffix: 18 distinct tasks in 400 commits.
|
|
52
|
+
|
|
53
|
+
test('a subject that LEADS with the task counts, with or without a conventional prefix', () => {
|
|
54
|
+
for (const subject of [
|
|
55
|
+
'task 1005094: Running Autobongos on a device is now one button.',
|
|
56
|
+
'task 1005094 - the device card sheet uses the type and radius tokens',
|
|
57
|
+
'fix: task 1005094 grade round 1 - https-only setup command', // words between the id and the dash: not this form
|
|
58
|
+
'fix: task 1005094 - https-only setup command',
|
|
59
|
+
'chore: task 1005094 - merge main, regenerate the copy registry',
|
|
60
|
+
'wip(autobongos): task 1005094 - a Run switch per device',
|
|
61
|
+
'feat(hall)!: task 1005094: the button is the cooldown',
|
|
62
|
+
'Task 1005094 \u2014 an em dash, as some editors write it',
|
|
63
|
+
]) {
|
|
64
|
+
const ids = [...authoredIds(subject)];
|
|
65
|
+
if (subject.includes('grade round 1 -')) assert.deepEqual(ids, [], `"${subject}" has words between the task and the dash`);
|
|
66
|
+
else assert.deepEqual(ids, ['1005094'], subject);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a task that is only MENTIONED is still not counted: not mid-subject, and not in the body', () => {
|
|
71
|
+
assert.deepEqual([...authoredIds('see task 1005094 for why this is here')], []);
|
|
72
|
+
assert.deepEqual([...authoredIds('Merge origin/main into task 1004893 (keep CORE_VERSION 1.23.0 above the 1.22.2 carrier)')], []);
|
|
73
|
+
assert.deepEqual([...authoredIds('docs: a session log\n\ntask 1005094 - settings add pc, mentioned at the start of a body line\ntask 1005095: and another')], [],
|
|
74
|
+
'the leading form is read off the subject, never off body lines');
|
|
75
|
+
// The suffix form is still read from anywhere, exactly as before.
|
|
76
|
+
assert.deepEqual([...authoredIds('docs: a session log\n\nfix: the real work (task 1000001)')], ['1000001']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a merge commit is read by the pull request title GitHub echoes into its body', () => {
|
|
80
|
+
const merge = ['Merge pull request #2033 from an-owner/claude/autobongos-run-button', '', 'task 1005094: Running Autobongos on a device is now one button.', '', 'task 1005095 - a later line that only mentions another task'].join('\n');
|
|
81
|
+
assert.deepEqual([...authoredIds(merge)], ['1005094'], 'the echoed title, and only that line');
|
|
82
|
+
// Any other subject does not get its body read this way.
|
|
83
|
+
assert.deepEqual([...authoredIds('release: core 1.24.78\n\ntask 1005094: carried by this release')], []);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('both conventions in one log are counted once each, first sha kept', () => {
|
|
87
|
+
const raw = [
|
|
88
|
+
`${SEP}cccccccc3333\nchore: task 1000002 - regenerate after merging main`,
|
|
89
|
+
`${SEP}aaaaaaaa1111\nfix: later touch (task 1000001)`,
|
|
90
|
+
`${SEP}bbbbbbbb2222\ntask 1000002: the original work`,
|
|
91
|
+
].join('\n');
|
|
92
|
+
const m = scanLog(raw);
|
|
93
|
+
assert.deepEqual([...m.keys()].sort(), ['1000001', '1000002']);
|
|
94
|
+
assert.equal(m.get('1000002'), 'cccccccc3333');
|
|
95
|
+
});
|
|
96
|
+
|
|
47
97
|
test('scanLog keeps the FIRST sha that claimed an id', () => {
|
|
48
98
|
const raw = [
|
|
49
99
|
`${SEP}aaaaaaaa1111\nfix: later touch (task 1000001)`,
|