@bongos/core 1.19.562 → 1.19.563
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 +56 -31
- 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 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md +79 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +69 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +127 -127
- package/docs/copy-registry.json +162 -162
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/lifecycle/db-versions.js +22 -0
- package/modules/lifecycle/db.js +2 -1
- package/modules/lifecycle/routes/version-route-authz.js +56 -0
- package/modules/lifecycle/routes/versions.js +22 -0
- package/modules/platform-identity/invite-suggestions.js +57 -0
- package/modules/platform-identity/routes/sso.js +8 -0
- package/modules/provisioning/routes/provisioning.js +56 -0
- package/modules/public-landing/public/assets/cosmos.css +16 -0
- package/modules/public-landing/public/projects.html +85 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/one_planning_version.mjs +161 -0
- package/tests/project_invite_suggestions.mjs +133 -0
- package/tests/project_invite_ui.mjs +171 -18
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// tests/one_planning_version.mjs — exactly one version plans at a time
|
|
2
|
+
// (BV1.R04, task 1003592, goal 1000086, ADR 0250 D5).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS AT ALL. On its own the rule looks like housekeeping. It is the
|
|
5
|
+
// second half of R03: once a building version's goal set is closed, the first
|
|
6
|
+
// thing a refused caller reaches for is a NEW version to put the goal on — and
|
|
7
|
+
// the scope gate becomes decorative. One planning version means one queue for
|
|
8
|
+
// future work and no parallel escape hatch.
|
|
9
|
+
//
|
|
10
|
+
// Scope, stated so the next reader does not think it was forgotten: this guards
|
|
11
|
+
// the PLANNING slot only. Creating a `shipped` or `frozen` version is archival
|
|
12
|
+
// bookkeeping. Refusing a second BUILDING version is D5's other half and belongs
|
|
13
|
+
// to task 1003606 (R19).
|
|
14
|
+
//
|
|
15
|
+
// The rule is a pure predicate (`authorizeVersionCreate`, the `authorizeGoalCreate`
|
|
16
|
+
// precedent), so it is EXERCISED here rather than string-matched — including the
|
|
17
|
+
// shapes a live-Postgres test would never set up. The route's own write path stays
|
|
18
|
+
// covered by versions_route.mjs against real Postgres; what is checked of the route
|
|
19
|
+
// here is only the wiring, which is all the route now contains.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/one_planning_version.mjs
|
|
22
|
+
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { makeSqlAwareClient } from './helpers.mjs';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { versionsWithStatus } = require('../modules/lifecycle/db.js');
|
|
32
|
+
const { authorizeVersionCreate } = require('../modules/lifecycle/routes/version-route-authz.js');
|
|
33
|
+
|
|
34
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
35
|
+
const V2 = { id: 'BONGOS-V2', name: 'Next' };
|
|
36
|
+
const V3 = { id: 'BONGOS-V3', name: 'After' };
|
|
37
|
+
|
|
38
|
+
function fakePool(rows) {
|
|
39
|
+
const c = makeSqlAwareClient(() => ({ rows }));
|
|
40
|
+
return { query: (sql, p) => c._client.query(sql, p), connect: c.connect, queries: c._client.queries };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- the decision, exercised ------------------------------------------------
|
|
44
|
+
|
|
45
|
+
test('the planning slot is free → allowed', () => {
|
|
46
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: [] }), { ok: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the planning slot is taken → named 409 that says which version has it', () => {
|
|
50
|
+
const d = authorizeVersionCreate({ status: 'planning', planning: [V2] });
|
|
51
|
+
assert.equal(d.ok, false);
|
|
52
|
+
assert.equal(d.status, 409);
|
|
53
|
+
assert.equal(d.body.error, 'planning_version_exists');
|
|
54
|
+
assert.match(d.body.message, /BONGOS-V2/,
|
|
55
|
+
'a caller who has to go look up which version is one step from cutting one');
|
|
56
|
+
assert.deepEqual(d.body.planning, [V2], 'and the rows travel with the refusal');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('creating a shipped, frozen or building version is untouched', () => {
|
|
60
|
+
// Archival bookkeeping must not be caught by a rule about the future queue,
|
|
61
|
+
// even while a planning version exists.
|
|
62
|
+
for (const status of ['shipped', 'frozen', 'building']) {
|
|
63
|
+
assert.deepEqual(authorizeVersionCreate({ status, planning: [V2] }), { ok: true },
|
|
64
|
+
`'${status}' is not the planning slot`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('more than one planning version already present → still refuses, names the first', () => {
|
|
69
|
+
// The state this rule exists to prevent may already exist (the cutover is R28).
|
|
70
|
+
// The gate must not throw or wave it through on the way to fixing it.
|
|
71
|
+
const d = authorizeVersionCreate({ status: 'planning', planning: [V2, V3] });
|
|
72
|
+
assert.equal(d.body.error, 'planning_version_exists');
|
|
73
|
+
assert.equal(d.body.planning.length, 2, 'the caller sees the full mess, not a sample');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a missing or malformed planning list does not crash the gate', () => {
|
|
77
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning' }), { ok: true },
|
|
78
|
+
'omitted defaults to empty — the route only reads when status is planning');
|
|
79
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: null }), { ok: true });
|
|
80
|
+
assert.deepEqual(authorizeVersionCreate({ status: 'planning', planning: [null, undefined] }), { ok: true },
|
|
81
|
+
'empty-after-filtering is empty; a falsy row must not become a phantom refusal');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the refusal is data, not a rendered string the route has to parse', () => {
|
|
85
|
+
const d = authorizeVersionCreate({ status: 'planning', planning: [V2] });
|
|
86
|
+
assert.deepEqual(Object.keys(d).sort(), ['body', 'ok', 'status']);
|
|
87
|
+
assert.ok(typeof d.body.error === 'string' && typeof d.status === 'number');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---- the read ---------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
test('versionsWithStatus binds the status and returns id + name only', async () => {
|
|
93
|
+
const pool = fakePool([V2]);
|
|
94
|
+
const out = await versionsWithStatus('planning', { pool });
|
|
95
|
+
assert.deepEqual(out, [V2]);
|
|
96
|
+
assert.match(String(pool.queries[0].sql), /SELECT id, name FROM versions WHERE status = \$1/);
|
|
97
|
+
assert.deepEqual(pool.queries[0].params, ['planning'], 'the status is bound, not interpolated');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('no planning version reads as an empty list, not a null', async () => {
|
|
101
|
+
assert.deepEqual(await versionsWithStatus('planning', { pool: fakePool([]) }), [],
|
|
102
|
+
'and the gate treats empty as allowed, closing the loop between read and decision');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---- the wiring, which is all the route now carries -------------------------
|
|
106
|
+
|
|
107
|
+
const ROUTE = (() => {
|
|
108
|
+
const s = src('modules/lifecycle/routes/versions.js');
|
|
109
|
+
const a = s.indexOf("router.post('/versions'");
|
|
110
|
+
assert.notEqual(a, -1, 'POST /versions not found');
|
|
111
|
+
const b = s.indexOf('router.', a + 20);
|
|
112
|
+
return s.slice(a, b === -1 ? s.length : b);
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
test('the route renders the decision and does not carry the rule', () => {
|
|
116
|
+
assert.match(ROUTE, /const versionDecision = authorizeVersionCreate\(\{/);
|
|
117
|
+
assert.match(ROUTE, /return res\.status\(versionDecision\.status\)\.json\(versionDecision\.body\);/);
|
|
118
|
+
assert.equal(/planning_version_exists/.test(ROUTE), false,
|
|
119
|
+
'the refusal is the gate\'s to name, not a second copy in the handler');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the route reads the planning versions ONLY when it could matter', () => {
|
|
123
|
+
// Creating a shipped version must not pay for a query about the planning queue.
|
|
124
|
+
assert.match(ROUTE, /status === 'planning' \? await db\.versionsWithStatus\('planning'\) : \[\]/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the gate runs AFTER validation and BEFORE the insert', () => {
|
|
128
|
+
const badStatus = ROUTE.indexOf("'bad_status'");
|
|
129
|
+
const exists = ROUTE.indexOf("'version_exists'");
|
|
130
|
+
const gate = ROUTE.indexOf('authorizeVersionCreate');
|
|
131
|
+
const write = ROUTE.indexOf('db.createVersion');
|
|
132
|
+
assert.ok(badStatus !== -1 && exists !== -1 && gate !== -1 && write !== -1);
|
|
133
|
+
assert.ok(badStatus < gate, 'an invalid status is its own answer, not a queue conflict');
|
|
134
|
+
assert.ok(exists < gate, 'a duplicate id keeps its own answer too');
|
|
135
|
+
assert.ok(gate < write, 'a refusal after the insert is not a refusal');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the default status is planning, so an unqualified create hits the gate', () => {
|
|
139
|
+
// The body may omit `status`. If that default ever changed, the gate would
|
|
140
|
+
// silently stop applying to the most common call.
|
|
141
|
+
assert.match(ROUTE, /const status = body\.status \|\| 'planning';/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the accepted race is described as unmitigated, because it is', () => {
|
|
145
|
+
// An earlier draft of this comment said the race was "left to a partial unique
|
|
146
|
+
// index" — which reads as though one exists. It does not, and a future reader
|
|
147
|
+
// taking that at face value would believe two concurrent creates cannot both
|
|
148
|
+
// slip through. Pinned so the honest wording cannot quietly regress.
|
|
149
|
+
assert.match(ROUTE, /RACE, ACCEPTED AND UNMITIGATED/);
|
|
150
|
+
assert.match(ROUTE, /no database\s*\n?\s*\/\/ constraint backs it/,
|
|
151
|
+
'the comment must say plainly that nothing catches it');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---- the rule is one half of a pair ----------------------------------------
|
|
155
|
+
|
|
156
|
+
test('R03 and R04 are the pair: closing the goal set, and closing the way around it', () => {
|
|
157
|
+
assert.match(src('modules/lifecycle/routes/goal-route-authz.js'), /function authorizeGoalCreate/,
|
|
158
|
+
'R03 closes a building version\'s goal set');
|
|
159
|
+
assert.equal(authorizeVersionCreate({ status: 'planning', planning: [V2] }).ok, false,
|
|
160
|
+
'and R04 closes the obvious route around it — neither is much use alone');
|
|
161
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// tests/project_invite_suggestions.mjs — WHO the creation wizard suggests
|
|
2
|
+
// inviting (BV1.R28, task 1002347, goal 1000046 criterion C6, ADR 0251).
|
|
3
|
+
//
|
|
4
|
+
// tests/project_invite_ui.mjs pins the SURFACE (how a suggestion renders and what
|
|
5
|
+
// clicking it sends). This file pins the LOOKUP behind it, and above all the two
|
|
6
|
+
// properties that are security rather than behaviour:
|
|
7
|
+
//
|
|
8
|
+
// • it reads the RECRUITING DIRECTORY, not the leaderboard — so the recruiting
|
|
9
|
+
// opt-out is honoured. The two readers differ by exactly one SQL predicate
|
|
10
|
+
// and the wrong one would hand an owner the name of every builder who asked
|
|
11
|
+
// not to be recruited (community spec D3; see scoutingListableSql).
|
|
12
|
+
// • `github_id` never leaves the module — the invite route takes a LOGIN, and
|
|
13
|
+
// the id names someone who has consented to nothing.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test --test-reporter=tap tests/project_invite_suggestions.mjs
|
|
16
|
+
|
|
17
|
+
import test from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
|
|
20
|
+
// ESM named import off the CJS module: knip cannot trace a createRequire()
|
|
21
|
+
// import, so a test-only export would read as dead code and move the ratchet.
|
|
22
|
+
import { listInviteSuggestions } from '../modules/platform-identity/invite-suggestions.js';
|
|
23
|
+
|
|
24
|
+
// A pool that records the SQL it was handed and replays canned rows.
|
|
25
|
+
function fakePool(rows) {
|
|
26
|
+
const seen = [];
|
|
27
|
+
return {
|
|
28
|
+
seen,
|
|
29
|
+
query: async (text, params) => {
|
|
30
|
+
seen.push({ text, params });
|
|
31
|
+
return { rows };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BUILDERS = [
|
|
37
|
+
{ github_id: 1, github_login: 'ada', display_name: 'Ada L', avatar_url: 'a.png', disciplines: ['engineer'], project_count: 3, total_tasks_shipped: 40, total_credits: 900 },
|
|
38
|
+
{ github_id: 2, github_login: 'grace', display_name: 'Grace H', avatar_url: 'g.png', disciplines: ['engineer'], project_count: 2, total_tasks_shipped: 30, total_credits: 800 },
|
|
39
|
+
{ github_id: 3, github_login: 'linus', display_name: null, avatar_url: null, disciplines: null, project_count: 1, total_tasks_shipped: 10, total_credits: 100 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
test('it reads the RECRUITING directory — the consent predicate is in the query it runs', async () => {
|
|
43
|
+
// Arrange
|
|
44
|
+
const pool = fakePool(BUILDERS);
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
await listInviteSuggestions({ limit: 3, pool });
|
|
48
|
+
|
|
49
|
+
// Assert — the opt-out axis (`recruiter_reach`/opt-out columns via
|
|
50
|
+
// scoutingListableSql) must appear in the SQL. A leaderboard read would join
|
|
51
|
+
// rollupVisibleSql instead and quietly include opted-out builders.
|
|
52
|
+
const sql = pool.seen[0].text;
|
|
53
|
+
assert.match(sql, /platform_identity_builder_project_activity/, 'the cross-project activity rollup, not the credits ledger');
|
|
54
|
+
assert.match(sql, /platform_identity_accounts/, 'joined to accounts for the consent predicate');
|
|
55
|
+
assert.doesNotMatch(sql, /FROM\s+builders\b/, 'never the instance leaderboard table');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('github_id never crosses the boundary — the caller gets the login, which is what an invite takes', async () => {
|
|
59
|
+
// Act
|
|
60
|
+
const out = await listInviteSuggestions({ limit: 3, pool: fakePool(BUILDERS) });
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
assert.equal(out.length, 3);
|
|
64
|
+
for (const row of out) {
|
|
65
|
+
assert.ok(!('github_id' in row), 'no github_id on a suggestion');
|
|
66
|
+
assert.ok(row.github_login, 'the login IS the address the invite route takes');
|
|
67
|
+
}
|
|
68
|
+
assert.deepEqual(Object.keys(out[0]).sort(),
|
|
69
|
+
['avatar_url', 'disciplines', 'display_name', 'github_login', 'project_count', 'total_tasks_shipped'],
|
|
70
|
+
'an explicit whitelist, so a new column on the rollup cannot leak by default');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the owner is dropped from their own suggestions, and the list still fills', async () => {
|
|
74
|
+
// Act — ada (1) is the owner asking
|
|
75
|
+
const out = await listInviteSuggestions({ limit: 2, exclude: [1], pool: fakePool(BUILDERS) });
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
assert.deepEqual(out.map((r) => r.github_login), ['grace', 'linus'],
|
|
79
|
+
'the owner is gone and the next builder moved up — the limit is still met');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the over-read is sized to the exclusion, so filtering can never return short', async () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
const pool = fakePool(BUILDERS);
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
await listInviteSuggestions({ limit: 3, exclude: [1, 2], pool });
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
assert.equal(pool.seen[0].params[0], 5, 'asked for limit + the two it may have to drop');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a limit of zero asks the database nothing at all', async () => {
|
|
94
|
+
// Arrange
|
|
95
|
+
const pool = fakePool(BUILDERS);
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
const out = await listInviteSuggestions({ limit: 0, pool });
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
assert.deepEqual(out, []);
|
|
102
|
+
assert.equal(pool.seen.length, 0, 'no seek, no query — the solo answer costs nothing');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the limit is clamped, so a caller cannot turn a suggestion into a directory dump', async () => {
|
|
106
|
+
// Arrange
|
|
107
|
+
const pool = fakePool(BUILDERS);
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
await listInviteSuggestions({ limit: 9999, pool });
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
113
|
+
assert.equal(pool.seen[0].params[0], 25, 'clamped to the ceiling');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a row with no login is dropped — it cannot be invited, so it is not a suggestion', async () => {
|
|
117
|
+
// Act
|
|
118
|
+
const out = await listInviteSuggestions({
|
|
119
|
+
limit: 5,
|
|
120
|
+
pool: fakePool([{ github_id: 9, github_login: null, display_name: 'Ghost' }, ...BUILDERS]),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
assert.deepEqual(out.map((r) => r.github_login), ['ada', 'grace', 'linus']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a missing disciplines array reads as empty rather than null', async () => {
|
|
128
|
+
// Act
|
|
129
|
+
const out = await listInviteSuggestions({ limit: 5, pool: fakePool(BUILDERS) });
|
|
130
|
+
|
|
131
|
+
// Assert — linus has disciplines: null on the row
|
|
132
|
+
assert.deepEqual(out[2].disciplines, [], 'the surface can map() it without a guard');
|
|
133
|
+
});
|
|
@@ -48,7 +48,8 @@ const END = SCRIPT.indexOf('/* ── boot: who am I');
|
|
|
48
48
|
assert.ok(START > 0, 'the invite region must start at INVITE_MOUNTS');
|
|
49
49
|
assert.ok(END > START, 'the invite region must end before the boot block');
|
|
50
50
|
const REGION = SCRIPT.slice(START, END);
|
|
51
|
-
for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal('
|
|
51
|
+
for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function skipInvite(', 'function inviteRefusal(',
|
|
52
|
+
'function setInviteSuggestions(', 'function renderInviteSugg(', 'function loadInviteSuggestions(']) {
|
|
52
53
|
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -59,24 +60,43 @@ for (const fn of ['function setInviteSlug(', 'function doInvite(', 'function ski
|
|
|
59
60
|
const ROW = /<li><b>([^<]*)<\/b><small>([^<]*)<\/small><\/li>/g;
|
|
60
61
|
const parseRows = (html) => [...String(html).matchAll(ROW)].map((m) => ({ login: m[1], said: m[2] }));
|
|
61
62
|
|
|
63
|
+
// A suggestion row: whatever the row NAMES, plus the login its button carries.
|
|
64
|
+
// Parsed off the button so the test reads the address the click will actually
|
|
65
|
+
// send, not the label a row happened to render.
|
|
66
|
+
const SUGG = /<li><span class="suggWho">(.*?)<\/span><button [^>]*data-invite-pick="([^"]*)"[^>]*>/g;
|
|
67
|
+
const parseSugg = (html) => [...String(html).matchAll(SUGG)].map((m) => ({ named: m[1], login: m[2] }));
|
|
68
|
+
|
|
62
69
|
function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
63
70
|
const requests = [];
|
|
64
71
|
const els = new Map();
|
|
65
72
|
const listeners = new Map(); // "elId:type" → [fn]
|
|
66
73
|
const focused = [];
|
|
67
74
|
|
|
68
|
-
const makeEl = (id) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
const makeEl = (id) => {
|
|
76
|
+
const classes = new Set();
|
|
77
|
+
return {
|
|
78
|
+
_id: id, _html: '', textContent: '', className: '', value: '', hidden: false,
|
|
79
|
+
_classes: classes,
|
|
80
|
+
set innerHTML(v) { this._html = String(v); },
|
|
81
|
+
get innerHTML() { return this._html; },
|
|
82
|
+
// task 1002347 (R28): the suggestion surface shows its reason line the
|
|
83
|
+
// .modWhy way — a class toggle, not a hidden flag — so the stub grows the
|
|
84
|
+
// one DOM affordance that answer needs.
|
|
85
|
+
classList: {
|
|
86
|
+
toggle(name, on) { if (on) classes.add(name); else classes.delete(name); },
|
|
87
|
+
add(name) { classes.add(name); },
|
|
88
|
+
remove(name) { classes.delete(name); },
|
|
89
|
+
contains(name) { return classes.has(name); },
|
|
90
|
+
},
|
|
91
|
+
addEventListener(type, fn) {
|
|
92
|
+
const key = id + ':' + type;
|
|
93
|
+
if (!listeners.has(key)) listeners.set(key, []);
|
|
94
|
+
listeners.get(key).push(fn);
|
|
95
|
+
},
|
|
96
|
+
setAttribute() {},
|
|
97
|
+
focus() { focused.push(id); },
|
|
98
|
+
};
|
|
99
|
+
};
|
|
80
100
|
const $ = (id) => {
|
|
81
101
|
if (id == null) return null;
|
|
82
102
|
if (!els.has(id)) els.set(id, makeEl(id));
|
|
@@ -86,7 +106,9 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
86
106
|
// Every mount's elements exist before the region wires them, exactly as the
|
|
87
107
|
// real page has them: the script runs at the end of <body>.
|
|
88
108
|
['wizInvite', 'wizInviteWho', 'wizInviteList', 'wizInviteMsg', 'wizInviteLater', 'doneStepsHead',
|
|
89
|
-
'
|
|
109
|
+
'wizInviteWhy', 'wizInviteSugg',
|
|
110
|
+
'mInvite', 'mInviteWho', 'mInviteList', 'mInviteMsg',
|
|
111
|
+
'mInviteWhy', 'mInviteSugg'].forEach($);
|
|
90
112
|
|
|
91
113
|
const queued = answers.slice();
|
|
92
114
|
const fetch = (url, init = {}) => {
|
|
@@ -113,19 +135,20 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
113
135
|
vm.runInContext(REGION, sandbox, { filename: 'projects.html#project-invite' });
|
|
114
136
|
|
|
115
137
|
const mounts = {
|
|
116
|
-
wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg' },
|
|
117
|
-
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg' },
|
|
138
|
+
wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
|
|
139
|
+
mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', why: 'mInviteWhy', sugg: 'mInviteSugg' },
|
|
118
140
|
};
|
|
119
141
|
const m = mounts[mount];
|
|
120
142
|
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
121
143
|
|
|
144
|
+
let pickLogin = '';
|
|
122
145
|
const press = async (marker) => {
|
|
123
146
|
const handlers = listeners.get(mount + ':click') || [];
|
|
124
147
|
assert.equal(handlers.length, 1, 'the mount wires exactly one delegated click handler');
|
|
125
148
|
handlers[0]({
|
|
126
149
|
target: {
|
|
127
|
-
closest: (sel) => (sel === '[data-invite-send],[data-invite-skip]'
|
|
128
|
-
? { hasAttribute: (a) => a === marker }
|
|
150
|
+
closest: (sel) => (sel === '[data-invite-send],[data-invite-skip],[data-invite-pick]'
|
|
151
|
+
? { hasAttribute: (a) => a === marker, getAttribute: () => pickLogin }
|
|
129
152
|
: null),
|
|
130
153
|
},
|
|
131
154
|
});
|
|
@@ -142,6 +165,13 @@ function boot({ mount = 'wizInvite', answers = [] } = {}) {
|
|
|
142
165
|
async type(login) { $(m.who).value = login; },
|
|
143
166
|
async send() { await press('data-invite-send'); },
|
|
144
167
|
async skip() { await press('data-invite-skip'); },
|
|
168
|
+
// ── the suggestion half (task 1002347, BV1.R28) ──────────────────────
|
|
169
|
+
why: () => $(m.why),
|
|
170
|
+
whyOn: () => $(m.why)._classes.has('on'),
|
|
171
|
+
suggested: () => parseSugg($(m.sugg).innerHTML),
|
|
172
|
+
async suggest(payload) { sandbox.setInviteSuggestions(mount, payload); await settle(); },
|
|
173
|
+
async load(instanceId) { sandbox.loadInviteSuggestions(mount, instanceId); await settle(); },
|
|
174
|
+
async pick(login) { pickLogin = login; await press('data-invite-pick'); },
|
|
145
175
|
async enter() {
|
|
146
176
|
const handlers = listeners.get(m.who + ':keydown') || [];
|
|
147
177
|
assert.equal(handlers.length, 1, 'the field takes Enter');
|
|
@@ -500,3 +530,126 @@ test('the manage view has a rendered state for the invite line', () => {
|
|
|
500
530
|
assert.ok(states['in-manage-invite'], 'the section has a kit state');
|
|
501
531
|
assert.deepEqual(states['in-manage-invite'].expect.visible, ['#mInvite', '#mInvite h2', '#mInviteWho', '#mInvite [data-invite-send]']);
|
|
502
532
|
});
|
|
533
|
+
|
|
534
|
+
// ── who is worth inviting (task 1002347, BV1.R28, ADR 0251) ──────────────────
|
|
535
|
+
// The engine states WHAT to look for and never who (ADR 0239 §6); these are the
|
|
536
|
+
// names, and the rules they must not break: the reason is always said, "nobody"
|
|
537
|
+
// is an answer rather than a blank, and a picked name takes the SAME write path
|
|
538
|
+
// as a typed one.
|
|
539
|
+
|
|
540
|
+
test('a suggestion list renders its names, and each row carries the login its button will send', async () => {
|
|
541
|
+
const h = boot();
|
|
542
|
+
await h.open('mercury');
|
|
543
|
+
await h.suggest({
|
|
544
|
+
suggested: [
|
|
545
|
+
{ github_login: 'ada', display_name: 'Ada L', total_tasks_shipped: 12, project_count: 3 },
|
|
546
|
+
{ github_login: 'grace', display_name: null, total_tasks_shipped: 0, project_count: 0 },
|
|
547
|
+
],
|
|
548
|
+
seeking: { kind: 'top-builders', limit: 3 },
|
|
549
|
+
reason: 'A small team is the shape that fills fastest.',
|
|
550
|
+
});
|
|
551
|
+
assert.deepEqual(h.suggested().map((r) => r.login), ['ada', 'grace']);
|
|
552
|
+
assert.match(h.suggested()[0].named, /Ada L/, 'a display name is used when the row carries one');
|
|
553
|
+
assert.match(h.suggested()[0].named, /@ada/, 'and the login is still shown — it is the address');
|
|
554
|
+
assert.match(h.suggested()[1].named, /<b>@grace<\/b>/, 'with no display name the login stands alone');
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('the reason is ALWAYS said — including when the answer is nobody', async () => {
|
|
558
|
+
const h = boot();
|
|
559
|
+
await h.open('mercury');
|
|
560
|
+
await h.suggest({ suggested: [], seeking: null, reason: 'You said you are building alone, so nobody is suggested.' });
|
|
561
|
+
assert.equal(h.suggested().length, 0, 'no names');
|
|
562
|
+
assert.equal(h.why().textContent, 'You said you are building alone, so nobody is suggested.');
|
|
563
|
+
assert.ok(h.whyOn(), 'and the line is shown — "nobody" is an answer, not a blank state');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('no payload at all says nothing — a failed lookup must not invent a reason', async () => {
|
|
567
|
+
const h = boot();
|
|
568
|
+
await h.open('mercury');
|
|
569
|
+
await h.suggest(null);
|
|
570
|
+
assert.equal(h.suggested().length, 0);
|
|
571
|
+
assert.equal(h.why().textContent, '');
|
|
572
|
+
assert.ok(!h.whyOn(), 'the reason line is off entirely, not empty-but-present');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('picking a suggestion sends exactly what typing that name would have sent', async () => {
|
|
576
|
+
const h = boot();
|
|
577
|
+
await h.open('mercury');
|
|
578
|
+
await h.suggest({ suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
|
|
579
|
+
await h.pick('ada');
|
|
580
|
+
const posts = h.requests.filter((r) => r.method === 'POST');
|
|
581
|
+
assert.equal(posts.length, 1, 'one invite, through the one write path');
|
|
582
|
+
assert.deepEqual(JSON.parse(posts[0].body), { client_id: 'mercury', github_login: 'ada' },
|
|
583
|
+
'the same body shape as a typed invite — the slug IS the client_id');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('a name invited this session stops being suggested — the pending line is its receipt', async () => {
|
|
587
|
+
const h = boot();
|
|
588
|
+
await h.open('mercury');
|
|
589
|
+
await h.suggest({ suggested: [{ github_login: 'ada' }, { github_login: 'grace' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
|
|
590
|
+
assert.equal(h.suggested().length, 2);
|
|
591
|
+
await h.pick('ada');
|
|
592
|
+
assert.deepEqual(h.suggested().map((r) => r.login), ['grace'], 'ada moved to the pending list and left the suggestions');
|
|
593
|
+
assert.deepEqual(h.rows().map((r) => r.login), ['ada'], 'and she is on the pending list exactly once');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('switching the mount to another project drops the suggestions with the pending rows', async () => {
|
|
597
|
+
const h = boot();
|
|
598
|
+
await h.open('mercury');
|
|
599
|
+
await h.suggest({ suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' });
|
|
600
|
+
await h.open('venus');
|
|
601
|
+
assert.equal(h.suggested().length, 0, 'the names belonged to the project they were suggested for');
|
|
602
|
+
assert.ok(!h.whyOn(), 'and so did the reason');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test('the suggestions read is own-scoped by ADDRESS: it asks the instance route, with the session', async () => {
|
|
606
|
+
const h = boot({ answers: [{ ok: true, status: 200, body: { suggested: [{ github_login: 'ada' }], seeking: { kind: 'top-builders', limit: 3 }, reason: 'r' } }] });
|
|
607
|
+
await h.open('mercury');
|
|
608
|
+
await h.load(41);
|
|
609
|
+
const get = h.requests.find((r) => r.method === 'GET');
|
|
610
|
+
assert.equal(get.url, '/api/bongos/provisioning/instances/41/invite-suggestions');
|
|
611
|
+
assert.equal(get.credentials, 'same-origin', 'the cookie is the whole gate — the route 404s for anyone else');
|
|
612
|
+
assert.deepEqual(h.suggested().map((r) => r.login), ['ada']);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('no instance id asks nothing at all', async () => {
|
|
616
|
+
const h = boot();
|
|
617
|
+
await h.open('mercury');
|
|
618
|
+
await h.load('');
|
|
619
|
+
assert.equal(h.requests.filter((r) => r.method === 'GET').length, 0, 'no project, no question');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('a refused or unreachable lookup is SILENT — a created project never looks broken over an extra', async () => {
|
|
623
|
+
for (const answer of [{ ok: false, status: 404, body: {} }, { reject: true }]) {
|
|
624
|
+
const h = boot({ answers: [answer] });
|
|
625
|
+
await h.open('mercury');
|
|
626
|
+
await h.load(41);
|
|
627
|
+
assert.equal(h.suggested().length, 0, 'no names');
|
|
628
|
+
assert.equal(h.msg().textContent, '', 'and NOT a word in the invite message line');
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('a suggestion never renders a github_id, and its name goes through the escaper', async () => {
|
|
633
|
+
const h = boot();
|
|
634
|
+
await h.open('mercury');
|
|
635
|
+
await h.suggest({
|
|
636
|
+
suggested: [{ github_login: 'x"><img src=x onerror=1>', display_name: '<b>bad</b>', github_id: 4242, total_tasks_shipped: 1 }],
|
|
637
|
+
seeking: { kind: 'top-builders', limit: 3 },
|
|
638
|
+
reason: 'r',
|
|
639
|
+
});
|
|
640
|
+
const html = h.sandbox.$('wizInviteSugg').innerHTML;
|
|
641
|
+
assert.doesNotMatch(html, /4242/, 'the id names someone who has accepted nothing');
|
|
642
|
+
assert.doesNotMatch(html, /<img/, 'the login is escaped inside the attribute it rides in');
|
|
643
|
+
assert.doesNotMatch(html, /<b>bad<\/b>/, 'and the display name is escaped in the body');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('the suggestion surface is mounted on BOTH the wizard and the project page', () => {
|
|
647
|
+
assert.match(HUB, /<ul class="suggList" id="wizInviteSugg"><\/ul>/, 'the wizard mount');
|
|
648
|
+
assert.match(HUB, /<ul class="suggList" id="mInviteSugg"><\/ul>/, 'and the project page mount');
|
|
649
|
+
assert.match(SCRIPT, /loadInviteSuggestions\('wizInvite', \(me && created && created\.slug\) \? created\.id : ''\)/,
|
|
650
|
+
'the wizard asks keyed on the SAME created record the slug comes from (ADR 0249 §1)');
|
|
651
|
+
assert.match(SCRIPT, /loadInviteSuggestions\('mInvite', inst\.slug \? inst\.id : ''\)/,
|
|
652
|
+
'and the manage page asks with the id whose read already proved ownership');
|
|
653
|
+
assert.match(COSMOS, /\.suggList\{/, 'styled in cosmos.css like its sibling list');
|
|
654
|
+
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>') + 1), /suggList/, 'not in the page head');
|
|
655
|
+
});
|