@bongos/core 1.21.96 → 1.21.98
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 +62 -47
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0152-metic-task-abandonment.md +2 -0
- package/docs/api/openapi.json +52 -5
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +395 -393
- package/docs/copy-registry.json +486 -468
- package/docs/design/project-startup-direction.md +13 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +260 -260
- package/migrations/core_274_parked_backlog_stays_parked.sql +140 -0
- package/modules/hall-ui/public/studio-lib.js +22 -0
- package/modules/hall-ui/public/studio.css +138 -7
- package/modules/hall-ui/public/studio.html +10 -4
- package/modules/hall-ui/public/studio.js +39 -1
- package/modules/hall-ui/records/studio.md +16 -0
- package/modules/lifecycle/db-ship.js +7 -2
- package/modules/lifecycle/db-tasks.js +25 -0
- package/modules/lifecycle/routes/tasks.js +5 -5
- package/modules/provisioning/github-setup.js +73 -4
- package/modules/provisioning/routes/github-setup.js +43 -1
- package/modules/provisioning/routes/provisioning.js +8 -4
- package/modules/public-landing/public/projects.html +185 -30
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/provision-github-setup.js +29 -9
- package/scripts/gds/provision.js +20 -9
- package/src/module-api.js +1 -1
- package/tests/abandon_task.mjs +1 -1
- package/tests/github_one_trip.mjs +4 -4
- package/tests/github_org_account.mjs +3 -2
- package/tests/github_pick_up.mjs +366 -0
- package/tests/hall_studio_home.mjs +140 -5
- package/tests/parked_backlog_stays_parked.mjs +182 -0
- package/tests/projects_hub_pre_uat.mjs +3 -2
- package/tests/promotion_triggers_skip_cascade.mjs +5 -3
- package/tests/provisioning_github_app_routes.mjs +73 -0
- package/tests/wizard_github_feedback.mjs +95 -29
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.98",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.98",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.98",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9406,5 +9406,21 @@
|
|
|
9406
9406
|
"id": "1004651",
|
|
9407
9407
|
"text": "Founders can now put a new project under a GitHub organisation, not only their own account. When connecting GitHub, they choose \"On my account\" or \"On an organisation\". Their organisations are listed; one they only belong to ("
|
|
9408
9408
|
}
|
|
9409
|
+
],
|
|
9410
|
+
"1.21.97": [
|
|
9411
|
+
{
|
|
9412
|
+
"id": "1004652",
|
|
9413
|
+
"text": "Connecting GitHub no longer makes a second app when something goes wrong. If a step stops, the checklist shows which one, says in one plain sentence that nothing is lost, and gives a \"Pick up where it stopped\" button that carr"
|
|
9414
|
+
}
|
|
9415
|
+
],
|
|
9416
|
+
"1.21.98": [
|
|
9417
|
+
{
|
|
9418
|
+
"id": "1004654",
|
|
9419
|
+
"text": "The artist studio now has a day room. From 7 in the morning until 7 in the evening, by the clock on the artist's own computer, the studio shows a sunny sky with drifting clouds, a sun instead of the moon, lighter buildings wit"
|
|
9420
|
+
},
|
|
9421
|
+
{
|
|
9422
|
+
"id": "1004592",
|
|
9423
|
+
"text": "When someone restores a cut task or takes a task back off the board, it now stays put until a person promotes it. Before, the automatic promoter put it straight back on the claimable list within about five minutes."
|
|
9424
|
+
}
|
|
9409
9425
|
]
|
|
9410
9426
|
}
|
|
@@ -19,14 +19,19 @@
|
|
|
19
19
|
// A verified fact is recorded once as a provisioning_events row; the checklist reads it.
|
|
20
20
|
//
|
|
21
21
|
// BOUNDED. Only attempts that settled within VERIFY_WINDOW_MS, or whose install was
|
|
22
|
-
// reported within it, are checked, at most SWEEP_LIMIT per tick — so
|
|
23
|
-
// abandoned project costs nothing, and
|
|
24
|
-
//
|
|
22
|
+
// reported or a check requested within it, are checked, at most SWEEP_LIMIT per tick — so
|
|
23
|
+
// a finished or abandoned project costs nothing, and "Pick up where it stopped" / "Check
|
|
24
|
+
// again" on the project page re-opens the window (task 1004652).
|
|
25
|
+
// SIGN-IN, TOO (task 1004652). An attempt that stopped because the project did not confirm
|
|
26
|
+
// sign-in in time ('error' with the app's keys placed) is re-asked on each tick in the
|
|
27
|
+
// window, with the keys already placed: when the project now answers auth_configured the
|
|
28
|
+
// attempt becomes 'done' — the same app, never a second one.
|
|
25
29
|
// Secrets: web.env is read on the box, parsed in memory, and only NAMES ever reach a log.
|
|
26
30
|
|
|
27
31
|
const crypto = require('node:crypto');
|
|
28
32
|
const { webEnvPath } = require('./provision-units.js');
|
|
29
33
|
const { SETUP_EVENTS, VERIFY_WINDOW_MS, githubSetupFacts, targetRepo } = require('../../modules/provisioning/github-setup.js');
|
|
34
|
+
const { parseHealthzAuth } = require('./oauth-secret.js');
|
|
30
35
|
|
|
31
36
|
const SWEEP_LIMIT = 20;
|
|
32
37
|
const UA = 'cloud-bongos-provisioner';
|
|
@@ -95,22 +100,29 @@ async function attemptsToVerify(db) {
|
|
|
95
100
|
const { rows } = await db.query(
|
|
96
101
|
`WITH latest AS (
|
|
97
102
|
SELECT DISTINCT ON (instance_id) * FROM provisioning_oauth_manifests ORDER BY instance_id, created_at DESC)
|
|
98
|
-
SELECT l.id, l.instance_id, l.created_at, l.requested_by, i.slug, i.target_ref, i.hosting_shape, i.status AS instance_status
|
|
103
|
+
SELECT l.id, l.instance_id, l.created_at, l.requested_by, l.status, i.slug, i.port, i.target_ref, i.hosting_shape, i.status AS instance_status
|
|
99
104
|
FROM latest l JOIN provisioning_instances i ON i.id = l.instance_id
|
|
100
105
|
WHERE l.app_id IS NOT NULL AND l.status IN ('done', 'error') AND i.status = 'active'
|
|
101
106
|
AND (l.updated_at > now() - ($1 * interval '1 second')
|
|
102
107
|
OR EXISTS (SELECT 1 FROM provisioning_events e WHERE e.instance_id = l.instance_id
|
|
103
|
-
AND e.event = $2 AND e.created_at > now() - ($1 * interval '1 second')))
|
|
108
|
+
AND e.event = ANY($2) AND e.created_at > now() - ($1 * interval '1 second')))
|
|
104
109
|
ORDER BY l.updated_at DESC LIMIT ${SWEEP_LIMIT}`,
|
|
105
|
-
[windowSec, SETUP_EVENTS.installReported]);
|
|
110
|
+
[windowSec, [SETUP_EVENTS.installReported, SETUP_EVENTS.checkRequested]]);
|
|
106
111
|
return rows;
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
// Does the project answer that sign-in is configured? The project's JSON health read
|
|
115
|
+
// (the same probe as provision.js authHealthzCmd — the root /healthz says a plain "ok").
|
|
116
|
+
function signInAnswers(r, boxExec) {
|
|
117
|
+
const out = boxExec(`curl -sS --max-time 5 http://127.0.0.1:${r.port}/api/bongos/healthz`, { allowFail: true });
|
|
118
|
+
return parseHealthzAuth(out && out.stdout).authConfigured === true;
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
// One tick: for every attempt in the window, check whatever is not verified yet and record
|
|
110
122
|
// what GitHub confirmed. Returns { checked, installed, deviceFlow }.
|
|
111
123
|
async function verifyGithubAppSetups(deps) {
|
|
112
124
|
const { db, provisioning, log, apply } = deps;
|
|
113
|
-
const out = { checked: 0, installed: 0, deviceFlow: 0 };
|
|
125
|
+
const out = { checked: 0, installed: 0, deviceFlow: 0, signIn: 0 };
|
|
114
126
|
const rows = await attemptsToVerify(db);
|
|
115
127
|
if (!apply) {
|
|
116
128
|
for (const r of rows) log(` would check the GitHub install + device flow for ${r.slug} (manifest #${r.id})`);
|
|
@@ -118,9 +130,17 @@ async function verifyGithubAppSetups(deps) {
|
|
|
118
130
|
}
|
|
119
131
|
for (const r of rows) {
|
|
120
132
|
const facts = await githubSetupFacts(db, r.instance_id, r.created_at);
|
|
121
|
-
|
|
133
|
+
const signInStopped = r.status === 'error';
|
|
134
|
+
if (facts.installedAt && facts.deviceFlowAt && !signInStopped) continue;
|
|
122
135
|
const onControlBox = r.hosting_shape === 'cloud-host' || r.hosting_shape === 'control-plane';
|
|
123
136
|
const boxExec = onControlBox ? (deps.controlExec || deps.exec) : deps.exec;
|
|
137
|
+
if (signInStopped && signInAnswers(r, boxExec)) {
|
|
138
|
+
await provisioning.resolveOAuthManifest(db, r.id, 'done', { github_code: null });
|
|
139
|
+
await provisioning.recordEvent(db, { instanceId: r.instance_id, ownerBuilderId: r.requested_by, event: 'oauth-manifest-done',
|
|
140
|
+
detail: 'sign-in confirmed on a later check, with the app already made', actor: 'runner' }).catch(() => {});
|
|
141
|
+
out.signIn++; log(` ✓ ${r.slug}: sign-in now confirmed`);
|
|
142
|
+
}
|
|
143
|
+
if (facts.installedAt && facts.deviceFlowAt) { out.checked++; continue; }
|
|
124
144
|
const read = boxExec(`${deps.privileged ? 'sudo ' : ''}cat ${webEnvPath(r)}`, { allowFail: true });
|
|
125
145
|
const creds = appCredsFromEnv(read && read.stdout);
|
|
126
146
|
out.checked++;
|
|
@@ -141,7 +161,7 @@ async function verifyGithubAppSetups(deps) {
|
|
|
141
161
|
}
|
|
142
162
|
}
|
|
143
163
|
}
|
|
144
|
-
if (out.checked) log(`github-setup — checked=${out.checked} installed=${out.installed} device-flow=${out.deviceFlow}`);
|
|
164
|
+
if (out.checked) log(`github-setup — checked=${out.checked} installed=${out.installed} device-flow=${out.deviceFlow} sign-in=${out.signIn}`);
|
|
145
165
|
return out;
|
|
146
166
|
}
|
|
147
167
|
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1081,14 +1081,22 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1081
1081
|
// 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
|
|
1082
1082
|
// writeFile logs only a byte count; the value is base64/temp-file placed).
|
|
1083
1083
|
log(` [oauth-manifest] place creds in ${webEnvPath(inst)} + restart ${inst.slug}`);
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1084
|
+
try {
|
|
1085
|
+
boxWriteFile(webEnvPath(inst), webEnvBody(inst, {
|
|
1086
|
+
clientId: creds.clientId, clientSecret: creds.clientSecret, ownerLogin: await ownerLoginOf(inst, deps.db),
|
|
1087
|
+
// The App id + private key double as the ADR 0055 publish credential (task
|
|
1088
|
+
// 1003049); older Apps (pre-permission manifests) return a pem too — placing
|
|
1089
|
+
// it is harmless and heals once the owner grants the permissions + installs.
|
|
1090
|
+
appId: creds.appId, appPem: creds.appPem,
|
|
1091
|
+
}), { mode: 0o600, sudo: true });
|
|
1092
|
+
pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
// The code is spent and the keys are not placed: a retry would re-convert a burned
|
|
1095
|
+
// code, so say so (codeSpent) and name the app GitHub made for the platform team
|
|
1096
|
+
// (task 1004652 — the wizard offers to send it to them; the slug stays OFF the row,
|
|
1097
|
+
// because a recorded slug reads as "keys placed").
|
|
1098
|
+
return { ok: false, codeSpent: apply, error: `GitHub made the app ${creds.appSlug || creds.appId || '?'}, but placing its keys failed: ${e.message}` };
|
|
1099
|
+
}
|
|
1092
1100
|
// The app now exists AND its credentials are placed — the checklist's first tick, and the
|
|
1093
1101
|
// slug the callback page needs to send the founder on to installing it (task 1004649).
|
|
1094
1102
|
if (apply && deps.onCredsPlaced) await Promise.resolve().then(() => deps.onCredsPlaced({ appId: creds.appId, appSlug: creds.appSlug })).catch(() => {});
|
|
@@ -1182,7 +1190,10 @@ async function drainOAuthManifests(deps) {
|
|
|
1182
1190
|
converted++;
|
|
1183
1191
|
}
|
|
1184
1192
|
} catch (e) {
|
|
1185
|
-
|
|
1193
|
+
// GitHub's 404 means the code is used or past its hour: drop it, so "pick up where it
|
|
1194
|
+
// stopped" (task 1004652) never re-queues a dead code. Any other failure keeps it.
|
|
1195
|
+
const dead = /conversion failed: (Not Found|HTTP 404)/.test(String(e.message));
|
|
1196
|
+
await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: e.message, ...(dead ? { github_code: null } : {}) }).catch(() => {});
|
|
1186
1197
|
errors++; log(` ✖ oauth-manifest #${mrow.id}: ${e.message}`);
|
|
1187
1198
|
}
|
|
1188
1199
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.98'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/abandon_task.mjs
CHANGED
|
@@ -168,7 +168,7 @@ await test("restore: clears only an 'Abandoned: …' value_summary, leaving any
|
|
|
168
168
|
assert.match(sql, /status = 'backlog'/);
|
|
169
169
|
assert.match(sql, /value_summary LIKE 'Abandoned: %' THEN NULL ELSE value_summary END/,
|
|
170
170
|
'the retire stamp is cleared conditionally — an unrelated summary survives');
|
|
171
|
-
assert.
|
|
171
|
+
assert.deepEqual(upd.params, [TASK, null], 'the task id, then the parking builder (none passed here)');
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
await test('restore: clears merged_into_task_id + triage_note (task 1001478 grade finding)', async () => {
|
|
@@ -153,7 +153,7 @@ test('the runner records what GitHub confirmed — and only that — reading the
|
|
|
153
153
|
exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: ENV }; },
|
|
154
154
|
provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
|
|
155
155
|
});
|
|
156
|
-
assert.deepEqual(out, { checked: 1, installed: 1, deviceFlow: 0 });
|
|
156
|
+
assert.deepEqual(out, { checked: 1, installed: 1, deviceFlow: 0, signIn: 0 });
|
|
157
157
|
assert.deepEqual(cmds, ['sudo cat /etc/mercury/web.env'], 'the creds are read where the runner placed them');
|
|
158
158
|
assert.deepEqual(events.map((e) => e.event), ['github-app-installed'], 'device flow off records nothing');
|
|
159
159
|
assert.match(events[0].detail, /acme\/mercury/);
|
|
@@ -168,7 +168,7 @@ test('an attempt already fully verified is skipped; dry-run asks GitHub nothing'
|
|
|
168
168
|
db: fakeDb({ attempts: [ATTEMPT], facts: [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }, { event: 'github-app-device-flow', at: '2026-10-08T10:00:00Z' }] }),
|
|
169
169
|
exec: () => { throw new Error('must not read the box'); }, provisioning: { recordEvent: async () => { throw new Error('nothing to record'); } },
|
|
170
170
|
};
|
|
171
|
-
assert.deepEqual(await RUN.verifyGithubAppSetups(deps), { checked: 0, installed: 0, deviceFlow: 0 });
|
|
171
|
+
assert.deepEqual(await RUN.verifyGithubAppSetups(deps), { checked: 0, installed: 0, deviceFlow: 0, signIn: 0 });
|
|
172
172
|
const lines = [];
|
|
173
173
|
await RUN.verifyGithubAppSetups({ ...deps, apply: false, log: (m) => lines.push(m), db: fakeDb({ attempts: [ATTEMPT] }) });
|
|
174
174
|
assert.equal(asked, 0);
|
|
@@ -181,7 +181,7 @@ test('the window query takes each project\'s LATEST attempt, past placement, sti
|
|
|
181
181
|
assert.match(sql, /DISTINCT ON \(instance_id\)[\s\S]*ORDER BY instance_id, created_at DESC/);
|
|
182
182
|
assert.match(sql, /l\.app_id IS NOT NULL AND l\.status IN \('done', 'error'\) AND i\.status = 'active'/);
|
|
183
183
|
assert.match(sql, /LIMIT 20/);
|
|
184
|
-
assert.deepEqual(params, [GS.VERIFY_WINDOW_MS / 1000, 'github-app-install-reported']);
|
|
184
|
+
assert.deepEqual(params, [GS.VERIFY_WINDOW_MS / 1000, ['github-app-install-reported', 'github-app-check-requested']], 'an install report or a check request (task 1004652) re-opens it');
|
|
185
185
|
assert.match(fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'provision.js'), 'utf8'), /require\('\.\/provision-github-setup\.js'\)\.verifyGithubAppSetups\(deps\)/, 'it runs on every runner tick, after the conversions');
|
|
186
186
|
});
|
|
187
187
|
|
|
@@ -202,7 +202,7 @@ function page({ manifest, appSlug = null }) {
|
|
|
202
202
|
const sandbox = {
|
|
203
203
|
live: { manifest, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug, setup: null, installUrl: null, instStatus: 'active', addrReady: true },
|
|
204
204
|
state: { name: 'Mer<cury', slug: 'mercury', owner: 'acme', repo: 'mercury', step: 2 },
|
|
205
|
-
|
|
205
|
+
autoOpenArmed: false,
|
|
206
206
|
resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com',
|
|
207
207
|
esc: (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
208
208
|
GH_ICON: '', manualOauthDetails: () => '', projectPageLink: (t) => t, autoOpenTick: () => '',
|
|
@@ -68,6 +68,7 @@ let targetRef = 'founder/mercury';
|
|
|
68
68
|
const events = [];
|
|
69
69
|
provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 7, slug: 'mercury', domain: 'mercury.example.com', target_ref: targetRef, status: 'requested' });
|
|
70
70
|
provisioning.createOAuthManifest = async (_db, row) => ({ id: 99, ...row });
|
|
71
|
+
provisioning.getLatestOAuthManifestForInstance = async () => null; // no earlier attempt (task 1004652's mint guard)
|
|
71
72
|
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
72
73
|
const branding = require(path.join(ROOT, 'src/branding.js'));
|
|
73
74
|
const realBranding = branding.branding;
|
|
@@ -164,9 +165,9 @@ function page({ state = {}, live = {} } = {}) {
|
|
|
164
165
|
live: { manifest: null, manifestMsg: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, ...live },
|
|
165
166
|
repoAccess: true, instanceId: 42, me: { github_login: 'founder' },
|
|
166
167
|
hopForRepoAccess: () => false, syncHome() {}, saveDraft() {},
|
|
167
|
-
rememberManifest() {}, renderSteps() {}, pollManifest() {},
|
|
168
|
+
rememberManifest() {}, renderSteps() {}, pollManifest() {}, watchGhTab() {},
|
|
168
169
|
};
|
|
169
|
-
sandbox.manifestStartedAt = 0;
|
|
170
|
+
sandbox.manifestStartedAt = 0;
|
|
170
171
|
vm.createContext(sandbox);
|
|
171
172
|
vm.runInContext([PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS, "var appPick = acctPicker('aa', { needsOwner: true });"].join('\n'), sandbox);
|
|
172
173
|
return { sb: sandbox, els, posted, forms, settle: () => new Promise((r) => setTimeout(r, 60)) };
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// tests/github_pick_up.mjs — "GitHub retry picks up where it stopped, never makes a second
|
|
2
|
+
// app, and every failure has a button that fixes it" (task 1004652).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Retrying the GitHub step used to mint a new link, which makes a SECOND app on the
|
|
5
|
+
// founder's account; "Start again" appeared only after 90 seconds; and two failures ended
|
|
6
|
+
// at "contact us" or "delete the orphan app by hand". Now every state part 03 can reach
|
|
7
|
+
// has one sentence and an action that resumes from that step with the app already made.
|
|
8
|
+
//
|
|
9
|
+
// Four layers, DB-free and network-free:
|
|
10
|
+
// 1. modules/provisioning/github-setup.js — pickUpFor (what the action does, for EVERY
|
|
11
|
+
// status × app × verified-facts shape), mintGuard (reuse or refuse, never a second
|
|
12
|
+
// attempt once GitHub approved one), and the checking window a check request re-opens.
|
|
13
|
+
// 2. the runner — a stopped sign-in is re-asked with the keys already placed and becomes
|
|
14
|
+
// 'done'; a placement that throws after GitHub's conversion is a SPENT code (never
|
|
15
|
+
// re-queued); GitHub's 404 on a conversion drops the dead code.
|
|
16
|
+
// 3. the REAL status route's answer run into the REAL page (seedSetup + the step renderers):
|
|
17
|
+
// every reachable state draws a sentence and an action, none a chore or "contact us".
|
|
18
|
+
// 4. the page's own actions: pick-up posts to the pick-up route and watches again, Reopen
|
|
19
|
+
// GitHub goes through the mint route (which hands back the SAME state), and "Send it to
|
|
20
|
+
// the platform team" files a blocker without a secret.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/github_pick_up.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import vm from 'node:vm';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const GS = require(path.join(ROOT, 'modules', 'provisioning', 'github-setup.js'));
|
|
35
|
+
const RUN = require(path.join(ROOT, 'scripts', 'gds', 'provision-github-setup.js'));
|
|
36
|
+
const P = require(path.join(ROOT, 'scripts', 'gds', 'provision.js'));
|
|
37
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
38
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
39
|
+
|
|
40
|
+
const NOW = Date.parse('2026-10-09T12:00:00Z');
|
|
41
|
+
const ago = (min) => new Date(NOW - min * 60000).toISOString();
|
|
42
|
+
const SOON = new Date(NOW + 10 * 60000).toISOString();
|
|
43
|
+
|
|
44
|
+
// ── 1. what picking it up does ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
test('pickUpFor: every attempt shape has its action, and none of them is "make another app"', () => {
|
|
47
|
+
const c = (status, slug, facts) => GS.githubChecklist(status, slug, facts);
|
|
48
|
+
const at = (row, status, facts = {}, verifying = false) => GS.pickUpFor(row, status, c(status, row && row.app_slug, facts), verifying, NOW);
|
|
49
|
+
assert.equal(at(null, null), null, 'no attempt: Connect GitHub is the first click, not a pick-up');
|
|
50
|
+
assert.equal(at({ attempts: 0 }, 'pending'), 'reopen', 'waiting on GitHub: post the SAME link again');
|
|
51
|
+
assert.equal(at({ attempts: 0 }, 'expired'), 'fresh', 'lapsed before approval: nothing was made, a new link is safe');
|
|
52
|
+
assert.equal(at({ attempts: 0 }, 'error'), 'fresh', 'the runner never took it up: nothing was made');
|
|
53
|
+
assert.equal(at({ attempts: 1 }, 'code_received'), null, 'in flight: nothing to pick up');
|
|
54
|
+
assert.equal(at({ attempts: 1 }, 'converting'), null);
|
|
55
|
+
// GitHub approved; the conversion stopped
|
|
56
|
+
assert.equal(at({ attempts: 2, github_code: 'C', created_at: ago(20) }, 'error'), 'convert', 'the code is still young: convert it again');
|
|
57
|
+
assert.equal(at({ attempts: 2, github_code: 'C', created_at: ago(61) }, 'error'), 'operator', 'past GitHub\'s hour the code is dead');
|
|
58
|
+
assert.equal(at({ attempts: 2, github_code: null, created_at: ago(5) }, 'error'), 'operator', 'a spent code: the app exists, its keys never reached us');
|
|
59
|
+
// the app exists with its keys placed
|
|
60
|
+
const app = { attempts: 1, app_id: 5, app_slug: 'mercury-app' };
|
|
61
|
+
assert.equal(at(app, 'error'), 'check', 'sign-in unconfirmed, the runner stopped asking: ask again');
|
|
62
|
+
assert.equal(at(app, 'error', {}, true), null, 'still asking: nothing to press');
|
|
63
|
+
assert.equal(at(app, 'done'), 'check', 'install never confirmed and the window closed: check again');
|
|
64
|
+
assert.equal(at(app, 'done', { installedAt: ago(1) }), 'check', 'device flow still off after the window: check again');
|
|
65
|
+
assert.equal(at(app, 'done', { installedAt: ago(1), deviceFlowAt: ago(1) }), null, 'all four ticked: nothing left');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('mintGuard: a waiting link is handed back, and once GitHub approved an app no new link is made', () => {
|
|
69
|
+
assert.equal(GS.mintGuard(null, NOW), null);
|
|
70
|
+
assert.deepEqual(GS.mintGuard({ status: 'pending', state: 'S', expires_at: SOON }, NOW), { reuse: 'S', expiresAt: SOON });
|
|
71
|
+
assert.equal(GS.mintGuard({ status: 'pending', state: 'S', expires_at: ago(1) }, NOW), null, 'lapsed: mint fresh');
|
|
72
|
+
assert.equal(GS.mintGuard({ status: 'error', attempts: 0 }, NOW), null, 'nothing made: mint fresh');
|
|
73
|
+
for (const row of [{ status: 'error', attempts: 1, app_id: 5 }, { status: 'done', attempts: 1, app_id: 5 }, { status: 'error', attempts: 3, app_id: null }]) {
|
|
74
|
+
assert.equal(GS.mintGuard(row, NOW).refuse, 'app_exists', JSON.stringify(row));
|
|
75
|
+
}
|
|
76
|
+
assert.equal(GS.mintGuard({ status: 'converting', attempts: 1 }, NOW).refuse, 'github_in_progress');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a check request re-opens the runner\'s checking window, like an install report', () => {
|
|
80
|
+
const settled = { resolved_at: ago(5 * 60) };
|
|
81
|
+
assert.equal(GS.stillVerifying(settled, {}, NOW), false);
|
|
82
|
+
assert.equal(GS.stillVerifying(settled, { checkRequestedAt: ago(1) }, NOW), true);
|
|
83
|
+
assert.equal(GS.SETUP_EVENTS.checkRequested, 'github-app-check-requested');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── 2. the runner ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const STOPPED = { id: 21, instance_id: 42, created_at: ago(300), requested_by: 7, status: 'error', slug: 'mercury', port: 4100, target_ref: 'acme/mercury', hosting_shape: 'co-tenant', instance_status: 'active' };
|
|
89
|
+
function runnerDeps({ healthz, facts = [] }) {
|
|
90
|
+
const resolved = [], events = [], cmds = [];
|
|
91
|
+
return {
|
|
92
|
+
resolved, events, cmds,
|
|
93
|
+
deps: {
|
|
94
|
+
apply: true, log: () => {}, fetchImpl: async () => ({ status: 500, ok: false, json: async () => ({}) }),
|
|
95
|
+
db: { query: async (sql) => (/WITH latest AS/.test(sql) ? { rows: [STOPPED] } : /FROM provisioning_events/.test(sql) ? { rows: facts } : { rows: [] }) },
|
|
96
|
+
exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: /healthz/.test(cmd) ? healthz : '' }; },
|
|
97
|
+
provisioning: {
|
|
98
|
+
resolveOAuthManifest: async (_db, id, status, patch) => { resolved.push({ id, status, patch }); },
|
|
99
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('a stopped sign-in is asked again with the keys already placed, and becomes done — the same attempt', async () => {
|
|
106
|
+
const w = runnerDeps({ healthz: '{"ok":true,"auth_configured":true}' });
|
|
107
|
+
const out = await RUN.verifyGithubAppSetups(w.deps);
|
|
108
|
+
assert.equal(out.signIn, 1);
|
|
109
|
+
assert.ok(w.cmds.includes('curl -sS --max-time 5 http://127.0.0.1:4100/api/bongos/healthz'), 'the JSON health read, as the placement uses (authHealthzCmd)');
|
|
110
|
+
assert.equal(w.cmds[0], P.authHealthzCmd({ port: 4100 }), 'byte-for-byte the placement probe');
|
|
111
|
+
assert.deepEqual(w.resolved, [{ id: 21, status: 'done', patch: { github_code: null } }], 'attempt 21 itself — nothing new is made');
|
|
112
|
+
assert.ok(w.events.some((e) => e.event === 'oauth-manifest-done'));
|
|
113
|
+
// still not answering: nothing changes, the next tick asks again
|
|
114
|
+
const no = runnerDeps({ healthz: '{"ok":true,"auth_configured":false}' });
|
|
115
|
+
assert.equal((await RUN.verifyGithubAppSetups(no.deps)).signIn, 0);
|
|
116
|
+
assert.deepEqual(no.resolved, []);
|
|
117
|
+
// an attempt that is 'done' is never re-asked
|
|
118
|
+
const done = runnerDeps({ healthz: '{"auth_configured":true}', facts: [{ event: 'github-app-installed', at: ago(1) }, { event: 'github-app-device-flow', at: ago(1) }] });
|
|
119
|
+
STOPPED.status = 'done';
|
|
120
|
+
try { await RUN.verifyGithubAppSetups(done.deps); } finally { STOPPED.status = 'error'; }
|
|
121
|
+
assert.deepEqual(done.cmds, [], 'a finished attempt costs nothing');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const INST = { id: 1, slug: 'mercury', hosting_shape: 'co-tenant', status: 'active', owner_builder_id: 7, port: 3003, db_name: 'mercury', domain: 'mercury.example.com' };
|
|
125
|
+
const CREDS = { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 9, appSlug: 'm-app' };
|
|
126
|
+
|
|
127
|
+
test('keys that fail to land after GitHub\'s conversion are a SPENT code: never re-queued, the app named for the platform team', async () => {
|
|
128
|
+
const r = await P.placeManifestCreds(INST, { github_code: 'C', attempts: 1 }, {
|
|
129
|
+
apply: true, log: () => {}, convertManifestCode: async () => CREDS,
|
|
130
|
+
exec: () => ({ ok: true, stdout: '' }), writeFile: () => { throw new Error('ssh: connection reset'); },
|
|
131
|
+
});
|
|
132
|
+
assert.equal(r.ok, false);
|
|
133
|
+
assert.equal(r.codeSpent, true, 'the code is burned: a retry would 404 forever');
|
|
134
|
+
assert.match(r.error, /GitHub made the app m-app, but placing its keys failed: ssh: connection reset/);
|
|
135
|
+
assert.equal(r.appSlug, undefined, 'no slug on the row: a recorded slug reads as "keys placed"');
|
|
136
|
+
// …and the drain records it so: error, code dropped — pickUpFor then says 'operator', not 'convert'
|
|
137
|
+
let claims = 0; const resolved = [];
|
|
138
|
+
await P.drainOAuthManifests({
|
|
139
|
+
apply: true, log: () => {}, db: {}, exec: () => ({ ok: true, stdout: '' }), writeFile: () => { throw new Error('disk full'); },
|
|
140
|
+
convertManifestCode: async () => CREDS,
|
|
141
|
+
provisioning: {
|
|
142
|
+
claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 31, instance_id: 1, github_code: 'C', attempts: 1 } : null),
|
|
143
|
+
getInstanceById: async () => INST, resolveOAuthManifest: async (_db, id, status, patch) => { resolved.push({ id, status, patch }); }, recordEvent: async () => {},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
assert.equal(resolved.length, 1);
|
|
147
|
+
assert.equal(resolved[0].status, 'error');
|
|
148
|
+
assert.equal(resolved[0].patch.github_code, null);
|
|
149
|
+
assert.equal(GS.pickUpFor({ attempts: 1, github_code: null, created_at: ago(1) }, 'error', GS.githubChecklist('error', null), false, NOW), 'operator');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('GitHub\'s 404 on a conversion drops the dead code; any other failure keeps it for a pick-up', async () => {
|
|
153
|
+
const drain = async (message) => {
|
|
154
|
+
let claims = 0; const resolved = [];
|
|
155
|
+
await P.drainOAuthManifests({
|
|
156
|
+
apply: true, log: () => {}, db: {}, exec: () => ({ ok: true }), writeFile: () => ({ ok: true }),
|
|
157
|
+
convertManifestCode: async () => { throw new Error(message); },
|
|
158
|
+
provisioning: {
|
|
159
|
+
claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 32, instance_id: 1, github_code: 'C', attempts: 1 } : null),
|
|
160
|
+
getInstanceById: async () => INST, resolveOAuthManifest: async (_db, id, status, patch) => { resolved.push({ status, patch }); }, recordEvent: async () => {},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return resolved[0];
|
|
164
|
+
};
|
|
165
|
+
const dead = await drain('GitHub manifest conversion failed: Not Found');
|
|
166
|
+
assert.equal(dead.status, 'error');
|
|
167
|
+
assert.equal(dead.patch.github_code, null);
|
|
168
|
+
const flaky = await drain('GitHub manifest conversion failed: HTTP 502');
|
|
169
|
+
assert.equal(flaky.status, 'error');
|
|
170
|
+
assert.ok(!('github_code' in flaky.patch), 'the code may still convert: "Pick up where it stopped" re-queues it');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── 3. the REAL status route into the REAL page ─────────────────────────────
|
|
174
|
+
|
|
175
|
+
const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
|
|
176
|
+
let attempt = null;
|
|
177
|
+
provisioning.getOAuthManifestByState = async (_db, state) => (state === 'S1' ? attempt : null);
|
|
178
|
+
provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 7, slug: 'mercury', domain: 'mercury.example.com', target_ref: 'acme/mercury', status: 'active' });
|
|
179
|
+
let eventRows = [];
|
|
180
|
+
const realQuery = { query: async () => ({ rows: eventRows }) };
|
|
181
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
182
|
+
api.pool.query = realQuery.query; // the one read the status route makes: the verified facts
|
|
183
|
+
const express = require('express');
|
|
184
|
+
const app = express();
|
|
185
|
+
app.use('/api/bongos', require(path.join(ROOT, 'modules', 'provisioning', 'routes', 'github-setup.js'))());
|
|
186
|
+
|
|
187
|
+
const RS = SCRIPT.indexOf(' /* The four GitHub steps of part 03');
|
|
188
|
+
const RE = SCRIPT.indexOf(' var STEP_RENDERERS = {', RS);
|
|
189
|
+
const SS = SCRIPT.indexOf(' /* the checklist the server derived (task 1004649)');
|
|
190
|
+
const SE = SCRIPT.indexOf(' function setupOpen() {', SS);
|
|
191
|
+
const AS = SCRIPT.indexOf(' /* ── Pick up where it stopped (task 1004652).');
|
|
192
|
+
const AE = SCRIPT.indexOf(' function startManifest(btn) {', AS);
|
|
193
|
+
|
|
194
|
+
test('the page regions are findable (a rename must fail loudly)', () => {
|
|
195
|
+
assert.ok(RS > 0 && RE > RS && SS > 0 && SE > SS && AS > 0 && AE > AS);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const esc = (x) => String(x).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
199
|
+
function page(extra = {}) {
|
|
200
|
+
const sandbox = {
|
|
201
|
+
live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, instStatus: 'active', addrReady: true, pickUp: null, verifying: false, askedPlatform: false },
|
|
202
|
+
state: { name: 'Mercury', slug: 'mercury', owner: 'acme', repo: 'mercury' }, autoOpenArmed: false,
|
|
203
|
+
resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com', esc,
|
|
204
|
+
GH_ICON: '', manualOauthDetails: () => '<details>manual</details>', projectPageLink: (t) => t, autoOpenTick: () => '',
|
|
205
|
+
progressMarkup: (a, b, note) => `<prog>${note}</prog>`, encodeURIComponent, JSON,
|
|
206
|
+
...extra,
|
|
207
|
+
};
|
|
208
|
+
vm.createContext(sandbox);
|
|
209
|
+
vm.runInContext(`${SCRIPT.slice(RS, RE)}\n${SCRIPT.slice(SS, SE)}\n${SCRIPT.slice(AS, AE)}`, sandbox);
|
|
210
|
+
return sandbox;
|
|
211
|
+
}
|
|
212
|
+
// what the page's poll does with one answer (pollManifest's tick, minus the timer)
|
|
213
|
+
function seedFrom(sb, d) {
|
|
214
|
+
sb.live.appSlug = d.app_slug || null;
|
|
215
|
+
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
|
|
216
|
+
sb.live.manifest = d.ready ? 'done' : d.status;
|
|
217
|
+
sb.live.manifestFailure = d.failure || null;
|
|
218
|
+
}
|
|
219
|
+
const ROWS = ['renderStepOauthApp', 'renderStepAppInstall', 'renderStepFirstSignIn', 'renderStepDeviceFlow'];
|
|
220
|
+
const CHORE = /\/contact|Contact us|delete it|Developer settings → GitHub Apps\) so|Try again anyway|Start the setup again|by hand/;
|
|
221
|
+
|
|
222
|
+
// every shape the status derivation can reach, as rows the real route reads
|
|
223
|
+
const SHAPES = [
|
|
224
|
+
['waiting on GitHub', { status: 'pending', attempts: 0, expires_at: SOON }, []],
|
|
225
|
+
['link lapsed', { status: 'pending', attempts: 0, expires_at: ago(1) }, []],
|
|
226
|
+
['runner never took it up', { status: 'error', attempts: 0, expires_at: ago(1) }, []],
|
|
227
|
+
['approved, converting', { status: 'converting', attempts: 1, expires_at: ago(1) }, []],
|
|
228
|
+
['conversion stopped, code young', { status: 'error', attempts: 3, github_code: 'C', created_at: ago(15), expires_at: ago(1), resolved_at: ago(10) }, []],
|
|
229
|
+
['conversion stopped, code spent', { status: 'error', attempts: 1, github_code: null, created_at: ago(15), expires_at: ago(1), resolved_at: ago(10) }, []],
|
|
230
|
+
['sign-in unconfirmed, re-asking', { status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(15), resolved_at: ago(10) }, []],
|
|
231
|
+
['sign-in unconfirmed, window over', { status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, []],
|
|
232
|
+
['signed in, install unverified, checking', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(15), resolved_at: ago(10) }, []],
|
|
233
|
+
['signed in, install never confirmed, window over', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, []],
|
|
234
|
+
['installed, device flow off, window over', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, [{ event: 'github-app-installed', at: ago(280) }]],
|
|
235
|
+
['all four ticked', { status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: ago(300), resolved_at: ago(290) }, [{ event: 'github-app-installed', at: ago(280) }, { event: 'github-app-device-flow', at: ago(280) }]],
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
async function statusSays(row, events) {
|
|
239
|
+
attempt = { id: 99, instance_id: 42, requested_by: 7, state: 'S1', ...row };
|
|
240
|
+
eventRows = events;
|
|
241
|
+
const server = app.listen(0);
|
|
242
|
+
await new Promise((r) => server.once('listening', r));
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/github-app/status?state=S1`);
|
|
245
|
+
return await res.json();
|
|
246
|
+
} finally { server.close(); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
test('every state the setup can reach: the REAL status answer draws a sentence and an action — never a chore, never "contact us"', async (t) => {
|
|
250
|
+
// the page reads NOW from the real clock; age the rows against it
|
|
251
|
+
const shift = Date.now() - NOW;
|
|
252
|
+
const aged = (row) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, /_at$/.test(k) && v ? new Date(Date.parse(v) + shift).toISOString() : v]));
|
|
253
|
+
const seen = {};
|
|
254
|
+
for (const [name, row, events] of SHAPES) {
|
|
255
|
+
const d = await statusSays(aged(row), events.map((e) => ({ ...e, at: new Date(Date.parse(e.at) + shift).toISOString() })));
|
|
256
|
+
assert.equal(d.ok, true, name);
|
|
257
|
+
const sb = page();
|
|
258
|
+
seedFrom(sb, d);
|
|
259
|
+
const html = ROWS.map((f) => vm.runInContext(`${f}()`, sb)).join('\n');
|
|
260
|
+
seen[name] = { pick: d.pick_up, html };
|
|
261
|
+
assert.doesNotMatch(html, CHORE, `${name}: no manual chore and no "contact us"`);
|
|
262
|
+
// a stopped step always carries the action that fixes it
|
|
263
|
+
for (const li of html.split('\n').filter((x) => /class="sstopped/.test(x))) {
|
|
264
|
+
assert.match(li, /data-action="(pick-up|manifest-start|ask-platform)"/, `${name}: a stopped step has its button — ${li.slice(0, 120)}`);
|
|
265
|
+
}
|
|
266
|
+
// a pick-up the server offers is on the page
|
|
267
|
+
if (d.pick_up === 'check' || d.pick_up === 'convert') assert.match(html, /data-action="pick-up"/, `${name}: ${d.pick_up} is offered`);
|
|
268
|
+
if (d.pick_up === 'operator') assert.match(html, /data-action="ask-platform"/, `${name}: the platform team is one click away`);
|
|
269
|
+
if (d.pick_up === 'reopen') assert.match(html, /data-action="reopen-github"/, `${name}: the same link can be reopened`);
|
|
270
|
+
// a new link is offered ONLY where nothing was made on GitHub
|
|
271
|
+
if (/data-action="manifest-start"/.test(html)) assert.ok(d.pick_up === 'fresh', `${name}: Connect GitHub is offered only when no app exists (pick_up=${d.pick_up})`);
|
|
272
|
+
}
|
|
273
|
+
assert.equal(seen['waiting on GitHub'].pick, 'reopen');
|
|
274
|
+
assert.equal(seen['link lapsed'].pick, 'fresh');
|
|
275
|
+
assert.equal(seen['conversion stopped, code young'].pick, 'convert');
|
|
276
|
+
assert.equal(seen['conversion stopped, code spent'].pick, 'operator');
|
|
277
|
+
assert.equal(seen['sign-in unconfirmed, re-asking'].pick, null);
|
|
278
|
+
assert.match(seen['sign-in unconfirmed, re-asking'].html, /We’re asking it again/);
|
|
279
|
+
assert.equal(seen['sign-in unconfirmed, window over'].pick, 'check');
|
|
280
|
+
assert.match(seen['signed in, install never confirmed, window over'].html, /GitHub stopped before the app was installed on your repo\. Nothing is lost, and no second app will be made\./, 'the mockup\'s sentence, from the real read');
|
|
281
|
+
assert.match(seen['installed, device flow off, window over'].html, /Turned it on\? Check again/);
|
|
282
|
+
assert.equal(seen['all four ticked'].pick, null);
|
|
283
|
+
assert.doesNotMatch(seen['all four ticked'].html, /sstopped|data-action="pick-up"/);
|
|
284
|
+
t.diagnostic(Object.entries(seen).map(([k, v]) => `${k}: ${v.pick}`).join(' · '));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── 4. the page's own actions ───────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
function actions({ pickUp, answer, manifestState = null, blockerStatus = 201 }) {
|
|
290
|
+
const calls = { fetched: [], polled: [], progress: [], started: 0, renders: 0 };
|
|
291
|
+
const sb = page({
|
|
292
|
+
API: '/api/bongos', instanceId: 42, manifestState, pollTimer: null, lastStepsHtml: 'x', manifestStartedAt: 1700000000000, ghTab: null,
|
|
293
|
+
fetch: async (url, init) => {
|
|
294
|
+
calls.fetched.push({ url, init });
|
|
295
|
+
if (/\/blockers$/.test(url)) return { ok: blockerStatus < 300, status: blockerStatus, json: async () => ({}) };
|
|
296
|
+
return { ok: true, status: 200, json: async () => answer };
|
|
297
|
+
},
|
|
298
|
+
rememberManifest: () => {}, renderSteps: () => { calls.renders++; },
|
|
299
|
+
pollManifest: (s) => calls.polled.push(s), startProgress: (id) => calls.progress.push(id),
|
|
300
|
+
startManifest: () => { calls.started++; },
|
|
301
|
+
});
|
|
302
|
+
sb.live.pickUp = pickUp;
|
|
303
|
+
return { sb, calls };
|
|
304
|
+
}
|
|
305
|
+
const settle = () => new Promise((r) => setTimeout(r, 20));
|
|
306
|
+
const btn = () => ({ tagName: 'BUTTON', disabled: false, textContent: '' });
|
|
307
|
+
|
|
308
|
+
test('pick-up posts to the pick-up route, seeds its fresh read, and watches again', async () => {
|
|
309
|
+
const answer = { ok: true, action: 'convert', status: 'code_received', failure: null, checklist: GS.githubChecklist('code_received', null), install_url: null, verifying: false, pick_up: null };
|
|
310
|
+
const { sb, calls } = actions({ pickUp: 'convert', answer, manifestState: 'S1' });
|
|
311
|
+
vm.runInContext('pickUpSetup', sb)(btn());
|
|
312
|
+
await settle();
|
|
313
|
+
assert.equal(calls.fetched.length, 1);
|
|
314
|
+
assert.equal(calls.fetched[0].url, '/api/bongos/provisioning/instances/42/github-app/pick-up');
|
|
315
|
+
assert.equal(calls.fetched[0].init.method, 'POST');
|
|
316
|
+
assert.equal(calls.started, 0, 'no new link is minted');
|
|
317
|
+
assert.equal(sb.live.manifest, 'code_received', 'the step moves on at once');
|
|
318
|
+
assert.equal(sb.live.pickUp, null);
|
|
319
|
+
assert.deepEqual(calls.polled, ['S1'], 'the attempt\'s own poll runs again');
|
|
320
|
+
// without the state (a reload, another device) the instance read watches instead
|
|
321
|
+
const other = actions({ pickUp: 'check', answer: { ...answer, action: 'check', status: 'error', verifying: true } });
|
|
322
|
+
vm.runInContext('pickUpSetup', other.sb)(btn());
|
|
323
|
+
await settle();
|
|
324
|
+
assert.deepEqual(other.calls.progress, [42]);
|
|
325
|
+
assert.equal(other.sb.live.verifying, true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('Reopen GitHub and a lapsed link go through the mint route (which hands back the SAME waiting state); a refused pick-up says so', async () => {
|
|
329
|
+
for (const pickUp of ['reopen', 'fresh']) {
|
|
330
|
+
const { sb, calls } = actions({ pickUp, answer: {} });
|
|
331
|
+
vm.runInContext('pickUpSetup', sb)(btn());
|
|
332
|
+
await settle();
|
|
333
|
+
assert.equal(calls.started, 1, pickUp);
|
|
334
|
+
assert.equal(calls.fetched.length, 0, `${pickUp}: the pick-up route is not asked`);
|
|
335
|
+
}
|
|
336
|
+
const r = actions({ pickUp: null, answer: {} });
|
|
337
|
+
vm.runInContext('reopenGithub', r.sb)(btn());
|
|
338
|
+
assert.equal(r.calls.started, 1);
|
|
339
|
+
// a refusal (409) re-enables the button with a plain line
|
|
340
|
+
const refused = actions({ pickUp: 'check', answer: { ok: false } });
|
|
341
|
+
const b = btn();
|
|
342
|
+
vm.runInContext('pickUpSetup', refused.sb)(b);
|
|
343
|
+
await settle();
|
|
344
|
+
assert.equal(b.disabled, false);
|
|
345
|
+
assert.match(b.textContent, /That didn’t go through\. Try again/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('"Send it to the platform team" files one blocker for the attempt, with no secret, and the step says it was sent', async () => {
|
|
349
|
+
const { sb, calls } = actions({ pickUp: 'operator', answer: {} });
|
|
350
|
+
vm.runInContext('pickUpSetup', sb)(btn());
|
|
351
|
+
await settle();
|
|
352
|
+
const filed = calls.fetched.find((c) => /\/blockers$/.test(c.url));
|
|
353
|
+
assert.ok(filed, 'POST /api/bongos/blockers');
|
|
354
|
+
assert.equal(filed.url, '/api/bongos/blockers');
|
|
355
|
+
const body = JSON.parse(filed.init.body);
|
|
356
|
+
assert.equal(body.source, 'github-setup');
|
|
357
|
+
assert.equal(body.source_ref, 'instance-42-1700000000000', 'one per attempt: a second press is the same blocker');
|
|
358
|
+
assert.match(body.body_md, /Project 42/);
|
|
359
|
+
assert.doesNotMatch(filed.init.body, /state|github_code|secret=|Iv1\./i, 'no token, code or secret');
|
|
360
|
+
assert.equal(sb.live.askedPlatform, true);
|
|
361
|
+
assert.ok(calls.renders > 0);
|
|
362
|
+
const failed = actions({ pickUp: 'operator', answer: {}, blockerStatus: 500 });
|
|
363
|
+
vm.runInContext('askPlatform', failed.sb)(btn());
|
|
364
|
+
await settle();
|
|
365
|
+
assert.equal(failed.sb.live.askedPlatform, 'failed');
|
|
366
|
+
});
|