@bongos/core 1.19.577 → 1.19.578
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/docs/module-api-changelog.md +2 -0
- package/modules/public-landing/public/projects.html +94 -59
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/manage_manifest_shared_read.mjs +205 -0
- package/tests/projects_hub.mjs +31 -4
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.578",
|
|
6
|
+
"core_contract": "1.19.578",
|
|
7
|
+
"source_commit": "a3f42dff6ab98712306ee86eaedf8191cafa3340",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-07T17:15:54.070Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 451,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2047,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2522,
|
|
20
|
+
"tree_sha256": "96be19d76442ed1b3e663525652a70c6ef60434e80d4ad907df8e294be5f38fe",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2697,7 +2697,7 @@
|
|
|
2697
2697
|
{
|
|
2698
2698
|
"path": "docs/module-api-changelog.md",
|
|
2699
2699
|
"mode": "0000644",
|
|
2700
|
-
"sha256": "
|
|
2700
|
+
"sha256": "072128c45a7036c0d01bc029a1267bf34ff7bd175226e83d691b8001e4a8eefa"
|
|
2701
2701
|
},
|
|
2702
2702
|
{
|
|
2703
2703
|
"path": "docs/modules-contract.md",
|
|
@@ -6812,7 +6812,7 @@
|
|
|
6812
6812
|
{
|
|
6813
6813
|
"path": "modules/public-landing/public/projects.html",
|
|
6814
6814
|
"mode": "0000644",
|
|
6815
|
-
"sha256": "
|
|
6815
|
+
"sha256": "22201cd63fde334156b40954707d836f8c65a8380a3f346fe7c77abb03ed3b51"
|
|
6816
6816
|
},
|
|
6817
6817
|
{
|
|
6818
6818
|
"path": "modules/public-landing/public/projects.probes.json",
|
|
@@ -7557,12 +7557,12 @@
|
|
|
7557
7557
|
{
|
|
7558
7558
|
"path": "package-lock.json",
|
|
7559
7559
|
"mode": "0000644",
|
|
7560
|
-
"sha256": "
|
|
7560
|
+
"sha256": "e5249bc21a0842ce0e47203e1b7052aa146161702db57b1c50740d409a7347b2"
|
|
7561
7561
|
},
|
|
7562
7562
|
{
|
|
7563
7563
|
"path": "package.json",
|
|
7564
7564
|
"mode": "0000644",
|
|
7565
|
-
"sha256": "
|
|
7565
|
+
"sha256": "d328d1b9a42a9003642815e8a17cc14f648d53a996c2c6db92a8d1250c570ea8"
|
|
7566
7566
|
},
|
|
7567
7567
|
{
|
|
7568
7568
|
"path": "public-docs/index.html",
|
|
@@ -9267,7 +9267,7 @@
|
|
|
9267
9267
|
{
|
|
9268
9268
|
"path": "src/module-api.js",
|
|
9269
9269
|
"mode": "0000644",
|
|
9270
|
-
"sha256": "
|
|
9270
|
+
"sha256": "71207830c631f2604b55d9e2e6a2db08b0d129499249c4db009c00e871fc2fd7"
|
|
9271
9271
|
},
|
|
9272
9272
|
{
|
|
9273
9273
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11179,6 +11179,11 @@
|
|
|
11179
11179
|
"mode": "0000644",
|
|
11180
11180
|
"sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
|
|
11181
11181
|
},
|
|
11182
|
+
{
|
|
11183
|
+
"path": "tests/manage_manifest_shared_read.mjs",
|
|
11184
|
+
"mode": "0000644",
|
|
11185
|
+
"sha256": "419234978bb7688f83ec14bb2db7fb4be1eec99205595795c705befe5620b89c"
|
|
11186
|
+
},
|
|
11182
11187
|
{
|
|
11183
11188
|
"path": "tests/manage_settings_shared_read.mjs",
|
|
11184
11189
|
"mode": "0000644",
|
|
@@ -11642,7 +11647,7 @@
|
|
|
11642
11647
|
{
|
|
11643
11648
|
"path": "tests/projects_hub.mjs",
|
|
11644
11649
|
"mode": "0000644",
|
|
11645
|
-
"sha256": "
|
|
11650
|
+
"sha256": "ec536c7bcfe2c9ea29d0df8a0dcbbb5c2764b4f60e0d14218aa0a10bf6afb469"
|
|
11646
11651
|
},
|
|
11647
11652
|
{
|
|
11648
11653
|
"path": "tests/projects_hub_module_picker.mjs",
|
|
@@ -1603,5 +1603,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1603
1603
|
landed since 1.19.575 with no explicit bump. run 34132732488. (task 1002620)
|
|
1604
1604
|
1.19.577 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1605
1605
|
landed since 1.19.576 with no explicit bump. run 34142767912. (task 1002620)
|
|
1606
|
+
1.19.578 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1607
|
+
landed since 1.19.577 with no explicit bump. run 34146835487. (task 1002620)
|
|
1606
1608
|
---------------------------------------------------------------------------
|
|
1607
1609
|
```
|
|
@@ -5590,6 +5590,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5590
5590
|
three cards below share ONE read of the row (task 1003582), so
|
|
5591
5591
|
dropping the memo here is what keeps each entry a fresh read */
|
|
5592
5592
|
if (!keepRepo) forgetSettings();
|
|
5593
|
+
if (!keepRepo) forgetManifest();
|
|
5593
5594
|
if (!keepRepo) loadVisibility(inst);
|
|
5594
5595
|
if (!keepRepo) loadJoinability(inst);
|
|
5595
5596
|
if (!keepRepo) loadDoor();
|
|
@@ -5892,6 +5893,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5892
5893
|
if (!inst || !$('mVisRuns')) return;
|
|
5893
5894
|
if (visPushOpen(inst)) {
|
|
5894
5895
|
mVisPushSeen = true;
|
|
5896
|
+
forgetManifest(); /* stale from the moment a push is queued — and by the
|
|
5897
|
+
time it settles both cards share one fresh read */
|
|
5895
5898
|
visRuns(inst.open_intent.state === 'running'
|
|
5896
5899
|
? 'Restarting the project to apply a settings change…'
|
|
5897
5900
|
: 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
|
|
@@ -5900,43 +5903,81 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5900
5903
|
if (mVisPushSeen) { mVisPushSeen = false; loadVisRuns(inst); }
|
|
5901
5904
|
}
|
|
5902
5905
|
|
|
5906
|
+
/* ── the project's own manifest, read ONCE per manage entry (task 1003554) ─
|
|
5907
|
+
Two cards render off the SAME cross-origin GET of the project's manifest —
|
|
5908
|
+
how the project is reached (brand.project.platformVisibility) and how
|
|
5909
|
+
people join (brand.project.joinability) — and each used to ask for itself:
|
|
5910
|
+
two identical round trips to somebody else's server on every entry.
|
|
5911
|
+
|
|
5912
|
+
Memoised like ensureSettings() and keyed on the project the same way, so a
|
|
5913
|
+
switch is never served the last one's answer. The difference that matters
|
|
5914
|
+
is WHEN it is dropped. The settings row is read once per entry and a memo
|
|
5915
|
+
that lives for that entry is right; this answer instead goes stale the
|
|
5916
|
+
moment a settings push is queued, and the re-read after that push settles
|
|
5917
|
+
is the ONLY thing that can say the new value took. So forgetManifest() is
|
|
5918
|
+
called from the push-open branch of renderVisState/renderJoinState, which
|
|
5919
|
+
runs on every repaint while the push is open and reads nothing itself: by
|
|
5920
|
+
the time it settles the memo is already empty, and the two cards' re-reads
|
|
5921
|
+
then share one read rather than racing two. A lifetime memo here would
|
|
5922
|
+
freeze both lines at their pre-restart value — an efficiency cleanup
|
|
5923
|
+
turned into a wrong answer on screen.
|
|
5924
|
+
|
|
5925
|
+
It RESOLVES with every outcome rather than throwing, so one unreachable
|
|
5926
|
+
project fans out to both lines and neither goes silent: status 0 means it
|
|
5927
|
+
could not be reached at all, any other status is the project's own
|
|
5928
|
+
refusal. The 8s budget and its abort belong to the shared read now — one
|
|
5929
|
+
timer for the pair, not one each. */
|
|
5930
|
+
var manifestPromise = null;
|
|
5931
|
+
var manifestFor = null; /* which project the memo holds — a switch must not reuse it */
|
|
5932
|
+
|
|
5933
|
+
function ensureManifest(domain) {
|
|
5934
|
+
if (manifestPromise && manifestFor === manageId) return manifestPromise;
|
|
5935
|
+
manifestFor = manageId;
|
|
5936
|
+
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
5937
|
+
var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
|
|
5938
|
+
manifestPromise = fetch('https://' + domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
|
|
5939
|
+
.then(function (res) {
|
|
5940
|
+
/* a project that ANSWERED but refused (its own pre-launch gate, a proxy
|
|
5941
|
+
error page) is not an older core — carry the status, don't guess */
|
|
5942
|
+
if (!res.ok) return { ok: false, status: res.status, data: null };
|
|
5943
|
+
return res.json().then(function (m) { return { ok: true, status: res.status, data: m }; });
|
|
5944
|
+
})
|
|
5945
|
+
.catch(function () { return { ok: false, status: 0, data: null }; })
|
|
5946
|
+
.then(function (ans) { if (timer) clearTimeout(timer); return ans; });
|
|
5947
|
+
return manifestPromise;
|
|
5948
|
+
}
|
|
5949
|
+
|
|
5950
|
+
/* Drop the memo so the next ensureManifest() is a real read — at each manage
|
|
5951
|
+
entry, and for the whole time a settings push is open. */
|
|
5952
|
+
function forgetManifest() { manifestPromise = null; manifestFor = null; }
|
|
5953
|
+
|
|
5903
5954
|
/* what the project RUNS: its own public manifest, read cross-origin (the
|
|
5904
5955
|
same GET /instance the platform's runner reads after a restart) */
|
|
5905
5956
|
function loadVisRuns(inst) {
|
|
5906
5957
|
if (!inst || inst.status !== 'active' || !inst.domain) { visRuns(''); return; }
|
|
5907
5958
|
var asked = manageId;
|
|
5908
5959
|
visRuns('Checking what the project is running…');
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
error page) is not an older core — say what happened, not a guess */
|
|
5915
|
-
if (!res.ok) throw new Error('http:' + res.status);
|
|
5916
|
-
return res.json();
|
|
5917
|
-
})
|
|
5918
|
-
.then(function (m) {
|
|
5919
|
-
if (asked !== manageId) return;
|
|
5920
|
-
var p = m && m.brand && m.brand.project;
|
|
5921
|
-
var v = p && p.platformVisibility;
|
|
5922
|
-
if (!p || typeof p !== 'object') {
|
|
5923
|
-
visRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
|
|
5924
|
-
return;
|
|
5925
|
-
}
|
|
5926
|
-
var o = VIS_OPTIONS[v];
|
|
5927
|
-
if (!o) { visRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
|
|
5928
|
-
var want = mVisSettings && mVisSettings.value;
|
|
5929
|
-
visRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
|
|
5930
|
-
(want && want !== v && VIS_OPTIONS[want] ? ' — the saved choice (' + esc(VIS_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
|
|
5931
|
-
})
|
|
5932
|
-
.catch(function (err) {
|
|
5933
|
-
if (asked !== manageId) return;
|
|
5934
|
-
var http = /^http:(\d+)$/.exec(String(err && err.message));
|
|
5935
|
-
visRuns(http
|
|
5936
|
-
? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
|
|
5960
|
+
ensureManifest(inst.domain).then(function (ans) {
|
|
5961
|
+
if (asked !== manageId) return;
|
|
5962
|
+
if (!ans.ok) {
|
|
5963
|
+
visRuns(ans.status
|
|
5964
|
+
? 'The project answered but didn’t share what it’s running (it replied ' + esc(String(ans.status)) + ').'
|
|
5937
5965
|
: 'Couldn’t reach the project to check what it’s running right now.');
|
|
5938
|
-
|
|
5939
|
-
|
|
5966
|
+
return;
|
|
5967
|
+
}
|
|
5968
|
+
var m = ans.data;
|
|
5969
|
+
var p = m && m.brand && m.brand.project;
|
|
5970
|
+
var v = p && p.platformVisibility;
|
|
5971
|
+
if (!p || typeof p !== 'object') {
|
|
5972
|
+
visRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
|
|
5973
|
+
return;
|
|
5974
|
+
}
|
|
5975
|
+
var o = VIS_OPTIONS[v];
|
|
5976
|
+
if (!o) { visRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
|
|
5977
|
+
var want = mVisSettings && mVisSettings.value;
|
|
5978
|
+
visRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
|
|
5979
|
+
(want && want !== v && VIS_OPTIONS[want] ? ' — the saved choice (' + esc(VIS_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
|
|
5980
|
+
});
|
|
5940
5981
|
}
|
|
5941
5982
|
|
|
5942
5983
|
/* ── the settings row, read ONCE per manage entry (task 1003582) ─────────
|
|
@@ -6305,6 +6346,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6305
6346
|
if (!inst || !$('mJoinRuns')) return;
|
|
6306
6347
|
if (visPushOpen(inst)) {
|
|
6307
6348
|
mJoinPushSeen = true;
|
|
6349
|
+
forgetManifest(); /* the same one settle, the same one drop */
|
|
6308
6350
|
joinRuns(inst.open_intent.state === 'running'
|
|
6309
6351
|
? 'Restarting the project to apply a settings change…'
|
|
6310
6352
|
: 'A settings change is queued — the project restarts to pick it up. Until then it keeps its current setting.');
|
|
@@ -6313,40 +6355,33 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6313
6355
|
if (mJoinPushSeen) { mJoinPushSeen = false; loadJoinRuns(inst); }
|
|
6314
6356
|
}
|
|
6315
6357
|
|
|
6316
|
-
/* what the project RUNS:
|
|
6358
|
+
/* what the project RUNS: the SAME shared manifest read the reach card uses
|
|
6359
|
+
(task 1003554) — a different key out of one answer, not a second GET */
|
|
6317
6360
|
function loadJoinRuns(inst) {
|
|
6318
6361
|
if (!inst || inst.status !== 'active' || !inst.domain) { joinRuns(''); return; }
|
|
6319
6362
|
var asked = manageId;
|
|
6320
6363
|
joinRuns('Checking what the project is running…');
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
return res.json();
|
|
6327
|
-
})
|
|
6328
|
-
.then(function (m) {
|
|
6329
|
-
if (asked !== manageId) return;
|
|
6330
|
-
var p = m && m.brand && m.brand.project;
|
|
6331
|
-
var v = p && p.joinability;
|
|
6332
|
-
if (!p || typeof p !== 'object') {
|
|
6333
|
-
joinRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
|
|
6334
|
-
return;
|
|
6335
|
-
}
|
|
6336
|
-
var o = JOIN_OPTIONS[v];
|
|
6337
|
-
if (!o) { joinRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
|
|
6338
|
-
var want = mJoinSettings && mJoinSettings.value;
|
|
6339
|
-
joinRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
|
|
6340
|
-
(want && want !== v && JOIN_OPTIONS[want] ? ' — the saved choice (' + esc(JOIN_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
|
|
6341
|
-
})
|
|
6342
|
-
.catch(function (err) {
|
|
6343
|
-
if (asked !== manageId) return;
|
|
6344
|
-
var http = /^http:(\d+)$/.exec(String(err && err.message));
|
|
6345
|
-
joinRuns(http
|
|
6346
|
-
? 'The project answered but didn’t share what it’s running (it replied ' + esc(http[1]) + ').'
|
|
6364
|
+
ensureManifest(inst.domain).then(function (ans) {
|
|
6365
|
+
if (asked !== manageId) return;
|
|
6366
|
+
if (!ans.ok) {
|
|
6367
|
+
joinRuns(ans.status
|
|
6368
|
+
? 'The project answered but didn’t share what it’s running (it replied ' + esc(String(ans.status)) + ').'
|
|
6347
6369
|
: 'Couldn’t reach the project to check what it’s running right now.');
|
|
6348
|
-
|
|
6349
|
-
|
|
6370
|
+
return;
|
|
6371
|
+
}
|
|
6372
|
+
var m = ans.data;
|
|
6373
|
+
var p = m && m.brand && m.brand.project;
|
|
6374
|
+
var v = p && p.joinability;
|
|
6375
|
+
if (!p || typeof p !== 'object') {
|
|
6376
|
+
joinRuns('This project’s server doesn’t report this setting yet — it runs an older platform version, so the setting can’t take effect until the project is updated.');
|
|
6377
|
+
return;
|
|
6378
|
+
}
|
|
6379
|
+
var o = JOIN_OPTIONS[v];
|
|
6380
|
+
if (!o) { joinRuns('This project’s server reports a setting this page doesn’t recognise (' + esc(String(v)) + ').'); return; }
|
|
6381
|
+
var want = mJoinSettings && mJoinSettings.value;
|
|
6382
|
+
joinRuns('Right now the project runs as <b>' + esc(o.label) + '</b>' +
|
|
6383
|
+
(want && want !== v && JOIN_OPTIONS[want] ? ' — the saved choice (' + esc(JOIN_OPTIONS[want].label) + ') hasn’t taken effect there yet.' : '.'));
|
|
6384
|
+
});
|
|
6350
6385
|
}
|
|
6351
6386
|
|
|
6352
6387
|
function loadJoinability(inst) {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.578",
|
|
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.578",
|
|
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.578",
|
|
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/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.578'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// tests/manage_manifest_shared_read.mjs — one read of the project's OWN
|
|
2
|
+
// manifest per manage entry, EXECUTED (task 1003554).
|
|
3
|
+
//
|
|
4
|
+
// The defect this pins shut: entering /projects?view=manage fetched
|
|
5
|
+
// https://<project>/api/gds/instance twice, identically and cross-origin,
|
|
6
|
+
// because the two cards that render a "what it runs right now" line — how the
|
|
7
|
+
// project is reached (brand.project.platformVisibility) and how people join
|
|
8
|
+
// (brand.project.joinability) — each asked the project for themselves. Two
|
|
9
|
+
// round trips to somebody else's server, each with its own 8s budget, for one
|
|
10
|
+
// answer.
|
|
11
|
+
//
|
|
12
|
+
// It is the sibling of tests/manage_settings_shared_read.mjs, and the reason it
|
|
13
|
+
// is a separate reader rather than the same memo: the settings row is read once
|
|
14
|
+
// per entry and never again, so a memo that lives for the entry is right. This
|
|
15
|
+
// answer goes stale the moment a settings push is queued, and the re-read once
|
|
16
|
+
// that push settles is the ONLY thing that can say the new value took. So the
|
|
17
|
+
// memo is dropped for as long as the push is open. A lifetime memo here would
|
|
18
|
+
// freeze both lines at their pre-restart value — an efficiency cleanup turned
|
|
19
|
+
// into a wrong answer on screen.
|
|
20
|
+
//
|
|
21
|
+
// tests/projects_hub.mjs pins that both cards go through the shared reader;
|
|
22
|
+
// this file lifts the reader out of modules/public-landing/public/projects.html,
|
|
23
|
+
// runs it in a vm under a scripted fetch, and counts the round trips. The
|
|
24
|
+
// vm-harness precedent is tests/project_door_ui.mjs.
|
|
25
|
+
//
|
|
26
|
+
// What it proves, and why each matters:
|
|
27
|
+
// • both cards of one entry cost ONE round trip — the defect itself
|
|
28
|
+
// • forgetManifest() makes the next read real, so entry and tab return each
|
|
29
|
+
// ask the project as it is now, never as it was last visit
|
|
30
|
+
// • the push-open drop is what makes the settle re-read real, and the two
|
|
31
|
+
// cards re-reading after one settle still cost one round trip
|
|
32
|
+
// • switching projects is never served the previous project's manifest
|
|
33
|
+
// • a refusal RESOLVES carrying its status and an unreachable project
|
|
34
|
+
// resolves with status 0 — so ONE failure reaches BOTH lines and neither
|
|
35
|
+
// card goes silent waiting on a rejection nobody handed it
|
|
36
|
+
// • the 8s abort budget is the shared read's, and it is cleared once
|
|
37
|
+
//
|
|
38
|
+
// Run: node --test --test-reporter=tap tests/manage_manifest_shared_read.mjs
|
|
39
|
+
|
|
40
|
+
import assert from 'node:assert/strict';
|
|
41
|
+
import { test } from 'node:test';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import vm from 'node:vm';
|
|
45
|
+
import { fileURLToPath } from 'node:url';
|
|
46
|
+
|
|
47
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
|
|
49
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
50
|
+
|
|
51
|
+
// The reader, sliced from the page verbatim. Both boundaries are asserted so a
|
|
52
|
+
// rename cannot leave this file executing an empty string and passing.
|
|
53
|
+
const START = SCRIPT.indexOf(' var manifestPromise = null;');
|
|
54
|
+
const END = SCRIPT.indexOf(' function loadVisRuns(');
|
|
55
|
+
assert.ok(START > 0, 'the shared reader must start at its memo declaration');
|
|
56
|
+
assert.ok(END > START, 'the shared reader must sit above its first consumer');
|
|
57
|
+
const REGION = SCRIPT.slice(START, END);
|
|
58
|
+
for (const fn of ['function ensureManifest(', 'function forgetManifest(']) {
|
|
59
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── the harness ──────────────────────────────────────────────────────────────
|
|
63
|
+
// Narrow on purpose: the region touches fetch, manageId and the abort timer,
|
|
64
|
+
// and nothing else — so the stub stays honest about what it can prove.
|
|
65
|
+
|
|
66
|
+
function boot({ ok = true, status = 200, body = { brand: { project: { platformVisibility: 'public', joinability: 'open' } } }, reject = false } = {}) {
|
|
67
|
+
const requests = [];
|
|
68
|
+
const timers = { set: 0, cleared: 0 };
|
|
69
|
+
const sandbox = {
|
|
70
|
+
Promise, JSON, String, Object, Error, AbortController,
|
|
71
|
+
manageId: '18',
|
|
72
|
+
setTimeout: (fn, ms) => { timers.set++; timers.ms = ms; return { fn }; },
|
|
73
|
+
clearTimeout: () => { timers.cleared++; },
|
|
74
|
+
fetch: (url, init = {}) => {
|
|
75
|
+
requests.push({ url, mode: init.mode });
|
|
76
|
+
return reject
|
|
77
|
+
? Promise.reject(new Error('network'))
|
|
78
|
+
: Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
sandbox.globalThis = sandbox;
|
|
82
|
+
vm.createContext(sandbox);
|
|
83
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#shared-manifest-read' });
|
|
84
|
+
|
|
85
|
+
return { requests, timers, sandbox };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── the tests ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
test('the two cards of one manage entry cost ONE round trip, not two', async () => {
|
|
91
|
+
// Arrange — the page's own entry pass: the reach card, then the join card
|
|
92
|
+
const ui = boot();
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
const answers = await Promise.all([
|
|
96
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
97
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
assert.equal(ui.requests.length, 1, 'one cross-origin GET served both lines');
|
|
102
|
+
assert.equal(ui.requests[0].url, 'https://demo.example.com/api/gds/instance');
|
|
103
|
+
assert.equal(ui.requests[0].mode, 'cors', 'the project is somebody else’s origin');
|
|
104
|
+
for (const a of answers) {
|
|
105
|
+
assert.equal(a.ok, true);
|
|
106
|
+
assert.equal(a.data.brand.project.joinability, 'open', 'both callers got the same manifest');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('forgetManifest makes the next read real — entry and tab return never reuse the last visit’s answer', async () => {
|
|
111
|
+
// Arrange
|
|
112
|
+
const ui = boot();
|
|
113
|
+
await ui.sandbox.ensureManifest('demo.example.com');
|
|
114
|
+
|
|
115
|
+
// Act — what loadManage does at the top of every entry + tab return pass
|
|
116
|
+
ui.sandbox.forgetManifest();
|
|
117
|
+
await ui.sandbox.ensureManifest('demo.example.com');
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
assert.equal(ui.requests.length, 2, 'the second entry asked the project again');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a settled push re-reads for real, and the two cards still share ONE round trip', async () => {
|
|
124
|
+
// Arrange — the reason this memo is not ensureSettings()'s: an answer read
|
|
125
|
+
// before a push cannot say whether the restart took
|
|
126
|
+
const ui = boot();
|
|
127
|
+
await ui.sandbox.ensureManifest('demo.example.com');
|
|
128
|
+
|
|
129
|
+
// Act — the push opens (renderVisState/renderJoinState each drop the memo on
|
|
130
|
+
// every repaint while it is open), then it settles and both cards re-read
|
|
131
|
+
ui.sandbox.forgetManifest();
|
|
132
|
+
ui.sandbox.forgetManifest();
|
|
133
|
+
const after = await Promise.all([
|
|
134
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
135
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
assert.equal(ui.requests.length, 2, 'one re-read after the settle — not one per card, and not zero');
|
|
140
|
+
assert.equal(after[0].ok, true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('switching projects is never served the previous project’s manifest', async () => {
|
|
144
|
+
// Arrange — the memo is warm for project 18
|
|
145
|
+
const ui = boot();
|
|
146
|
+
await ui.sandbox.ensureManifest('one.example.com');
|
|
147
|
+
|
|
148
|
+
// Act — the owner opens a different project; loadManage has not forgotten yet
|
|
149
|
+
ui.sandbox.manageId = '19';
|
|
150
|
+
await ui.sandbox.ensureManifest('two.example.com');
|
|
151
|
+
|
|
152
|
+
// Assert
|
|
153
|
+
assert.equal(ui.requests.length, 2, 'the memo is keyed on the project, not merely present');
|
|
154
|
+
assert.equal(ui.requests[1].url, 'https://two.example.com/api/gds/instance');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a project that answered-and-refused RESOLVES carrying its status — both lines can say so', async () => {
|
|
158
|
+
// Arrange — a pre-launch gate or a proxy error page, which is NOT an older core
|
|
159
|
+
const ui = boot({ ok: false, status: 403 });
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
const [a, b] = await Promise.all([
|
|
163
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
164
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
// Assert
|
|
168
|
+
assert.equal(a.ok, false);
|
|
169
|
+
assert.equal(a.status, 403, 'the status reaches the card, which prints it verbatim');
|
|
170
|
+
assert.equal(a.data, null, 'no body is claimed for a refusal');
|
|
171
|
+
assert.deepEqual(b, a, 'the second card is handed the same refusal, not left waiting');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('an unreachable project RESOLVES with status 0, so one failure fans out to both lines', async () => {
|
|
175
|
+
// Arrange — the failure that used to reject into two separate catches
|
|
176
|
+
const ui = boot({ reject: true });
|
|
177
|
+
|
|
178
|
+
// Act
|
|
179
|
+
const [a, b] = await Promise.all([
|
|
180
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
181
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
assert.equal(a.ok, false);
|
|
186
|
+
assert.equal(a.status, 0, 'status 0 is "could not reach it at all" — a different sentence from a refusal');
|
|
187
|
+
assert.deepEqual(b, a, 'neither line goes silent on a shared rejection');
|
|
188
|
+
assert.equal(ui.requests.length, 1, 'one failed attempt, not one per card');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('the 8s abort budget belongs to the shared read, and is cleared once', async () => {
|
|
192
|
+
// Arrange
|
|
193
|
+
const ui = boot();
|
|
194
|
+
|
|
195
|
+
// Act
|
|
196
|
+
await Promise.all([
|
|
197
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
198
|
+
ui.sandbox.ensureManifest('demo.example.com'),
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
// Assert
|
|
202
|
+
assert.equal(ui.timers.set, 1, 'one timer for the pair, not one each');
|
|
203
|
+
assert.equal(ui.timers.ms, 8000, 'the budget the two cards used to keep separately');
|
|
204
|
+
assert.equal(ui.timers.cleared, 1, 'and it is cleared when the answer lands');
|
|
205
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1418,14 +1418,17 @@ test('the save sends the choice and shows the platform’s receipt verbatim —
|
|
|
1418
1418
|
|
|
1419
1419
|
test('what the project RUNS comes only from its own manifest, and every answer is said', () => {
|
|
1420
1420
|
const runs = fnHub('loadVisRuns');
|
|
1421
|
+
const manifest = fnHub('ensureManifest');
|
|
1421
1422
|
assert.match(runs, /inst\.status !== 'active' \|\| !inst\.domain/, 'only a live project with an address can be asked');
|
|
1422
|
-
assert.match(
|
|
1423
|
+
assert.match(manifest, /'https:\/\/' \+ domain \+ '\/api\/gds\/instance'/,
|
|
1423
1424
|
'the same public GET /instance the platform’s runner reads after a restart');
|
|
1425
|
+
assert.doesNotMatch(runs, /fetch\(/, 'the card fires no GET of its own — one manifest read serves the pair (task 1003554)');
|
|
1424
1426
|
assert.match(runs, /m\.brand && m\.brand\.project/);
|
|
1425
1427
|
assert.match(runs, /doesn’t report this setting yet/, 'an older core is named, not shown as applied');
|
|
1426
1428
|
assert.match(runs, /hasn’t taken effect there yet/, 'a saved-but-not-applied value is said');
|
|
1427
1429
|
assert.match(runs, /Couldn’t reach the project/, 'an unreachable project is said');
|
|
1428
|
-
assert.match(
|
|
1430
|
+
assert.match(manifest, /if \(!res\.ok\) return \{ ok: false, status: res\.status, data: null \};/,
|
|
1431
|
+
'a refusal is not an older core — its status is carried out to both cards, not thrown away');
|
|
1429
1432
|
assert.match(runs, /answered but didn’t share what it’s running/, 'a project that answered-and-refused is said as such');
|
|
1430
1433
|
assert.doesNotMatch(runs, /mVisSettings\.value\)\s*\+\s*'<\/b>'|runs as <b>' \+ esc\(VIS_OPTIONS\[want\]/,
|
|
1431
1434
|
'the platform’s stored choice is never painted as what the project runs');
|
|
@@ -1598,15 +1601,39 @@ test('the joinability save sends the one key and shows the platform’s receipt
|
|
|
1598
1601
|
test('what the project RUNS for joinability comes only from its own manifest, and every answer is said', () => {
|
|
1599
1602
|
const runs = fnHub('loadJoinRuns');
|
|
1600
1603
|
assert.match(runs, /inst\.status !== 'active' \|\| !inst\.domain/, 'only a live project with an address can be asked');
|
|
1601
|
-
assert.match(runs, /
|
|
1604
|
+
assert.match(runs, /ensureManifest\(inst\.domain\)/, 'the same public GET /instance the runner reads, through the shared reader');
|
|
1605
|
+
assert.doesNotMatch(runs, /fetch\(/, 'the second card of the pair adds no round trip of its own (task 1003554)');
|
|
1602
1606
|
assert.match(runs, /p && p\.joinability/, 'the manifest key is brand.project.joinability');
|
|
1603
1607
|
assert.match(runs, /doesn’t report this setting yet/, 'an older core is named, not shown as applied');
|
|
1604
1608
|
assert.match(runs, /hasn’t taken effect there yet/, 'a saved-but-not-applied value is said');
|
|
1605
1609
|
assert.match(runs, /Couldn’t reach the project/, 'an unreachable project is said');
|
|
1606
|
-
assert.match(runs, /if \(!res\.ok\) throw new Error\('http:' \+ res\.status\)/, 'a refusal is not an older core');
|
|
1607
1610
|
assert.match(runs, /answered but didn’t share what it’s running/, 'a project that answered-and-refused is said as such');
|
|
1608
1611
|
});
|
|
1609
1612
|
|
|
1613
|
+
/* One read of the project's own manifest per manage entry (task 1003554), the
|
|
1614
|
+
sibling of the settings-row share above. Two cards read two keys out of the
|
|
1615
|
+
SAME cross-origin GET and each used to fetch it for itself. The memo differs
|
|
1616
|
+
from ensureSettings() in one load-bearing way — it is dropped for as long as
|
|
1617
|
+
a push is open, because the re-read after it settles is the only thing that
|
|
1618
|
+
can say the new value took. tests/manage_manifest_shared_read.mjs runs the
|
|
1619
|
+
reader; this pins that both cards go through it and that the drop is there. */
|
|
1620
|
+
test('the two “what it runs” lines share ONE read of the project’s manifest', () => {
|
|
1621
|
+
const ensure = fnHub('ensureManifest');
|
|
1622
|
+
assert.match(ensure, /if \(manifestPromise && manifestFor === manageId\) return manifestPromise;/,
|
|
1623
|
+
'memoised AND keyed on the project — a switch must never be served the last one’s manifest');
|
|
1624
|
+
assert.match(ensure, /setTimeout\(function \(\) \{ ctl\.abort\(\); \}, 8000\)/, 'one 8s budget for the pair, not one each');
|
|
1625
|
+
for (const fn of ['loadVisRuns', 'loadJoinRuns']) {
|
|
1626
|
+
assert.match(fnHub(fn), /ensureManifest\(inst\.domain\)/, `${fn} reads the manifest through the shared reader`);
|
|
1627
|
+
assert.doesNotMatch(fnHub(fn), /fetch\(/, `${fn} fires no GET of its own`);
|
|
1628
|
+
}
|
|
1629
|
+
assert.match(SCRIPT, /if \(!keepRepo\) forgetManifest\(\);/,
|
|
1630
|
+
'the memo is dropped on entry + tab return, so neither is ever served the last visit’s answer');
|
|
1631
|
+
for (const fn of ['renderVisState', 'renderJoinState']) {
|
|
1632
|
+
assert.match(fnHub(fn), /forgetManifest\(\);/,
|
|
1633
|
+
`${fn} drops the memo while a push is open — a lifetime memo would freeze the line at its pre-restart value`);
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1610
1637
|
test('the joinability in-flight line shares the visibility section’s push predicate and re-asks the manifest when it settles', () => {
|
|
1611
1638
|
const st = fnHub('renderJoinState');
|
|
1612
1639
|
assert.match(st, /if \(visPushOpen\(inst\)\)/, 'one settings push carries every key — one predicate, shared');
|