@bongos/core 1.19.980 → 1.19.982

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.
@@ -16,31 +16,35 @@ import { createRequire } from 'node:module';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
  const lib = require('../modules/hall-ui/public/blockers-lib.js');
19
+ // The lib owns no esc/fmtDate — callers pass the kit's (task 1003984).
20
+ const kit = require('../modules/hall-ui/public/hall-kit.js');
21
+ const F = { esc: kit.esc, fmtDate: kit.fmtAbsDate };
19
22
 
20
23
  test('loads standalone via require() — no window, no DOM (Node-require-safe like hall-kit.js)', () => {
21
24
  assert.ok(lib, 'module.exports must be set');
22
- for (const k of ['esc', 'fmtDate', 'blockingLine', 'blockerFacts']) {
25
+ for (const k of ['blockingLine', 'blockerFacts']) {
23
26
  assert.ok(k in lib, `missing export: ${k}`);
24
27
  }
28
+ for (const k of ['esc', 'fmtDate']) assert.ok(!(k in lib), `lib must not re-own ${k}`);
25
29
  });
26
30
 
27
31
  test('blockerFacts: title links into the SAME #/blocker/N overlay task-detail.js renders', () => {
28
32
  const b = { id: 42, title: 'Need the owner’s AWS card on file', created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [] };
29
- const { titleHtml } = lib.blockerFacts(b, { blockerHref: (id) => `#/blocker/${id}` });
33
+ const { titleHtml } = lib.blockerFacts(b, { ...F, blockerHref: (id) => `#/blocker/${id}` });
30
34
  assert.match(titleHtml, /href="#\/blocker\/42"/);
31
35
  assert.match(titleHtml, /Need the owner/);
32
36
  });
33
37
 
34
38
  test('blockerFacts: with no blockerHref, the title renders as plain text, not a dead link', () => {
35
39
  const b = { id: 42, title: 'Something stuck', created_at: null, blocking_task_ids: [] };
36
- const { titleHtml } = lib.blockerFacts(b);
40
+ const { titleHtml } = lib.blockerFacts(b, F);
37
41
  assert.doesNotMatch(titleHtml, /<a /);
38
42
  assert.match(titleHtml, /<span/);
39
43
  });
40
44
 
41
45
  test('blockerFacts: the caller-supplied classes land on the right fragments', () => {
42
46
  const b = { id: 1, title: 'x', body_md: 'a reason', created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [] };
43
- const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, {
47
+ const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, { ...F,
44
48
  titleClass: 'ginbox__goal', noteClass: 'ginbox__note', whenClass: 'ginbox__sub',
45
49
  });
46
50
  assert.match(titleHtml, /class="ginbox__goal"/);
@@ -49,19 +53,19 @@ test('blockerFacts: the caller-supplied classes land on the right fragments', ()
49
53
  });
50
54
 
51
55
  test('blockingLine: no linked tasks reads honestly, not as a false "0 tasks"', () => {
52
- assert.equal(lib.blockingLine({ blocking_task_ids: [] }, null), 'Blocking nothing yet');
53
- assert.equal(lib.blockingLine({}, null), 'Blocking nothing yet');
56
+ assert.equal(lib.blockingLine({ blocking_task_ids: [] }, null, kit.esc), 'Blocking nothing yet');
57
+ assert.equal(lib.blockingLine({}, null, kit.esc), 'Blocking nothing yet');
54
58
  });
55
59
 
56
60
  test('blockingLine: singular/plural noun agreement, and every id becomes a link when taskHref is given', () => {
57
- const one = lib.blockingLine({ blocking_task_ids: [5] }, (id) => `/task/${id}`);
61
+ const one = lib.blockingLine({ blocking_task_ids: [5] }, (id) => `/task/${id}`, kit.esc);
58
62
  assert.equal(one, 'Blocking 1 task: <a href="/task/5">#5</a>');
59
- const many = lib.blockingLine({ blocking_task_ids: [5, 6] }, (id) => `/task/${id}`);
63
+ const many = lib.blockingLine({ blocking_task_ids: [5, 6] }, (id) => `/task/${id}`, kit.esc);
60
64
  assert.equal(many, 'Blocking 2 tasks: <a href="/task/5">#5</a>, <a href="/task/6">#6</a>');
61
65
  });
62
66
 
63
67
  test('blockingLine: with no taskHref the ids still render, as plain text — never a dead link', () => {
64
- const line = lib.blockingLine({ blocking_task_ids: [5] }, null);
68
+ const line = lib.blockingLine({ blocking_task_ids: [5] }, null, kit.esc);
65
69
  assert.equal(line, 'Blocking 1 task: #5');
66
70
  assert.doesNotMatch(line, /<a /);
67
71
  });
@@ -71,21 +75,21 @@ test('blockerFacts: the facts line is ONE `·`-joined span, not several ungapped
71
75
  // between them (unlike .ov-row__facts, which is a flex container) and would
72
76
  // render Filed-date and Blocking-line running together with no separator.
73
77
  const b = { id: 1, title: 'x', created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [7] };
74
- const { factsHtml } = lib.blockerFacts(b, { taskHref: (id) => `/task/${id}` });
78
+ const { factsHtml } = lib.blockerFacts(b, { ...F, taskHref: (id) => `/task/${id}` });
75
79
  assert.equal((factsHtml.match(/<span/g) || []).length, 1, 'exactly one wrapping span');
76
80
  assert.match(factsHtml, /Filed .* · Blocking 1 task/);
77
81
  });
78
82
 
79
83
  test('blockerFacts: body_md becomes a quoted note; an empty one renders nothing', () => {
80
- const withNote = lib.blockerFacts({ id: 1, title: 'x', body_md: 'reason', blocking_task_ids: [] });
84
+ const withNote = lib.blockerFacts({ id: 1, title: 'x', body_md: 'reason', blocking_task_ids: [] }, F);
81
85
  assert.match(withNote.noteHtml, /<p class="ov-row__sub">“reason”<\/p>/);
82
- const noNote = lib.blockerFacts({ id: 1, title: 'x', body_md: ' ', blocking_task_ids: [] });
86
+ const noNote = lib.blockerFacts({ id: 1, title: 'x', body_md: ' ', blocking_task_ids: [] }, F);
83
87
  assert.equal(noNote.noteHtml, '');
84
88
  });
85
89
 
86
90
  test('blockerFacts: a very long body_md is clipped, so one enormous blocker cannot blow up the row', () => {
87
91
  const long = 'x'.repeat(500);
88
- const { noteHtml } = lib.blockerFacts({ id: 1, title: 't', body_md: long, blocking_task_ids: [] });
92
+ const { noteHtml } = lib.blockerFacts({ id: 1, title: 't', body_md: long, blocking_task_ids: [] }, F);
89
93
  assert.ok(noteHtml.length < 500, 'clipped well below the source length');
90
94
  assert.match(noteHtml, /…”<\/p>$/);
91
95
  });
@@ -95,8 +99,12 @@ test('blockerFacts: HTML in a title/body/task id is escaped, not injected', () =
95
99
  id: 1, title: '<script>alert(1)</script>', body_md: '<img onerror=alert(1)>',
96
100
  created_at: '2026-08-30T00:00:00.000Z', blocking_task_ids: [1],
97
101
  };
98
- const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, { taskHref: (id) => `/task/"><script>${id}` });
102
+ const { titleHtml, noteHtml, factsHtml } = lib.blockerFacts(b, { ...F, taskHref: (id) => `/task/"><script>${id}` });
99
103
  assert.doesNotMatch(titleHtml, /<script>/);
