@bongos/core 1.19.511 → 1.19.512
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 +38 -28
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +124 -118
- package/docs/copy-registry.json +223 -158
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/provisioning.js +27 -1
- package/modules/provisioning/tests/provisioning.mjs +17 -14
- package/modules/public-landing/public/projects.html +125 -0
- package/modules/ui-design/kit/serve.js +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/currency_label.mjs +5 -4
- package/tests/project_door_ui.mjs +267 -0
- package/tests/projects_hub.mjs +73 -1
- package/tests/projects_hub_pre_uat.mjs +4 -1
- package/tests/provision_settings_apply.mjs +8 -7
- package/tests/provisioning_settings.mjs +65 -6
- package/tests/provisioning_settings_apply.mjs +3 -3
- package/tests/provisioning_settings_env.mjs +10 -8
|
@@ -1459,5 +1459,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1459
1459
|
landed since 1.19.509 with no explicit bump. run 33791237400. (task 1002620)
|
|
1460
1460
|
1.19.511 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1461
1461
|
landed since 1.19.510 with no explicit bump. run 33792868990. (task 1002620)
|
|
1462
|
+
1.19.512 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1463
|
+
landed since 1.19.511 with no explicit bump. run 33793208383. (task 1002620)
|
|
1462
1464
|
---------------------------------------------------------------------------
|
|
1463
1465
|
```
|
|
@@ -118,11 +118,37 @@ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
|
|
|
118
118
|
// (migration provisioning_009) is a dumb store with room for future keys.
|
|
119
119
|
// DEFAULT_SETTINGS is TODAY's behavior (public surfaces, apply-to-join),
|
|
120
120
|
// applied at read time so pre-existing rows need no data backfill.
|
|
121
|
+
//
|
|
122
|
+
// `visibility` is ADR 0182's STORED JOIN DOOR (BV1.R05, task 1002324) and it is a
|
|
123
|
+
// third axis, not a synonym of its neighbours — the three answer three different
|
|
124
|
+
// questions and an owner may set them independently:
|
|
125
|
+
//
|
|
126
|
+
// platform_visibility who may READ this project's own pages (its sign-in door)
|
|
127
|
+
// joinability HOW someone becomes a member (self-join / apply / invite)
|
|
128
|
+
// visibility how the project stands on the PLATFORM's map: named and
|
|
129
|
+
// open (public), named and closed (private), or present but
|
|
130
|
+
// anonymous (stealth)
|
|
131
|
+
//
|
|
132
|
+
// The name is ADR 0182 D1's deliberate choice — `goals.visibility` established the
|
|
133
|
+
// word and one word across both planes beat an accurate name used in one place —
|
|
134
|
+
// and D1 also records that it is a mild lie: this key does NOT decide whether the
|
|
135
|
+
// project is on the map. PUBLISHABILITY does (a name + a description, ADR 0182 D4),
|
|
136
|
+
// it is derived and never stored, and no value here can put a husk on the map.
|
|
137
|
+
//
|
|
138
|
+
// It is a POLICY, not a platform-drawn key: ADR 0182 D6 makes the door the CEILING
|
|
139
|
+
// that `joinability` may only narrow, and D1/D5 put that authority on the instance —
|
|
140
|
+
// so it rides web.env like its two neighbours and a restart applies it. The instance
|
|
141
|
+
// reads it back as `project.visibility` (src/branding.js), which is what lets a later
|
|
142
|
+
// task enforce the door there and report the value to the hub catalog (R08).
|
|
121
143
|
const SETTINGS_VOCAB = Object.freeze({
|
|
122
144
|
platform_visibility: ['public', 'gated'],
|
|
123
145
|
joinability: ['open', 'apply', 'invite_only'],
|
|
146
|
+
visibility: ['public', 'private', 'stealth'],
|
|
124
147
|
});
|
|
125
|
-
|
|
148
|
+
// Default = today's behavior on every axis, including the join door: ADR 0182's
|
|
149
|
+
// migration gives the hub column the same `public` default, "existing rows keep
|
|
150
|
+
// today's behaviour".
|
|
151
|
+
const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
126
152
|
|
|
127
153
|
// The project's PLANET (task 1003347, direction record §16): how the platform
|
|
128
154
|
// DRAWS it on the sky, in the owner's list and on the card — its template
|
|
@@ -263,21 +263,24 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
|
|
|
263
263
|
console.log('\nPer-project settings (task 1003040):');
|
|
264
264
|
|
|
265
265
|
t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
|
|
266
|
-
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply' });
|
|
267
|
-
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply' });
|
|
268
|
-
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply' });
|
|
266
|
+
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
267
|
+
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
268
|
+
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
269
269
|
assert.deepEqual(
|
|
270
270
|
P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
|
|
271
|
-
{ platform_visibility: 'gated', joinability: 'apply' },
|
|
271
|
+
{ platform_visibility: 'gated', joinability: 'apply', visibility: 'public' },
|
|
272
272
|
'a stored key overlays only itself'
|
|
273
273
|
);
|
|
274
274
|
assert.deepEqual(
|
|
275
|
-
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x' } }),
|
|
276
|
-
{ platform_visibility: 'public', joinability: 'invite_only' },
|
|
275
|
+
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted' } }),
|
|
276
|
+
{ platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' },
|
|
277
277
|
'off-vocabulary values fall back to the default; unknown keys never leak out'
|
|
278
278
|
);
|
|
279
279
|
assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
|
|
280
|
-
{ platform_visibility: 'public', joinability: 'apply' }, 'a non-object settings value reads as defaults');
|
|
280
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'a non-object settings value reads as defaults');
|
|
281
|
+
// the join door reads back like any other policy key (task 1002324)
|
|
282
|
+
assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
|
|
283
|
+
{ platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' }, 'a stored door overlays only itself');
|
|
281
284
|
});
|
|
282
285
|
|
|
283
286
|
t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
|
|
@@ -336,7 +339,7 @@ t('the planet (task 1003347): PLANET_TEMPLATES mirrors platform-identity\'s PROJ
|
|
|
336
339
|
assert.equal(P.PLANET_TEMPLATES.length, 36);
|
|
337
340
|
assert.deepEqual([...P.PLANET_ACCENTS], ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent']);
|
|
338
341
|
for (const a of P.PLANET_ACCENTS) assert.doesNotMatch(a, /^#|rgb|hsl/, 'a name, never a colour (the Fifteen Rule)');
|
|
339
|
-
assert.deepEqual(Object.keys(P.PLANET_VOCAB), ['planet_template', 'planet_accent']);
|
|
342
|
+
assert.deepEqual(Object.keys(P.PLANET_VOCAB), ['planet_template', 'planet_accent', 'card_backdrop']);
|
|
340
343
|
// the deliberate asymmetry: the planet is NOT a setting — no default, never in SETTINGS_VOCAB
|
|
341
344
|
for (const k of Object.keys(P.PLANET_VOCAB)) {
|
|
342
345
|
assert.ok(!(k in P.SETTINGS_VOCAB), `${k} is not a policy the instance obeys`);
|
|
@@ -345,12 +348,12 @@ t('the planet (task 1003347): PLANET_TEMPLATES mirrors platform-identity\'s PROJ
|
|
|
345
348
|
});
|
|
346
349
|
|
|
347
350
|
t('planetChoice: null for a bare row or an unset key; a stored on-vocabulary value reads back; junk reads null, never leaks', () => {
|
|
348
|
-
assert.deepEqual(P.planetChoice(null), { planet_template: null, planet_accent: null });
|
|
349
|
-
assert.deepEqual(P.planetChoice({}), { planet_template: null, planet_accent: null });
|
|
350
|
-
assert.deepEqual(P.planetChoice({ settings: { platform_visibility: 'gated' } }), { planet_template: null, planet_accent: null });
|
|
351
|
-
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' } }), { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' });
|
|
352
|
-
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'cube', planet_accent: '#fff' } }), { planet_template: null, planet_accent: null });
|
|
353
|
-
assert.deepEqual(P.planetChoice({ settings: ['nope'] }), { planet_template: null, planet_accent: null });
|
|
351
|
+
assert.deepEqual(P.planetChoice(null), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
352
|
+
assert.deepEqual(P.planetChoice({}), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
353
|
+
assert.deepEqual(P.planetChoice({ settings: { platform_visibility: 'gated' } }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
354
|
+
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit' } }), { planet_template: 'pair-frost-tint', planet_accent: 'sun-lit', card_backdrop: null });
|
|
355
|
+
assert.deepEqual(P.planetChoice({ settings: { planet_template: 'cube', planet_accent: '#fff' } }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
356
|
+
assert.deepEqual(P.planetChoice({ settings: ['nope'] }), { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
354
357
|
});
|
|
355
358
|
|
|
356
359
|
t('publicInstance carries the planet — both keys, null when unset — so the list and the manage page read it off the row', () => {
|
|
@@ -792,6 +792,25 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
792
792
|
</details>
|
|
793
793
|
</div>
|
|
794
794
|
|
|
795
|
+
<!-- live since task 1002324: the join door (ADR 0182 D1) — how the project
|
|
796
|
+
stands on the platform's map. The third axis, deliberately its own
|
|
797
|
+
section: the two above are about the project's OWN pages and its OWN
|
|
798
|
+
membership; this one is about the map everyone else reads. Same two
|
|
799
|
+
reads, key for key, as its siblings: the choice shown is the platform's
|
|
800
|
+
REQUEST (its settings row) and the save's reply is a receipt, never a
|
|
801
|
+
claim the project already runs it. Plain labels only — Public, Private,
|
|
802
|
+
Stealth — because the space words are the drawn orb's, not the copy's
|
|
803
|
+
(the goal's interview decision). The fold is a SIBLING of the volatile
|
|
804
|
+
slot so no re-render can drop it. -->
|
|
805
|
+
<div class="card mSec" id="mDoorState">
|
|
806
|
+
<h2>On the map</h2>
|
|
807
|
+
<div id="mDoor"><p class="finePrint">Reading the project’s settings…</p></div>
|
|
808
|
+
<details class="finePrint" id="mDoorWhat">
|
|
809
|
+
<summary>What each choice changes</summary>
|
|
810
|
+
<p><b>Public</b> — the project is on the map under its name, and anyone who finds it can join. <b>Private</b> — it is on the map under its name, but nobody joins on their own: people ask, and you decide. <b>Stealth</b> — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.</p>
|
|
811
|
+
</details>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
795
814
|
<!-- the planet picker (task 1003347; direction record §16, v1 the bands): the
|
|
796
815
|
owner's own choice of how the platform draws this project — the sky,
|
|
797
816
|
the list, this page's head and the card read the same row. The pick is
|
|
@@ -5168,6 +5187,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5168
5187
|
the GitHub read: entry + tab return, never the in-flight tick */
|
|
5169
5188
|
if (!keepRepo) loadVisibility(inst);
|
|
5170
5189
|
if (!keepRepo) loadJoinability(inst);
|
|
5190
|
+
if (!keepRepo) loadDoor();
|
|
5171
5191
|
/* the publish gate (task 1002335) needs no second read — the verdict
|
|
5172
5192
|
rode in on the instance itself. It sits behind the same keepRepo
|
|
5173
5193
|
guard because its card holds a textarea the in-flight tick must
|
|
@@ -5953,6 +5973,111 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5953
5973
|
if (t && t.hasAttribute('data-join-save')) doJoinSave();
|
|
5954
5974
|
});
|
|
5955
5975
|
|
|
5976
|
+
/* ── on the map (task 1002324): the join door, ADR 0182 D1 — the third axis,
|
|
5977
|
+
beside "who can see it" (the project's own sign-in door) and "how people
|
|
5978
|
+
join" (its membership door). The two reads above, key for key: the CHOICE
|
|
5979
|
+
comes from the platform's settings row and the save's reply is a RECEIPT —
|
|
5980
|
+
its push.message is shown verbatim and never painted as the live state,
|
|
5981
|
+
because the value reaches a running project only by the restart the runner
|
|
5982
|
+
performs. Unlike its siblings this section asks the project's manifest
|
|
5983
|
+
nothing: an already-deployed project reports no door yet, and a line that
|
|
5984
|
+
could only ever say "your project doesn't report this" is worse than no
|
|
5985
|
+
line. The read-back arrives with the enforcement (BV1.R11/R12).
|
|
5986
|
+
Labels are PLAIN — Public / Private / Stealth — per the goal's interview:
|
|
5987
|
+
the space metaphor is the drawn orb's, never the copy's. ── */
|
|
5988
|
+
|
|
5989
|
+
var DOOR_OPTIONS = {
|
|
5990
|
+
public: { label: 'Public', blurb: 'The project is on the map under its name, and anyone who finds it can join.' },
|
|
5991
|
+
private: { label: 'Private', blurb: 'On the map under its name, but nobody joins on their own — people ask, and you decide.' },
|
|
5992
|
+
stealth: { label: 'Stealth', blurb: 'The map shows only that something is there. Its name, and everything else about it, stay yours — and nobody can ask to join.' }
|
|
5993
|
+
};
|
|
5994
|
+
var mDoorSettings = null; /* the platform row's last answer: { value, options } */
|
|
5995
|
+
|
|
5996
|
+
function doorMsg(html) { $('mDoor').innerHTML = html; }
|
|
5997
|
+
|
|
5998
|
+
function renderDoor() {
|
|
5999
|
+
var s = mDoorSettings;
|
|
6000
|
+
if (!s) return;
|
|
6001
|
+
var opts = (s.options || []).filter(function (v) { return !!DOOR_OPTIONS[v]; });
|
|
6002
|
+
doorMsg('<div role="radiogroup" aria-label="On the map">' + opts.map(function (v) {
|
|
6003
|
+
var o = DOOR_OPTIONS[v];
|
|
6004
|
+
return '<label class="visOpt"><input type="radio" name="mDoorPick" value="' + esc(v) + '"' +
|
|
6005
|
+
(v === s.value ? ' checked' : '') + '><span><b>' + esc(o.label) + '</b>' + esc(o.blurb) + '</span></label>';
|
|
6006
|
+
}).join('') + '</div>' +
|
|
6007
|
+
'<div class="actions"><button type="button" class="btn small" data-door-save>Save</button></div>' +
|
|
6008
|
+
'<div class="projMsg" id="mDoorMsg" role="status" aria-live="polite"></div>');
|
|
6009
|
+
}
|
|
6010
|
+
|
|
6011
|
+
function loadDoor() {
|
|
6012
|
+
mDoorSettings = null;
|
|
6013
|
+
doorMsg('<p class="finePrint">Reading the project’s settings…</p>');
|
|
6014
|
+
var asked = manageId; /* a stale answer must never describe a newer project */
|
|
6015
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
|
|
6016
|
+
.then(function (res) {
|
|
6017
|
+
if (asked !== manageId) throw new Error('stale');
|
|
6018
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6019
|
+
if (res.ok) {
|
|
6020
|
+
mDoorSettings = {
|
|
6021
|
+
value: (data.settings || {}).visibility,
|
|
6022
|
+
options: (data.options || {}).visibility || Object.keys(DOOR_OPTIONS)
|
|
6023
|
+
};
|
|
6024
|
+
renderDoor();
|
|
6025
|
+
return;
|
|
6026
|
+
}
|
|
6027
|
+
doorMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
|
|
6028
|
+
});
|
|
6029
|
+
})
|
|
6030
|
+
.catch(function (err) {
|
|
6031
|
+
if (String(err && err.message) === 'stale') return;
|
|
6032
|
+
doorMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
|
|
6033
|
+
});
|
|
6034
|
+
}
|
|
6035
|
+
|
|
6036
|
+
/* the save sends the CHOICE and shows the platform's receipt verbatim */
|
|
6037
|
+
function doDoorSave() {
|
|
6038
|
+
if (!manageInst || !mDoorSettings) return;
|
|
6039
|
+
var picked = document.querySelector('#mDoor input[name="mDoorPick"]:checked');
|
|
6040
|
+
if (!picked) return;
|
|
6041
|
+
var value = picked.value;
|
|
6042
|
+
var box = $('mDoorMsg');
|
|
6043
|
+
var btn = document.querySelector('#mDoor [data-door-save]');
|
|
6044
|
+
if (btn) btn.disabled = true;
|
|
6045
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
6046
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', {
|
|
6047
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
6048
|
+
body: JSON.stringify({ visibility: value })
|
|
6049
|
+
}).then(function (res) {
|
|
6050
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6051
|
+
if (res.ok) {
|
|
6052
|
+
/* the row now holds the choice — the radio follows it, and nothing
|
|
6053
|
+
here claims the project is already running it */
|
|
6054
|
+
mDoorSettings.value = (data.saved || {}).visibility || value;
|
|
6055
|
+
box.className = 'projMsg ok';
|
|
6056
|
+
box.textContent = (data.push && data.push.message) || 'Saved.';
|
|
6057
|
+
return;
|
|
6058
|
+
}
|
|
6059
|
+
var e = apiError(data, res.status);
|
|
6060
|
+
if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
|
|
6061
|
+
/* 409 intent_conflict: the change IS saved (recorded) — the server's
|
|
6062
|
+
sentence says so and names the run holding the slot; show it as is */
|
|
6063
|
+
box.className = e.code === 'intent_conflict' ? 'projMsg' : 'projMsg err';
|
|
6064
|
+
box.textContent = e.message || 'The change could not be saved — try again in a moment.';
|
|
6065
|
+
});
|
|
6066
|
+
}).catch(function () {
|
|
6067
|
+
box.className = 'projMsg err';
|
|
6068
|
+
box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
|
|
6069
|
+
}).then(function () {
|
|
6070
|
+
if (btn) btn.disabled = false;
|
|
6071
|
+
/* the strip + the sibling sections read open_intent — re-read it */
|
|
6072
|
+
loadManage(true, true);
|
|
6073
|
+
});
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
$('mDoor').addEventListener('click', function (ev) {
|
|
6077
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
6078
|
+
if (t && t.hasAttribute('data-door-save')) doDoorSave();
|
|
6079
|
+
});
|
|
6080
|
+
|
|
5956
6081
|
/* ── delete the project (task 1003042): the manage page's teardown control.
|
|
5957
6082
|
It renders from status + open_intent only — no second request — so the
|
|
5958
6083
|
minute tick can repaint it and a queued teardown appears without a reload.
|
|
@@ -134,7 +134,7 @@ const FLOW_RECEIPTS = {
|
|
|
134
134
|
domain: { recorded: true, queued: true, message: 'Address recorded.' },
|
|
135
135
|
// the wizard's filing (task 1003346): the receipt names the instance the page then polls — Harbor, the fixture's standing-up row — and the address the page promises is the one the server assigned
|
|
136
136
|
provision: { queued: true, message: 'Queued. The platform picks it up from here.', instance: { id: 102, domain: 'mercury.cloudbongos.com' } },
|
|
137
|
-
settings: { settings: { platform_visibility: 'public', joinability: 'open' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
|
|
137
|
+
settings: { settings: { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
|
|
138
138
|
};
|
|
139
139
|
// a teardown or restart ask is REMEMBERED for the run: the page re-reads the project right
|
|
140
140
|
// after a receipt (doDelete → loadManage), and what it must then see is the queued intent —
|
|
@@ -160,6 +160,7 @@ function readBody(req, cb) {
|
|
|
160
160
|
const STUB_VOCAB = {
|
|
161
161
|
platform_visibility: ['public', 'gated'],
|
|
162
162
|
joinability: ['open', 'apply', 'invite_only'],
|
|
163
|
+
visibility: ['public', 'private', 'stealth'],
|
|
163
164
|
planet_template: null, // the template set is long; shape is checked, not membership
|
|
164
165
|
planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
165
166
|
card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
|
|
@@ -178,7 +179,7 @@ function settingsPatch(res, id, body) {
|
|
|
178
179
|
const bad = settingsRejection(body);
|
|
179
180
|
if (bad) return json(res, 400, { error: { code: 'validation_failed', message: bad } });
|
|
180
181
|
if (PLANET_KEYS.some((k) => k in body)) PLANETS.set(String(id), Object.fromEntries(PLANET_KEYS.map((k) => [k, body[k] || null])));
|
|
181
|
-
const policy = ['platform_visibility', 'joinability'].some((k) => k in body);
|
|
182
|
+
const policy = ['platform_visibility', 'joinability', 'visibility'].some((k) => k in body);
|
|
182
183
|
const push = policy ? FLOW_RECEIPTS.settings.push
|
|
183
184
|
: { queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' };
|
|
184
185
|
return json(res, 200, { ok: true, saved: FLOW_RECEIPTS.settings.settings, planet: PLANETS.get(String(id)) || NO_PLANET, push });
|
|
@@ -209,7 +210,7 @@ function instances(res, id, settings) {
|
|
|
209
210
|
if (id === null) return json(res, 200, { instances: list });
|
|
210
211
|
const inst = list.find((i) => String(i.id) === String(id));
|
|
211
212
|
if (!inst) return json(res, 404, { error: { code: 'not_found', message: 'no such instance' } });
|
|
212
|
-
if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open' }, planet: PLANETS.get(String(id)) || NO_PLANET, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] } });
|
|
213
|
+
if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, planet: PLANETS.get(String(id)) || NO_PLANET, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'], visibility: ['public', 'private', 'stealth'] } });
|
|
213
214
|
return json(res, 200, { instance: inst, open_intent: inst.open_intent || null });
|
|
214
215
|
}
|
|
215
216
|
// A project's art in the featured fixture may be root-relative (the surface's
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.512",
|
|
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.512",
|
|
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.512",
|
|
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/branding.js
CHANGED
|
@@ -60,6 +60,11 @@ const ENV_OVERRIDES = [
|
|
|
60
60
|
// — which is exactly why applying a change RESTARTS the instance.
|
|
61
61
|
['PLATFORM_VISIBILITY', ['project', 'platformVisibility']],
|
|
62
62
|
['JOINABILITY', ['project', 'joinability']],
|
|
63
|
+
// The join door the owner set on the platform (ADR 0182 D1, task 1002324) —
|
|
64
|
+
// public | private | stealth. Distinct from PLATFORM_VISIBILITY above, which is
|
|
65
|
+
// this instance's own sign-in door; this one is how the project stands on the
|
|
66
|
+
// platform's map, and D6 makes it the ceiling JOINABILITY may only narrow.
|
|
67
|
+
['VISIBILITY', ['project', 'visibility']],
|
|
63
68
|
];
|
|
64
69
|
|
|
65
70
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -215,6 +220,10 @@ function clientBranding(b = branding()) {
|
|
|
215
220
|
project: {
|
|
216
221
|
platformVisibility: b.project?.platformVisibility || 'public',
|
|
217
222
|
joinability: b.project?.joinability || 'apply',
|
|
223
|
+
// The join door (task 1002324). Public by nature for the same reason as its
|
|
224
|
+
// two neighbours: it is how the project asks to be seen on the map, and it is
|
|
225
|
+
// the READ-BACK a manage page needs to tell a saved choice from a live one.
|
|
226
|
+
visibility: b.project?.visibility || 'public',
|
|
218
227
|
},
|
|
219
228
|
};
|
|
220
229
|
}
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.512'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/currency_label.mjs
CHANGED
|
@@ -48,10 +48,11 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
|
|
51
|
-
|
|
51
|
+
// `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1)
|
|
52
|
+
assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply', visibility: 'public' });
|
|
52
53
|
assert.deepEqual(
|
|
53
|
-
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only' } }).project,
|
|
54
|
-
{ platformVisibility: 'gated', joinability: 'invite_only' },
|
|
54
|
+
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' } }).project,
|
|
55
|
+
{ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' },
|
|
55
56
|
'what the instance actually resolved (env override included) is what it publishes');
|
|
56
57
|
});
|
|
57
58
|
|
|
@@ -70,7 +71,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
|
|
|
70
71
|
// `project` joined the allow-list in task 1003140: the per-project policy is public
|
|
71
72
|
// by nature and is the settings read-back a manage page needs (see clientBranding).
|
|
72
73
|
assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
|
|
73
|
-
assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility']);
|
|
74
|
+
assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility', 'visibility']);
|
|
74
75
|
for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
|
|
75
76
|
assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
|
|
76
77
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// tests/project_door_ui.mjs — the owner's join-door control, EXECUTED (BV1.R05,
|
|
2
|
+
// task 1002324, ADR 0182 D1).
|
|
3
|
+
//
|
|
4
|
+
// tests/projects_hub.mjs pins this section's source text; this file pins its
|
|
5
|
+
// BEHAVIOUR. It lifts the real region out of modules/public-landing/public/
|
|
6
|
+
// projects.html — the option table, renderDoor, loadDoor, doDoorSave and the
|
|
7
|
+
// delegated click — runs it in a vm under a minimal DOM stub, PARSES the markup
|
|
8
|
+
// it writes into innerHTML, and drives a real click on the Save control.
|
|
9
|
+
//
|
|
10
|
+
// Why a vm harness rather than jsdom: there is no jsdom in this repo. The
|
|
11
|
+
// precedent is tests/task_detail_ui.mjs (after tests/goal_criterion_confirm_ui.mjs);
|
|
12
|
+
// this one differs in where the source comes from — the hub's script is INLINE in
|
|
13
|
+
// the page, so the region is sliced out rather than read from a .js file, and the
|
|
14
|
+
// slice boundaries are asserted so a rename can never quietly test nothing.
|
|
15
|
+
//
|
|
16
|
+
// What it proves, and why each matters:
|
|
17
|
+
// • the three choices render with the goal's PLAIN interview labels
|
|
18
|
+
// • the project's CURRENT door is the preselected one — an owner opening the
|
|
19
|
+
// page reads their own setting, never a default that misreports it
|
|
20
|
+
// • only values the SERVER offered are rendered (the options list is the source)
|
|
21
|
+
// • Save PATCHes the own-scoped settings channel with exactly { visibility }
|
|
22
|
+
// • the platform's receipt is shown verbatim, and a refusal is SAID — the radio
|
|
23
|
+
// never moves to a value the server did not accept
|
|
24
|
+
//
|
|
25
|
+
// Run: node --test --test-reporter=tap tests/project_door_ui.mjs
|
|
26
|
+
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
import { test } from 'node:test';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import vm from 'node:vm';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
|
|
36
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
37
|
+
|
|
38
|
+
// The region under test, sliced from the page verbatim. Both boundaries are
|
|
39
|
+
// asserted: a rename that moved either one would otherwise leave this file
|
|
40
|
+
// executing an empty string and passing.
|
|
41
|
+
const START = SCRIPT.indexOf('var DOOR_OPTIONS = {');
|
|
42
|
+
const END = SCRIPT.indexOf('/* ── delete the project');
|
|
43
|
+
assert.ok(START > 0, 'the join-door region must start at DOOR_OPTIONS');
|
|
44
|
+
assert.ok(END > START, 'the join-door region must end before the delete section');
|
|
45
|
+
const REGION = SCRIPT.slice(START, END);
|
|
46
|
+
for (const fn of ['function renderDoor(', 'function loadDoor(', 'function doDoorSave(']) {
|
|
47
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
51
|
+
// Narrow on purpose (the task_detail_ui.mjs rule): it supports exactly the
|
|
52
|
+
// selectors the region uses, so it stays honest about what it can prove.
|
|
53
|
+
|
|
54
|
+
const RADIO = /<input type="radio" name="mDoorPick" value="([^"]*)"( checked)?>/g;
|
|
55
|
+
const OPTION = /<label class="visOpt"><input type="radio" name="mDoorPick" value="([^"]*)"( checked)?><span><b>([^<]*)<\/b>([^<]*)<\/span><\/label>/g;
|
|
56
|
+
|
|
57
|
+
function parseOptions(html) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const m of String(html).matchAll(OPTION)) {
|
|
60
|
+
out.push({ value: m[1], checked: !!m[2], label: m[3], blurb: m[4] });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Boot the region against a scripted settings channel and hand back the handles
|
|
66
|
+
// a test drives it with.
|
|
67
|
+
function boot({ settings, options = ['public', 'private', 'stealth'], patchAnswer } = {}) {
|
|
68
|
+
const requests = [];
|
|
69
|
+
const els = new Map();
|
|
70
|
+
const listeners = new Map();
|
|
71
|
+
|
|
72
|
+
const makeEl = (id) => ({
|
|
73
|
+
_id: id, _html: '', textContent: '', className: '', disabled: false,
|
|
74
|
+
set innerHTML(v) { this._html = String(v); },
|
|
75
|
+
get innerHTML() { return this._html; },
|
|
76
|
+
addEventListener(type, fn) {
|
|
77
|
+
if (type !== 'click') return;
|
|
78
|
+
if (!listeners.has(id)) listeners.set(id, []);
|
|
79
|
+
listeners.get(id).push(fn);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const $ = (id) => {
|
|
83
|
+
if (!els.has(id)) els.set(id, makeEl(id));
|
|
84
|
+
return els.get(id);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// what the owner has clicked; starts as whatever the render marked checked
|
|
88
|
+
let picked = null;
|
|
89
|
+
const slotHtml = () => ($('mDoor')._html || '');
|
|
90
|
+
const saveBtn = { hasAttribute: (a) => a === 'data-door-save', disabled: false };
|
|
91
|
+
|
|
92
|
+
const document = {
|
|
93
|
+
querySelector(sel) {
|
|
94
|
+
if (sel === '#mDoor input[name="mDoorPick"]:checked') {
|
|
95
|
+
if (picked !== null) return { value: picked };
|
|
96
|
+
const found = [...slotHtml().matchAll(RADIO)].find((m) => m[2]);
|
|
97
|
+
return found ? { value: found[1] } : null;
|
|
98
|
+
}
|
|
99
|
+
if (sel === '#mDoor [data-door-save]') return slotHtml().includes('data-door-save') ? saveBtn : null;
|
|
100
|
+
throw new Error(`the stub does not support the selector ${sel}`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const answer = (body, { ok = true, status = 200 } = {}) => Promise.resolve({ ok, status, _json: body });
|
|
105
|
+
const fetch = (url, init = {}) => {
|
|
106
|
+
requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
|
|
107
|
+
if ((init.method || 'GET') === 'GET') {
|
|
108
|
+
return answer({ ok: true, settings, options: { visibility: options } });
|
|
109
|
+
}
|
|
110
|
+
return patchAnswer || answer({ ok: true, saved: { ...settings, visibility: JSON.parse(init.body).visibility }, push: { queued: true, message: 'Saved. Your project will restart to pick this up — until then it keeps its current setting.' } });
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const sandbox = {
|
|
114
|
+
$, document, fetch, console, Promise, JSON, String, Object, Array, Error, RegExp,
|
|
115
|
+
encodeURIComponent, setTimeout, clearTimeout,
|
|
116
|
+
API: '/api/bongos',
|
|
117
|
+
manageId: '18',
|
|
118
|
+
manageInst: { id: 18, status: 'active' },
|
|
119
|
+
esc: (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
120
|
+
jsonOrEmpty: (res) => Promise.resolve(res._json),
|
|
121
|
+
apiError: (data, status) => ({ code: (data && data.error && data.error.code) || 'error', message: (data && data.error && data.error.message) || `HTTP ${status}` }),
|
|
122
|
+
signInAgain: () => '<a href="/signin">Sign in again</a>',
|
|
123
|
+
loadManage: () => { requests.push({ url: 'loadManage' }); },
|
|
124
|
+
};
|
|
125
|
+
sandbox.globalThis = sandbox;
|
|
126
|
+
vm.createContext(sandbox);
|
|
127
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#join-door' });
|
|
128
|
+
|
|
129
|
+
const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
|
130
|
+
return {
|
|
131
|
+
requests, sandbox, settle,
|
|
132
|
+
options: () => parseOptions(slotHtml()),
|
|
133
|
+
slot: () => slotHtml(),
|
|
134
|
+
msg: () => $('mDoorMsg'),
|
|
135
|
+
pick: (v) => { picked = v; },
|
|
136
|
+
async load() { sandbox.loadDoor(); await settle(); },
|
|
137
|
+
async clickSave() {
|
|
138
|
+
const handlers = listeners.get('mDoor') || [];
|
|
139
|
+
assert.equal(handlers.length, 1, 'the section wires exactly one delegated click handler');
|
|
140
|
+
handlers[0]({ target: { closest: (sel) => (sel === 'button' ? saveBtn : null) } });
|
|
141
|
+
await settle();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── the tests ────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
test('the three choices render with the goal’s plain interview labels', async () => {
|
|
149
|
+
// Arrange
|
|
150
|
+
const ui = boot({ settings: { visibility: 'public' } });
|
|
151
|
+
|
|
152
|
+
// Act
|
|
153
|
+
await ui.load();
|
|
154
|
+
|
|
155
|
+
// Assert
|
|
156
|
+
assert.deepEqual(ui.options().map((o) => o.label), ['Public', 'Private', 'Stealth']);
|
|
157
|
+
assert.deepEqual(ui.options().map((o) => o.value), ['public', 'private', 'stealth']);
|
|
158
|
+
assert.match(ui.slot(), /role="radiogroup"/, 'the pick is a real radio group, not a list of buttons');
|
|
159
|
+
for (const o of ui.options()) assert.ok(o.blurb.length > 20, `${o.label} says what it changes`);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the project’s current door is the one preselected — never a default that misreports it', async () => {
|
|
163
|
+
// Arrange
|
|
164
|
+
const ui = boot({ settings: { visibility: 'stealth' } });
|
|
165
|
+
|
|
166
|
+
// Act
|
|
167
|
+
await ui.load();
|
|
168
|
+
|
|
169
|
+
// Assert
|
|
170
|
+
const checked = ui.options().filter((o) => o.checked);
|
|
171
|
+
assert.equal(checked.length, 1, 'exactly one option is preselected');
|
|
172
|
+
assert.equal(checked[0].value, 'stealth');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('only the values the SERVER offered are rendered', async () => {
|
|
176
|
+
// Arrange — a server that offers two (an older platform, or a narrowed list)
|
|
177
|
+
const ui = boot({ settings: { visibility: 'public' }, options: ['public', 'private'] });
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
await ui.load();
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
183
|
+
assert.deepEqual(ui.options().map((o) => o.value), ['public', 'private']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('saving PATCHes the own-scoped settings channel with exactly { visibility }', async () => {
|
|
187
|
+
// Arrange
|
|
188
|
+
const ui = boot({ settings: { visibility: 'public' } });
|
|
189
|
+
await ui.load();
|
|
190
|
+
ui.pick('private');
|
|
191
|
+
|
|
192
|
+
// Act
|
|
193
|
+
await ui.clickSave();
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
const patch = ui.requests.find((r) => r.method === 'PATCH');
|
|
197
|
+
assert.ok(patch, 'a PATCH was sent');
|
|
198
|
+
assert.equal(patch.url, '/api/bongos/provisioning/instances/18/settings');
|
|
199
|
+
assert.deepEqual(JSON.parse(patch.body), { visibility: 'private' }, 'exactly the one key this section owns');
|
|
200
|
+
assert.equal(patch.credentials, 'same-origin', 'the cookie session is the auth — this page carries no token');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the platform’s receipt is shown verbatim — the page never writes its own promise', async () => {
|
|
204
|
+
// Arrange
|
|
205
|
+
const ui = boot({ settings: { visibility: 'public' } });
|
|
206
|
+
await ui.load();
|
|
207
|
+
ui.pick('stealth');
|
|
208
|
+
|
|
209
|
+
// Act
|
|
210
|
+
await ui.clickSave();
|
|
211
|
+
|
|
212
|
+
// Assert
|
|
213
|
+
assert.equal(ui.msg().textContent,
|
|
214
|
+
'Saved. Your project will restart to pick this up — until then it keeps its current setting.');
|
|
215
|
+
assert.equal(ui.msg().className, 'projMsg ok');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a refusal is SAID, and the radio never moves to a value the server did not accept', async () => {
|
|
219
|
+
// Arrange
|
|
220
|
+
const ui = boot({
|
|
221
|
+
settings: { visibility: 'public' },
|
|
222
|
+
patchAnswer: Promise.resolve({ ok: false, status: 400, _json: { error: { code: 'validation_failed', message: 'visibility is not one of: public, private, stealth' } } }),
|
|
223
|
+
});
|
|
224
|
+
await ui.load();
|
|
225
|
+
ui.pick('unlisted');
|
|
226
|
+
|
|
227
|
+
// Act
|
|
228
|
+
await ui.clickSave();
|
|
229
|
+
|
|
230
|
+
// Assert
|
|
231
|
+
assert.equal(ui.msg().className, 'projMsg err');
|
|
232
|
+
assert.match(ui.msg().textContent, /not one of: public, private, stealth/, 'the server’s own sentence');
|
|
233
|
+
assert.equal(ui.sandbox.mDoorSettings.value, 'public', 'the stored choice is untouched by a refused save');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('a 409 intent_conflict is saved-but-waiting, not a failure', async () => {
|
|
237
|
+
// Arrange — the settings row IS written; another run holds the restart slot
|
|
238
|
+
const ui = boot({
|
|
239
|
+
settings: { visibility: 'public' },
|
|
240
|
+
patchAnswer: Promise.resolve({ ok: false, status: 409, _json: { error: { code: 'intent_conflict', message: 'The change was saved, but a \'provision\' run is currently running for this project.' } } }),
|
|
241
|
+
});
|
|
242
|
+
await ui.load();
|
|
243
|
+
ui.pick('private');
|
|
244
|
+
|
|
245
|
+
// Act
|
|
246
|
+
await ui.clickSave();
|
|
247
|
+
|
|
248
|
+
// Assert
|
|
249
|
+
assert.equal(ui.msg().className, 'projMsg', 'not painted as an error — the change is recorded');
|
|
250
|
+
assert.match(ui.msg().textContent, /The change was saved/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('a signed-out save offers a way back in rather than a dead end', async () => {
|
|
254
|
+
// Arrange
|
|
255
|
+
const ui = boot({
|
|
256
|
+
settings: { visibility: 'public' },
|
|
257
|
+
patchAnswer: Promise.resolve({ ok: false, status: 401, _json: {} }),
|
|
258
|
+
});
|
|
259
|
+
await ui.load();
|
|
260
|
+
ui.pick('private');
|
|
261
|
+
|
|
262
|
+
// Act
|
|
263
|
+
await ui.clickSave();
|
|
264
|
+
|
|
265
|
+
// Assert
|
|
266
|
+
assert.match(ui.msg().innerHTML, /Sign in again/);
|
|
267
|
+
});
|