@bongos/core 1.19.1063 → 1.19.1064

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.bongos-core.json +72 -37
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +6 -0
  6. package/clients/bongos-client/index.cjs +6 -0
  7. package/clients/bongos-client/index.d.ts +10 -0
  8. package/clients/bongos-client/index.mjs +6 -0
  9. package/docs/api/openapi.json +258 -3
  10. package/docs/api-reference.md +5 -2
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +2 -0
  13. package/modules/lifecycle/db-grade.js +16 -0
  14. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  15. package/modules/lifecycle/page-tweak-hold.js +74 -0
  16. package/modules/lifecycle/page-tweak-reads.js +33 -1
  17. package/modules/lifecycle/routes/tasks.js +4 -2
  18. package/modules/lifecycle/routes/visuals.js +82 -0
  19. package/modules/lifecycle/task-visual-db.js +40 -1
  20. package/modules/lifecycle/task-visuals.js +24 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +6 -0
  24. package/scripts/gds/copy-apply.js +277 -1
  25. package/scripts/gds/page-reader.js +2 -0
  26. package/scripts/gds/ship-finish.js +27 -2
  27. package/scripts/gds/ship-flow.js +17 -1
  28. package/scripts/gds/ship-land.js +30 -7
  29. package/scripts/gds/ship-merge.js +43 -15
  30. package/scripts/gds/strand-watch.js +41 -1
  31. package/scripts/gds/task.js +23 -5
  32. package/scripts/gds/tweak-renders.js +200 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/page_tweak_hold.mjs +265 -0
  35. package/tests/publish_status_branch.mjs +23 -0
  36. package/tests/task_visual_slots.mjs +161 -0
  37. package/tests/tweak_batch_apply.mjs +265 -0