100
104
  assert.doesNotMatch(noteHtml, /<img /);
101
105
  assert.doesNotMatch(factsHtml, /<script>/);
102
106
  });
107
+
108
+ test('blockerFacts: refuses to render without the caller-supplied esc/fmtDate', () => {
109
+ assert.throws(() => lib.blockerFacts({ id: 1, title: 'x' }), /opts.esc and opts.fmtDate are required/);
110
+ });
@@ -3,10 +3,11 @@
3
3
  //
4
4
  // The load-bearing assertions:
5
5
  // • the PRIVATE direction is refused BEFORE GitHub is ever called (409
6
- // private_not_deployable): the deploy pull runs as the shared deploy key,
7
- // which cannot read a private repo (walk finding N4), so flipping private
8
- // would break the project's next deploy — the honest-refusal leg this task
9
- // ships. The refusal must not even consult the stored token.
6
+ // private_not_deployable): a project stood up public reads with the shared
7
+ // key, which cannot read a private repo (walk finding N4), so a bare flip
8
+ // would break its next deploy. The refusal points at the manage page, whose
9
+ // queued route wires the project's own deploy key first (task 1003078). It
10
+ // must not even consult the stored token.
10
11
  // • flip-to-public calls makeRepoPublic with the parsed owner/name and
11
12
  // forwards GitHub's own answer — the new state is never assumed
