@bongos/core 1.21.45 → 1.21.47

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.
@@ -327,6 +327,7 @@ test('the page: the desk acts in place — the row clears, the floor lights with
327
327
  assert.deepEqual(p.sent, [{ method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } }], 'the card\'s own route, nothing else');
328
328
  assert.match(p.byId.get('streetToast').textContent, /Ana Ruiz is invited in/);
329
329
  assert.equal(p.byId.get('streetToast').hidden, false);
330
+ assert.match(p.byId.get('streetToast').className, /street-toast--desk/, "the desk's own toast, as on the board (task 1004562)");
330
331
  assert.equal(p.byId.get('streetTodayN').textContent, '1 decided');
331
332
  assert.equal(p.byId.get('streetTodayPips').children.filter((x) => x.className.includes('--on')).length, 1, 'one pip per decision');
332
333
  assert.equal(JSON.parse(p.store.get('gov-desk-today')).n, 1, 'the day keeps its count');
@@ -624,6 +625,12 @@ test('Release: the core update is checked first; take is offered only after a pa
624
625
  assert.equal(C.request(passed.card, 'take', {}), null, 'no version chip, no move');
625
626
  assert.deepEqual(coreRoute.coreDeskRows({ ...PREVIEW, open_intent: { state: 'running' } }), [], 'a run in progress offers nothing');
626
627
  assert.deepEqual(coreRoute.coreDeskRows({ ...PREVIEW, behind: false }), []);
628
+ // the stand-in keeps what it stands for: the core update needs you, so the
629
+ // desk says so in orange, as the floor's sign and the street's count do (task 1004562)
630
+ const desk = D.deskModel({ key: 'release', floor: 3, docket: moreDesks({ preflight: null }), goals: GOALS, now: NOW, viewerId: '95' });
631
+ assert.equal(desk.hot, true);
632
+ assert.match(desk.status, /^1 update/);
633
+ assert.deepEqual(D.breaks(desk), []);
627
634
  });
628
635
 