@@ -0,0 +1,265 @@
1
+ // tests/page_tweak_hold.mjs — a passed page tweak waits at completed for its
2
+ // artist, and nothing else changes (task 1004322 / BV2.TW11; ADR 0341 D7).
3
+ //
4
+ // Four carriers, one per thing the TW03 builder listed:
5
+ // 1. applyGrade does not promote a passed page tweak, and every OTHER grade
6
+ // sends the same SQL as before, plus exactly one read of `source`;
7
+ // 2. publish-branch publishes a held pass without arming auto-merge;
8
+ // 3. ship.js on a held pass publishes (never merges) and says it waits;
9
+ // 4. strand-watch tells a held pass from a failed grade.
10
+ // No DB, no network: fake pools, the real routers, stubbed GitHub reads.
11
+ //
12
+ // Run: node tests/page_tweak_hold.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import http from 'node:http';
17
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
18
+
19
+ process.env.NODE_ENV = 'test';
20
+ const require = createRequire(import.meta.url);
21
+
22
+ const achievements = require('../modules/economy/achievements.js');
23
+ achievements.evaluateAfterShip = async () => [];
24
+ achievements.evaluateAfterCreditAward = async () => [];
25
+ achievements.evaluateAfterClaim = async () => [];
26
+ achievements.reconcileBuilderAchievements = async () => [];
27
+ const seams = require('../src/module-seams.js');
28
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
29
+ const { applyGrade } = require('../modules/lifecycle/db.js');
30
+ const hold = require('../modules/lifecycle/page-tweak-hold.js');
31
+
32
+ const { test, summary } = makeRunner();
33
+
34
+ const PASS = { score: 0.95, passed: true, threshold: 0.7, grader_kind: 'subagent', grader_version: 't', rubric_scores: {}, signals: {}, issues: [], notes_md: null };
35
+ const FAIL = { ...PASS, score: 0.3, passed: false };
36
+
37
+ function gradePool({ source }) {
38
+ return makeSqlAwareClient((s) => {
39
+ if (/SELECT id, title, status, credits_reward, kind FROM tasks/.test(s)) return { rows: [{ id: 7, title: 'x', status: 'completed', credits_reward: 30, kind: 'feature' }] };
40
+ if (/^SELECT source FROM tasks/.test(s)) return { rows: [{ source }] };
41
+ if (/SELECT builder_id FROM claims/.test(s)) return { rows: [{ builder_id: 25 }] };
42
+ return { rows: [] };
43
+ });
44
+ }
45
+ const flat = (q) => q.map((x) => String(x.sql).replace(/\s+/g, ' ').trim());
46
+
47
+ // ---- 1. applyGrade ---------------------------------------------------------
48
+
49
+ await test('a PASSED page tweak is recorded and HELD: no promotion, no merge, held=true', async () => {
50
+ const pool = gradePool({ source: 'page-tweak' });
51
+ const out = await applyGrade({ taskId: 7, builderId: 1, gradeResult: PASS }, { pool });
52
+ assert.equal(out.taskStatus, 'completed');
53
+ assert.equal(out.held, true);
54
+ assert.equal(out.advanceToMerge, false);
55
+ const q = flat(pool._client.queries);
56
+ assert.ok(q.some((s) => /^INSERT INTO task_grades/.test(s)), 'the grade row is still written');
57
+ assert.ok(!q.some((s) => /^UPDATE tasks/.test(s)), 'the task is NOT promoted to confirmed');
58
+ });
59
+
60
+ await test('an ordinary pass is promoted exactly as before: the same SQL plus ONE read of source', async () => {
61
+ const pool = gradePool({ source: 'planning-session' });
62
+ const out = await applyGrade({ taskId: 7, builderId: 1, gradeResult: PASS }, { pool });
63
+ assert.equal(out.taskStatus, 'confirmed');
64
+ assert.equal(out.held, false);
65
+ assert.equal(out.advanceToMerge, true);
66
+ const q = flat(pool._client.queries);
67
+ const reads = q.filter((s) => /^SELECT source FROM tasks/.test(s));
68
+ assert.equal(reads.length, 1, 'one extra read, and only one');
69
+ // The pre-change statement sequence, with that one read removed, is unchanged.
70
+ const legacy = q.filter((s) => !/^SELECT source FROM tasks/.test(s));
71
+ const pool2 = gradePool({ source: null });
72
+ await applyGrade({ taskId: 7, builderId: 1, gradeResult: PASS }, { pool: pool2 });
73
+ assert.deepEqual(flat(pool2._client.queries).filter((s) => !/^SELECT source FROM tasks/.test(s)), legacy, 'a null source takes the same path');
74
+ assert.ok(legacy.some((s) => /^UPDATE tasks SET status = 'confirmed'/.test(s)));
75
+ });
76
+
77
+ await test('a FAILED grade never asks about the source (the hold is asked only on a pass)', async () => {
78
+ for (const source of ['page-tweak', 'planning-session']) {
79
+ const pool = gradePool({ source });
80
+ const out = await applyGrade({ taskId: 7, builderId: 1, gradeResult: FAIL }, { pool });
81
+ assert.equal(out.taskStatus, 'completed');
82
+ assert.equal(out.held, false);
83
+ assert.ok(!flat(pool._client.queries).some((s) => /^SELECT source FROM tasks/.test(s)));
84
+ }
85
+ });
86
+
87
+ await test('the hold predicate: only a page tweak, only at completed, only a strict pass', () => {
88
+ const t = (source, status) => ({ source, status });
89
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'completed'), true), true);
90
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'completed'), false), false);
91
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'completed'), null), false);
92
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'confirmed'), true), false);
93
+ assert.equal(hold.isHeldForArtist(t('page-tweak-unplaced', 'completed'), true), false, 'the engineer task an unplaced line files is ordinary work');
94
+ assert.equal(hold.isHeldForArtist(t('copy-proposal', 'completed'), true), false);
95
+ assert.equal(hold.isHeldForArtist(null, true), false);
96
+ });
97
+
98
+ // ---- 2. publish-branch -----------------------------------------------------
99
+
100
+ const express = require('express');
101
+ const api = require('../src/module-api.js');
102
+ const db = require('../modules/lifecycle/db.js');
103
+ const gp = require('../modules/lifecycle/github-push.js');
104
+ const prAssign = require('../modules/lifecycle/pr-assign.js');
105
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
106
+
107
+ let heldNow = false;
108
+ let armCalls = 0;
109
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
110
+ db.getLastClaimHolder = async () => ({ builder_id: 5, github_login: 'some-builder' });
111
+ db.setClaimPublishedSha = async () => {};
112
+ gp.isConfigured = () => true;
113
+ gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
114
+ gp.openOrFindPullRequest = async () => ({ number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' });
115
+ gp.enableAutoMergeDetailed = async () => { armCalls++; return { ok: true }; };
116
+ gp.setCommitStatus = async () => true;
117
+ prAssign.assignPullRequest = async () => ({ assigned: true });
118
+ pageTweakReads.heldForArtistFailClosed = async () => heldNow;
119
+
120
+ const app = express();
121
+ 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(); });
122
+ app.use(require('../modules/lifecycle/routes/tasks.js')());
123
+ const server = http.createServer(app);
124
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
125
+ const base = `http://127.0.0.1:${server.address().port}`;
126
+ const publish = async () => {
127
+ const res = await fetch(`${base}/tasks/5001/publish-branch`, {
128
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify({ branch: 'task-5001', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'Page tweak.' }),
130
+ });
131
+ return { status: res.status, body: await res.json() };
132
+ };
133
+
134
+ await test('publish-branch on a HELD pass: the PR is opened, auto-merge is NOT armed, and the reason says why', async () => {
135
+ heldNow = true; armCalls = 0;
136
+ const r = await publish();
137
+ assert.equal(r.status, 201, JSON.stringify(r.body));
138
+ assert.equal(r.body.pr_url, 'https://github.com/o/r/pull/77', 'published: the Approval queue can show the branch');
139
+ assert.equal(r.body.auto_merge, false);
140
+ assert.equal(armCalls, 0, 'GitHub is never asked to arm it');
141
+ assert.match(r.body.auto_merge_reason, /artist/);
142
+ });
143
+
144
+ await test('publish-branch on every other task arms auto-merge exactly as before', async () => {
145
+ heldNow = false; armCalls = 0;
146
+ const r = await publish();
147
+ assert.equal(r.status, 201);
148
+ assert.equal(r.body.auto_merge, true);
149
+ assert.equal(armCalls, 1);
150
+ assert.equal(r.body.auto_merge_reason, undefined);
151
+ });
152
+ server.close();
153
+
154
+ // ---- 3. ship.js ------------------------------------------------------------
155
+
156
+ const shipMerge = require('../scripts/gds/ship-merge.js');
157
+ const shipFinish = require('../scripts/gds/ship-finish.js');
158
+ const shipLand = require('../scripts/gds/ship-land.js');
159
+
160
+ await test('publishHeld publishes through the PR path with hold:true, after the same regeneration a land runs', async () => {
161
+ const calls = [];
162
+ const out = await shipMerge.publishHeld(5001, 'sum', {
163
+ currentBranch: () => 'task-5001',
164
+ regenerate: () => calls.push('regen'),
165
+ ciLand: async (id, branch, summary, opts) => { calls.push(['ciLand', id, branch, summary, opts]); return shipLand.landHeld({ prUrl: 'u' }); },
166
+ });
167
+ assert.deepEqual(calls, ['regen', ['ciLand', 5001, 'task-5001', 'sum', { hold: true }]]);
168
+ assert.equal(out.held, true);
169
+ assert.equal(out.ok, false, 'not a land');
170
+ assert.equal(out.pending, false, 'and not "landing automatically"');
171
+ });
172
+
173
+ await test('publishHeld on main has no branch to publish: a named bail, and nothing is pushed', async () => {
174
+ let pushed = false;
175
+ const out = await shipMerge.publishHeld(5001, 'sum', { currentBranch: () => 'main', localTaskBranchExists: () => true, regenerate: () => { pushed = true; }, ciLand: async () => { pushed = true; } });
176
+ assert.equal(pushed, false);
177
+ assert.equal(out.ok, false);
178
+ assert.match(out.reason, /not the task branch/);
179
+ assert.match(out.nextStep, /git checkout task-5001/);
180
+ });
181
+
182
+ await test('finishHeld says the page is WAITING FOR THE ARTIST, syncs the session, and never merges', async () => {
183
+ const printed = [];
184
+ const orig = console.log;
185
+ console.log = (m) => printed.push(String(m));
186
+ let synced = null;
187
+ try {
188
+ await shipFinish.finishHeld({ taskId: 5001, summary: 's', postGradeStatus: 'completed', card: null }, {
189
+ publishHeld: async () => shipLand.landHeld({ prUrl: 'https://github.com/o/r/pull/77' }),
190
+ postShipSync: async (id, status) => { synced = status; return null; },
191
+ printCompletionCard: async () => {},
192
+ });
193
+ } finally { console.log = orig; }
194
+ const text = printed.join('\n');
195
+ assert.match(text, /WAITING FOR THE ARTIST/);
196
+ assert.match(text, /pull\/77/);
197
+ assert.match(text, /auto-merge is NOT armed/);
198
+ assert.equal(synced, 'confirmed', 'the finished session is uploaded like a confirmed one');
199
+ });
200
+
201
+ await test('finishShip routes a held pass away from mergeAndDeploy (the source says so, and only when held)', async () => {
202
+ const fs = require('node:fs');
203
+ const src = fs.readFileSync(require.resolve('../scripts/gds/ship-finish.js'), 'utf8');
204
+ const body = src.slice(src.indexOf('async function finishShip('));
205
+ const holdAt = body.indexOf('if (held) return finishHeld(');
206
+ const mergeAt = body.indexOf('await mergeAndDeploy(');
207
+ assert.ok(holdAt > 0 && mergeAt > holdAt, 'the hold is decided BEFORE any merge is attempted');
208
+ const flow = fs.readFileSync(require.resolve('../scripts/gds/ship-flow.js'), 'utf8');
209
+ assert.equal((flow.match(/held = gradePost\.data\.held === true/g) || []).length, 1, 'shipMain reads the hold off the grade response');
210
+ assert.equal((flow.match(/held: gradePost\.data\.held === true/g) || []).length, 1, 'regradeMain does too');
211
+ assert.match(flow, /tweakHold\.isHeldForArtist\(task, tRes\.data\.grade/, 'a re-run of a held task re-publishes instead of re-grading');
212
+ });
213
+
214
+ // ---- 4. strand-watch -------------------------------------------------------
215
+
216
+ const sw = require('../scripts/gds/strand-watch.js');
217
+ const OLD = new Date(Date.now() - 3 * 86400000).toISOString();
218
+
219
+ function fakeCall({ completed, grades, throwFor = new Set() }) {
220
+ const gradeReads = [];
221
+ const call = async (method, url) => {
222
+ const m = /\/tasks\/(\d+)\?include=grade/.exec(url);
223
+ if (m) {
224
+ gradeReads.push(m[1]);
225
+ if (throwFor.has(m[1])) throw new Error('boom');
226
+ return { ok: true, data: { task: {}, grade: grades[m[1]] ?? null } };
227
+ }
228
+ if (/status=completed/.test(url)) return { ok: true, data: { tasks: completed } };
229
+ return { ok: true, data: { tasks: [] } };
230
+ };
231
+ return { call, gradeReads };
232
+ }
233
+
234
+ await test('strand-watch: a held pass is WAITING, not a strand; a failed page tweak and ordinary work still are', async () => {
235
+ const completed = [
236
+ { id: '11', status: 'completed', source: 'page-tweak', updated_at: OLD },
237
+ { id: '12', status: 'completed', source: 'page-tweak', updated_at: OLD },
238
+ { id: '13', status: 'completed', source: 'planning-session', updated_at: OLD },
239
+ ];
240
+ const f = fakeCall({ completed, grades: { 11: { passed: true }, 12: { passed: false } } });
241
+ const r = await sw.check({ call: f.call });
242
+ assert.deepEqual(r.waiting.map((w) => w.id), ['11']);
243
+ assert.equal(r.waiting[0].shape, 'waiting_for_artist');
244
+ assert.deepEqual(r.strands.map((s) => s.id).sort(), ['12', '13']);
245
+ assert.deepEqual(f.gradeReads.sort(), ['11', '12'], 'grades are read for page tweaks only');
246
+ assert.ok(!(sw.line(r) || '').includes('task 11'), 'the session-start line never names a held pass');
247
+ });
248
+
249
+ await test('strand-watch: an unreadable grade stays LOUD (classified as usual), never explained away as a hold', async () => {
250
+ const f = fakeCall({ completed: [{ id: '11', status: 'completed', source: 'page-tweak', updated_at: OLD }], grades: {}, throwFor: new Set(['11']) });
251
+ const r = await sw.check({ call: f.call });
252
+ assert.deepEqual(r.waiting, []);
253
+ assert.deepEqual(r.strands.map((s) => s.id), ['11']);
254
+ });
255
+
256
+ await test('/grade-recover stops on a held pass instead of recovering it', () => {
257
+ const fs = require('node:fs');
258
+ const p = new URL('../.claude/skills/grade-recover/SKILL.md', import.meta.url);
259
+ if (!fs.existsSync(p)) return;
260
+ const md = fs.readFileSync(p, 'utf8');
261
+ assert.match(md, /waiting for the artist/);
262
+ assert.match(md, /page-tweak/);
263
+ });
264
+
265
+ summary();
@@ -18,6 +18,10 @@ const require = createRequire(import.meta.url);
18
18
 
19
19
  const gp = require('../modules/lifecycle/github-push.js');
20
20
  const buildTasksRouter = require('../modules/lifecycle/routes/tasks.js');
21
+ // task 1004322: the route asks whether the task is a page tweak held for its
22
+ // artist before it nudges a merge. That read goes to the DB, so the harness
23
+ // stubs it (false = an ordinary task, the case every test below was written for).
24
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
21
25
 
22
26
  let pass = 0;
23
27
  let fail = 0;
@@ -33,6 +37,8 @@ const CRED_KEYS = ['GITHUB_PUSH_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_INSTALLATIO
33
37
  async function callRoute(query, stubs = {}) {
34
38
  const saved = {};
35
39
  for (const k of ['findTaskPullRequest', 'getPublishStatus', 'mergeIfGreen']) saved[k] = gp[k];
40
+ const savedHold = pageTweakReads.heldForArtistFailClosed;
41
+ pageTweakReads.heldForArtistFailClosed = async () => stubs.held === true;
36
42
  const savedEnv = CRED_KEYS.map((k) => [k, process.env[k]]);
37
43
  const calls = { findTaskPullRequest: [], getPublishStatus: [], mergeIfGreen: [] };
38
44
  gp.findTaskPullRequest = async (a) => { calls.findTaskPullRequest.push(a); return stubs.taskPr === undefined ? null : stubs.taskPr; };
@@ -58,6 +64,7 @@ async function callRoute(query, stubs = {}) {
58
64
  return { ...out, calls };
59
65
  } finally {
60
66
  Object.assign(gp, saved);
67
+ pageTweakReads.heldForArtistFailClosed = savedHold;
61
68
  for (const [k, v] of savedEnv) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
62
69
  }
63
70
  }
@@ -124,5 +131,21 @@ await t('a genuinely unsafe branch is still refused 400 — in a sentence, befor
124
131
  }
125
132
  });