12
13
  // • a GitHub 401 drops the stored token and re-prompts (the /github/repos
@@ -80,7 +81,7 @@ test('GET /github/repo: forwards GitHub\'s answer for the caller\'s own credenti
80
81
  assert.equal(res._status, 200);
81
82
  assert.equal(res._body.repo.private, true);
82
83
  assert.equal(res._body.repo.admin, true);
83
- assert.equal(res._body.private_deployable, false, 'the platform truth rides every answer');
84
+ assert.equal(res._body.private_deployable, true, 'a private repo deploys through its own key (ADR 0176)');
84
85
  assert.deepEqual(s.calls.read, [{ owner: 'jaxri', name: 'mercury' }]);
85
86
  } finally { s.restore(); }
86
87
  });
@@ -129,7 +130,9 @@ test('POST visibility=private: refused 409 private_not_deployable — GitHub and
129
130
  await postHandler()({ builder: { id: 1 }, body: { repo: 'jaxri/mercury', visibility: 'private' } }, res);
130
131
  assert.equal(res._status, 409);
131
132
  assert.equal(res._body.error.code, 'private_not_deployable');
132
- assert.match(res._body.error.message, /deploy/i, 'the refusal says WHY in plain words');
133
+ assert.match(res._body.error.message, /deploy key/i, 'the refusal says WHY in plain words');
134
+ assert.match(res._body.error.message, /manage page/, 'and names the door that CAN do it');
135
+ assert.doesNotMatch(res._body.error.message, /can’t be deployed yet/, 'the old platform-wide refusal is gone');
133
136
  assert.equal(s.calls.flip.length, 0, 'no GitHub call for a refused direction');
134
137
  assert.equal(tokenReads, 0, 'the refusal happens before the credential is even read');
135
138
  } finally { db.getGithubToken = origGet; s.restore(); }
