@bongos/core 1.19.170 → 1.19.171
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 +18 -13
- package/modules/hall-ui/public/profile.html +13 -1
- package/modules/hall-ui/public/profile.js +98 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +3 -1
- package/tests/profile_ui_cross_project.mjs +182 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.171",
|
|
6
|
+
"core_contract": "1.19.171",
|
|
7
|
+
"source_commit": "d102a0205fd62edcc81a804a8c439af4cde60606",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-16T19:20:36.950Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 305,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1269,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1595,
|
|
20
|
+
"tree_sha256": "1596294ffbb9fe146a18ced51823d4b074c23b5dd47601619bcd16752c5eaf04",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -3317,12 +3317,12 @@
|
|
|
3317
3317
|
{
|
|
3318
3318
|
"path": "modules/hall-ui/public/profile.html",
|
|
3319
3319
|
"mode": "0000644",
|
|
3320
|
-
"sha256": "
|
|
3320
|
+
"sha256": "b8d5fb31d92f6d65e8f257d10c77f4ee0aa0ebec50d977d4c7d918ff2253da1e"
|
|
3321
3321
|
},
|
|
3322
3322
|
{
|
|
3323
3323
|
"path": "modules/hall-ui/public/profile.js",
|
|
3324
3324
|
"mode": "0000644",
|
|
3325
|
-
"sha256": "
|
|
3325
|
+
"sha256": "a4cf0c4c2d088fd1fb84c41c93cc28e2acd2b656b0bc995cd46043881b3611fa"
|
|
3326
3326
|
},
|
|
3327
3327
|
{
|
|
3328
3328
|
"path": "modules/hall-ui/public/ranks.html",
|
|
@@ -4212,12 +4212,12 @@
|
|
|
4212
4212
|
{
|
|
4213
4213
|
"path": "package-lock.json",
|
|
4214
4214
|
"mode": "0000644",
|
|
4215
|
-
"sha256": "
|
|
4215
|
+
"sha256": "90e1fc07f655eb2874620b5a85f965e4f32cc20aeddb552dbc8885c4681150d9"
|
|
4216
4216
|
},
|
|
4217
4217
|
{
|
|
4218
4218
|
"path": "package.json",
|
|
4219
4219
|
"mode": "0000644",
|
|
4220
|
-
"sha256": "
|
|
4220
|
+
"sha256": "2b1408849261b93418e448820da8081a7ec83fa6b5c2a51b9e1aa5dca7456efb"
|
|
4221
4221
|
},
|
|
4222
4222
|
{
|
|
4223
4223
|
"path": "public-docs/index.html",
|
|
@@ -5477,7 +5477,7 @@
|
|
|
5477
5477
|
{
|
|
5478
5478
|
"path": "scripts/hall-preview/server.js",
|
|
5479
5479
|
"mode": "0000644",
|
|
5480
|
-
"sha256": "
|
|
5480
|
+
"sha256": "1cd66475d610b107b0f3b7f19688133e7c482b41b034e2cef6009d552df31cb2"
|
|
5481
5481
|
},
|
|
5482
5482
|
{
|
|
5483
5483
|
"path": "scripts/migrate.sh",
|
|
@@ -5822,7 +5822,7 @@
|
|
|
5822
5822
|
{
|
|
5823
5823
|
"path": "src/module-api.js",
|
|
5824
5824
|
"mode": "0000644",
|
|
5825
|
-
"sha256": "
|
|
5825
|
+
"sha256": "17202c8572c75ca1af5cd2eee1c8dd1ba4b6ea4c5fd7cd757eaf97907d26a268"
|
|
5826
5826
|
},
|
|
5827
5827
|
{
|
|
5828
5828
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7389,6 +7389,11 @@
|
|
|
7389
7389
|
"mode": "0000644",
|
|
7390
7390
|
"sha256": "3b3045a6143d67c963516ed5703b5dabb9b8945a392118721e6a0a56258f1732"
|
|
7391
7391
|
},
|
|
7392
|
+
{
|
|
7393
|
+
"path": "tests/profile_ui_cross_project.mjs",
|
|
7394
|
+
"mode": "0000644",
|
|
7395
|
+
"sha256": "6a7a223a678b56333befaed017429e5b9d8a81e4405efae72a23355902517d9d"
|
|
7396
|
+
},
|
|
7392
7397
|
{
|
|
7393
7398
|
"path": "tests/project_stats.mjs",
|
|
7394
7399
|
"mode": "0000644",
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
</div>
|
|
41
41
|
</section>
|
|
42
42
|
|
|
43
|
+
<!-- Cross-project record — hub-only (ADR 0171). profile.js reveals this
|
|
44
|
+
only when the profile response carries a cross_project key; on a
|
|
45
|
+
vanilla instance the key is absent and the section never renders. -->
|
|
46
|
+
<section class="scroll" id="xp-scroll" aria-labelledby="xp-h" hidden>
|
|
47
|
+
<h2 id="xp-h" class="scroll__h">Cross-project record</h2>
|
|
48
|
+
<div id="xp-body"></div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
43
51
|
<!-- Achievements — the builder's earned shelf. -->
|
|
44
52
|
<section class="scroll scroll--bare" id="ach-scroll" aria-labelledby="ach-h">
|
|
45
53
|
<h2 id="ach-h" class="scroll__h">Achievements</h2>
|
|
@@ -66,6 +74,10 @@
|
|
|
66
74
|
</tbody>
|
|
67
75
|
</table>
|
|
68
76
|
</section>
|
|
77
|
+
|
|
78
|
+
<!-- Reserved slots — connections, guilds, and actions (profile-page
|
|
79
|
+
spec sections 6–8) mount here in later phases; keeping the order
|
|
80
|
+
fixed now means the page is not re-laid-out twice (Decision 5). -->
|
|
69
81
|
</main>
|
|
70
82
|
|
|
71
83
|
<footer class="app-foot" id="app-foot"></footer>
|
|
@@ -83,6 +95,6 @@
|
|
|
83
95
|
<!-- shared frontend toolkit (window.OTB) — loaded before profile.js, which pulls from it. -->
|
|
84
96
|
<script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
|
|
85
97
|
<script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
|
|
86
|
-
<script src="/builders/profile.js?v=2026-08-
|
|
98
|
+
<script src="/builders/profile.js?v=2026-08-16-r04"></script>
|
|
87
99
|
</body>
|
|
88
100
|
</html>
|
|
@@ -160,6 +160,82 @@
|
|
|
160
160
|
}).join('');
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// ---- render: cross-project record (hub-only, ADR 0171 / task 1002280) ----
|
|
164
|
+
//
|
|
165
|
+
// The section reveals only when the response carries a cross_project KEY —
|
|
166
|
+
// present iff the hub port resolved; on a vanilla instance the key is absent
|
|
167
|
+
// and the section stays hidden (degrade, don't 500). A null value means "no
|
|
168
|
+
// public cross-project record" — hidden, provisional, or none, deliberately
|
|
169
|
+
// indistinguishable — so all three render the same private line and NOTHING
|
|
170
|
+
// derived from the rollup (no partial numbers; a count plus a hidden total is
|
|
171
|
+
// a leak by subtraction). The response is viewer-independent (ADR 0171 D4):
|
|
172
|
+
// self is detected client-side by comparing the /me id, and the self "toggle"
|
|
173
|
+
// is a link to the real control in Settings (R03). No caching assumptions —
|
|
174
|
+
// hub profile responses are no-store (ADR 0171 D5).
|
|
175
|
+
|
|
176
|
+
function xpProjectRow(p) {
|
|
177
|
+
const name = escapeHtml(p.name || 'Project');
|
|
178
|
+
// origin is third-party-reported; link only a well-formed https URL.
|
|
179
|
+
const origin = typeof p.origin === 'string' && /^https:\/\//.test(p.origin) ? p.origin : null;
|
|
180
|
+
const nameCell = origin ? `<a href="${escapeHtml(origin)}" rel="noopener">${name}</a>` : name;
|
|
181
|
+
// rank_held is THAT project's standing as it reported it — plain text, never
|
|
182
|
+
// the local rank badge or the instance's own rank labels (spec Decision 1).
|
|
183
|
+
const held = typeof p.rank_held === 'string' && p.rank_held
|
|
184
|
+
? escapeHtml(p.rank_held.charAt(0).toUpperCase() + p.rank_held.slice(1))
|
|
185
|
+
: '—';
|
|
186
|
+
return `<tr><td>${nameCell}</td><td>${held}</td></tr>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderCrossProject(data, selfId) {
|
|
190
|
+
const section = $('#xp-scroll');
|
|
191
|
+
const body = $('#xp-body');
|
|
192
|
+
if (!section || !body) return;
|
|
193
|
+
if (!data || !('cross_project' in data)) {
|
|
194
|
+
section.hidden = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// ids may serialize as strings (pg bigint) — compare canonicalized.
|
|
198
|
+
const isSelf = selfId != null && data.builder && String(data.builder.id) === String(selfId);
|
|
199
|
+
const manage = isSelf
|
|
200
|
+
? `<p class="scroll__lede">This is you — <a href="/settings#cross-project">manage its visibility in Settings → Cross-project record</a>.</p>`
|
|
201
|
+
: '';
|
|
202
|
+
const cp = data.cross_project;
|
|
203
|
+
if (!cp) {
|
|
204
|
+
body.innerHTML = `<p class="scroll__lede">This builder keeps their track record private.</p>${manage}`;
|
|
205
|
+
section.hidden = false;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const t = cp.totals || {};
|
|
209
|
+
const tiles =
|
|
210
|
+
`<div class="stat-row">` +
|
|
211
|
+
tile('Projects', fmtNum(t.projects)) +
|
|
212
|
+
tile('Works shipped', fmtNum(t.works_shipped)) +
|
|
213
|
+
tile('Credits', fmtNum(t.credits)) +
|
|
214
|
+
tile('Karma', fmtNum(t.karma)) +
|
|
215
|
+
`</div>`;
|
|
216
|
+
const discs = Array.isArray(cp.disciplines) ? cp.disciplines.filter((d) => typeof d === 'string') : [];
|
|
217
|
+
const metaBits = [];
|
|
218
|
+
if (discs.length) metaBits.push(`Disciplines: ${discs.join(', ')}`);
|
|
219
|
+
if (cp.last_active) metaBits.push(`Last active ${fmtDate(cp.last_active)}`);
|
|
220
|
+
const meta = metaBits.length ? `<p class="scroll__lede">${escapeHtml(metaBits.join(' · '))}</p>` : '';
|
|
221
|
+
const named = Array.isArray(cp.projects) ? cp.projects : [];
|
|
222
|
+
const moreCount = Number(cp.more_projects) || 0;
|
|
223
|
+
const projectsTable = named.length
|
|
224
|
+
? `<table class="ledger">
|
|
225
|
+
<thead><tr><th>Project</th><th title="That project's own standing, as reported by that project.">Standing there</th></tr></thead>
|
|
226
|
+
<tbody>${named.map(xpProjectRow).join('')}</tbody>
|
|
227
|
+
</table>`
|
|
228
|
+
: '';
|
|
229
|
+
const more = moreCount > 0
|
|
230
|
+
? `<p class="scroll__lede">+ ${fmtNum(moreCount)} more project${moreCount === 1 ? '' : 's'} not publicly listed.</p>`
|
|
231
|
+
: '';
|
|
232
|
+
const none = !named.length && !moreCount
|
|
233
|
+
? `<p class="scroll__lede">No cross-project activity reported yet.</p>`
|
|
234
|
+
: '';
|
|
235
|
+
body.innerHTML = tiles + meta + projectsTable + more + none + manage;
|
|
236
|
+
section.hidden = false;
|
|
237
|
+
}
|
|
238
|
+
|
|
163
239
|
// ---- states -------------------------------------------------------------
|
|
164
240
|
|
|
165
241
|
function showMessage(text) {
|
|
@@ -167,13 +243,14 @@
|
|
|
167
243
|
if (sub) sub.textContent = text;
|
|
168
244
|
}
|
|
169
245
|
|
|
170
|
-
function renderProfile(data) {
|
|
246
|
+
function renderProfile(data, selfId) {
|
|
171
247
|
const builder = data.builder || {};
|
|
172
248
|
const name = builder.display_name || builder.github_login || 'Builder';
|
|
173
249
|
const shipped = Number(data.shipped_count) || 0;
|
|
174
250
|
showMessage(`${name} — ${rankLabel(normalizeRank(builder.rank))} · ${fmtNum(shipped)} work${shipped === 1 ? '' : 's'} shipped`);
|
|
175
251
|
renderHead(builder);
|
|
176
252
|
renderNumbers(data.shipped_count, data.achievements);
|
|
253
|
+
renderCrossProject(data, selfId);
|
|
177
254
|
renderAchievements(data.achievements);
|
|
178
255
|
renderWorks(data.recent_ships);
|
|
179
256
|
}
|
|
@@ -197,23 +274,39 @@
|
|
|
197
274
|
const ach = $('#ach-grid'); if (ach) ach.innerHTML = '';
|
|
198
275
|
const works = $('#works-table tbody'); if (works) works.innerHTML = '';
|
|
199
276
|
const worksLede = $('#works-lede'); if (worksLede) worksLede.hidden = true;
|
|
277
|
+
const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
|
|
200
278
|
}
|
|
201
279
|
|
|
202
|
-
async function fetchProfile(id) {
|
|
280
|
+
async function fetchProfile(id, selfId) {
|
|
203
281
|
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(id)}/profile`);
|
|
204
282
|
if (res.status === 404) {
|
|
205
283
|
showMessage('No builder found with that id.');
|
|
206
284
|
return;
|
|
207
285
|
}
|
|
208
286
|
if (!res.ok) throw new Error(`profile → ${res.status}`);
|
|
209
|
-
renderProfile(res.data || {});
|
|
287
|
+
renderProfile(res.data || {}, selfId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The viewer's own builder id, or null when signed out / unresolvable. Used
|
|
291
|
+
// ONLY for client-side self-detection (ADR 0171 D4 — the profile response is
|
|
292
|
+
// viewer-independent); a failure here just means no self link renders.
|
|
293
|
+
async function resolveSelfId() {
|
|
294
|
+
try {
|
|
295
|
+
const me = await api.request('GET', `${API}/me`);
|
|
296
|
+
if (!me.ok) return null;
|
|
297
|
+
const builder = (me.data && me.data.builder) ? me.data.builder : me.data;
|
|
298
|
+
return (builder && builder.id) || null;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error('[profile] /me self-detect failed', err);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
210
303
|
}
|
|
211
304
|
|
|
212
305
|
async function load() {
|
|
213
306
|
const idParam = new URLSearchParams(location.search).get('id');
|
|
214
307
|
try {
|
|
215
308
|
if (idParam) {
|
|
216
|
-
await fetchProfile(idParam);
|
|
309
|
+
await fetchProfile(idParam, await resolveSelfId());
|
|
217
310
|
return;
|
|
218
311
|
}
|
|
219
312
|
// No id — soft-resolve the viewer's OWN id from /me (no hard redirect; a
|
|
@@ -230,7 +323,7 @@
|
|
|
230
323
|
renderEmpty();
|
|
231
324
|
return;
|
|
232
325
|
}
|
|
233
|
-
await fetchProfile(selfId);
|
|
326
|
+
await fetchProfile(selfId, selfId);
|
|
234
327
|
} catch (err) {
|
|
235
328
|
console.error('[profile] failed to load', err);
|
|
236
329
|
showMessage('Could not load this profile. Return to the hall and try again.');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.171",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.171",
|
|
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.19.
|
|
3
|
+
"version": "1.19.171",
|
|
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",
|
|
@@ -81,6 +81,7 @@ const FIXTURE_FALLBACKS = [
|
|
|
81
81
|
[/^blockers\/\d+$/, 'blockers__detail'],
|
|
82
82
|
[/^analytics\/builder\/\d+$/, 'analytics__builder'],
|
|
83
83
|
[/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
|
|
84
|
+
[/^builders\/\d+\/profile$/, 'builders__profile'],
|
|
84
85
|
];
|
|
85
86
|
|
|
86
87
|
function loadFixture(name) {
|
package/src/module-api.js
CHANGED
|
@@ -767,8 +767,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
767
767
|
// landed since 1.19.168 with no explicit bump. run 31964702865. (task 1002620)
|
|
768
768
|
// 1.19.170 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
769
769
|
// landed since 1.19.169 with no explicit bump. run 31965483417. (task 1002620)
|
|
770
|
+
// 1.19.171 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
771
|
+
// landed since 1.19.170 with no explicit bump. run 31967255167. (task 1002620)
|
|
770
772
|
// ---------------------------------------------------------------------------
|
|
771
|
-
const CORE_VERSION = '1.19.
|
|
773
|
+
const CORE_VERSION = '1.19.171'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
772
774
|
|
|
773
775
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
774
776
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// tests/profile_ui_cross_project.mjs — the profile page's cross-project section
|
|
2
|
+
// (task 1002280, R04 · goal 1000045, ADR 0171).
|
|
3
|
+
//
|
|
4
|
+
// EXECUTES the real modules/hall-ui/public/profile.js in a minimal DOM stub
|
|
5
|
+
// (the hall_signout.mjs pattern) against canned API responses, and asserts the
|
|
6
|
+
// consent-sensitive render contract:
|
|
7
|
+
//
|
|
8
|
+
// - no `cross_project` key in the response (vanilla instance) → the section
|
|
9
|
+
// stays hidden entirely;
|
|
10
|
+
// - `cross_project: null` (hidden / provisional / none — indistinguishable by
|
|
11
|
+
// design) → the "keeps their track record private" line renders and NOTHING
|
|
12
|
+
// derived from the rollup does (no tiles, no digits — a count plus a hidden
|
|
13
|
+
// total is a leak by subtraction);
|
|
14
|
+
// - the self hide-toggle is client-side only (ADR 0171 D4): the Settings link
|
|
15
|
+
// renders iff the /me id matches the profile id, on both the ?id= and the
|
|
16
|
+
// own-profile load paths;
|
|
17
|
+
// - the full object renders totals tiles, named project rows (https origins
|
|
18
|
+
// linked, anything else plain text), `rank_held` as plain text — never the
|
|
19
|
+
// local rank-badge component (spec Decision 1) — and the counted-never-named
|
|
20
|
+
// "+N more" line.
|
|
21
|
+
//
|
|
22
|
+
// Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
|
|
32
|
+
const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.html'), 'utf8');
|
|
33
|
+
|
|
34
|
+
// ---- fixtures -------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const BUILDER = {
|
|
37
|
+
id: 7, github_login: 'pheidias', display_name: 'Pheidias', avatar_url: null,
|
|
38
|
+
rank: 'metic', total_credits: 120, karma: 3, created_at: '2026-01-01T00:00:00.000Z',
|
|
39
|
+
preferred_disciplines: ['engineer'],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const CP_FULL = {
|
|
43
|
+
totals: { projects: 3, credits: 150, works_shipped: 7, karma: 4 },
|
|
44
|
+
disciplines: ['engineer', 'ui'],
|
|
45
|
+
last_active: '2026-08-01T00:00:00.000Z',
|
|
46
|
+
projects: [
|
|
47
|
+
{ name: 'Public Proj', origin: 'https://pub.example', rank_held: 'metic' },
|
|
48
|
+
{ name: 'Odd Origin', origin: 'http://insecure.example', rank_held: null },
|
|
49
|
+
],
|
|
50
|
+
more_projects: 1,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const baseProfile = () => ({ builder: { ...BUILDER }, shipped_count: 1, recent_ships: [], achievements: [] });
|
|
54
|
+
|
|
55
|
+
// ---- harness --------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
// Boot profile.js with canned /me + profile responses; returns the memoised
|
|
58
|
+
// element map after the async load() settles.
|
|
59
|
+
async function boot({ search = '', me, profile } = {}) {
|
|
60
|
+
const els = new Map();
|
|
61
|
+
const makeEl = () => ({
|
|
62
|
+
hidden: false, textContent: '', _html: '',
|
|
63
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
64
|
+
});
|
|
65
|
+
const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
|
|
66
|
+
// Mirror the markup: the section ships with the `hidden` attribute.
|
|
67
|
+
$('#xp-scroll').hidden = true;
|
|
68
|
+
|
|
69
|
+
const escapeHtml = (s) => String(s)
|
|
70
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
71
|
+
const api = {
|
|
72
|
+
async request(_method, url) {
|
|
73
|
+
if (url === '/api/bongos/me') return me ?? { status: 401, ok: false, data: null };
|
|
74
|
+
if (/\/builders\/[^/]+\/profile$/.test(url)) return profile ?? { status: 404, ok: false, data: null };
|
|
75
|
+
return { status: 404, ok: false, data: null };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const windowObj = {
|
|
79
|
+
__BRANDING__: {},
|
|
80
|
+
BongosClient: { createClient: () => api },
|
|
81
|
+
OTB: {
|
|
82
|
+
$, escapeHtml,
|
|
83
|
+
fmtNum: (n) => String(Number(n) || 0),
|
|
84
|
+
fmtDate: (d) => String(d).slice(0, 10),
|
|
85
|
+
rankLabel: (r) => r,
|
|
86
|
+
toast() {},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const sandbox = {
|
|
90
|
+
window: windowObj, location: { search }, URLSearchParams, console,
|
|
91
|
+
};
|
|
92
|
+
sandbox.globalThis = sandbox;
|
|
93
|
+
vm.createContext(sandbox);
|
|
94
|
+
vm.runInContext(SRC, sandbox, { filename: 'profile.js' });
|
|
95
|
+
// load() is fire-and-forget; let its await chain settle.
|
|
96
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
|
|
97
|
+
return { els, $ };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const meOk = (id) => ({ status: 200, ok: true, data: { builder: { id } } });
|
|
101
|
+
const profileOk = (data) => ({ status: 200, ok: true, data });
|
|
102
|
+
|
|
103
|
+
// ---- the markup ships the section hidden ----------------------------------
|
|
104
|
+
|
|
105
|
+
test('profile.html carries the hidden cross-project section + reserved slots', () => {
|
|
106
|
+
assert.match(HTML, /<section[^>]*id="xp-scroll"[^>]*hidden>/, 'the section must ship hidden — it reveals only when the response carries a cross_project key');
|
|
107
|
+
assert.match(HTML, /Reserved slots[\s\S]*connections, guilds, and actions/, 'the spec Decision 5 slot reservation must stay in the layout');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---- vanilla instance: key absent → section absent -------------------------
|
|
111
|
+
|
|
112
|
+
test('no cross_project key (vanilla instance) → the section stays hidden', async () => {
|
|
113
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(baseProfile()) });
|
|
114
|
+
assert.equal($('#xp-scroll').hidden, true);
|
|
115
|
+
assert.equal($('#xp-body').innerHTML, '');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---- cross_project: null → private line, nothing derived -------------------
|
|
119
|
+
|
|
120
|
+
test('cross_project null, stranger view → private line only, no numbers, no self link', async () => {
|
|
121
|
+
const data = { ...baseProfile(), cross_project: null };
|
|
122
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(data) });
|
|
123
|
+
const xp = $('#xp-scroll');
|
|
124
|
+
const html = $('#xp-body').innerHTML;
|
|
125
|
+
assert.equal(xp.hidden, false, 'on the hub the section reveals even when the record is private');
|
|
126
|
+
assert.match(html, /keeps their track record private/);
|
|
127
|
+
assert.ok(!html.includes('stat-tile'), 'no tiles in the private state');
|
|
128
|
+
assert.ok(!/\d/.test(html), 'no digits at all — no partial numbers (leak by subtraction)');
|
|
129
|
+
assert.ok(!html.includes('/settings#cross-project'), 'no self link for a stranger');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('cross_project null, self view via ?id= → private line + the Settings link', async () => {
|
|
133
|
+
const data = { ...baseProfile(), cross_project: null };
|
|
134
|
+
// /me id as a STRING (pg bigint serialization) against the numeric profile id
|
|
135
|
+
// — self-detection must be serialization-proof.
|
|
136
|
+
const { $ } = await boot({ search: '?id=7', me: meOk('7'), profile: profileOk(data) });
|
|
137
|
+
const html = $('#xp-body').innerHTML;
|
|
138
|
+
assert.match(html, /keeps their track record private/);
|
|
139
|
+
assert.ok(html.includes('/settings#cross-project'), 'the self hide-toggle links to Settings → Cross-project record');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('self view with no ?id= (own-profile path) also gets the Settings link', async () => {
|
|
143
|
+
const data = { ...baseProfile(), cross_project: null };
|
|
144
|
+
const { $ } = await boot({ search: '', me: meOk(7), profile: profileOk(data) });
|
|
145
|
+
assert.ok($('#xp-body').innerHTML.includes('/settings#cross-project'));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- the full record -------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test('full cross_project → tiles, named rows, plain-text standing, +N more', async () => {
|
|
151
|
+
const data = { ...baseProfile(), cross_project: CP_FULL };
|
|
152
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk(data) });
|
|
153
|
+
const xp = $('#xp-scroll');
|
|
154
|
+
const html = $('#xp-body').innerHTML;
|
|
155
|
+
assert.equal(xp.hidden, false);
|
|
156
|
+
assert.equal((html.match(/stat-tile__num/g) || []).length, 4, 'projects / works shipped / credits / karma tiles');
|
|
157
|
+
assert.match(html, /Public Proj/);
|
|
158
|
+
assert.ok(html.includes('href="https://pub.example"'), 'a well-formed https origin links out');
|
|
159
|
+
assert.match(html, /Odd Origin/);
|
|
160
|
+
assert.ok(!html.includes('href="http://insecure.example"'), 'a non-https origin renders as plain text');
|
|
161
|
+
assert.match(html, /Metic/, 'rank_held renders capitalized as that project\'s own standing');
|
|
162
|
+
assert.ok(!html.includes('rank-badge'), 'never the local rank-badge component for another project\'s rank (spec Decision 1)');
|
|
163
|
+
assert.match(html, /\+ 1 more project/, 'non-public projects are counted, never named');
|
|
164
|
+
assert.match(html, /Disciplines: engineer, ui/);
|
|
165
|
+
assert.match(html, /Last active 2026-08-01/);
|
|
166
|
+
assert.ok(!html.includes('/settings#cross-project'), 'no self link for a stranger');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('zero-activity record → the empty line, no project table', async () => {
|
|
170
|
+
const cp = { ...CP_FULL, projects: [], more_projects: 0 };
|
|
171
|
+
const { $ } = await boot({ search: '?id=7', me: meOk(99), profile: profileOk({ ...baseProfile(), cross_project: cp }) });
|
|
172
|
+
const html = $('#xp-body').innerHTML;
|
|
173
|
+
assert.match(html, /No cross-project activity reported yet\./);
|
|
174
|
+
assert.ok(!html.includes('<table'), 'no empty ledger');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---- signed-out empty state ------------------------------------------------
|
|
178
|
+
|
|
179
|
+
test('signed-out visitor with no ?id= → empty state keeps the section hidden', async () => {
|
|
180
|
+
const { $ } = await boot({ search: '', me: { status: 401, ok: false, data: null } });
|
|
181
|
+
assert.equal($('#xp-scroll').hidden, true);
|
|
182
|
+
});
|