629
636
  test('Rules: the rot timer as day chips, work categories as a link, modules on in one tap and off with a yes', () => {
@@ -747,10 +754,11 @@ test('Ideas to ratify: a promise ratifies in one tap; a carried Full Idea become
747
754
  const dk = planningDocket();
748
755
  const rows = D.rowsFor('planning', 1, dk, { now: NOW, goals: GOALS });
749
756
  const promise = rows.find((r) => r.name === 'Calm mode promise');
750
- assert.deepEqual(promise.actions.map((a) => a.label), ['ratify', 'open']);
757
+ assert.deepEqual(promise.actions.map((a) => a.label), ['ratify'], 'a row that acts here keeps its detail in place: no link out (task 1004562)');
758
+ assert.ok(promise.facts.length >= 1, 'its detail opens in place');
751
759
  assert.deepEqual(j(C.request(promise.card, 'ratify')), { method: 'POST', url: '/api/bongos/inbox/5/ratify', body: { notes_md: 'Ratified on the floor desk.' } });
752
760
  const full = rows.find((r) => r.name === 'Objection goes back');
753
- assert.deepEqual(full.actions.map((a) => a.label), ['make goal', 'open']);
761
+ assert.deepEqual(full.actions.map((a) => a.label), ['make goal'], 'no link out beside an act');
754
762
  assert.deepEqual(full.choices.ratify_goal.options.map((o) => o.label), ['V2', 'V3'], 'the target version is a chip');
755
763
  assert.deepEqual(j(C.request(full.card, 'ratify_goal', full.choices.ratify_goal.options[1].input)), { method: 'POST', url: '/api/bongos/inbox/6/ratify-goal', body: { target_version: 'V3', notes_md: 'Ratified on the floor desk.' } });
756
764
  assert.ok(!rows.some((r) => r.name === 'Still sitting'), 'a Full Idea the board has not carried is not ratified from here');
@@ -832,3 +840,57 @@ test('the page: one tap on its option decides a blocker, its options standing op
832
840
  assert.deepEqual(p.sent, [{ method: 'POST', url: '/api/bongos/blockers/12/resolve', body: { resolution_note: 'Decided on the floor desk: Paddle.' } }]);
833
841
  assert.match(p.byId.get('streetToast').textContent, /Decided/);
834
842
  });
843
+
844
+ // ── the live walkthrough's differences (task 1004562) ─────────────────────────
845
+ function floorRow(key, count, line) {
846
+ return { key, building: key.split('.')[0], status: 'live', count, line, lines: [], hot: false, reason: null };
847
+ }
848
+ test('walkthrough: a quiet floor\'s tile says what its number counts, in two words that read as a label', () => {
849
+ const dk = docket([], { history: [], floors: [
850
+ floorRow('people.testers', '3', 'Lu, Bo and Ines'),
851
+ floorRow('people.council', '5', '5 seats · no term yet'),
852
+ floorRow('people.newcomers', '2', '2 newcomer tasks being built'),
853
+ floorRow('release.live_month', '1', '1 piece of work went live'),
854
+ floorRow('review.signed_off', '2', '2 signed off this month'),
855
+ ] });
856
+ const tile = (key, floor) => D.deskModel({ key, floor, docket: dk, now: NOW }).tiles.map((t) => t.v + ' ' + t.l);
857
+ assert.deepEqual(tile('people', 1), ['3 testers'], 'a line that is not a count names nothing: the floor does');
858
+ assert.deepEqual(tile('people', 0), ['5 seats'], 'the line\'s own noun when it follows the same number');
859
+ assert.deepEqual(tile('people', 3), ['2 newcomers'], 'a long line: the floor\'s name');
860
+ assert.deepEqual(tile('release', 4), ['1 went live'], 'a long line under a long name: its last two words');
861
+ assert.deepEqual(tile('review', 2), ['2 signed off']);
862
+ });
863
+ test('walkthrough: a cleared floor shows no count of the work just done, and says its own clear line', () => {
864
+ const m = matter({ id: 'removal_proposals:61', source: 'removal_proposals', building: 'disputes', state: 'needs', title: 'Old search page', ref: { id: 61 } });
865
+ const dk = docket([m], { history: [], floors: [floorRow('disputes.removals', '2', '1 needs you')] });
866
+ const desk = D.deskModel({ key: 'disputes', floor: 1, docket: dk, now: NOW, done: { 'removal_proposals:61': true }, cleared: true, today: 2 });
867
+ assert.equal(desk.cleared, true);
868
+ assert.deepEqual(desk.tiles, [], 'no "2 needs you" tile on a floor you just cleared');
869
+ assert.deepEqual([desk.status, desk.clearLine, desk.clearSub], ['floor clear', 'No removals asked', 'lit up · 2 decided today']);
870
+ assert.deepEqual(D.breaks(desk), []);
871
+ // with a tool left on the floor (the invite), the status does not repeat the stale count
872
+ const join = matter({ id: 'access_requests:7', source: 'access_requests', building: 'people', state: 'needs', proposer: { name: 'Builder 4' }, ref: { id: 7 } });
873
+ const invite = { key: 'people.invite', status: 'live', rows: [{ id: 'people.invite:new', desk: 'people.invite', building: 'people', name: 'Invite someone', fact: 'by their GitHub name', facts: [], needs: false, tool: true, ref: { type: 'invite', id: 'new' }, acts: [{ key: 'invite', field: 'login' }] }] };
874
+ const left = D.deskModel({ key: 'people', floor: 4, docket: docket([join], { history: [], desks: [invite], floors: [floorRow('people.join', '1', '1 needs you')] }), now: NOW, done: { 'access_requests:7': true } });
875
+ assert.deepEqual(left.rows.map((r) => r.name), ['Invite someone']);
876
+ assert.equal(left.status, 'nothing needs you');
877
+ assert.equal(left.hot, false);
878
+ });
879
+ test('walkthrough: a count agrees with its noun, in the status and on the tile', () => {
880
+ const votes = [1, 2].map((n) => matter({ id: 'board:' + n, source: 'board', building: 'charter', state: 'needs', title: 'Vote ' + n }));
881
+ const two = D.deskModel({ key: 'charter', floor: 0, docket: docket(votes, { history: [] }), now: NOW });
882
+ assert.match(two.status, /^2 open votes · oldest/);
883
+ assert.deepEqual(two.tiles.map((t) => t.v + ' ' + t.l), ['2 open votes']);
884
+ const one = D.deskModel({ key: 'charter', floor: 0, docket: docket(votes.slice(0, 1), { history: [] }), now: NOW });
885
+ assert.match(one.status, /^1 open vote · oldest/);
886
+ });
887
+ test('walkthrough: a record item keeps its age, and what you did keeps its "now"', () => {
888
+ const hist = [
889
+ { id: 'a', building: 'release', title: 'The new sign-up', outcome: 'live', at: '2026-10-01T12:00:00.000Z' },
890
+ { id: 'b', building: 'release', title: 'Mira holds the Treasurer seat', outcome: 'granted', at: '2026-09-12T12:00:00.000Z' },
891
+ { id: 'c', building: 'release', title: 'Today it went out', outcome: '', at: '2026-10-03T09:00:00.000Z' },
892
+ ];
893
+ const desk = D.deskModel({ key: 'release', floor: 2, docket: docket([], { history: hist }), now: NOW, mine: ['voted yes A returned idea carries it back'] });
894
+ assert.deepEqual(desk.record.map((x) => x.text), ['voted yes A returned id… · now', 'The new sign-up · live · 2d', 'Mira holds the Treasurer… · 3w', 'Today it went out · today']);
895
+ assert.ok(desk.record.every((x) => x.text.length <= D.LIMITS.recordLen));
896
+ });
@@ -206,7 +206,8 @@ test('the fallback checklist carries every human step the canonical plan has', (
206
206
 
207
207
  /* ── the manifest leg: acknowledges GitHub, offers a way out, survives a reload ── */
208
208
  test('the manifest step acknowledges code_received, offers a restart after a stall, and rides the CREATED record', () => {
209
- assert.match(SCRIPT, /if \(live\.manifest === 'code_received'\)/, 'GitHub’s half is acknowledged');
209
+ // 'converting' is the same stage — it used to fall through to "waiting for approval" (task 1004572)
210
+ assert.match(SCRIPT, /if \(live\.manifest === 'code_received' \|\| live\.manifest === 'converting'\)/, 'GitHub’s half is acknowledged');
210
211
  assert.match(SCRIPT, /GitHub’s part is done/);
211
212
  assert.match(SCRIPT, /manifestStale \? '<button type="button" class="linklike" data-action="manifest-start">/, 'a stalled wait gets a restart door');
212
213
  assert.match(SCRIPT, /\(Date\.now\(\) - manifestStartedAt\) > 90000/, 'after ~90 seconds');
@@ -558,7 +559,7 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
558
559
  assert.match(steps, /\(raw \? '<details><summary>Technical details<\/summary><p>' \+ esc\(raw\) \+ '<\/p><\/details>' : ''\)/);
559
560
  assert.match(SCRIPT, /e\.code = \(d\.error && d\.error\.code\) \|\| '';/, 'the route code travels with the error');
560
561
  assert.match(SCRIPT, /live\.manifestCode = String\(\(err && err\.code\) \|\| ''\);/);
561
- assert.equal((SCRIPT.match(/manifestMsg: '', manifestCode: '', appSlug: null/g) || []).length, 2, 'both resets carry the code');
562
+ assert.equal((SCRIPT.match(/manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null/g) || []).length, 2, 'both resets carry the code (and, since task 1004572, the failure stage)');
562
563
  });
563
564
 
564
565
  test('the progress rail is gone (task 1004504): no painter, no repaint at the done panel, no static rail', () => {
@@ -1723,6 +1723,59 @@ await ta('a SPENT code is never re-converted — terminal error, no code_receive
1723
1723
  assert.ok(!resolves.some((x) => x.status === 'code_received'), 'never reset for a doomed retry');
1724
1724
  });
1725
1725
 
1726
+ // Task 1004572: once the credentials are placed, an App EXISTS on the owner's GitHub
1727
+ // account whether or not the project then confirms sign-in. The failed row must keep
1728
+ // its id + slug, or the wizard can only say "GitHub setup didn't start" — false, and
1729
+ // it sends the founder to make a second App (live on mercury, 2026-10-04).
1730
+ await ta('a sign-in check that never confirms still records WHICH App GitHub made (task 1004572)', async () => {
1731
+ const inst = { ...coTenant, domain: 'mercury.example.com' };
1732
+ const deps = {
1733
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1734
+ convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 5189633, appSlug: 'mercury-cloudbongos-com' }),
1735
+ exec: () => ({ ok: true, stdout: '{"ok":true,"auth_configured":false}' }),
1736
+ writeFile: () => ({ ok: true }),
1737
+ };
1738
+ const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1739
+ assert.equal(r.ok, false);
1740
+ assert.equal(r.appId, 5189633, 'the App id rides the failure');
1741
+ assert.equal(r.appSlug, 'mercury-cloudbongos-com', 'the App slug rides the failure');
1742
+
1743
+ const resolves = [];
1744
+ let claims = 0;
1745
+ await P.drainOAuthManifests({
1746
+ ...deps, db: {},
1747
+ provisioning: {
1748
+ claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 18, instance_id: 53, github_code: 'C', attempts: 1 } : null),
1749
+ getInstanceById: async () => inst,
1750
+ resolveOAuthManifest: async (_db, _id, status, patch = {}) => { resolves.push({ status, patch }); },
1751
+ recordEvent: async () => {},
1752
+ },
1753
+ });
1754
+ assert.equal(resolves.length, 1);
1755
+ assert.equal(resolves[0].status, 'error');
1756
+ assert.equal(resolves[0].patch.app_slug, 'mercury-cloudbongos-com', 'the error row names the App');
1757
+ assert.equal(resolves[0].patch.app_id, 5189633);
1758
+ assert.equal(resolves[0].patch.github_code, null, 'the burned code is still scrubbed');
1759
+ });
1760
+
1761
+ await ta('a failure BEFORE GitHub handed over credentials claims no App (task 1004572)', async () => {
1762
+ const resolves = [];
1763
+ let claims = 0;
1764
+ await P.drainOAuthManifests({
1765
+ apply: true, privileged: false, log: () => {}, sleep: async () => {}, db: {},
1766
+ exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
1767
+ convertManifestCode: async () => { throw new Error('GitHub manifest conversion failed: code expired'); },
1768
+ provisioning: {
1769
+ claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 19, instance_id: 53, github_code: 'C', attempts: 1 } : null),
1770
+ getInstanceById: async () => ({ ...coTenant, domain: 'mercury.example.com' }),
1771
+ resolveOAuthManifest: async (_db, _id, status, patch = {}) => { resolves.push({ status, patch }); },
1772
+ recordEvent: async () => {},
1773
+ },
1774
+ });
1775
+ assert.ok(resolves.length >= 1);
1776
+ assert.ok(resolves.every((x) => !('app_slug' in x.patch)), 'no App is named when none was handed over');
1777
+ });
1778
+
1726
1779
  await ta('a PRE-conversion failure stays retryable — the narrowing spared the retry path (F23)', async () => {
1727
1780
  let claims = 0; const resolves = [];
1728
1781
  const deps = {
@@ -158,6 +158,29 @@ console.log('\nGET /provisioning/github-app/status (the page poll → auto-open
158
158
  t("status 'done' → ready:true + terminal:true", res.status === 200 && j.ready === true && j.terminal === true, JSON.stringify(j));
159
159
  provisioning.getOAuthManifestByState = prev;
160
160
  }
161
+ {
162
+ // Task 1004572: a setup link nobody finished lapses after 30 minutes, and the
163
+ // callback refuses it from then on — so the poll must say 'expired' (terminal)
164
+ // rather than 'pending' forever, or the page waits on a link that can never land.
165
+ const prev = provisioning.getOAuthManifestByState;
166
+ provisioning.getOAuthManifestByState = async () => ({ id: 17, instance_id: 42, status: 'pending', attempts: 0, expires_at: new Date(Date.now() - 1000).toISOString() });
167
+ const res = await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`);
168
+ const j = await res.json();
169
+ t('a LAPSED pending link reads expired + terminal (task 1004572)', res.status === 200 && j.status === 'expired' && j.terminal === true && j.ready === false && j.failure === null, JSON.stringify(j));
170
+ provisioning.getOAuthManifestByState = prev;
171
+ }
172
+ {
173
+ // A failure after GitHub handed over credentials: the poll names how far it got,
174
+ // and never the raw last_error (this route is public by state token).
175
+ const prev = provisioning.getOAuthManifestByState;
176
+ provisioning.getOAuthManifestByState = async () => ({ id: 18, instance_id: 42, status: 'error', attempts: 1, app_slug: 'mercury-signin', last_error: 'the credentials are placed, but the project did not report sign-in ready in time', expires_at: new Date(Date.now() - 1000).toISOString() });
177
+ const res = await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`);
178
+ const raw = await res.text();
179
+ const j = JSON.parse(raw);
180
+ t("a post-approval failure says failure:'sign_in_unconfirmed' (task 1004572)", j.status === 'error' && j.failure === 'sign_in_unconfirmed' && j.terminal === true, raw);
181
+ t('…and never leaks last_error', !/did not report/.test(raw) && !('last_error' in j), raw);
182
+ provisioning.getOAuthManifestByState = prev;
183
+ }
161
184
  {
162
185
  const res = await fetch(`${base}/provisioning/github-app/status?state=NOPE`);
163
186
  t('unknown state → 404 (no oracle)', res.status === 404, `status=${res.status}`);
@@ -115,10 +115,12 @@ await ta('owner reads their own instance', async () => {
115
115
 
116
116
  await ta('the read carries the GitHub-App leg\'s truth — github_app {status, app_slug} from the latest manifest attempt (task 1003235)', async () => {
117
117
  reset();
118
- manifest = { id: 3, instance_id: 18, status: 'done', app_slug: 'spike-bongos', github_code: 'never-surfaced', state: 'never-surfaced' };
118
+ manifest = { id: 3, instance_id: 18, status: 'done', app_slug: 'spike-bongos', github_code: 'never-surfaced', state: 'never-surfaced', last_error: 'never-surfaced', created_at: '2026-10-04T20:20:56.930Z' };
119
119
  const r = await get('/provisioning/instances/18');
120
120
  assert.equal(r.status, 200, r.raw);
121
- assert.deepEqual(r.body.github_app, { status: 'done', app_slug: 'spike-bongos' }, 'an allow-list projection — the code and the state token never ride the read');
121
+ // task 1004572 added failure + started_at to the allow-list; nothing else rides
122
+ assert.deepEqual(r.body.github_app, { status: 'done', app_slug: 'spike-bongos', failure: null, started_at: '2026-10-04T20:20:56.930Z' }, 'an allow-list projection — the code, the state token and last_error never ride the read');
123
+ assert.doesNotMatch(r.raw, /never-surfaced/);
122
124
  assert.equal(r.body.instance.slug, 'spike', 'the instance projection is untouched');
123
125
  });
124
126
 
@@ -134,8 +136,8 @@ await ta('no manifest attempt yet → github_app is null, not absent (a reload c
134
136
  await ta('publicGithubApp is a pure allow-list projection with the status route\'s vocabulary', async () => {
135
137
  assert.equal(provisioning.publicGithubApp(null), null);
136
138
  assert.equal(provisioning.publicGithubApp({ app_slug: 'x' }), null, 'a row with no status is no truth');
137
- assert.deepEqual(provisioning.publicGithubApp({ status: 'pending', github_code: 'secret' }), { status: 'pending', app_slug: null });
138
- assert.deepEqual(provisioning.publicGithubApp({ status: 'code_received', app_slug: 'mercury-bongos' }), { status: 'code_received', app_slug: 'mercury-bongos' });
139
+ assert.deepEqual(provisioning.publicGithubApp({ status: 'pending', github_code: 'secret' }), { status: 'pending', app_slug: null, failure: null, started_at: null });
140
+ assert.deepEqual(provisioning.publicGithubApp({ status: 'code_received', app_slug: 'mercury-bongos' }), { status: 'code_received', app_slug: 'mercury-bongos', failure: null, started_at: null });
139
141
  });
140
142
 
141
143
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -0,0 +1,239 @@
1
+ // tests/wizard_github_feedback.mjs — the setup checklist tells the founder the truth
2
+ // at every GitHub sign-in stage (task 1004572).
3
+ //
4
+ // WHY. Live on 2026-10-04, a founder setting up mercury was told, in turn:
5
+ // • "Waiting for your approval on GitHub…" while the platform was already placing
6
+ // the credentials ('converting' fell through to the pending branch);
7
+ // • "GitHub setup didn't start. Nothing was changed on GitHub" after GitHub HAD made
8
+ // an app on their account — so they made another, and another;
9
+ // • and, once a setup link lapsed, nothing at all: no button, forever, because a
10
+ // lapsed 'pending' row was never read as expired and the "start again" offer was
11
+ // timed from a click that a reload forgets;
12
+ // • and on the project itself, a bare "auth not configured".
13
+ //
14
+ // Four layers, all DB-free and network-free:
15
+ // 1. manifest-truth.js — the effective status and how far a failure got (PURE).
16
+ // 2. renderStepOauthApp, EXECUTED by the vm-slice idiom: the words for each stage.
17
+ // 3. the reload path, EXECUTED: the seed from the instance read and the wait timer.
18
+ // 4. the project's own sign-in door with nothing configured: a page in words.
19
+ //
20
+ // Run: node --test tests/wizard_github_feedback.mjs
21
+
22
+ import assert from 'node:assert/strict';
23
+ import { test } from 'node:test';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import vm from 'node:vm';
27
+ import { createRequire } from 'node:module';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
33
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
34
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
35
+
36
+ const truth = require(path.join(ROOT, 'modules', 'provisioning', 'manifest-truth.js'));
37
+
38
+ // ── 1. manifest-truth.js ────────────────────────────────────────────────────
39
+
40
+ const NOW = Date.parse('2026-10-04T20:30:00Z');
41
+ const LATER = '2026-10-04T20:45:00Z';
42
+ const EARLIER = '2026-10-04T20:15:00Z';
43
+
44
+ test('a pending link past its expiry reads EXPIRED; one still live stays pending', () => {
45
+ assert.equal(truth.effectiveManifestStatus({ status: 'pending', expires_at: EARLIER }, NOW), 'expired');
46
+ assert.equal(truth.effectiveManifestStatus({ status: 'pending', expires_at: LATER }, NOW), 'pending');
47
+ // only 'pending' decays — a finished or failed attempt keeps its word
48
+ for (const s of ['code_received', 'converting', 'done', 'error']) {
49
+ assert.equal(truth.effectiveManifestStatus({ status: s, expires_at: EARLIER }, NOW), s, s);
50
+ }
51
+ });
52
+
53
+ test('how far a failure got is read from the row\'s shape, never from error text', () => {
54
+ assert.equal(truth.manifestFailure({ attempts: 0 }), 'not_approved');
55
+ assert.equal(truth.manifestFailure({ attempts: 1, app_slug: 'mercury-cloudbongos-com' }), 'sign_in_unconfirmed');
56
+ assert.equal(truth.manifestFailure({ attempts: 1, app_slug: null, last_error: 'did not report sign-in ready' }), 'finish_failed',
57
+ 'an error message alone claims nothing — only a recorded App does');
58
+ assert.deepEqual([...truth.MANIFEST_FAILURES].sort(), ['finish_failed', 'not_approved', 'sign_in_unconfirmed']);
59
+ });
60
+
61
+ test('publicGithubApp: effective status, failure only on error, started_at, and nothing else', () => {
62
+ const row = { status: 'pending', expires_at: EARLIER, created_at: '2026-10-04T19:57:35.018Z', github_code: 'SECRET', state: 'SECRET', last_error: 'SECRET' };
63
+ const pub = truth.publicGithubApp(row, NOW);
64
+ assert.deepEqual(pub, { status: 'expired', app_slug: null, failure: null, started_at: '2026-10-04T19:57:35.018Z' });
65
+ assert.doesNotMatch(JSON.stringify(pub), /SECRET/);
66
+ assert.equal(truth.publicGithubApp({ status: 'error', attempts: 1, app_slug: 'a' }, NOW).failure, 'sign_in_unconfirmed');
67
+ assert.equal(truth.publicGithubApp(null), null);
68
+ });
69
+
70
+ // ── 2. the words for each stage ─────────────────────────────────────────────
71
+
72
+ const RS = SCRIPT.indexOf(' /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow');
73
+ const RE = SCRIPT.indexOf(' /* device-flow', RS);
74
+ const RENDER = SCRIPT.slice(RS, RE);
75
+
76
+ test('the GitHub step region is still findable (a rename must fail loudly)', () => {
77
+ assert.ok(RS > 0 && RE > RS, 'both slice boundaries found');
78
+ assert.match(RENDER, /function renderStepOauthApp\(\)/);
79
+ });
80
+
81
+ function stage(live, { stale = false } = {}) {
82
+ const sandbox = {
83
+ live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, instStatus: 'active', addrReady: true, ...live },
84
+ manifestStale: stale,
85
+ autoOpenArmed: false,
86
+ resolvedDomain: () => 'mercury.example.com',
87
+ apexBase: () => 'example.com',
88
+ esc: (x) => String(x).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])),
89
+ GH_ICON: '',
90
+ manualOauthDetails: () => '<details>manual</details>',
91
+ projectPageLink: (t) => '<a>' + t + '</a>',
92
+ autoOpenTick: () => '<label>auto</label>',
93
+ progressMarkup: (now, done, note, cls) => `<prog now=${now} done=${done} cls="${cls}">${note}</prog>`,
94
+ };
95
+ vm.createContext(sandbox);
96
+ vm.runInContext(`${RENDER}\nvar out = renderStepOauthApp();`, sandbox);
97
+ return sandbox.out;
98
+ }
99
+ const startButton = /data-action="manifest-start"/;
100
+
101
+ test('pending: waiting for approval, and says where a missing GitHub tab went', () => {
102
+ const html = stage({ manifest: 'pending' });
103
+ assert.match(html, /Waiting for your approval on GitHub/);
104
+ assert.match(html, /No GitHub tab\? It may have opened behind this window or in another one/);
105
+ assert.doesNotMatch(html, startButton, 'no restart offer while the wait is young');
106
+ assert.match(stage({ manifest: 'pending' }, { stale: true }), /Start the setup again/, 'offered once the wait has gone wrong');
107
+ });
108
+
109
+ test('converting is the SAME stage as code_received — approved, placing credentials — never "waiting for approval"', () => {
110
+ for (const m of ['code_received', 'converting']) {
111
+ const html = stage({ manifest: m });
112
+ assert.match(html, /GitHub’s part is done/, m);
113
+ assert.match(html, /<prog now=1 done=1/, `${m}: the strip is past the approval stage`);
114
+ assert.doesNotMatch(html, /Waiting for your approval/, m);
115
+ }
116
+ });
117
+
118
+ test('a failure AFTER approval says an app now exists, names it, and does not push a second one', () => {
119
+ const html = stage({ manifest: 'error', manifestFailure: 'sign_in_unconfirmed', appSlug: 'mercury-cloudbongos-com' });
120
+ assert.match(html, /GitHub approved the app and the platform placed its credentials/);
121
+ assert.match(html, /href="https:\/\/github\.com\/settings\/apps\/mercury-cloudbongos-com"/);
122
+ assert.match(html, /would make a second app/);
123
+ assert.match(html, /href="\/contact"/);
124
+ assert.match(html, /cls="isErr"/, 'the strip shows where it stopped');
125
+ assert.doesNotMatch(html, /Nothing was changed on GitHub/, 'the old claim was false here');
126
+ assert.doesNotMatch(html, /class="ghbtn"/, 'no big primary "set up" button inviting a second app');
127
+ assert.match(html, /Try again anyway/, '…but the founder is never left with no way forward');
128
+ });
129
+
130
+ test('approved but never finished: delete the app GitHub made first, then try again', () => {
131
+ const html = stage({ manifest: 'error', manifestFailure: 'finish_failed' });
132
+ assert.match(html, /GitHub approved the app, but the platform couldn’t finish/);
133
+ assert.match(html, /delete it first/);
134
+ assert.doesNotMatch(html, /Nothing was changed on GitHub/);
135
+ assert.match(html, startButton);
136
+ });
137
+
138
+ test('a failure before GitHub (or a refused start) keeps the honest "nothing changed" line', () => {
139
+ assert.match(stage({ manifest: 'error', manifestFailure: 'not_approved' }), /Nothing was changed on GitHub/);
140
+ const refused = stage({ manifest: 'error', manifestMsg: 'HTTP 500' });
141
+ assert.match(refused, /Nothing was changed on GitHub/);
142
+ assert.match(refused, /Technical details/);
143
+ assert.match(refused, startButton);
144
+ });
145
+
146
+ test('an expired link offers a fresh one', () => {
147
+ const html = stage({ manifest: 'expired' });
148
+ assert.match(html, /That setup link expired/);
149
+ assert.match(html, startButton);
150
+ });
151
+
152
+ // ── 3. the reload path ──────────────────────────────────────────────────────
153
+
154
+ const WS = SCRIPT.indexOf(' function manifestInFlight() {');
155
+ const WE = SCRIPT.indexOf(' function pollManifest(stateToken) {', WS);
156
+ const WAIT = SCRIPT.slice(WS, WE);
157
+ const SS = SCRIPT.indexOf(' var ga = data.github_app;');
158
+ const SE = SCRIPT.indexOf(' /* the checklist reflects live state (task 1002694)', SS);
159
+ const SEED = SCRIPT.slice(SS, SE);
160
+
161
+ test('the reload regions are still findable (a rename must fail loudly)', () => {
162
+ assert.ok(WS > 0 && WE > WS, 'wait-timer boundaries found');
163
+ assert.ok(SS > 0 && SE > SS, 'instance-read seed boundaries found');
164
+ assert.match(SCRIPT, /if \(!terminal \|\| manifestInFlight\(\)\) pollTimer = setTimeout\(tick, 5000\);/,
165
+ 'a GitHub leg in flight keeps the instance read alive once the project is up — after a reload it is the only watcher');
166
+ });
167
+
168
+ // A page that RELOADED: no click in this tab, no manifest poll, just the instance read.
169
+ function reloaded(githubApp, now) {
170
+ const renders = [];
171
+ const sandbox = {
172
+ data: { github_app: githubApp },
173
+ live: { manifest: null, manifestFailure: null, appSlug: null },
174
+ manifestPollTimer: null, manifestStartedAt: 0, manifestStale: false,
175
+ rememberManifest: () => {}, renderSteps: () => renders.push(1),
176
+ Date: { now: () => now, parse: Date.parse },
177
+ };
178
+ vm.createContext(sandbox);
179
+ vm.runInContext(`${WAIT}\n${SEED}`, sandbox);
180
+ return { sandbox, renders, tick: () => vm.runInContext(SEED, sandbox) };
181
+ }
182
+
183
+ test('a reloaded page times the wait from when the link was minted, and still reaches "start again"', () => {
184
+ const minted = '2026-10-04T20:20:00Z';
185
+ const young = reloaded({ status: 'pending', app_slug: null, failure: null, started_at: minted }, Date.parse(minted) + 30000);
186
+ assert.equal(young.sandbox.live.manifest, 'pending');
187
+ assert.equal(young.sandbox.manifestStartedAt, Date.parse(minted), 'seeded from the server');
188
+ assert.equal(young.sandbox.manifestStale, false);
189
+
190
+ const old = reloaded({ status: 'pending', app_slug: null, failure: null, started_at: minted }, Date.parse(minted) + 120000);
191
+ old.tick(); // the next 5s read: same status, so the wait timer decides
192
+ assert.equal(old.sandbox.manifestStale, true, 'the offer appears — it used to be never after a reload');
193
+ assert.equal(vm.runInContext('manifestInFlight()', old.sandbox), true);
194
+ });
195
+
196
+ test('a reloaded page learns HOW a failure happened, not just that it did', () => {
197
+ const r = reloaded({ status: 'error', app_slug: 'mercury-cloudbongos-com', failure: 'sign_in_unconfirmed', started_at: null }, NOW);
198
+ assert.equal(r.sandbox.live.manifest, 'error');
199
+ assert.equal(r.sandbox.live.manifestFailure, 'sign_in_unconfirmed');
200
+ assert.equal(r.sandbox.live.appSlug, 'mercury-cloudbongos-com');
201
+ assert.equal(vm.runInContext('manifestInFlight()', r.sandbox), false, 'a finished attempt stops the watch');
202
+ });
203
+
204
+ test('the reset literal and the initial literal still agree, failure field included', () => {
205
+ const lits = SCRIPT.match(/live = \{ instStatus: null[^}]*\}/g) || [];
206
+ assert.equal(lits.length, 2);
207
+ assert.equal(lits[0].replace(/^var /, ''), lits[1]);
208
+ assert.match(lits[0], /manifestFailure: null/);
209
+ });
210
+
211
+ // ── 4. the project's own door with nothing configured ───────────────────────
212
+
213
+ test('a project with no sign-in configured answers a page in words, not a bare "auth not configured"', async () => {
214
+ const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
215
+ const prev = { a: auth.authConfigured, i: auth.idpConfigured };
216
+ auth.authConfigured = () => false;
217
+ auth.idpConfigured = () => false;
218
+ const express = require('express');
219
+ const buildAuthRouter = require(path.join(ROOT, 'src', 'bongos', 'routes', 'auth.js'));
220
+ const app = express();
221
+ app.use('/api/bongos', buildAuthRouter());
222
+ const server = app.listen(0);
223
+ try {
224
+ await new Promise((r) => server.once('listening', r));
225
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
226
+ for (const p of ['/auth/web/start', '/auth/web/callback']) {
227
+ const res = await fetch(base + p);
228
+ const html = await res.text();
229
+ assert.equal(res.status, 503, p);
230
+ assert.match(res.headers.get('content-type') || '', /text\/html/, p);
231
+ assert.match(html, /Sign-in isn&#39;t set up on this project yet|Sign-in isn't set up on this project yet/, p);
232
+ assert.match(html, /Wire GitHub sign-in/, `${p}: points the owner at the step that fixes it`);
233
+ assert.doesNotMatch(html, /^auth not configured$/, p);
234
+ }
235
+ } finally {
236
+ server.close();
237
+ auth.authConfigured = prev.a; auth.idpConfigured = prev.i;
238
+ }
239
+ });