@bongos/core 1.19.1015 → 1.19.1017
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 +159 -94
- package/.claude/skills/planning-session/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -2
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +12 -0
- package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +60 -5
- package/docs/api-reference.md +8 -2
- package/docs/architecture.md +4 -1
- package/docs/copy-inventory.md +5 -3
- package/docs/copy-registry.json +22 -4
- package/docs/file-map.md +1 -2
- package/docs/module-api-changelog.md +5 -1
- package/migrations/core_253_idea_filed_via.sql +59 -0
- package/modules/agents/pollers/event-router.js +2 -0
- package/modules/discord/discord-bugs.js +2 -0
- package/modules/discord/discord-inbound.js +3 -0
- package/modules/hall-ui/public/people.html +11 -0
- package/modules/hall-ui/public/people.js +77 -11
- package/modules/hall-ui/public/people.states.json +1 -1
- package/modules/hall-ui/public/sky-data.js +22 -8
- package/modules/hall-ui/public/sky-draw.js +3 -2
- package/modules/hall-ui/public/sky-panel.js +1 -1
- package/modules/ideas/born-of.js +55 -0
- package/modules/ideas/inbox.js +23 -8
- package/modules/ideas/projection.js +48 -0
- package/modules/ideas/routes/inbox.js +20 -0
- package/modules/lifecycle/sky.js +25 -4
- package/modules/platform-identity/account-visibility.js +33 -2
- package/modules/platform-identity/activity-snapshots.js +47 -0
- package/modules/platform-identity/builder-search.js +153 -0
- package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
- package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +13 -0
- package/modules/platform-identity/routes/community-search.js +120 -0
- package/modules/platform-identity/routes/scouting.js +14 -7
- package/modules/platform-identity/scouting-name-only.js +47 -0
- package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
- package/modules/platform-identity/tests/platform-identity.mjs +21 -0
- package/modules/public-landing/public/projects-floor.states.json +9 -2
- package/modules/public-landing/public/projects.html +69 -4
- package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
- package/modules/ui-design/styles/expedition/mock.html +16 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +11 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +4 -1
- package/tests/activity_snapshots_db.mjs +97 -0
- package/tests/card_apply_ui.mjs +297 -0
- package/tests/community_search_route.mjs +385 -0
- package/tests/discord_bugs.mjs +1 -0
- package/tests/discord_inbound.mjs +1 -0
- package/tests/effective_visibility_predicate.mjs +49 -1
- package/tests/goal_map_page.mjs +14 -0
- package/tests/guild_namespace_reserved.mjs +76 -0
- package/tests/hall_sky.mjs +33 -16
- package/tests/idea_filed_via.mjs +185 -0
- package/tests/landing_page.mjs +8 -0
- package/tests/people_name_only.mjs +262 -0
- package/tests/profile_rollup_consent.mjs +5 -1
- package/tests/profile_route.mjs +23 -5
- package/tests/project_admin_console_model.mjs +105 -0
- package/tests/recruiter_sliver_boundary.mjs +3 -0
- package/tests/scouting_owner_access_and_reach.mjs +112 -2
- package/tests/sky_read.mjs +19 -2
- package/tests/ui_design_skills.mjs +9 -0
- package/tests/ui_design_styles.mjs +13 -0
- package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
- package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
- package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
- package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
|
@@ -1649,6 +1649,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1649
1649
|
}
|
|
1650
1650
|
var catalog = [];
|
|
1651
1651
|
var mineOrigins = {}; /* normalized host → 'owner' | 'builder' */
|
|
1652
|
+
var appliedOrigins = {}; /* normalized host → true; GET /my-projects' applied echo (R10, task 1002286) */
|
|
1652
1653
|
var ownedInstances = []; /* the viewer's OWN provisioning rows (never torn_down) */
|
|
1653
1654
|
var ownedByHost = {}; /* lower(domain) → instance row — the dedupe join */
|
|
1654
1655
|
/* What the platform's own entry is FOR SEARCH. It is deliberately not pushed
|
|
@@ -2166,6 +2167,63 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2166
2167
|
: '';
|
|
2167
2168
|
}
|
|
2168
2169
|
|
|
2170
|
+
/* ══ APPLY TO JOIN (R10, task 1002286) ═══════════════════════════════
|
|
2171
|
+
The card's own door onto the join relay platform-identity already runs
|
|
2172
|
+
(POST /my-projects/join, R08 task 1002284): one click, on the origin the
|
|
2173
|
+
card already has — no URL to type, unlike the separate join-by-URL field
|
|
2174
|
+
task 1003774 is adding to View · Your projects. Hidden for the platform's
|
|
2175
|
+
own card, a member (the “yours”/“you build here” chip already says so) and
|
|
2176
|
+
a signed-out visitor (there is no session to file the request under).
|
|
2177
|
+
Applied is read from the hub's own echo (GET /my-projects' `applied`,
|
|
2178
|
+
loadMemberships) and updated the moment a request lands — the echo
|
|
2179
|
+
carries no status (ADR 0201 D1.3), so Applied IS pending here; there is
|
|
2180
|
+
nothing truer to say until membership itself appears. ══ */
|
|
2181
|
+
function cardApplyHtml(p, rel) {
|
|
2182
|
+
if (p.isSelf || isMember(rel) || !me) return '';
|
|
2183
|
+
return appliedOrigins[hostOf(p.origin)]
|
|
2184
|
+
? '<button type="button" class="btn ghost" id="cardApply" disabled>Applied</button>'
|
|
2185
|
+
: '<button type="button" class="btn ghost" id="cardApply">Apply to join</button>';
|
|
2186
|
+
}
|
|
2187
|
+
/* The relay's own outcome (my-projects.js RELAY_SENTENCE) is shown verbatim
|
|
2188
|
+
— this page invents no wording of its own for a door it did not build.
|
|
2189
|
+
Only `status === 'requested'` moves the button to Applied; every other
|
|
2190
|
+
2xx (already_member, invite_only, open_enrollment, members_only,
|
|
2191
|
+
not_published) is the door's answer, not a click that went through. */
|
|
2192
|
+
function submitApply(btn, p) {
|
|
2193
|
+
btn.disabled = true;
|
|
2194
|
+
btn.textContent = 'Applying…';
|
|
2195
|
+
var msg = $('cardApplyMsg');
|
|
2196
|
+
fetch(API + '/my-projects/join', {
|
|
2197
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
2198
|
+
body: JSON.stringify({ origin: p.origin }),
|
|
2199
|
+
}).then(function (res) {
|
|
2200
|
+
if (res.status === 401) {
|
|
2201
|
+
btn.textContent = 'Apply to join'; btn.disabled = false;
|
|
2202
|
+
if (msg) { msg.className = 'projMsg err'; msg.innerHTML = signInAgain(); }
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
2206
|
+
if (res.ok && data && data.status === 'requested') {
|
|
2207
|
+
appliedOrigins[hostOf(p.origin)] = true;
|
|
2208
|
+
btn.textContent = 'Applied';
|
|
2209
|
+
if (msg) { msg.className = 'projMsg ok'; msg.textContent = data.message || 'Requested.'; }
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
btn.textContent = 'Apply to join';
|
|
2213
|
+
btn.disabled = false;
|
|
2214
|
+
var e = apiError(data, res.status);
|
|
2215
|
+
if (msg) {
|
|
2216
|
+
msg.className = 'projMsg' + (res.ok ? '' : ' err');
|
|
2217
|
+
msg.textContent = (res.ok && data && data.message) || e.message || 'Could not reach that project — try again in a moment.';
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
}).catch(function () {
|
|
2221
|
+
btn.textContent = 'Apply to join';
|
|
2222
|
+
btn.disabled = false;
|
|
2223
|
+
if (msg) { msg.className = 'projMsg err'; msg.textContent = 'Couldn’t reach the platform — check your connection and try again.'; }
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2169
2227
|
function openCard(p, rel, returnTo) {
|
|
2170
2228
|
var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
|
|
2171
2229
|
/* the art: catalog text an Archon typed, scheme-checked server-side and
|
|
@@ -2195,13 +2253,16 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2195
2253
|
is member-gated AND address-gated: a member's entry, only where the hall
|
|
2196
2254
|
is somewhere other than the site the first door already opens. */
|
|
2197
2255
|
var hall = isMember(rel) ? hallUrlOf(p) : '';
|
|
2256
|
+
var applyBtn = cardApplyHtml(p, rel);
|
|
2198
2257
|
$('cardActions').innerHTML = (href
|
|
2199
2258
|
? '<a class="cardOpen" id="cardOpen" href="' + esc(href) + '"' +
|
|
2200
2259
|
(p.isSelf ? '' : ' target="_blank" rel="noopener"') + '>' +
|
|
2201
2260
|
(p.isSelf ? 'Home →' : 'Open site →') + '</a>'
|
|
2202
2261
|
: '') + (hall
|
|
2203
2262
|
? '<a class="cardHall" id="cardHall" href="' + esc(hall) + '" target="_blank" rel="noopener">' + HALL_GLYPH + 'Builders\' hall</a>'
|
|
2204
|
-
: '');
|
|
2263
|
+
: '') + applyBtn + (applyBtn ? '<p class="projMsg" id="cardApplyMsg"></p>' : '');
|
|
2264
|
+
var applyEl = $('cardApply');
|
|
2265
|
+
if (applyEl && !applyEl.disabled) applyEl.addEventListener('click', function () { submitApply(applyEl, p); });
|
|
2205
2266
|
closeMenus();
|
|
2206
2267
|
cardReturn = returnTo || null;
|
|
2207
2268
|
$('cardScrim').hidden = false;
|
|
@@ -2446,16 +2507,20 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2446
2507
|
/* "Mine" is a MEMBERSHIP, not the catalog's advisory owner column: the
|
|
2447
2508
|
authority is platform_identity_project_memberships, whose role is
|
|
2448
2509
|
owner|builder|visitor, and GET /my-projects already returns the
|
|
2449
|
-
{ owned, building } split
|
|
2510
|
+
{ owned, building, applied } split — `applied` is the statusless echo
|
|
2511
|
+
(ADR 0201 D1.3) the card's Apply button reads back (R10, task 1002286).
|
|
2512
|
+
401 (signed out) is not an error here. */
|
|
2450
2513
|
function loadMemberships() {
|
|
2451
2514
|
return fetch(API + '/my-projects', { credentials: 'same-origin' })
|
|
2452
|
-
.then(function (res) { return res.ok ? res.json() : { owned: [], building: [] }; })
|
|
2515
|
+
.then(function (res) { return res.ok ? res.json() : { owned: [], building: [], applied: [] }; })
|
|
2453
2516
|
.then(function (d) {
|
|
2454
2517
|
mineOrigins = {};
|
|
2518
|
+
appliedOrigins = {};
|
|
2455
2519
|
(d.owned || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'owner'; });
|
|
2456
2520
|
(d.building || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'builder'; });
|
|
2521
|
+
(d.applied || []).forEach(function (m) { if (m.origin) appliedOrigins[hostOf(m.origin)] = true; });
|
|
2457
2522
|
})
|
|
2458
|
-
.catch(function () { mineOrigins = {}; });
|
|
2523
|
+
.catch(function () { mineOrigins = {}; appliedOrigins = {}; });
|
|
2459
2524
|
}
|
|
2460
2525
|
|
|
2461
2526
|
/* The owner-scoped half of the sky (task 1002744). The public catalog
|
|
@@ -42,7 +42,7 @@ What changed in translation, on purpose. The method's **three persisted homes**
|
|
|
42
42
|
Read four live signals, then print two or three recommended next commands with a one-line reason each, followed by the full list grouped by category. Signals, all read locally and none written:
|
|
43
43
|
|
|
44
44
|
1. **The world files**: does `DESIGN.md` exist at the repo root; is `config/branding.json` present over the neutral pack; is `docs/project-context.template.md` filled or still the scaffold. A scaffold with no answers recommends `shape` first; a missing `DESIGN.md` alone does not make the project greenfield (the neutral pack is the world).
|
|
45
|
-
2. **The last kit report** for the target, if any: `report.json` under
|
|
45
|
+
2. **The last kit report** for the target, if any: `report.json` under the `--out` dir the last `render.js` run named — pass one explicitly on a shared machine, since the un-named default falls back to a world-readable OS temp dir (`os.tmpdir()`'s `ui-design-kit/<surface>/<page>/`: `%TMP%` on Windows, `$TMPDIR` on mac/linux). This is only ever read to shape the printed suggestion below, never trusted as authority. Problems listed recommend `audit` on that page; a clean report with no critique yet recommends `critique`; no report at all recommends `audit`, because the render is the read.
|
|
46
46
|
3. **Dirty files in version control**: `git status --porcelain` over the surfaces (`modules/*/public/`, `src/ui/`, `public*/`). Dirty UI files recommend `polish` on that path before anything new.
|
|
47
47
|
4. **A stub already listening**: `kit/serve.js` prints its port when it starts; a page already served means the live loop (the playwright plugin) is available without a second server. Never start a duplicate on an occupied port.
|
|
48
48
|
|
|
@@ -39,9 +39,7 @@ body {
|
|
|
39
39
|
--gutter: clamp(16px, 4vw, 26px);
|
|
40
40
|
--band: clamp(40px, 7vh, 72px);
|
|
41
41
|
margin: 0;
|
|
42
|
-
background: var(--bg);
|
|
43
|
-
background-image: radial-gradient(var(--dots) .9px, transparent 1px);
|
|
44
|
-
background-size: var(--dot-pitch) var(--dot-pitch);
|
|
42
|
+
background-color: var(--bg);
|
|
45
43
|
color: var(--ink);
|
|
46
44
|
font-family: var(--font-body);
|
|
47
45
|
font-size: 16px;
|
|
@@ -101,6 +99,21 @@ body {
|
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
101
|
|
|
102
|
+
/* the halftone screen, fixed under the content. Not body's own background-image on purpose:
|
|
103
|
+
the branding pack's `backdrop` emits a `body{background:...}` rule after the page's own
|
|
104
|
+
stylesheet (serve-internal.js themeMarkup()), and that shorthand resets background-image
|
|
105
|
+
to none on any served page. A texture on body silently does not exist; a texture on a
|
|
106
|
+
layer does (the same pattern as the blueprint look's grid, task 1003633). */
|
|
107
|
+
body::before {
|
|
108
|
+
content: "";
|
|
109
|
+
position: fixed;
|
|
110
|
+
inset: 0;
|
|
111
|
+
z-index: -1;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
background-image: radial-gradient(var(--dots) .9px, transparent 1px);
|
|
114
|
+
background-size: var(--dot-pitch) var(--dot-pitch);
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
*, *::before, *::after { box-sizing: border-box; }
|
|
105
118
|
a { color: inherit; }
|
|
106
119
|
:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1017",
|
|
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.1017",
|
|
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.1017",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/capture.js
CHANGED
|
@@ -269,6 +269,7 @@ async function main() {
|
|
|
269
269
|
console.error(' ... [--spark] offer it up for someone else to develop');
|
|
270
270
|
console.error(' ... [--full --fields-file f.json] file a Full Idea (five fields; see spark.js --template)');
|
|
271
271
|
console.error(' ... [--no-prompt] never ask anything (default when not a terminal)');
|
|
272
|
+
console.error(' ... [--via planning] a planning session\'s capture — the sky records the door (task 1004233)');
|
|
272
273
|
process.exit(2);
|
|
273
274
|
}
|
|
274
275
|
|
|
@@ -281,6 +282,15 @@ async function main() {
|
|
|
281
282
|
const fieldsFile = arg('--fields-file', args);
|
|
282
283
|
const noPrompt = hasFlag('--no-prompt', args);
|
|
283
284
|
const goalArg = arg('--goal', args);
|
|
285
|
+
// task 1004233 (sky part 5): WHICH DOOR. The server reads 'cli' off this
|
|
286
|
+
// session's token, so this CLI names nothing — except a planning session's
|
|
287
|
+
// capture, which no session can tell from any other CLI filing. Only that one
|
|
288
|
+
// value is accepted here; the server refuses every other claim the same way.
|
|
289
|
+
const viaFromArg = arg('--via', args);
|
|
290
|
+
if (viaFromArg !== null && viaFromArg !== 'planning') {
|
|
291
|
+
console.error(`--via "${viaFromArg}" is not a door this CLI may name. Where a filing came from is read off your session; the one value you may add is --via planning.`);
|
|
292
|
+
process.exit(2);
|
|
293
|
+
}
|
|
284
294
|
|
|
285
295
|
// A BAD --kind used to reach the server and come back a 400 (task 1003694).
|
|
286
296
|
// That is not merely late, it is invisible where it matters: a SCHEDULED routine
|
|
@@ -376,6 +386,7 @@ async function main() {
|
|
|
376
386
|
suggested_priority: priority,
|
|
377
387
|
};
|
|
378
388
|
if (kindFromArg) payload.kind = kindFromArg;
|
|
389
|
+
if (viaFromArg) payload.filed_via = viaFromArg;
|
|
379
390
|
if (goalResolution.goalId != null) payload.goal_id = goalResolution.goalId;
|
|
380
391
|
if (wantFull) {
|
|
381
392
|
// The server validates these against the R141 declaration and scores them
|
|
@@ -83,6 +83,10 @@ const INTEGRATION = new Set([
|
|
|
83
83
|
// rows is NULL rather than a free ship. Only a transaction engine settles
|
|
84
84
|
// those, so this runs here and the DB-free sibling covers the renderer.
|
|
85
85
|
'acclaim_feed_evidence_db',
|
|
86
|
+
// task 1002977: proves the changed-totals skip rule (a single INSERT ...
|
|
87
|
+
// WHERE NOT EXISTS statement) and the upsertActivity wiring against real SQL —
|
|
88
|
+
// a fake pool can prove the query shape but not which reports it inserts.
|
|
89
|
+
'activity_snapshots_db',
|
|
86
90
|
'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
|
|
87
91
|
'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
|
|
88
92
|
// consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1017'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -416,7 +416,10 @@ test('the recruiting surface really is exactly two reads, and each one joins REA
|
|
|
416
416
|
assert.match(route, /'\/scouting\/:handle'/, 'and the pre-accept sliver');
|
|
417
417
|
|
|
418
418
|
const av = readFileSync(join(ROOT, 'modules/platform-identity/account-visibility.js'), 'utf8');
|
|
419
|
-
|
|
419
|
+
// The directory reads TWO tiers since task 1002291 — ranked, and the hidden
|
|
420
|
+
// builders it lists by name — and the opt-out must empty both, or "removes you
|
|
421
|
+
// from the People directory" is false for a builder who also hid their record.
|
|
422
|
+
for (const composed of ['scoutingListableSql', 'scoutingNameOnlySql', 'recruiterSliverVisibleSql']) {
|
|
420
423
|
const body = av.slice(av.indexOf(`function ${composed}(`));
|
|
421
424
|
assert.match(body.slice(0, body.indexOf('\n}')), /recruiterReachSql\(alias\)/,
|
|
422
425
|
`${composed} stopped composing the reach axis — opting out would no longer empty that surface`);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// tests/activity_snapshots_db.mjs — task 1002977 (community spec D2; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves the windowed data plane's append-only history against real SQL. The
|
|
4
|
+
// skip-on-unchanged rule lives entirely inside one INSERT ... WHERE NOT EXISTS
|
|
5
|
+
// statement (activity-snapshots.js), so a fake pool can only prove the query
|
|
6
|
+
// SHAPE (modules/platform-identity/tests/activity-snapshots.mjs); only a real
|
|
7
|
+
// Postgres can prove WHICH reports it actually inserts, and that upsertActivity
|
|
8
|
+
// (the existing POST /sso/activity/rollup path) really calls it end to end.
|
|
9
|
+
//
|
|
10
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
11
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
12
|
+
// never runs it.
|
|
13
|
+
//
|
|
14
|
+
// Run: DATABASE_URL=... node tests/activity_snapshots_db.mjs
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { Pool } = require('pg');
|
|
23
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
24
|
+
const { recordActivitySnapshot } = require('../modules/platform-identity/activity-snapshots.js');
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
28
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
29
|
+
|
|
30
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
31
|
+
try {
|
|
32
|
+
await pool.query('SELECT 1');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(`activity_snapshots_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
35
|
+
await pool.end().catch(() => {});
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
40
|
+
const BUILDER = 90002977, OTHER_BUILDER = 90002978;
|
|
41
|
+
const IDS = [BUILDER, OTHER_BUILDER];
|
|
42
|
+
const CLIENT = 't1002977-client';
|
|
43
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
44
|
+
|
|
45
|
+
async function cleanup() {
|
|
46
|
+
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
47
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let pass = 0;
|
|
51
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
55
|
+
// upsertActivity's INSERT always names recent_ships (012_recent_ships adds the
|
|
56
|
+
// column); without it here, step 2 below fails 42703 on a fresh database.
|
|
57
|
+
await q(migrationSql('platform_identity_012_recent_ships.sql'));
|
|
58
|
+
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
59
|
+
await cleanup();
|
|
60
|
+
|
|
61
|
+
// 1 — recordActivitySnapshot directly: the changed-totals skip rule.
|
|
62
|
+
const first = await recordActivitySnapshot({ clientId: CLIENT, githubId: BUILDER, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
63
|
+
ok(first !== null, 'a first-ever report inserts — nothing to be unchanged from');
|
|
64
|
+
|
|
65
|
+
const unchanged = await recordActivitySnapshot({ clientId: CLIENT, githubId: BUILDER, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
66
|
+
ok(unchanged === null, 'an identical re-report inserts nothing');
|
|
67
|
+
|
|
68
|
+
const changed = await recordActivitySnapshot({ clientId: CLIENT, githubId: BUILDER, credits: 150, tasksShipped: 5, karma: 3 }, { pool });
|
|
69
|
+
ok(changed !== null, 'a changed report (credits differ) inserts a new row');
|
|
70
|
+
|
|
71
|
+
const { rows: history } = await q(
|
|
72
|
+
`SELECT credits FROM platform_identity_activity_snapshots
|
|
73
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
|
|
74
|
+
[CLIENT, BUILDER],
|
|
75
|
+
);
|
|
76
|
+
ok(history.length === 2, 'exactly two rows exist — the unchanged report left no trace');
|
|
77
|
+
assert.deepEqual(history.map((r) => r.credits), [100, 150]);
|
|
78
|
+
pass++; console.log(' ok - the history is in report order');
|
|
79
|
+
|
|
80
|
+
// 2 — upsertActivity (the real POST /sso/activity/rollup path) really calls it.
|
|
81
|
+
await pi.upsertActivity({ clientId: CLIENT, githubId: OTHER_BUILDER, credits: 10, tasksShipped: 1, karma: 0 }, { pool });
|
|
82
|
+
await pi.upsertActivity({ clientId: CLIENT, githubId: OTHER_BUILDER, credits: 10, tasksShipped: 1, karma: 0 }, { pool });
|
|
83
|
+
await pi.upsertActivity({ clientId: CLIENT, githubId: OTHER_BUILDER, credits: 20, tasksShipped: 1, karma: 0 }, { pool });
|
|
84
|
+
const { rows: viaRollup } = await q(
|
|
85
|
+
`SELECT credits FROM platform_identity_activity_snapshots
|
|
86
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
|
|
87
|
+
[CLIENT, OTHER_BUILDER],
|
|
88
|
+
);
|
|
89
|
+
ok(viaRollup.length === 2, 'upsertActivity appends a snapshot per changed report — two calls, one repeat, one row skipped');
|
|
90
|
+
assert.deepEqual(viaRollup.map((r) => r.credits), [10, 20]);
|
|
91
|
+
pass++; console.log(' ok - deleting the call inside upsertActivity would leave this red');
|
|
92
|
+
|
|
93
|
+
console.log(`\nactivity_snapshots_db.mjs: ${pass} passed`);
|
|
94
|
+
} finally {
|
|
95
|
+
await cleanup();
|
|
96
|
+
await pool.end().catch(() => {});
|
|
97
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// tests/card_apply_ui.mjs — the "Apply to join" affordance on the map's
|
|
2
|
+
// project card, EXECUTED (BV1.R10, task 1002286, goal 1000110).
|
|
3
|
+
//
|
|
4
|
+
// The idiom is tests/project_invite_ui.mjs beside it (there is no jsdom in
|
|
5
|
+
// this repo): lift the region out of modules/public-landing/public/projects.html,
|
|
6
|
+
// run it in a vm under a minimal DOM/fetch stub, and drive it directly —
|
|
7
|
+
// rather than reading the source and hoping. cardApplyHtml/submitApply are
|
|
8
|
+
// deliberately small and depend on only a handful of free variables
|
|
9
|
+
// (isMember, hostOf, appliedOrigins, me, $, fetch, jsonOrEmpty, apiError,
|
|
10
|
+
// signInAgain), all supplied by the sandbox the way project_invite_ui.mjs
|
|
11
|
+
// supplies esc()/jsonOrEmpty()/apiError() rather than slicing them too.
|
|
12
|
+
//
|
|
13
|
+
// What it proves, and why each matters:
|
|
14
|
+
// • the button is invisible for the platform's own card, for a member (the
|
|
15
|
+
// "yours"/"you build here" chip already says that), and for a signed-out
|
|
16
|
+
// visitor (there is no session to file the request under) — C6's rule
|
|
17
|
+
// applied to a fourth region
|
|
18
|
+
// • Apply sends exactly { origin } on the cookie session, to the join relay
|
|
19
|
+
// R08 already built (POST /my-projects/join)
|
|
20
|
+
// • only `status === 'requested'` moves the button to Applied and marks the
|
|
21
|
+
// origin applied; every other outcome the relay can give (already_member,
|
|
22
|
+
// invite_only, open_enrollment, members_only, not_published) is shown as
|
|
23
|
+
// the DOOR'S OWN WORDS and leaves the button re-tryable — R08's relay
|
|
24
|
+
// invented this vocabulary once already; the card must not invent a
|
|
25
|
+
// second one
|
|
26
|
+
// • a refusal, an expired session and an unreachable platform are each said
|
|
27
|
+
// honestly and never mistaken for a filed application
|
|
28
|
+
// • loadMemberships() reads the `applied` echo GET /my-projects returns
|
|
29
|
+
// (R08, ADR 0201 D1.3) into appliedOrigins, by host, the same way it
|
|
30
|
+
// already reads `owned`/`building` into mineOrigins
|
|
31
|
+
//
|
|
32
|
+
// Run: node --test --test-reporter=tap tests/card_apply_ui.mjs
|
|
33
|
+
|
|
34
|
+
import test from 'node:test';
|
|
35
|
+
import assert from 'node:assert/strict';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import vm from 'node:vm';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
43
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
44
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
45
|
+
|
|
46
|
+
// ── the card-apply region ────────────────────────────────────────────────
|
|
47
|
+
const START = SCRIPT.indexOf('/* ══ APPLY TO JOIN (R10, task 1002286)');
|
|
48
|
+
const END = SCRIPT.indexOf('function openCard(p, rel, returnTo) {');
|
|
49
|
+
assert.ok(START > 0, 'the region must start at the APPLY TO JOIN banner');
|
|
50
|
+
assert.ok(END > START, 'the region must end before openCard()');
|
|
51
|
+
const REGION = SCRIPT.slice(START, END);
|
|
52
|
+
for (const fn of ['function cardApplyHtml(', 'function submitApply(']) {
|
|
53
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function boot({ me = { rank: 'thetes' }, appliedOrigins = {}, answers = [] } = {}) {
|
|
57
|
+
const requests = [];
|
|
58
|
+
const els = new Map();
|
|
59
|
+
const makeEl = (id) => ({
|
|
60
|
+
_id: id, textContent: '', className: '', _html: '', disabled: false,
|
|
61
|
+
set innerHTML(v) { this._html = String(v); },
|
|
62
|
+
get innerHTML() { return this._html; },
|
|
63
|
+
});
|
|
64
|
+
const $ = (id) => {
|
|
65
|
+
if (id == null) return null;
|
|
66
|
+
if (!els.has(id)) els.set(id, makeEl(id));
|
|
67
|
+
return els.get(id);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const queued = answers.slice();
|
|
71
|
+
const fetch = (url, init = {}) => {
|
|
72
|
+
requests.push({ url, method: init.method, body: init.body, credentials: init.credentials, headers: init.headers });
|
|
73
|
+
const a = queued.shift() || { ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Requested.' } };
|
|
74
|
+
if (a.reject) return Promise.reject(new Error('offline'));
|
|
75
|
+
return Promise.resolve({ ok: a.ok, status: a.status, json: () => Promise.resolve(a.body) });
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// isMember/hostOf mirror the real page's own trivial pure functions — kept
|
|
79
|
+
// here rather than sliced in, the project_invite_ui.mjs precedent for a
|
|
80
|
+
// dependency simple enough that a second, hand-written copy cannot drift.
|
|
81
|
+
const sandbox = {
|
|
82
|
+
$, fetch, console, Promise, JSON, String, Object,
|
|
83
|
+
me, appliedOrigins,
|
|
84
|
+
API: '/api/bongos',
|
|
85
|
+
isMember: (rel) => rel === 'owner' || rel === 'builder',
|
|
86
|
+
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
87
|
+
jsonOrEmpty: (res) => res.json().catch(() => ({})),
|
|
88
|
+
apiError: (data, status) => ({
|
|
89
|
+
code: (data && data.error && data.error.code) || ('http_' + status),
|
|
90
|
+
message: (data && data.error && data.error.message) || '',
|
|
91
|
+
}),
|
|
92
|
+
signInAgain: () => 'Your session expired — <a href="/in">sign in again</a>.',
|
|
93
|
+
};
|
|
94
|
+
sandbox.globalThis = sandbox;
|
|
95
|
+
vm.createContext(sandbox);
|
|
96
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#card-apply' });
|
|
97
|
+
|
|
98
|
+
const settle = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
|
|
99
|
+
return { requests, sandbox, settle, $, msg: () => $('cardApplyMsg') };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── cardApplyHtml: who sees the button at all ───────────────────────────
|
|
103
|
+
|
|
104
|
+
test('the platform\'s own card never offers Apply', () => {
|
|
105
|
+
const { sandbox } = boot();
|
|
106
|
+
const html = sandbox.cardApplyHtml({ isSelf: true, origin: 'https://x.example' }, 'none');
|
|
107
|
+
assert.equal(html, '');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a member sees nothing — the relationship chip already says so', () => {
|
|
111
|
+
const { sandbox } = boot();
|
|
112
|
+
for (const rel of ['owner', 'builder']) {
|
|
113
|
+
assert.equal(sandbox.cardApplyHtml({ origin: 'https://x.example' }, rel), '', rel);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a signed-out visitor sees nothing — there is no session to apply under', () => {
|
|
118
|
+
const { sandbox } = boot({ me: null });
|
|
119
|
+
assert.equal(sandbox.cardApplyHtml({ origin: 'https://x.example' }, 'none'), '');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a signed-in visitor with no filed application gets an enabled Apply button', () => {
|
|
123
|
+
const { sandbox } = boot();
|
|
124
|
+
const html = sandbox.cardApplyHtml({ origin: 'https://x.example' }, 'none');
|
|
125
|
+
assert.match(html, /id="cardApply"/);
|
|
126
|
+
assert.doesNotMatch(html, /disabled/);
|
|
127
|
+
assert.match(html, />Apply to join</);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('an already-applied origin renders Applied, disabled — the echo, by host', () => {
|
|
131
|
+
const { sandbox } = boot({ appliedOrigins: { 'x.example': true } });
|
|
132
|
+
const html = sandbox.cardApplyHtml({ origin: 'https://x.example/' }, 'none');
|
|
133
|
+
assert.match(html, /id="cardApply" disabled/);
|
|
134
|
+
assert.match(html, />Applied</);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── submitApply: the request itself ─────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
test('Apply sends exactly { origin } to the join relay, on the cookie session', async () => {
|
|
140
|
+
const ui = boot();
|
|
141
|
+
const btn = { textContent: '', disabled: false };
|
|
142
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
143
|
+
await ui.settle();
|
|
144
|
+
|
|
145
|
+
assert.equal(ui.requests.length, 1);
|
|
146
|
+
const req = ui.requests[0];
|
|
147
|
+
assert.equal(req.url, '/api/bongos/my-projects/join');
|
|
148
|
+
assert.equal(req.method, 'POST');
|
|
149
|
+
assert.equal(req.credentials, 'same-origin');
|
|
150
|
+
assert.deepEqual(JSON.parse(req.body), { origin: 'https://mercury.example' });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a filed application moves the button to Applied and marks the origin', async () => {
|
|
154
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'requested', message: 'Requested access.' } }] });
|
|
155
|
+
const btn = { textContent: '', disabled: false };
|
|
156
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
157
|
+
await ui.settle();
|
|
158
|
+
|
|
159
|
+
assert.equal(btn.textContent, 'Applied');
|
|
160
|
+
assert.equal(btn.disabled, true, 'a filed application does not re-offer the click');
|
|
161
|
+
assert.equal(ui.sandbox.appliedOrigins['mercury.example'], true);
|
|
162
|
+
assert.equal(ui.msg().className, 'projMsg ok');
|
|
163
|
+
assert.equal(ui.msg().textContent, 'Requested access.');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('already_member is the door\'s own answer, not a filed application', async () => {
|
|
167
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'already_member', message: 'You already build on this project — sign in to it to see it here.' } }] });
|
|
168
|
+
const btn = { textContent: '', disabled: false };
|
|
169
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
170
|
+
await ui.settle();
|
|
171
|
+
|
|
172
|
+
assert.equal(btn.textContent, 'Apply to join', 're-tryable — this was not an application');
|
|
173
|
+
assert.equal(btn.disabled, false);
|
|
174
|
+
assert.equal(ui.sandbox.appliedOrigins['mercury.example'], undefined, 'not marked applied');
|
|
175
|
+
assert.equal(ui.msg().className, 'projMsg', 'the door\'s own answer, not an error');
|
|
176
|
+
assert.match(ui.msg().textContent, /already build on this project/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('invite_only is said in the door\'s own words, and the button stays re-tryable', async () => {
|
|
180
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'invite_only', message: 'This project is invite-only — it doesn’t take requests.' } }] });
|
|
181
|
+
const btn = { textContent: '', disabled: false };
|
|
182
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
183
|
+
await ui.settle();
|
|
184
|
+
|
|
185
|
+
assert.equal(btn.disabled, false);
|
|
186
|
+
assert.match(ui.msg().textContent, /invite-only/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a real refusal (e.g. the project is not yet on the map) is shown as an error, via apiError', async () => {
|
|
190
|
+
const ui = boot({ answers: [{ ok: false, status: 404, body: { error: { code: 'unknown_project', message: 'no such project' } } }] });
|
|
191
|
+
const btn = { textContent: '', disabled: false };
|
|
192
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
193
|
+
await ui.settle();
|
|
194
|
+
|
|
195
|
+
assert.equal(btn.textContent, 'Apply to join');
|
|
196
|
+
assert.equal(btn.disabled, false);
|
|
197
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
198
|
+
assert.equal(ui.msg().textContent, 'no such project');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('an expired session offers a way back in rather than a silent failure', async () => {
|
|
202
|
+
const ui = boot({ answers: [{ ok: false, status: 401, body: {} }] });
|
|
203
|
+
const btn = { textContent: '', disabled: false };
|
|
204
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
205
|
+
await ui.settle();
|
|
206
|
+
|
|
207
|
+
assert.equal(btn.disabled, false);
|
|
208
|
+
assert.match(ui.msg().innerHTML, /sign in again/i);
|
|
209
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an unreachable platform is said as a network problem, and the click is re-tryable', async () => {
|
|
213
|
+
const ui = boot({ answers: [{ reject: true }] });
|
|
214
|
+
const btn = { textContent: '', disabled: false };
|
|
215
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
216
|
+
await ui.settle();
|
|
217
|
+
|
|
218
|
+
assert.equal(btn.disabled, false);
|
|
219
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
220
|
+
assert.match(ui.msg().textContent, /reach the platform/i);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('the button disables and reads Applying… the instant it is pressed', () => {
|
|
224
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: { ok: true, status: 'requested', message: 'ok' } }] });
|
|
225
|
+
const btn = { textContent: '', disabled: false };
|
|
226
|
+
ui.sandbox.submitApply(btn, { origin: 'https://mercury.example' });
|
|
227
|
+
assert.equal(btn.disabled, true, 'disabled synchronously — no double-send on a fast second click');
|
|
228
|
+
assert.equal(btn.textContent, 'Applying…');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── loadMemberships: the applied echo, by host ───────────────────────────
|
|
232
|
+
|
|
233
|
+
test('loadMemberships reads GET /my-projects\' applied echo into appliedOrigins, by host', () => {
|
|
234
|
+
const START2 = SCRIPT.indexOf('function loadMemberships() {');
|
|
235
|
+
const END2 = SCRIPT.indexOf('\n }', START2) + 4;
|
|
236
|
+
assert.ok(START2 > 0 && END2 > START2, 'loadMemberships must be findable');
|
|
237
|
+
const region = SCRIPT.slice(START2, END2);
|
|
238
|
+
|
|
239
|
+
const sandbox = {
|
|
240
|
+
API: '/api/bongos',
|
|
241
|
+
mineOrigins: {},
|
|
242
|
+
appliedOrigins: {},
|
|
243
|
+
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
244
|
+
fetch: () => Promise.resolve({
|
|
245
|
+
ok: true,
|
|
246
|
+
json: () => Promise.resolve({
|
|
247
|
+
owned: [{ origin: 'https://owned.example' }],
|
|
248
|
+
building: [{ origin: 'https://built.example' }],
|
|
249
|
+
applied: [{ origin: 'https://applied.example/', applied_at: '2026-09-01T00:00:00Z' }],
|
|
250
|
+
}),
|
|
251
|
+
}),
|
|
252
|
+
};
|
|
253
|
+
sandbox.globalThis = sandbox;
|
|
254
|
+
vm.createContext(sandbox);
|
|
255
|
+
vm.runInContext(region, sandbox, { filename: 'projects.html#load-memberships' });
|
|
256
|
+
|
|
257
|
+
return sandbox.loadMemberships().then(() => {
|
|
258
|
+
// The region reassigns mineOrigins/appliedOrigins to a fresh `{}` it
|
|
259
|
+
// creates INSIDE the vm's own realm, so Object.assign re-parents a copy
|
|
260
|
+
// to this realm before comparing — a cross-realm deepStrictEqual reads
|
|
261
|
+
// an identically-shaped object as unequal over its prototype alone.
|
|
262
|
+
assert.deepEqual(Object.assign({}, sandbox.mineOrigins), { 'owned.example': 'owner', 'built.example': 'builder' });
|
|
263
|
+
assert.deepEqual(Object.assign({}, sandbox.appliedOrigins), { 'applied.example': true });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('a fetch failure clears appliedOrigins too, not just mineOrigins', () => {
|
|
268
|
+
const START2 = SCRIPT.indexOf('function loadMemberships() {');
|
|
269
|
+
const END2 = SCRIPT.indexOf('\n }', START2) + 4;
|
|
270
|
+
const region = SCRIPT.slice(START2, END2);
|
|
271
|
+
const sandbox = {
|
|
272
|
+
API: '/api/bongos',
|
|
273
|
+
mineOrigins: { stale: 'owner' },
|
|
274
|
+
appliedOrigins: { stale: true },
|
|
275
|
+
hostOf: (o) => String(o || ''),
|
|
276
|
+
fetch: () => Promise.reject(new Error('offline')),
|
|
277
|
+
};
|
|
278
|
+
sandbox.globalThis = sandbox;
|
|
279
|
+
vm.createContext(sandbox);
|
|
280
|
+
vm.runInContext(region, sandbox, { filename: 'projects.html#load-memberships-fail' });
|
|
281
|
+
|
|
282
|
+
return sandbox.loadMemberships().then(() => {
|
|
283
|
+
assert.deepEqual(Object.assign({}, sandbox.mineOrigins), {});
|
|
284
|
+
assert.deepEqual(Object.assign({}, sandbox.appliedOrigins), {});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── the card is actually wired to it (source pins) ───────────────────────
|
|
289
|
+
|
|
290
|
+
test('openCard builds the Apply button from cardApplyHtml and wires its click to submitApply', () => {
|
|
291
|
+
assert.match(SCRIPT, /var applyBtn = cardApplyHtml\(p, rel\);/);
|
|
292
|
+
assert.match(SCRIPT, /\) \+ applyBtn \+ \(applyBtn \? '<p class="projMsg" id="cardApplyMsg"><\/p>' : ''\);/,
|
|
293
|
+
'the message line only ships when there is a button that could produce one');
|
|
294
|
+
assert.match(SCRIPT, /var applyEl = \$\('cardApply'\);/);
|
|
295
|
+
assert.match(SCRIPT, /if \(applyEl && !applyEl\.disabled\) applyEl\.addEventListener\('click', function \(\) \{ submitApply\(applyEl, p\); \}\);/,
|
|
296
|
+
'an already-Applied card (disabled) is never re-wired to send a second request');
|
|
297
|
+
});
|