126
133
 
134
+ await t('a page tweak held for its artist is reported but NEVER nudged to merge (task 1004322, ADR 0341 D7)', async () => {
135
+ const r = await callRoute({}, { held: true, taskPr: { branch: 'task-1003747', pr_number: 673, pr_state: 'open', merged: false } });
136
+ assert.equal(r.failCode, null);
137
+ assert.equal(r.body.ok, true, 'the status is still answered: the Approval queue reads it');
138
+ assert.equal(r.body.pr_number, 673);
139
+ assert.deepEqual(r.calls.mergeIfGreen, [], 'a held pass must not be merged by a status poll');
140
+ assert.equal(r.body.merge_driver, null);
141
+ });
142
+
143
+ await t('the hold read failing is treated as held: fail-closed, no nudge (task 1004322)', async () => {
144
+ const real = pageTweakReads.heldForArtistFailClosed;
145
+ assert.equal(await real(1, async () => { throw new Error('db down'); }), true, 'a throwing read answers held');
146
+ assert.equal(await real(1, async () => ({ held: false })), false);
147
+ assert.equal(await real(1, async () => ({ held: true })), true);
148
+ });
149
+
127
150
  console.log(`\npublish-status branch resolution (task 1003764): ${pass} passed, ${fail} failed`);
128
151
  process.exitCode = fail > 0 ? 1 : 0;
