@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 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.577",
6
- "core_contract": "1.19.577",
7
- "source_commit": "762e093b407c02f640fd7c4f28f35b4aba92b905",
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-07T16:20:42.564Z",
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": 2046,
14
+ "functional_verbatim": 2047,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2521,
20
- "tree_sha256": "681bcb585fd2e75cf4e37463230354417f0f352734aa5057de24da8cfb8d8712",
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": "39e12981a4c704b133c9afc6c10cdb225fbe2cc882e667916eed2785ce0ae5ef"
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": "7aa74c059670bd351bf8a4295e408aef4a908e3095711aee93f75408ccd837d5"
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": "b2a595c7b297963949c8f0c4d662c56d6ce4334a8f9e92231f47f0cd9233d624"
7560
+ "sha256": "e5249bc21a0842ce0e47203e1b7052aa146161702db57b1c50740d409a7347b2"
7561
7561
  },
7562
7562
  {
7563
7563
  "path": "package.json",
7564
7564
  "mode": "0000644",
7565
- "sha256": "a2009a9bab221342b0b8b9c3c75a21d2ae02fb2fc281235f6fa7e17513a99ff8"
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": "a06af2e38ee9044c10f14f0ce1392ab85adb8058421222bf894f0f7ed6d77326"
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": "f93044429430c9fff4fe6860bae87ff8f67bc49a485391e659422e63f209e08c"
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
- var ctl = typeof AbortController === 'function' ? new AbortController() : null;
5910
- var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
5911
- fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
5912
- .then(function (res) {
5913
- /* a project that ANSWERED but refused (its own pre-launch gate, a proxy
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
- .then(function () { if (timer) clearTimeout(timer); });
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: its own public manifest, read cross-origin */
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
- var ctl = typeof AbortController === 'function' ? new AbortController() : null;
6322
- var timer = ctl ? setTimeout(function () { ctl.abort(); }, 8000) : null;
6323
- fetch('https://' + inst.domain + '/api/gds/instance', { mode: 'cors', signal: ctl ? ctl.signal : undefined })
6324
- .then(function (res) {
6325
- if (!res.ok) throw new Error('http:' + res.status);
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
- .then(function () { if (timer) clearTimeout(timer); });
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.577",
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.577",
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.577",
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.577'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
+ });
@@ -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(runs, /'https:\/\/' \+ inst\.domain \+ '\/api\/gds\/instance'/,
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(runs, /if \(!res\.ok\) throw new Error\('http:' \+ res\.status\)/, 'a refusal is not an older core');
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, /'https:\/\/' \+ inst\.domain \+ '\/api\/gds\/instance'/, 'the same public GET /instance the runner reads');
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');