@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.
- package/.bongos-core.json +40 -30
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +763 -761
- package/modules/hall-ui/public/street-card.css +8 -2
- package/modules/hall-ui/public/street-desk.js +78 -9
- package/modules/hall-ui/public/street.css +4 -1
- package/modules/hall-ui/public/street.js +9 -6
- package/modules/hall-ui/records/governor-street.md +14 -0
- package/modules/provisioning/manifest-truth.js +63 -0
- package/modules/provisioning/provisioning.js +3 -11
- package/modules/provisioning/routes/provisioning.js +3 -1
- package/modules/public-landing/public/projects.html +38 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/provision.js +4 -2
- package/src/bongos/routes/auth.js +15 -2
- package/src/module-api.js +1 -1
- package/tests/hall_street_desk.mjs +64 -2
- package/tests/projects_hub_pre_uat.mjs +3 -2
- package/tests/provision.mjs +53 -0
- package/tests/provisioning_github_app_routes.mjs +23 -0
- package/tests/provisioning_settings.mjs +6 -4
- package/tests/wizard_github_feedback.mjs +239 -0
|
@@ -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', '
|
|
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', '
|
|
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
|
-
|
|
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', () => {
|
package/tests/provision.mjs
CHANGED
|
@@ -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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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'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
|
+
});
|