@@ -0,0 +1,161 @@
1
+ // tests/task_visual_slots.mjs — task visuals widened to NAMED slots, and the
2
+ // eight page-tweak renders attached to them (task 1004322 / BV2.TW11;
3
+ // lifecycle_013; ADR 0341 D7, builder pick 9).
4
+ //
5
+ // Drives the real routes/visuals.js router with the slot queries stubbed at the
6
+ // module boundary and a temp store, then the pure halves of tweak-renders.js and
7
+ // its attach step against a fake client. No DB, no network, no browser.
8
+ //
9
+ // Run: node tests/task_visual_slots.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import http from 'node:http';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import fs from 'node:fs';
17
+ import { makeRunner } from './helpers.mjs';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'tw11-slots-'));
21
+ process.env.TASK_VISUALS_DIR = path.join(TMP, 'store');
22
+
23
+ const express = require('express');
24
+ const api = require('../src/module-api.js');
25
+ const visualDb = require('../modules/lifecycle/task-visual-db.js');
26
+ const visuals = require('../modules/lifecycle/task-visuals.js');
27
+ const renders = require('../scripts/gds/tweak-renders.js');
28
+
29
+ const { test, summary } = makeRunner();
30
+
31
+ // A 1x1 PNG, real enough for the magic-byte sniff.
32
+ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
33
+
34
+ const slots = new Map(); // `${task}|${slot}` -> row
35
+ visualDb.getTaskVisualAccess = async (id) => ({ id, status: 'completed', security_sensitive: false, visual_url: null });
36
+ visualDb.setTaskVisualSlot = async ({ taskId, slot, visualUrl, visualAlt }) => {
37
+ const k = `${taskId}|${slot}`;
38
+ const prev = slots.get(k);
39
+ slots.set(k, { slot, visual_url: visualUrl, visual_alt: visualAlt, updated_at: 't' });
40
+ return { slot, visual_url: visualUrl, visual_alt: visualAlt, replaced_url: prev ? prev.visual_url : null };
41
+ };
42
+ visualDb.deleteTaskVisualSlot = async ({ taskId, slot }) => { const k = `${taskId}|${slot}`; const p = slots.get(k); slots.delete(k); return p ? p.visual_url : null; };
43
+ visualDb.listTaskVisualSlots = async (taskId) => [...slots.entries()].filter(([k]) => k.startsWith(`${taskId}|`)).map(([, v]) => v).sort((a, b) => (a.slot < b.slot ? -1 : 1));
44
+ let rank = 'archon';
45
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank, github_login: 'x' }; next(); };
46
+
47
+ const app = express();
48
+ 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(); });
49
+ app.use(require('../modules/lifecycle/routes/visuals.js')());
50
+ const server = http.createServer(app);
51
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
52
+ const base = `http://127.0.0.1:${server.address().port}`;
53
+ const call = async (method, url, body) => {
54
+ const res = await fetch(base + url, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
55
+ return { status: res.status, body: await res.json() };
56
+ };
57
+ const png = { image_b64: PNG.toString('base64'), content_type: 'image/png' };
58
+ const stored = () => fs.existsSync(process.env.TASK_VISUALS_DIR) ? fs.readdirSync(process.env.TASK_VISUALS_DIR) : [];
59
+
60
+ await test('the eight render slots are declared once: before/after x desktop/phone x light/dark', () => {
61
+ assert.equal(visuals.TWEAK_RENDER_SLOTS.length, 8);
62
+ assert.equal(new Set(visuals.TWEAK_RENDER_SLOTS).size, 8);
63
+ for (const s of visuals.TWEAK_RENDER_SLOTS) assert.ok(visuals.isSafeSlot(s), s);
64
+ assert.deepEqual(visuals.TWEAK_RENDER_DEVICES, { desktop: 1440, phone: 390 }, 'the spec widths: 1440 and 390');
65
+ const sql = fs.readFileSync(new URL('../modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql', import.meta.url), 'utf8');
66
+ const re = new RegExp(/slot ~ '([^']+)'/.exec(sql)[1]);
67
+ for (const s of visuals.TWEAK_RENDER_SLOTS) assert.ok(re.test(s), `the column CHECK admits ${s}`);
68
+ for (const bad of ['', 'Before', '../x', 'a b', 'x'.repeat(49), 'a--b']) assert.equal(visuals.isSafeSlot(bad), false, JSON.stringify(bad));
69
+ });
70
+
71
+ await test('POST a named slot stores the image and lists it; the one ship-time visual is untouched', async () => {
72
+ const r = await call('POST', '/tasks/5001/visuals/after-phone-dark', png);
73
+ assert.equal(r.status, 201, JSON.stringify(r.body));
74
+ assert.equal(r.body.slot, 'after-phone-dark');
75
+ assert.match(r.body.visual_url, /^\/api\/bongos\/task-visuals\/task-5001-[0-9a-f]{16}\.png$/, 'the same store and serve path as the one visual');
76
+ const l = await call('GET', '/tasks/5001/visuals');
77
+ assert.deepEqual(l.body.slots.map((x) => x.slot), ['after-phone-dark']);
78
+ assert.equal(stored().length, 1);
79
+ });
80
+
81
+ await test('attaching a slot again REPLACES it and unlinks the superseded file', async () => {
82
+ const before = stored();
83
+ const r = await call('POST', '/tasks/5001/visuals/after-phone-dark', png);
84
+ assert.equal(r.status, 201);
85
+ const after = stored();
86
+ assert.equal(after.length, 1, 'one file per slot on disk');
87
+ assert.notDeepEqual(after, before);
88
+ });
89
+
90
+ await test('a bad slot name is refused before any byte is stored; a non-image is refused', async () => {
91
+ const n = stored().length;
92
+ assert.equal((await call('POST', '/tasks/5001/visuals/Not%20A%20Slot', png)).status, 400);
93
+ const r = await call('POST', '/tasks/5001/visuals/before-desktop-light', { image_b64: Buffer.from('<svg/>').toString('base64'), content_type: 'image/png' });
94
+ assert.equal(r.status, 400);
95
+ assert.equal(stored().length, n);
96
+ });
97
+
98
+ await test('only the claim holder or an Archon may attach (the one visual\'s own gate)', async () => {
99
+ rank = 'thetes';
100
+ const routes = require('../modules/lifecycle/routes/tasks.js');
101
+ const src = fs.readFileSync(require.resolve('../modules/lifecycle/routes/visuals.js'), 'utf8');
102
+ const post = src.slice(src.indexOf("router.post('/tasks/:id/visuals/:slot'"), src.indexOf("router.delete('/tasks/:id/visuals/:slot'"));
103
+ const del = src.slice(src.indexOf("router.delete('/tasks/:id/visuals/:slot'"), src.indexOf("router.get('/tasks/:id/visuals'"));
104
+ for (const body of [post, del]) assert.match(body, /gateTaskOwnership\(req, res, id\)/);
105
+ assert.ok(routes.gateTaskOwnership, 'the shared gate is the one used');
106
+ rank = 'archon';
107
+ });
108
+
109
+ await test('DELETE a slot removes its row and its file, and is idempotent', async () => {
110
+ const r = await call('DELETE', '/tasks/5001/visuals/after-phone-dark');
111
+ assert.equal(r.status, 200);
112
+ assert.equal(stored().length, 0);
113
+ assert.equal((await call('DELETE', '/tasks/5001/visuals/after-phone-dark')).status, 200);
114
+ assert.deepEqual((await call('GET', '/tasks/5001/visuals')).body.slots, []);
115
+ });
116
+ server.close();
117
+
118
+ // ---- tweak-renders.js -------------------------------------------------------
119
+
120
+ await test('shotsFor names one file per slot; a page pinning one mode is shot in it for both mode slots', () => {
121
+ const free = renders.shotsFor('before', { pinnedMode: null });
122
+ assert.deepEqual(free.map((s) => s.slot), visuals.TWEAK_RENDER_SLOTS.filter((s) => s.startsWith('before-')));
123
+ assert.deepEqual(free.map((s) => s.renderMode), ['light', 'dark', 'light', 'dark']);
124
+ const pinned = renders.shotsFor('after', { pinnedMode: 'dark' });
125
+ assert.deepEqual(pinned.map((s) => s.renderMode), ['dark', 'dark', 'dark', 'dark']);
126
+ assert.throws(() => renders.shotsFor('during', {}));
127
+ });
128
+
129
+ await test('planForPage reads the real inventory: an unknown page is refused, a known one has a url', () => {
130
+ const reader = require('../scripts/gds/page-reader.js');
131
+ const inventory = JSON.parse(fs.readFileSync(new URL('../docs/page-inventory.json', import.meta.url), 'utf8'));
132
+ assert.equal(renders.planForPage('nope:nope', { inventory, planRenders: reader.planRenders }).code, 'unknown_page');
133
+ const p = renders.planForPage(inventory.pages[0].id, { inventory, planRenders: reader.planRenders });
134
+ assert.equal(p.ok, true);
135
+ assert.ok(typeof p.plan.url === 'string' && p.plan.url.startsWith('/'));
136
+ });
137
+
138
+ await test('attach uploads every slot file in the directory to its NAMED slot, and names the missing ones', async () => {
139
+ const dir = path.join(TMP, 'shots');
140
+ fs.mkdirSync(dir, { recursive: true });
141
+ const present = visuals.TWEAK_RENDER_SLOTS.slice(0, 6);
142
+ for (const s of present) fs.writeFileSync(path.join(dir, `${s}.png`), PNG);
143
+ const posts = [];
144
+ const client = { tasks: { postTasksIdVisualsSlot: async (a) => { posts.push(a); return { ok: true, status: 201 }; } } };
145
+ const errs = [];
146
+ const origErr = console.error, origLog = console.log;
147
+ console.error = (m) => errs.push(String(m)); console.log = () => {};
148
+ let code;
149
+ try { code = await renders.attach({ taskId: 5001, dir }, { client }); } finally { console.error = origErr; console.log = origLog; }
150
+ assert.deepEqual(posts.map((p) => p.slot), present);
151
+ assert.ok(posts.every((p) => p.id === 5001 && p.body.content_type === 'image/png'));
152
+ assert.equal(code, 1, 'a missing slot is not a success');
153
+ assert.ok(errs.some((m) => m.includes('after-phone-light') && m.includes('after-phone-dark')), 'the missing slots are named');
154
+ for (const s of visuals.TWEAK_RENDER_SLOTS.slice(6)) fs.writeFileSync(path.join(dir, `${s}.png`), PNG);
155
+ console.log = () => {};
156
+ try { code = await renders.attach({ taskId: 5001, dir }, { client }); } finally { console.log = origLog; }
157
+ assert.equal(code, 0, 'all eight attached');
158
+ });
159
+
160
+ fs.rmSync(TMP, { recursive: true, force: true });
161
+ summary();