@@ -1210,10 +1210,10 @@ test('every owned card carries its Manage door, and a modified click keeps its m
1210
1210
 
1211
1211
  /* ── Repo visibility on the manage page (task 1003045) ──────────────────────
1212
1212
  The section reads the repo's REAL visibility from GitHub with the owner's
1213
- own repository credential and offers exactly one direction: private→public.
1214
- The private direction is refused — server-side and here — until the
1215
- private-repo deploy fix lands, because the deploy pull runs as the shared
1216
- deploy key that cannot read a private repo (walk finding N4). ── */
1213
+ own repository credential. Going public is the direct core call; going
1214
+ private (task 1003078) is QUEUED on the provisioning route, because the
1215
+ project's own deploy key must be wired before the flip — a project stood up
1216
+ public reads with the shared key, which cannot read a private repo (N4). ── */
1217
1217
 
1218
1218
  test('the repo-visibility section is live markup with its body slot (C7)', () => {
1219
1219
  assert.match(HUB, /id="mRepoVis"/, 'the section body must be real markup');
@@ -1221,16 +1221,14 @@ test('the repo-visibility section is live markup with its body slot (C7)', () =>
1221
1221
  'the pre-answer state is honest waiting copy, not a fake answer');
1222
1222
  });
1223
1223
 
1224
- test('flip-to-private cannot travel from this page — only the public direction exists', () => {
1225
- assert.match(SCRIPT, /visibility: 'public'/, 'the one POST body pins the public direction');
1224
+ test('flip-to-private travels ONLY through the queued provisioning route, never the core flip', () => {
1225
+ assert.match(SCRIPT, /visibility: 'public'/, 'the core POST body still pins the public direction');
1226
1226
  assert.doesNotMatch(SCRIPT, /visibility:\s*'private'/,
1227
- 'no code path may send the private direction');
1228
- // the refusal is SAID, with its reason, not silently missing — post-ADR-0176 the
1229
- // reason is THIS project's shared-credential wiring, not a platform-wide inability
1230
- assert.match(SCRIPT, /Making it private isn’t possible from here yet — see below for why\./,
1231
- 'the refusal is SAID inline, and points at the fold that carries the reason');
1232
- assert.match(HUB, /would break the next deploy/,
1233
- 'the flip-to-private limitation is stated in plain words, in the fold');
1227
+ 'the core flip is never asked for private — it cannot wire the deploy key');
1228
+ assert.match(SCRIPT, /'\/provisioning\/instances\/' \+ encodeURIComponent\(manageId\) \+ '\/repo-private'/,
1229
+ 'the private direction is the provisioning route, keyed on the project');
1230
+ assert.doesNotMatch(HUB, /isn’t possible from here yet/, 'the refusal copy is gone');
1231
+ assert.doesNotMatch(HUB, /Why can’t it be private yet\?/, 'and so is its fold');
1234
1232
  });
1235
1233
 
1236
1234
  test('the visibility shown is GitHub\'s answer, never an assumption', () => {
@@ -1261,19 +1259,15 @@ test('the reconnect door asks for repository access and returns to this project'
1261
1259
  'and returns to the exact project being managed');
1262
1260
  });
1263
1261
 
1264
- test('the refusal carries a LINK to the limitation, never prose alone (task 1003077)', () => {
1265
- assert.match(HUB, /id="mRepoVisWhy"/, 'the why-fold is real markup beside the section body');
1262
+ test('the fold says what making it private does, in the order it happens (task 1003078)', () => {
1263
+ assert.match(HUB, /id="mRepoVisWhy"/, 'the fold is real markup beside the section body');
1266
1264
  const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
1267
1265
  assert.ok(fold, 'the fold must be extractable');
1268
- assert.match(fold, /an older key that only works on public repositories/, 'the WHY is stated in plain words');
1269
- assert.match(fold, /\{\{buildersOrigin\}\}#\/task\/1002735/,
1270
- 'the tracked fix is a real link, hall-origin server-stamped (never client-filled)');
1271
- // apex-only: a child instance keeps the explanation, not another platform's tracker
1272
- const at = HUB.indexOf('{{buildersOrigin}}#/task/1002735');
1273
- const before = HUB.slice(0, at);
1274
- const opens = (before.match(/<!--apex-only:start-->/g) || []).length;
1275
- const closes = (before.match(/<!--apex-only:end-->/g) || []).length;
1276
- assert.equal(opens - closes, 1, 'the task link sits inside an apex-only sentinel pair');
1266
+ assert.match(fold, /What making it private does/);
1267
+ assert.match(fold, /its own read-only deploy key[\s\S]*only then makes the repository private/,
1268
+ 'the key comes first, the flip second — the safety property, in plain words');
1269
+ assert.match(fold, /needs admin rights on the repository/, 'the one thing the owner must bring');
1270
+ assert.doesNotMatch(fold, /an older key that only works on public repositories/, 'the old limitation is gone');
1277
1271
  });
1278
1272
 
1279
1273
  test('making a repo public asks first — exposure is never one accidental click', () => {
@@ -585,15 +585,16 @@ test('founder voice on the Device Flow step and the manual OAuth fallback', () =
585
585
  assert.doesNotMatch(manual, /auth_configured|healthz/, 'no JSON health check for a founder to perform');
586
586
  });
587
587
 
588
- test('repo visibility: the refusal is one line, the mechanism lives in the fold', () => {
588
+ test('repo visibility: the public line stays one line, then the door or the reason — the mechanism lives in the fold', () => {
589
589
  const rv = fn('renderRepoVis');
590
590
  assert.match(rv, /This repository is <b>public<\/b> — anyone can read its code\./, 'line one stays');
591
- assert.match(rv, /Making it private isn’t possible from here yet — see below for why\./);
591
+ assert.match(rv, /repoPrivDoor\(manageInst, repo\)/, 'the public branch offers the door, or says why there is none');
592
+ assert.doesNotMatch(rv, /isn’t possible from here yet/, 'the old refusal is gone');
592
593
  assert.doesNotMatch(rv, /shared credential/);
593
594
  const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
594
- assert.match(fold, /an older key that only works on public repositories/);
595
+ assert.match(fold, /What making it private does/);
595
596
  assert.doesNotMatch(fold, /shared read-only credential/);
596
- assert.match(fold, /<!--apex-only:start--> — shipped as <a href="\{\{buildersOrigin\}\}#\/task\/1002735"/, 'the hall task link stays inside its apex-only sentinel');
597
+ assert.doesNotMatch(fold, /#\/task\//, 'the fold explains; it no longer points at a tracked fix');
597
598
  });
598
599
 
599
600
  test('a failed manage read offers a retry; the no-address sentence offers a door', () => {
@@ -0,0 +1,238 @@
1
+ // tests/projects_hub_repo_private.mjs
2
+ //
3
+ // The manage page's "Make it private" (split (c) of task 1003078): the page's OWN functions,
4
+ // lifted out of projects.html and run in a sandbox with a fake DOM and fetch — so what is
5
+ // proved is behaviour, not the shape of the source.
6
+ //
7
+ // The load-bearing assertions:
8
+ // • the door shows only where the route would accept it, and every other case says why
9
+ // • the POST reply is a RECEIPT: it lands in the stable slot, and nothing claims the
10
+ // repository is private until GitHub is asked again after the run settles
11
+ // • the in-flight line is fetch-free, repaints only when the run's state MOVES (the
12
+ // minute tick never flickers it), and the settle triggers exactly ONE GitHub re-read
13
+ // • a failed make-private reads as a failed make-private on the card, not a failed standup
14
+ //
15
+ // Run: node tests/projects_hub_repo_private.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { readFileSync } from 'node:fs';
19
+ import vm from 'node:vm';
20
+
21
+ const HUB = readFileSync(new URL('../modules/public-landing/public/projects.html', import.meta.url), 'utf8').replace(/\r\n/g, '\n');
22
+
23
+ let passed = 0, failed = 0;
24
+ async function ta(name, fn) {
25
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
26
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
27
+ }
28
+
29
+ // A top-level (2-space) declaration, up to its own closing brace at that indent.
30
+ function grab(head) {
31
+ const at = HUB.indexOf(`\n ${head}`);
32
+ assert.ok(at >= 0, `${head} must exist`);
33
+ const end = HUB.indexOf('\n }', at + 1);
34
+ const close = HUB.slice(end, end + 5) === '\n };' ? end + 5 : end + 4;
35
+ return HUB.slice(at, close);
36
+ }
37
+ const SOURCES = [
38
+ 'function esc(', 'var FLOWS = {', 'function stripMarkup(', 'function stripFor(', 'function errCategory(',
39
+ 'function repoPrivOpen(', 'function repoPrivDoor(', 'function repoVisNote(', 'function renderRepoVis(',
40
+ 'function renderRepoVisState(', 'function askRepoPrivate(', 'function doRepoPrivate(',
41
+ ].map(grab).join('\n') + "\nfunction repoVisMsg(html) { $('mRepoVis').innerHTML = html; }\n";
42
+
43
+ const RECEIPT = { status: 200, body: { ok: true, queued: true, message: 'Queued <b>ok</b>.' } };
44
+ function world({ reply = RECEIPT } = {}) {
45
+ const els = { mRepoVis: { innerHTML: '' }, mRepoVisMsg: { className: 'projMsg', innerHTML: '' } };
46
+ const calls = { fetch: [], loadManage: [], reread: 0 };
47
+ let settle;
48
+ const done = new Promise((r) => { settle = r; });
49
+ const ctx = {
50
+ API: '/api/bongos', manageId: '18', manageInst: null, manageRepo: null,
51
+ mRepoPrivSeen: false, mRepoPrivState: '',
52
+ $: (id) => els[id],
53
+ document: { querySelector: () => null },
54
+ jsonOrEmpty: (res) => res.json(),
55
+ apiError: (data) => (data && data.error) || {},
56
+ signInAgain: () => 'SIGN-IN',
57
+ justLive: () => false,
58
+ loadRepoVisibility: () => { calls.reread++; },
59
+ loadManage: (...a) => { calls.loadManage.push(a); settle(); },
60
+ fetch: (url, opts) => {
61
+ calls.fetch.push({ url, opts });
62
+ return Promise.resolve({ ok: reply.status < 300, status: reply.status, json: async () => reply.body });
63
+ },
64
+ };
65
+ vm.createContext(ctx);
66
+ vm.runInContext(SOURCES, ctx, { filename: 'projects.html#repo-private' });
67
+ return { ctx, els, calls, done };
68
+ }
69
+
70
+ const LIVE = { id: 18, status: 'active', hosting_shape: 'standalone', target_ref: 'o/demo', open_intent: null };
71
+ const PUBLIC = { private: false, admin: true };
72
+
73
+ console.log('the door:');
74
+
75
+ await ta('a live Cloud-host project whose owner is a repo admin gets the button', async () => {
76
+ const { ctx } = world();
77
+ assert.match(ctx.repoPrivDoor(LIVE, PUBLIC), /data-repo-private>Make it private</);
78
+ });
79
+
80
+ for (const [label, inst, repo, why] of [
81
+ ['own host', { ...LIVE, hosting_shape: 'byo-host' }, PUBLIC, /on GitHub directly/],
82
+ ['another shape', { ...LIVE, hosting_shape: 'dedicated' }, PUBLIC, /Cloud host/],
83
+ ['not running', { ...LIVE, status: 'error' }, PUBLIC, /once the project is running/],
84
+ ['no admin rights', LIVE, { private: false, admin: false }, /can’t change this repository’s settings/],
85
+ ]) {
86
+ await ta(`${label}: no button, and a sentence saying why`, async () => {
87
+ const { ctx } = world();
88
+ const html = ctx.repoPrivDoor(inst, repo);
89
+ assert.doesNotMatch(html, /data-repo-private/);
90
+ assert.match(html, why);
91
+ });
92
+ }
93
+
94
+ console.log('\nthe request — a receipt, never a new state:');
95
+
96
+ await ta('Yes POSTs to the project’s repo-private route with NO body', async () => {
97
+ const w = world();
98
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
99
+ w.ctx.doRepoPrivate();
100
+ await w.done;
101
+ assert.equal(w.calls.fetch.length, 1);
102
+ assert.equal(w.calls.fetch[0].url, '/api/bongos/provisioning/instances/18/repo-private');
103
+ assert.equal(w.calls.fetch[0].opts.method, 'POST');
104
+ assert.equal(w.calls.fetch[0].opts.body, undefined, 'the route takes no fields');
105
+ });
106
+
107
+ await ta('a 200 puts the server’s sentence (escaped) in the stable slot, arms the re-read, and re-reads the row', async () => {
108
+ const w = world();
109
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
110
+ w.ctx.doRepoPrivate();
111
+ await w.done;
112
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg ok');
113
+ assert.equal(w.els.mRepoVisMsg.innerHTML, 'Queued &lt;b&gt;ok&lt;/b&gt;.');
114
+ assert.equal(w.ctx.mRepoPrivSeen, true, 'a run that settles before the re-read lands is still followed up');
115
+ assert.equal(JSON.stringify(w.ctx.manageInst.open_intent), JSON.stringify({ action: 'repo-private', state: 'pending' }),
116
+ 'the receipt proves a run is open, so the row says so — and the 5s watch follows it');
117
+ assert.match(w.els.mRepoVis.innerHTML, /Making it private is queued/, 'the door gives way to the in-flight line at once');
118
+ assert.deepEqual(w.calls.loadManage, [[true, true]], 'quiet, keep the repo read');
119
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /<b>private<\/b>/, 'nothing claims the flip happened');
120
+ });
121
+
122
+ await ta('a repaint from the row right after the click (the minute tick) keeps the run and its receipt', async () => {
123
+ const w = world();
124
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
125
+ w.ctx.doRepoPrivate();
126
+ await w.done;
127
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
128
+ assert.equal(w.calls.reread, 0, 'no settle is read into a run that has not been re-read yet');
129
+ assert.match(w.els.mRepoVisMsg.innerHTML, /Queued/);
130
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /data-repo-private/, 'the button does not come back');
131
+ });
132
+
133
+ await ta('a 404 (an archon on someone else’s project) says the rule plainly', async () => {
134
+ const w = world({ reply: { status: 404, body: { error: { code: 'instance_not_found' } } } });
135
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
136
+ w.ctx.doRepoPrivate();
137
+ await w.done;
138
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg err');
139
+ assert.match(w.els.mRepoVisMsg.innerHTML, /Only the project’s owner can make its repository private/);
140
+ assert.equal(w.ctx.mRepoPrivSeen, false);
141
+ });
142
+
143
+ await ta('a 409 shows the server’s own sentence, escaped', async () => {
144
+ const w = world({ reply: { status: 409, body: { error: { code: 'intent_conflict', message: "A 'restart' run is <i>running</i>" } } } });
145
+ w.ctx.manageInst = { ...LIVE }; w.ctx.manageRepo = PUBLIC;
146
+ w.ctx.doRepoPrivate();
147
+ await w.done;
148
+ assert.equal(w.els.mRepoVisMsg.className, 'projMsg err');
149
+ assert.match(w.els.mRepoVisMsg.innerHTML, /restart.* run is &lt;i&gt;running&lt;\/i&gt;/);
150
+ assert.doesNotMatch(w.els.mRepoVisMsg.innerHTML, /<i>/);
151
+ });
152
+
153
+ console.log('\nthe in-flight line and the settle:');
154
+
155
+ await ta('the receipt slot is painted INSIDE #mRepoVis, never a sibling of it', async () => {
156
+ // every .mSec>div takes the same grid cell (cosmos.css), so a sibling would sit on top
157
+ const card = (HUB.match(/<div class="card mSec" id="mRepoVisibility">[\s\S]*?\n <\/div>/) || [''])[0];
158
+ assert.ok(card, 'the card markup is extractable');
159
+ assert.doesNotMatch(card, /id="mRepoVisMsg"/, 'no second grid child in the card markup');
160
+ const w = world();
161
+ w.ctx.manageInst = { ...LIVE };
162
+ w.ctx.renderRepoVis(PUBLIC);
163
+ assert.match(w.els.mRepoVis.innerHTML, /<div class="projMsg" id="mRepoVisMsg" role="status" aria-live="polite"><\/div>$/);
164
+ });
165
+
166
+ await ta('an open run replaces the door with the in-flight line, worded by its state', async () => {
167
+ const w = world();
168
+ w.ctx.manageRepo = PUBLIC;
169
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'pending' } };
170
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
171
+ assert.match(w.els.mRepoVis.innerHTML, /Making it private is queued/);
172
+ assert.doesNotMatch(w.els.mRepoVis.innerHTML, /data-repo-private/);
173
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'running' } };
174
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
175
+ assert.match(w.els.mRepoVis.innerHTML, /the platform is giving this project its own deploy key first/);
176
+ });
177
+
178
+ await ta('the minute tick with an unchanged state does not repaint', async () => {
179
+ const w = world();
180
+ w.ctx.manageRepo = PUBLIC;
181
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'repo-private', state: 'running' } };
182
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
183
+ w.els.mRepoVis.innerHTML = 'SENTINEL';
184
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
185
+ assert.equal(w.els.mRepoVis.innerHTML, 'SENTINEL');
186
+ assert.equal(w.calls.reread, 0, 'and asks GitHub nothing while it runs');
187
+ });
188
+
189
+ await ta('the settle asks GitHub exactly once and clears the receipt', async () => {
190
+ const w = world();
191
+ w.ctx.manageRepo = PUBLIC;
192
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: { action: 'repo-private', state: 'running' } });
193
+ w.els.mRepoVisMsg.innerHTML = 'Queued.';
194
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: null });
195
+ w.ctx.renderRepoVisState({ ...LIVE, open_intent: null });
196
+ assert.equal(w.calls.reread, 1, 'only GitHub can say the flip took — asked once');
197
+ assert.equal(w.els.mRepoVisMsg.innerHTML, '');
198
+ });
199
+
200
+ await ta('another intent (a restart) is not mistaken for a make-private run', async () => {
201
+ const w = world();
202
+ w.ctx.manageRepo = PUBLIC;
203
+ w.ctx.manageInst = { ...LIVE, open_intent: { action: 'restart', state: 'running' } };
204
+ w.ctx.renderRepoVis(PUBLIC);
205
+ assert.match(w.els.mRepoVis.innerHTML, /data-repo-private/);
206
+ w.ctx.renderRepoVisState(w.ctx.manageInst);
207
+ assert.equal(w.ctx.mRepoPrivSeen, false);
208
+ });
209
+
210
+ console.log('\nthe card:');
211
+
212
+ await ta('the mine-card strip names the make-private flow and its running half', async () => {
213
+ const { ctx } = world();
214
+ const run = ctx.stripFor({ ...LIVE, open_intent: { action: 'repo-private', state: 'running' } });
215
+ assert.match(run, /Setting up its deploy key/);
216
+ assert.match(run, /setting up the project’s own deploy key first/);
217
+ const queued = ctx.stripFor({ ...LIVE, open_intent: { action: 'repo-private', state: 'pending' } });
218
+ assert.match(queued, /Making the repository private is queued/);
219
+ assert.doesNotMatch(queued, /repo-private run is queued/, 'not the generic fallback');
220
+ });
221
+
222
+ await ta('a retired make-private reads as that, not as a standup that stopped', async () => {
223
+ const { ctx } = world();
224
+ for (const note of [
225
+ 'o/demo is still public: its deploy key could not be set up on the server. Nothing on your side caused this, and trying again is safe.',
226
+ 'o/demo cannot be made private right now: GitHub could not be reached to set up its deploy key (fetch failed). Nothing has been changed — try again.',
227
+ 'The deploy key for o/demo could not be set up, so the repository was left public.',
228
+ ]) assert.match(ctx.errCategory(note), /Making the repository private didn’t finish/, note);
229
+ for (const note of [
230
+ "o/demo cannot be made private: there is no usable GitHub credential stored for this project, and without one its deploy key cannot be registered. Reconnect the project's GitHub access, then try again.",
231
+ "o/demo was not made private: the project's GitHub credential could not be read. Its deploy key is already in place, so reconnect GitHub and try again.",
232
+ ]) assert.match(ctx.errCategory(note), /needs reconnecting/, 'the one fix the owner can make is not folded away');
233
+ assert.match(ctx.errCategory('deploy-key setup failed for o/demo: GitHub deploy-key add failed (500).'), /deploy key on GitHub didn’t finish/);
234
+ assert.match(ctx.errCategory('repository not found: GitHub answered 404'), /couldn’t find the project’s repository/, 'the older categories are untouched');
235
+ });
236
+
237
+ console.log(`\nprojects_hub_repo_private: ${passed} passed, ${failed} failed`);
238
+ process.exit(failed ? 1 : 0);