@bongos/core 1.19.373 → 1.19.375
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 +98 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +159 -4
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +252 -233
- package/docs/copy-registry.json +503 -301
- package/modules/builder-settings/profile-nudge-prefs.js +125 -0
- package/modules/builder-settings/routes/settings.js +48 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/goal-inbox.js +10 -0
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/profile-nudge.js +122 -0
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/fitness-checks-write-validation.js +1 -0
- package/scripts/gds/login.js +24 -3
- package/scripts/gds/ship-land.js +94 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +22 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/profile_nudge_prefs.mjs +97 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
- package/tests/ship_push_diagnosis.mjs +78 -0
- package/tests/sound_player.mjs +38 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// tests/joinability_invite_only.mjs — the invite-only door (task 1003044).
|
|
2
|
+
//
|
|
3
|
+
// The project's joinability is read through branding(), memoized for the
|
|
4
|
+
// process — so, like tests/platform_visibility_gate.mjs, the pack is set to
|
|
5
|
+
// 'invite_only' BEFORE the first require and the REAL app is booted on it. What
|
|
6
|
+
// this proves, DB-free (the door answers before any lookup):
|
|
7
|
+
// - the one reader every admission surface uses says invite_only, and the
|
|
8
|
+
// legacy open-enrolment flag does not override the stricter choice;
|
|
9
|
+
// - the shared sign-in decision admits an invited first-timer and nobody else;
|
|
10
|
+
// - the public POST /access-requests refuses with the plain sentence, on
|
|
11
|
+
// every API prefix, before validating the body;
|
|
12
|
+
// - the Archon invite route is behind the session gate;
|
|
13
|
+
// - the manifest carries the policy for the hub's "runs as" line.
|
|
14
|
+
// The 'open' boot is tests/joinability_open.mjs; 'apply' (the default pack) is
|
|
15
|
+
// every other boot test in the suite plus tests/auth_admission_gate.mjs.
|
|
16
|
+
//
|
|
17
|
+
// Run: node --test tests/joinability_invite_only.mjs
|
|
18
|
+
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { test, before, after } from 'node:test';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
|
|
31
|
+
// set BEFORE anything that could read branding() is first required (a hoisted
|
|
32
|
+
// ESM import would run before this line)
|
|
33
|
+
const brandingFile = path.join(os.tmpdir(), `bongos-join-invite-${process.pid}.json`);
|
|
34
|
+
fs.writeFileSync(brandingFile, JSON.stringify({
|
|
35
|
+
identity: { productName: 'Spike Harbor' },
|
|
36
|
+
project: { platformVisibility: 'public', joinability: 'invite_only' },
|
|
37
|
+
}));
|
|
38
|
+
process.env.GDS_BRANDING_FILE = brandingFile;
|
|
39
|
+
|
|
40
|
+
const { joinabilityMode, openEnrollmentEnabled, firstTimeAdmissionBlocked, firstAdminLogin } = require('../src/bongos/auth-admission.js');
|
|
41
|
+
const platformMod = require('../src/platform-server.js');
|
|
42
|
+
|
|
43
|
+
function withLegacyFlag(val, fn) {
|
|
44
|
+
const prev = process.env.OTB_OPEN_XENOS_ENROLLMENT;
|
|
45
|
+
if (val === undefined) delete process.env.OTB_OPEN_XENOS_ENROLLMENT;
|
|
46
|
+
else process.env.OTB_OPEN_XENOS_ENROLLMENT = val;
|
|
47
|
+
try { return fn(); } finally {
|
|
48
|
+
if (prev === undefined) delete process.env.OTB_OPEN_XENOS_ENROLLMENT;
|
|
49
|
+
else process.env.OTB_OPEN_XENOS_ENROLLMENT = prev;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── the decision ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
test('invite_only: the one reader says so, and open enrolment is off', () => {
|
|
56
|
+
withLegacyFlag(undefined, () => {
|
|
57
|
+
assert.equal(joinabilityMode(), 'invite_only');
|
|
58
|
+
assert.equal(openEnrollmentEnabled(), false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('invite_only: the legacy OPEN_XENOS_ENROLLMENT flag does not override the stricter choice', () => {
|
|
63
|
+
withLegacyFlag('1', () => {
|
|
64
|
+
assert.equal(joinabilityMode(), 'invite_only');
|
|
65
|
+
assert.equal(openEnrollmentEnabled(), false);
|
|
66
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: 'stranger' }), true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('invite_only: only an invite admits a first-timer; a member always passes', () => {
|
|
71
|
+
withLegacyFlag(undefined, () => {
|
|
72
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: 'stranger' }), true, 'no invite → refused');
|
|
73
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: true, login: 'invited-one' }), false, 'an Archon-created invite admits');
|
|
74
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: { id: 3 }, approved: false, login: 'member' }), false, 'a member is never locked out');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('invite_only: the configured firstAdmin is a standing invite the door never closes (ADR 0062 §6 — intended)', () => {
|
|
79
|
+
const prev = process.env.OTB_FIRST_ADMIN;
|
|
80
|
+
process.env.OTB_FIRST_ADMIN = 'founding-owner';
|
|
81
|
+
try {
|
|
82
|
+
assert.equal(firstAdminLogin(), 'founding-owner');
|
|
83
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: 'Founding-Owner' }), false);
|
|
84
|
+
} finally {
|
|
85
|
+
if (prev === undefined) delete process.env.OTB_FIRST_ADMIN;
|
|
86
|
+
else process.env.OTB_FIRST_ADMIN = prev;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── the real booted app ──────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
let server, port;
|
|
93
|
+
before(async () => {
|
|
94
|
+
server = http.createServer(platformMod.buildPlatformApp());
|
|
95
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
96
|
+
port = server.address().port;
|
|
97
|
+
});
|
|
98
|
+
after(async () => {
|
|
99
|
+
await new Promise((r) => server.close(r));
|
|
100
|
+
delete process.env.GDS_BRANDING_FILE;
|
|
101
|
+
try { fs.unlinkSync(brandingFile); } catch { /* already gone */ }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function request(pathname, { method = 'GET', body } = {}) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
107
|
+
const req = http.request({
|
|
108
|
+
host: '127.0.0.1', port, path: pathname, method,
|
|
109
|
+
headers: {
|
|
110
|
+
host: 'spike.test',
|
|
111
|
+
...(payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}),
|
|
112
|
+
},
|
|
113
|
+
}, (res) => {
|
|
114
|
+
let data = '';
|
|
115
|
+
res.on('data', (c) => { data += c; });
|
|
116
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
|
|
117
|
+
});
|
|
118
|
+
req.on('error', reject);
|
|
119
|
+
if (payload) req.write(payload);
|
|
120
|
+
req.end();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test('booted invite_only: the public POST /access-requests refuses with the plain sentence, on every prefix', async () => {
|
|
125
|
+
for (const p of ['/api/gds/access-requests', '/api/bongos/access-requests']) {
|
|
126
|
+
const res = await request(p, { method: 'POST', body: { github_login: 'octocat', note: 'hi' } });
|
|
127
|
+
assert.equal(res.status, 403, p);
|
|
128
|
+
const j = JSON.parse(res.body);
|
|
129
|
+
assert.equal(j.error && j.error.code, 'invite_only', `${p}: the ADR 0116 envelope`);
|
|
130
|
+
assert.match(j.error.message, /invite-only/, 'says which door this is');
|
|
131
|
+
assert.match(j.error.message, /sign in with GitHub/i, 'and names the one way in');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('booted invite_only: the door answers before the body is read — nothing about the applicant is consulted', async () => {
|
|
136
|
+
const res = await request('/api/gds/access-requests', { method: 'POST', body: {} });
|
|
137
|
+
assert.equal(res.status, 403, 'not a 400 — the refusal is the door’s, not the form’s');
|
|
138
|
+
assert.equal(JSON.parse(res.body).error.code, 'invite_only');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('booted invite_only: the Archon invite route sits behind the session gate', async () => {
|
|
142
|
+
const res = await request('/api/gds/access-requests/invite', { method: 'POST', body: { github_login: 'octocat' } });
|
|
143
|
+
assert.equal(res.status, 401);
|
|
144
|
+
assert.equal(JSON.parse(res.body).error.code, 'unauthenticated');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('booted invite_only: the manifest carries the policy the hub reads back', async () => {
|
|
148
|
+
const res = await request('/api/gds/instance');
|
|
149
|
+
assert.equal(res.status, 200);
|
|
150
|
+
const m = JSON.parse(res.body);
|
|
151
|
+
assert.equal(m.brand.project.joinability, 'invite_only');
|
|
152
|
+
assert.equal(m.admission.policy, 'invite-only', 'the legacy binary field agrees');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── the copy, where a stranger meets it ──────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
158
|
+
|
|
159
|
+
test('the web sign-in refusal names the invite-only door instead of sending a stranger to a request form', () => {
|
|
160
|
+
const auth = src('src/bongos/routes/auth.js');
|
|
161
|
+
assert.match(auth, /auth\.joinabilityMode\(\) === 'invite_only'/);
|
|
162
|
+
assert.match(auth, /'This project is invite-only'/);
|
|
163
|
+
assert.match(auth, /doesn’t take applications/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the hall landing renders one door per mode, and its form collapses on the door’s own answers', () => {
|
|
167
|
+
const hall = src('modules/hall-ui/public/hall-render.js');
|
|
168
|
+
const door = (hall.match(/function landingDoorHtml\(mode\) \{[\s\S]*?\n \}\n/) || [''])[0];
|
|
169
|
+
assert.ok(door, 'landingDoorHtml() must exist');
|
|
170
|
+
assert.match(door, /mode === 'open'/);
|
|
171
|
+
assert.match(door, /mode === 'invite_only'/);
|
|
172
|
+
assert.match(door, /there is nothing to apply for here/);
|
|
173
|
+
assert.match(door, /id="access-request-form"/, 'the apply door keeps the request form');
|
|
174
|
+
assert.match(hall, /\$\{landingDoorHtml\(joinabilityMode\(\)\)\}/, 'renderLanding paints the door the pack names');
|
|
175
|
+
assert.match(hall, /code === 'invite_only' \|\| code === 'open_enrollment'/, 'the refusal codes are handled, not shown red');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('the CLI says invite-only and stops, rather than filing a request the project refuses', () => {
|
|
179
|
+
const cli = src('scripts/gds/login.js');
|
|
180
|
+
assert.match(cli, /joinability === 'invite_only'/);
|
|
181
|
+
assert.match(cli, /is invite-only — it doesn’t take applications/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('the Watch page offers the Archon an invite by username — the way in when nobody can apply', () => {
|
|
185
|
+
const watch = src('modules/hall-ui/public/watch.js');
|
|
186
|
+
assert.match(watch, /\/access-requests\/invite/);
|
|
187
|
+
assert.match(watch, /nobody can apply, so this is how people get in/);
|
|
188
|
+
const gate = (watch.match(/function renderGate\(data, target\) \{[\s\S]*?\n \}\n/) || [''])[0];
|
|
189
|
+
assert.ok(gate, 'renderGate() must exist');
|
|
190
|
+
assert.equal((gate.match(/wireInviteForm\(target\)/g) || []).length, 2, 'the form is wired in BOTH branches — an empty queue and a listed one');
|
|
191
|
+
assert.equal((gate.match(/inviteFormHtml\(\)/g) || []).length, 2, 'and rendered in both');
|
|
192
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// tests/joinability_open.mjs — the open door (task 1003044).
|
|
2
|
+
//
|
|
3
|
+
// The pack is set to 'open' BEFORE the first require (branding() memoizes for
|
|
4
|
+
// the process), then the real app boots on it. Proves: open enrolment follows
|
|
5
|
+
// the setting (a first-timer self-enrols, no invite needed), the public POST
|
|
6
|
+
// /access-requests says "no request needed — sign in", and the manifest reports
|
|
7
|
+
// the door both ways. The invite-only boot is tests/joinability_invite_only.mjs.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/joinability_open.mjs
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test, before, after } from 'node:test';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
// set BEFORE anything that could read branding() is first required
|
|
22
|
+
const brandingFile = path.join(os.tmpdir(), `bongos-join-open-${process.pid}.json`);
|
|
23
|
+
fs.writeFileSync(brandingFile, JSON.stringify({
|
|
24
|
+
identity: { productName: 'Spike Harbor' },
|
|
25
|
+
project: { platformVisibility: 'public', joinability: 'open' },
|
|
26
|
+
}));
|
|
27
|
+
process.env.GDS_BRANDING_FILE = brandingFile;
|
|
28
|
+
|
|
29
|
+
const { joinabilityMode, openEnrollmentEnabled, firstTimeAdmissionBlocked } = require('../src/bongos/auth-admission.js');
|
|
30
|
+
const platformMod = require('../src/platform-server.js');
|
|
31
|
+
|
|
32
|
+
test('open: the setting alone opens the door — no legacy flag needed', () => {
|
|
33
|
+
const prev = process.env.OTB_OPEN_XENOS_ENROLLMENT;
|
|
34
|
+
delete process.env.OTB_OPEN_XENOS_ENROLLMENT;
|
|
35
|
+
try {
|
|
36
|
+
assert.equal(joinabilityMode(), 'open');
|
|
37
|
+
assert.equal(openEnrollmentEnabled(), true);
|
|
38
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: 'stranger' }), false, 'a first-timer self-enrols');
|
|
39
|
+
} finally {
|
|
40
|
+
if (prev !== undefined) process.env.OTB_OPEN_XENOS_ENROLLMENT = prev;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let server, port;
|
|
45
|
+
before(async () => {
|
|
46
|
+
server = http.createServer(platformMod.buildPlatformApp());
|
|
47
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
48
|
+
port = server.address().port;
|
|
49
|
+
});
|
|
50
|
+
after(async () => {
|
|
51
|
+
await new Promise((r) => server.close(r));
|
|
52
|
+
delete process.env.GDS_BRANDING_FILE;
|
|
53
|
+
try { fs.unlinkSync(brandingFile); } catch { /* already gone */ }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function request(pathname, { method = 'GET', body } = {}) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
59
|
+
const req = http.request({
|
|
60
|
+
host: '127.0.0.1', port, path: pathname, method,
|
|
61
|
+
headers: {
|
|
62
|
+
host: 'spike.test',
|
|
63
|
+
...(payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}),
|
|
64
|
+
},
|
|
65
|
+
}, (res) => {
|
|
66
|
+
let data = '';
|
|
67
|
+
res.on('data', (c) => { data += c; });
|
|
68
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
69
|
+
});
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
if (payload) req.write(payload);
|
|
72
|
+
req.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test('booted open: the public POST /access-requests says no request is needed — sign in', async () => {
|
|
77
|
+
const res = await request('/api/gds/access-requests', { method: 'POST', body: { github_login: 'octocat' } });
|
|
78
|
+
assert.equal(res.status, 409);
|
|
79
|
+
const j = JSON.parse(res.body);
|
|
80
|
+
assert.equal(j.error.code, 'open_enrollment');
|
|
81
|
+
assert.match(j.error.message, /open/);
|
|
82
|
+
assert.match(j.error.message, /[Ss]ign in with GitHub/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('booted open: the manifest reports the door both ways', async () => {
|
|
86
|
+
const m = JSON.parse((await request('/api/gds/instance')).body);
|
|
87
|
+
assert.equal(m.brand.project.joinability, 'open');
|
|
88
|
+
assert.equal(m.admission.policy, 'open');
|
|
89
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const PUBLISHED_SURFACE = [
|
|
|
32
32
|
'llmCache', // BV1.R73: shared LLM-cost cache (1.6.0; tracked domain leak)
|
|
33
33
|
'llmPricing', // BV1.R77: shared token→USD pricing (1.7.0; tracked domain leak — economy carve)
|
|
34
34
|
'branding', 'clientBranding', 'userAgent', 'resolveEnv', 'readConfigFileSync', 'configHome', 'configPath', // userAgent added 1.12.0 (BV1.R86; github-push UA)
|
|
35
|
+
'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
|
|
35
36
|
'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
|
|
36
37
|
'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
|
|
37
38
|
'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// tests/profile_nudge_prefs.mjs — the "Complete your profile" nudge shaper
|
|
2
|
+
// (task 1002802 / P06, goal 1000045).
|
|
3
|
+
//
|
|
4
|
+
// The nudge replaced the profile-setup wizard spec Decision 2 rejected: a
|
|
5
|
+
// DISMISSIBLE line item in the hall's needs-attention inbox, shown when bio or
|
|
6
|
+
// preferred_disciplines are empty. These pin the pure decision layer — what
|
|
7
|
+
// counts as missing, when the line shows, and that a dismissal is durable and
|
|
8
|
+
// reversible without disturbing the sibling knobs sharing behavior_prefs.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/profile_nudge_prefs.mjs
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const p = require('../modules/builder-settings/profile-nudge-prefs.js');
|
|
18
|
+
|
|
19
|
+
test('missingProfileFields: empty, whitespace, and absent all count as missing', () => {
|
|
20
|
+
assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: [] }), ['bio', 'disciplines']);
|
|
21
|
+
assert.deepEqual(p.missingProfileFields({ bio: ' \n\t ', preferredDisciplines: [] }), ['bio', 'disciplines']);
|
|
22
|
+
assert.deepEqual(p.missingProfileFields({}), ['bio', 'disciplines']);
|
|
23
|
+
assert.deepEqual(p.missingProfileFields({ bio: null, preferredDisciplines: null }), ['bio', 'disciplines']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('missingProfileFields: a real value on either side drops only that entry', () => {
|
|
27
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'I build things', preferredDisciplines: [] }), ['disciplines']);
|
|
28
|
+
assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: ['engineer'] }), ['bio']);
|
|
29
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: ['engineer'] }), []);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('missingProfileFields: a non-array disciplines value is missing, not a crash', () => {
|
|
33
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: 'engineer' }), ['disciplines']);
|
|
34
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: {} }), ['disciplines']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('nudgeDismissed: defaults to NOT dismissed; only a real boolean overrides', () => {
|
|
38
|
+
assert.equal(p.nudgeDismissed({}), false);
|
|
39
|
+
assert.equal(p.nudgeDismissed(null), false);
|
|
40
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: true }), true);
|
|
41
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: false }), false);
|
|
42
|
+
// Garbage in the blob must not read as dismissed — that would silence the
|
|
43
|
+
// nudge for a builder who never dismissed it.
|
|
44
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 'yes' }), false);
|
|
45
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 1 }), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('shouldNudge: shows only when something is missing AND not dismissed', () => {
|
|
49
|
+
const missing = { bio: '', preferredDisciplines: [] };
|
|
50
|
+
const complete = { bio: 'x', preferredDisciplines: ['engineer'] };
|
|
51
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...missing }), true);
|
|
52
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...missing }), false);
|
|
53
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...complete }), false);
|
|
54
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...complete }), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('describeForApi: reports what is missing even while dismissed (show is the gate)', () => {
|
|
58
|
+
const d = p.describeForApi({ profile_nudge_dismissed: true }, { bio: '', preferredDisciplines: [] });
|
|
59
|
+
assert.equal(d.dismissed, true);
|
|
60
|
+
assert.equal(d.show, false, 'a dismissed nudge never shows');
|
|
61
|
+
assert.deepEqual(d.missing, ['bio', 'disciplines'], 'but the API still says what is empty');
|
|
62
|
+
assert.equal(d.override, true);
|
|
63
|
+
assert.equal(d.default_dismissed, false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('validatePatch: accepts dismissed true/false/null, refuses everything else', () => {
|
|
67
|
+
assert.deepEqual(p.validatePatch({ dismissed: true }), { ok: true, patch: { profile_nudge_dismissed: true } });
|
|
68
|
+
assert.deepEqual(p.validatePatch({ dismissed: false }), { ok: true, patch: { profile_nudge_dismissed: false } });
|
|
69
|
+
assert.deepEqual(p.validatePatch({ dismissed: null }), { ok: true, patch: { profile_nudge_dismissed: null } });
|
|
70
|
+
assert.deepEqual(p.validatePatch({}), { ok: true, patch: {} }, 'empty body is a valid no-op');
|
|
71
|
+
assert.equal(p.validatePatch({ dismissed: 'yes' }).ok, false);
|
|
72
|
+
assert.equal(p.validatePatch({ nope: true }).ok, false);
|
|
73
|
+
assert.equal(p.validatePatch(null).ok, false);
|
|
74
|
+
assert.equal(p.validatePatch([]).ok, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('applyPatch: null clears the key; sibling behavior knobs are never disturbed', () => {
|
|
78
|
+
const existing = { wandering: 'focused', widgets: false, interaction: { technical_level: 'engineer' } };
|
|
79
|
+
const set = p.applyPatch(existing, { profile_nudge_dismissed: true });
|
|
80
|
+
assert.equal(set.profile_nudge_dismissed, true);
|
|
81
|
+
assert.equal(set.wandering, 'focused');
|
|
82
|
+
assert.equal(set.widgets, false);
|
|
83
|
+
assert.deepEqual(set.interaction, { technical_level: 'engineer' });
|
|
84
|
+
// Clearing removes the key entirely (back to the sparse default) and still
|
|
85
|
+
// leaves the siblings alone — the undismiss path.
|
|
86
|
+
const cleared = p.applyPatch(set, { profile_nudge_dismissed: null });
|
|
87
|
+
assert.equal('profile_nudge_dismissed' in cleared, false);
|
|
88
|
+
assert.equal(cleared.wandering, 'focused');
|
|
89
|
+
assert.equal(p.nudgeDismissed(cleared), false, 'cleared → the nudge can return');
|
|
90
|
+
// Pure: the input blob is not mutated.
|
|
91
|
+
assert.equal('profile_nudge_dismissed' in existing, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('the reserved key is the documented one (behavior_prefs is shared)', () => {
|
|
95
|
+
assert.equal(p.RESERVED_KEY, 'profile_nudge_dismissed');
|
|
96
|
+
assert.equal(p.DEFAULT_DISMISSED, false);
|
|
97
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -910,8 +910,8 @@ test('the manage view ships as real markup with the four settings homes (C7)', (
|
|
|
910
910
|
'mVisibility', 'mJoinability', 'mRepoVisibility', 'mDelete']) {
|
|
911
911
|
assert.match(HUB, new RegExp(`id="${id}"`), `${id} must be real markup`);
|
|
912
912
|
}
|
|
913
|
-
//
|
|
914
|
-
assert.
|
|
913
|
+
// every settings home is live now (tasks 1003042 / 1003043 / 1003044) — no not-yet copy remains
|
|
914
|
+
assert.doesNotMatch(HUB, /isn’t wired up/, 'no section is a placeholder any more');
|
|
915
915
|
});
|
|
916
916
|
|
|
917
917
|
test('the manage view balances its own tree, like the wizard', () => {
|
|
@@ -1214,3 +1214,77 @@ test('the settings reads fire on entry + tab return only — never on the in-fli
|
|
|
1214
1214
|
assert.match(load, /res\.status === 401 \? signInAgain\(\)/, 'a lapsed session is said as sign-in, not as an outage');
|
|
1215
1215
|
assert.match(load, /var asked = manageId;/, 'a stale answer must never describe a newer project');
|
|
1216
1216
|
});
|
|
1217
|
+
|
|
1218
|
+
/* ── how people join (task 1003044): the joinability toggle — the visibility
|
|
1219
|
+
section's shape, key for key: the choice from the platform's row, what the
|
|
1220
|
+
project runs from its own manifest (brand.project.joinability), the save's
|
|
1221
|
+
push.message shown verbatim and never painted as the live state. ── */
|
|
1222
|
+
|
|
1223
|
+
test('the joinability section is live markup with its body slot and a sibling fold', () => {
|
|
1224
|
+
assert.match(HUB, /id="mJoin"/, 'the volatile slot is real markup, not JS-built');
|
|
1225
|
+
assert.match(HUB, /id="mJoinWhat"/, 'the what-changes fold ships with the page');
|
|
1226
|
+
const card = (HUB.match(/<div class="card" id="mJoinability">[\s\S]*?\n <\/div>/) || [''])[0];
|
|
1227
|
+
assert.ok(card, 'the joinability card must be extractable');
|
|
1228
|
+
const slotAt = card.indexOf('id="mJoin"');
|
|
1229
|
+
const foldAt = card.indexOf('id="mJoinWhat"');
|
|
1230
|
+
assert.ok(slotAt > 0 && foldAt > slotAt, 'the fold sits outside the slot it must survive');
|
|
1231
|
+
assert.doesNotMatch(card, /isn’t wired up|For now, people apply to join/, 'the placeholder copy is gone — the section is real now');
|
|
1232
|
+
const fold = (HUB.match(/<details class="finePrint" id="mJoinWhat">[\s\S]*?<\/details>/) || [''])[0];
|
|
1233
|
+
for (const label of ['Anyone can join', 'Apply to join', 'Invite only']) {
|
|
1234
|
+
assert.match(fold, new RegExp(`<b>${label}</b>`), `${label} is explained in the fold`);
|
|
1235
|
+
}
|
|
1236
|
+
assert.match(fold, /already members stay members/, 'a change never unseats a member');
|
|
1237
|
+
assert.match(fold, /restarts the project/, 'the restart is said, not hidden (ADR 0190)');
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
test('the joinability choices are the platform’s vocabulary, each in plain words, and the pick is a real radio group', () => {
|
|
1241
|
+
assert.match(SCRIPT, /var JOIN_OPTIONS = \{\s*open: \{ label: 'Anyone can join'/);
|
|
1242
|
+
assert.match(SCRIPT, /apply: \{ label: 'Apply to join'/);
|
|
1243
|
+
assert.match(SCRIPT, /invite_only: \{ label: 'Invite only'/);
|
|
1244
|
+
const render = fnHub('renderJoin');
|
|
1245
|
+
assert.match(render, /\(s\.options \|\| \[\]\)\.filter\(function \(v\) \{ return !!JOIN_OPTIONS\[v\]; \}\)/,
|
|
1246
|
+
'only values the page can describe are offered — the server’s options list is the source');
|
|
1247
|
+
assert.match(render, /role="radiogroup"/);
|
|
1248
|
+
assert.match(render, /type="radio" name="mJoinPick"/);
|
|
1249
|
+
assert.match(render, /data-join-save/, 'one Save control');
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test('the joinability save sends the one key and shows the platform’s receipt verbatim — never the reply as the live state', () => {
|
|
1253
|
+
const save = fnHub('doJoinSave');
|
|
1254
|
+
assert.match(save, /method: 'PATCH'/);
|
|
1255
|
+
assert.match(save, /\/settings'/, 'PATCH /provisioning/instances/:id/settings');
|
|
1256
|
+
assert.match(save, /JSON\.stringify\(\{ joinability: value \}\)/, 'the body is the one key this section owns');
|
|
1257
|
+
assert.match(save, /\(data\.push && data\.push\.message\)/, 'push.message is rendered as the server wrote it');
|
|
1258
|
+
assert.doesNotMatch(save, /joinRuns\(|mJoinRuns/, 'the "runs" line is never written from the save reply');
|
|
1259
|
+
assert.match(save, /e\.code === 'intent_conflict' \? 'projMsg' : 'projMsg err'/,
|
|
1260
|
+
'a 409 intent_conflict is saved-but-waiting — shown as the server’s sentence, not as a failure');
|
|
1261
|
+
assert.match(save, /if \(data\.push && data\.push\.queued\) mJoinPushSeen = true;/, 'a push that settles before the re-read still triggers the manifest read');
|
|
1262
|
+
assert.match(save, /loadManage\(true, true\)/, 'the strip + in-flight line re-read open_intent after a save');
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test('what the project RUNS for joinability comes only from its own manifest, and every answer is said', () => {
|
|
1266
|
+
const runs = fnHub('loadJoinRuns');
|
|
1267
|
+
assert.match(runs, /inst\.status !== 'active' \|\| !inst\.domain/, 'only a live project with an address can be asked');
|
|
1268
|
+
assert.match(runs, /'https:\/\/' \+ inst\.domain \+ '\/api\/gds\/instance'/, 'the same public GET /instance the runner reads');
|
|
1269
|
+
assert.match(runs, /p && p\.joinability/, 'the manifest key is brand.project.joinability');
|
|
1270
|
+
assert.match(runs, /doesn’t report this setting yet/, 'an older core is named, not shown as applied');
|
|
1271
|
+
assert.match(runs, /hasn’t taken effect there yet/, 'a saved-but-not-applied value is said');
|
|
1272
|
+
assert.match(runs, /Couldn’t reach the project/, 'an unreachable project is said');
|
|
1273
|
+
assert.match(runs, /if \(!res\.ok\) throw new Error\('http:' \+ res\.status\)/, 'a refusal is not an older core');
|
|
1274
|
+
assert.match(runs, /answered but didn’t share what it’s running/, 'a project that answered-and-refused is said as such');
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test('the joinability in-flight line shares the visibility section’s push predicate and re-asks the manifest when it settles', () => {
|
|
1278
|
+
const st = fnHub('renderJoinState');
|
|
1279
|
+
assert.match(st, /if \(visPushOpen\(inst\)\)/, 'one settings push carries every key — one predicate, shared');
|
|
1280
|
+
assert.doesNotMatch(st, /fetch\(/, 'fetch-free — the minute tick may call it');
|
|
1281
|
+
assert.match(st, /Until then it keeps its current setting\./, 'a queued push does not claim the new value');
|
|
1282
|
+
assert.match(st, /if \(mJoinPushSeen\) \{ mJoinPushSeen = false; loadJoinRuns\(inst\); \}/,
|
|
1283
|
+
'the manifest is asked again the moment the push is gone');
|
|
1284
|
+
assert.match(fnHub('loadJoinability'), /if \(!visPushOpen\(inst\)\) loadJoinRuns\(inst\);/, 'entering during an open push keeps the intent line');
|
|
1285
|
+
assert.match(fnHub('loadJoinability'), /var asked = manageId;/, 'a stale answer must never describe a newer project');
|
|
1286
|
+
assert.match(fnHub('renderManage'), /renderJoinState\(inst\);/, 'renderManage repaints it on every tick');
|
|
1287
|
+
assert.match(SCRIPT, /if \(!keepRepo\) loadJoinability\(inst\);/, 'the settings read fires on entry + tab return only');
|
|
1288
|
+
assert.match(SCRIPT, /\$\('mJoin'\)\.addEventListener\('click'[\s\S]{0,200}data-join-save[\s\S]{0,40}doJoinSave\(\)/,
|
|
1289
|
+
'the Save control is wired to ITS save — a copy that left the listener on #mVis ships a dead button');
|
|
1290
|
+
});
|
|
@@ -166,7 +166,7 @@ test('an in-flight project is watched on its own page; a settled one keeps the C
|
|
|
166
166
|
assert.match(SCRIPT, /setInterval\(manageWatchTick, 5000\)/);
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
test('
|
|
169
|
+
test('the manage sections keep their order, and no placeholder copy remains (tasks 1003043 + 1003044)', () => {
|
|
170
170
|
const repo = HUB.indexOf('id="mRepoVisibility"');
|
|
171
171
|
const vis = HUB.indexOf('id="mVisibility"');
|
|
172
172
|
const join = HUB.indexOf('id="mJoinability"');
|
|
@@ -175,7 +175,9 @@ test('working sections come before the not-wired one on manage, and the placehol
|
|
|
175
175
|
// "who can see it" is live since task 1003043 — its not-yet line is gone
|
|
176
176
|
const visCard = HUB.slice(vis, join);
|
|
177
177
|
assert.doesNotMatch(visCard, /isn’t wired up|For now, every project’s pages are open to everyone/);
|
|
178
|
-
|
|
178
|
+
// "how people join" is live since task 1003044 — the last placeholder is gone
|
|
179
|
+
const joinCard = HUB.slice(join, del);
|
|
180
|
+
assert.doesNotMatch(joinCard, /isn’t wired up|For now, people apply to join and you invite them/);
|
|
179
181
|
assert.doesNotMatch(HUB, /It lands in one of the next updates/);
|
|
180
182
|
});
|
|
181
183
|
|
|
@@ -107,20 +107,27 @@ test('a domainless instance gets an honest no-address state', async () => {
|
|
|
107
107
|
assert.match(body, /[Nn]o address yet/);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
test('the manage view is served: identity block +
|
|
110
|
+
test('the manage view is served: identity block + the four settings homes, all live (task 1003041)', async () => {
|
|
111
111
|
const { body } = await request('/projects', 'game.test.local');
|
|
112
112
|
assert.match(body, /id="view-manage"/);
|
|
113
|
-
// the four settings homes the
|
|
113
|
+
// the four settings homes the manage-wave tasks filled in
|
|
114
114
|
for (const id of ['mVisibility', 'mJoinability', 'mRepoVisibility', 'mDelete']) {
|
|
115
115
|
assert.match(body, new RegExp(`id="${id}"`), `${id} must be served`);
|
|
116
116
|
}
|
|
117
|
-
assert.
|
|
117
|
+
assert.doesNotMatch(body, /isn’t wired up/, 'every settings section is live — no not-yet copy is served');
|
|
118
118
|
// the not-found state is one clean answer — never a permission hint
|
|
119
119
|
const missing = (body.match(/<section id="mMissing"[\s\S]*?<\/section>/) || [''])[0];
|
|
120
120
|
assert.ok(missing, 'the not-found state must be served');
|
|
121
121
|
assert.doesNotMatch(missing, /permission|allowed|access|owner/i);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test('the joinability section is served live, with its fold (task 1003044)', async () => {
|
|
125
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
126
|
+
assert.match(body, /id="mJoin"/, 'the live section body is served');
|
|
127
|
+
assert.match(body, /id="mJoinWhat"/, 'the what-changes fold is served');
|
|
128
|
+
assert.match(body, /already members stay members/, 'the promise survives to the wire, not just the source');
|
|
129
|
+
});
|
|
130
|
+
|
|
124
131
|
test('the delete section is served live, and never claims to erase (task 1003042)', async () => {
|
|
125
132
|
const { body } = await request('/projects', 'game.test.local');
|
|
126
133
|
assert.match(body, /id="mDel"/, 'the live section body is served');
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// tests/ship_push_diagnosis.mjs — a terminal push failure must not advise a retry
|
|
2
|
+
// (task 1003293).
|
|
3
|
+
//
|
|
4
|
+
// A failed `git push` in ciLandLocal already parks the task at `confirmed` and
|
|
5
|
+
// opens no PR, which is correct. The ADVICE was the defect: "Fix the push and
|
|
6
|
+
// re-ship" cannot work for the most common cause — a commit touching
|
|
7
|
+
// .github/workflows/ pushed with a credential lacking GitHub's `workflow` scope.
|
|
8
|
+
// Re-shipping fails identically, and the reconciler cannot heal a branch that
|
|
9
|
+
// never reached the remote, so the task sits at `confirmed` looking like an
|
|
10
|
+
// ordinary pending land while its code exists on exactly one machine. That
|
|
11
|
+
// happened twice in one session.
|
|
12
|
+
//
|
|
13
|
+
// These tests pin the distinction the fix turns on: TERMINAL (retrying is
|
|
14
|
+
// pointless — say what actually resolves it) vs transient (retrying is the
|
|
15
|
+
// answer).
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const { pushDiagnosis, PUSH_DIAGNOSES } = require(path.join(ROOT, 'scripts', 'gds', 'ship-land.js'));
|
|
25
|
+
|
|
26
|
+
test('THE case that stranded two tasks: the workflow scope rejection', () => {
|
|
27
|
+
// GitHub's real wording, verbatim from the run that stranded task 1003283.
|
|
28
|
+
const d = pushDiagnosis(
|
|
29
|
+
'remote: error: refusing to allow an OAuth App to create or update workflow '
|
|
30
|
+
+ '`.github/workflows/publish.yml` without `workflow` scope\n'
|
|
31
|
+
+ 'To https://github.com/owner/repo.git\n ! [remote rejected] branch -> branch');
|
|
32
|
+
assert.equal(d.id, 'workflow_scope');
|
|
33
|
+
assert.equal(d.terminal, true, 'retrying this can never succeed — saying "re-ship" is what stranded the work');
|
|
34
|
+
assert.match(d.fix, /ssh|workflow/i, 'the advice must name the remedy that actually works');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('branch protection and a dead credential are terminal too', () => {
|
|
38
|
+
assert.equal(pushDiagnosis('remote: error: GH006: Protected branch update failed').terminal, true);
|
|
39
|
+
assert.equal(pushDiagnosis('fatal: Authentication failed for https://github.com/o/r.git/').terminal, true);
|
|
40
|
+
assert.equal(pushDiagnosis('git@github.com: Permission denied (publickey).').terminal, true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a stale branch IS retryable — the honest "re-ship" case', () => {
|
|
44
|
+
const d = pushDiagnosis('error: failed to push some refs\nUpdates were rejected because the remote contains work that you do not have locally');
|
|
45
|
+
assert.equal(d.id, 'stale_branch');
|
|
46
|
+
assert.equal(d.terminal, false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an unrecognised failure defaults to RETRYABLE, never to terminal', () => {
|
|
50
|
+
// The safe direction. A wrong "this can never work" sends someone hunting a
|
|
51
|
+
// problem that does not exist; a wrong "try again" costs one command.
|
|
52
|
+
const d = pushDiagnosis('fatal: the remote end hung up unexpectedly');
|
|
53
|
+
assert.equal(d.id, 'unknown');
|
|
54
|
+
assert.equal(d.terminal, false);
|
|
55
|
+
assert.equal(pushDiagnosis('').terminal, false);
|
|
56
|
+
assert.equal(pushDiagnosis(null).terminal, false);
|
|
57
|
+
assert.equal(pushDiagnosis(undefined).terminal, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('every diagnosis carries both a cause and a remedy', () => {
|
|
61
|
+
// A classification with no `fix` is just a nicer way of saying "it broke",
|
|
62
|
+
// which is the state this replaced.
|
|
63
|
+
for (const d of PUSH_DIAGNOSES) {
|
|
64
|
+
assert.ok(d.id && d.why && d.fix, `incomplete diagnosis: ${JSON.stringify(d)}`);
|
|
65
|
+
assert.ok(d.why.length > 20, `${d.id}: "why" must explain, not label`);
|
|
66
|
+
assert.equal(typeof d.terminal, 'boolean');
|
|
67
|
+
}
|
|
68
|
+
const fallback = pushDiagnosis('anything at all');
|
|
69
|
+
assert.ok(fallback.why && fallback.fix);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the patterns do not collide — the first match is the RIGHT match', () => {
|
|
73
|
+
// A workflow-scope rejection also contains "error:" and often "403"; if the
|
|
74
|
+
// auth pattern matched it first, the advice would send someone to re-login
|
|
75
|
+
// instead of to SSH, and the push would fail again.
|
|
76
|
+
const workflowMsg = 'remote: error: refusing to allow an OAuth App to create or update workflow `.github/workflows/x.yml` without `workflow` scope';
|
|
77
|
+
assert.equal(pushDiagnosis(workflowMsg).id, 'workflow_scope');
|
|
78
|
+
});
|