@bongos/core 1.20.59 → 1.20.60
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 +136 -86
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +308 -3
- package/docs/api-reference.md +7 -2
- package/docs/copy-inventory.md +338 -329
- package/docs/copy-registry.json +515 -430
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +757 -706
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +5 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +8 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/provisioning/look.js +169 -0
- package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +4 -3
- package/modules/provisioning/routes/look.js +204 -0
- package/modules/provisioning/routes/provisioning.js +5 -59
- package/modules/provisioning/settings-push.js +76 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +499 -28
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +6 -3
- package/modules/ui-design/kit/serve.js +50 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/fitness.js +4 -0
- package/src/bongos/routes.js +4 -1
- package/src/bongos/serve-internal.js +6 -0
- package/src/branding.js +26 -1
- package/src/hall-look.js +99 -0
- package/src/module-api.js +1 -1
- package/src/night-tint.js +26 -0
- package/tests/currency_label.mjs +7 -4
- package/tests/hall_tokens.mjs +10 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/look_night_tint.mjs +169 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/projects_hub.mjs +5 -4
- package/tests/projects_hub_app_status.mjs +1 -1
- package/tests/projects_hub_app_step.mjs +8 -7
- package/tests/projects_hub_look.mjs +255 -0
- package/tests/projects_hub_module_picker.mjs +2 -2
- package/tests/provision_settings_apply.mjs +12 -4
- package/tests/provisioning_look.mjs +376 -0
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +4 -4
- package/tests/provisioning_settings_env.mjs +4 -0
- package/tests/wizard_draft_resume.mjs +2 -2
- package/tests/wizard_front_door.mjs +2 -2
- package/tests/wizard_intent_resume.mjs +16 -15
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// tests/provisioning_look.mjs — a project's look, night tint and logo, platform side
|
|
2
|
+
// (task 1004421, BV2.PS06; ADR 0358).
|
|
3
|
+
//
|
|
4
|
+
// The load-bearing assertions:
|
|
5
|
+
// • the vocabulary IS the style library plus `own` and `custom` — a look added to
|
|
6
|
+
// modules/ui-design/styles/ without joining the list (or the reverse) fails here;
|
|
7
|
+
// • the accent nudge ADJUSTS, never refuses: every colour round the hue wheel comes
|
|
8
|
+
// back readable in both modes, a readable one comes back untouched, and a moved
|
|
9
|
+
// one keeps its hue;
|
|
10
|
+
// • the wizard's copy of that nudge IS the server's, character for character;
|
|
11
|
+
// • a logo is screened by its BYTES — an SVG, a renamed file or an oversized one is
|
|
12
|
+
// refused before anything is stored;
|
|
13
|
+
// • the routes are the owner's (404 for anyone else), store the ADJUSTED accent and
|
|
14
|
+
// say so, refuse a custom look with no accent, and push like PATCH …/settings;
|
|
15
|
+
// • the public logo read serves only a content-addressed name, as an image only.
|
|
16
|
+
//
|
|
17
|
+
// DB-free: data access is stubbed on the cached module objects (the
|
|
18
|
+
// provisioning_repo_private_route.mjs pattern), over a real mounted router.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/provisioning_look.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
32
|
+
const look = require('../modules/provisioning/look.js');
|
|
33
|
+
|
|
34
|
+
let passed = 0, failed = 0;
|
|
35
|
+
async function ta(name, fn) {
|
|
36
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
37
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── contrast, restated independently of look.js so the nudge is checked, not echoed ──
|
|
41
|
+
const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
42
|
+
const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
43
|
+
const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
|
|
44
|
+
const rgb = (hex) => { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
|
45
|
+
const mix = (a, p, b) => a.map((v, i) => v * p + b[i] * (1 - p));
|
|
46
|
+
function readsEverywhere(hex) {
|
|
47
|
+
const c = rgb(hex);
|
|
48
|
+
const fails = [];
|
|
49
|
+
if (ratio([42, 19, 5], c) < 4.5) fails.push('dark ink on the accent fill');
|
|
50
|
+
for (const [name, ground] of [['paper', [237, 236, 238]], ['card', [251, 250, 252]]]) {
|
|
51
|
+
if (ratio(mix(c, 0.45, [36, 14, 0]), ground) < 4.5) fails.push(`light accent ink on ${name}`);
|
|
52
|
+
}
|
|
53
|
+
for (const [name, ground] of [['black', [0, 0, 0]], ['dark card', [16, 16, 18]],
|
|
54
|
+
['tinted night', mix(c, 0.06, [0, 0, 0])], ['tinted card', mix(c, 0.05, [16, 16, 18])]]) {
|
|
55
|
+
if (ratio(c, ground) < 4.5) fails.push(`accent on ${name}`);
|
|
56
|
+
}
|
|
57
|
+
const info = mix([91, 111, 208], 0.8, [255, 255, 255]);
|
|
58
|
+
if (ratio(info, mix(info, 0.14, mix(c, 0.05, [16, 16, 18]))) < 4.5) fails.push('info chip on the tinted card');
|
|
59
|
+
return fails;
|
|
60
|
+
}
|
|
61
|
+
const hueOf = (hex) => {
|
|
62
|
+
const [r, g, b] = rgb(hex).map((v) => v / 255);
|
|
63
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
|
|
64
|
+
if (!d) return null;
|
|
65
|
+
const h = max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
66
|
+
return h * 60;
|
|
67
|
+
};
|
|
68
|
+
const hslHex = (h, s, l) => {
|
|
69
|
+
const a = s * Math.min(l, 1 - l), k = (n) => (n + h / 30) % 12;
|
|
70
|
+
const f = (n) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))));
|
|
71
|
+
return '#' + [f(0), f(8), f(4)].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
console.log('\nThe vocabulary is the style library:');
|
|
75
|
+
|
|
76
|
+
await ta('LIBRARY_LOOKS is exactly the style directories, and the setting adds own + custom around them', async () => {
|
|
77
|
+
const dirs = fs.readdirSync(path.join(ROOT, 'modules', 'ui-design', 'styles'))
|
|
78
|
+
.filter((d) => fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'styles', d, 'pack.json'))).sort();
|
|
79
|
+
assert.deepEqual([...look.LIBRARY_LOOKS].sort(), dirs);
|
|
80
|
+
assert.deepEqual(look.LOOK_SETTINGS_VOCAB.look, ['own', ...look.LIBRARY_LOOKS, 'custom']);
|
|
81
|
+
assert.deepEqual(provisioning.SETTINGS_VOCAB.look, look.LOOK_SETTINGS_VOCAB.look, 'the look is a policy setting');
|
|
82
|
+
assert.deepEqual(provisioning.SETTINGS_VOCAB.night_tint, ['on', 'off']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await ta('the defaults change nothing: own pack, and a tint that only a look can switch on', async () => {
|
|
86
|
+
const s = provisioning.effectiveSettings({ settings: {} });
|
|
87
|
+
assert.equal(s.look, 'own');
|
|
88
|
+
assert.equal(s.night_tint, 'on');
|
|
89
|
+
const vars = provisioning.settingsEnvVars({ settings: {} }, 'X');
|
|
90
|
+
assert.equal(vars.X_LOOK, 'own');
|
|
91
|
+
assert.equal(vars.X_LOOK_ACCENT, '');
|
|
92
|
+
assert.equal(vars.X_LOGO_URL, '');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await ta('the companions carry only a well-formed value — a stored accent or address that is not one goes out empty', async () => {
|
|
96
|
+
const good = provisioning.settingsEnvVars({ settings: { look: 'custom', look_accent: '#B48CF2', logo_url: 'https://x.test/api/bongos/provisioning/logos/a.png' } }, 'X');
|
|
97
|
+
assert.equal(good.X_LOOK_ACCENT, '#b48cf2');
|
|
98
|
+
assert.equal(good.X_LOGO_URL, 'https://x.test/api/bongos/provisioning/logos/a.png');
|
|
99
|
+
const bad = provisioning.settingsEnvVars({ settings: { look_accent: 'red', logo_url: 'javascript:alert(1)' } }, 'X');
|
|
100
|
+
assert.equal(bad.X_LOOK_ACCENT, '');
|
|
101
|
+
assert.equal(bad.X_LOGO_URL, '');
|
|
102
|
+
const spaced = provisioning.settingsEnvVars({ settings: { logo_url: 'https://x.test/a b.png' } }, 'X');
|
|
103
|
+
assert.equal(spaced.X_LOGO_URL, '', 'a space would split the env line');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
console.log('\nThe accent nudge adjusts, never refuses:');
|
|
107
|
+
|
|
108
|
+
await ta('a colour that already reads in both modes comes back untouched', async () => {
|
|
109
|
+
for (const hex of ['#fb9569', '#b48cf2', '#6fb3e3', '#e3c39c']) {
|
|
110
|
+
assert.deepEqual(look.lookAdjustAccent(hex), { hex, adjusted: false }, hex);
|
|
111
|
+
assert.deepEqual(readsEverywhere(hex), [], hex);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await ta('every colour round the hue wheel comes back readable everywhere the hall paints it, its hue kept', async () => {
|
|
116
|
+
const fails = [];
|
|
117
|
+
let moved = 0;
|
|
118
|
+
for (let h = 0; h < 360; h += 10) {
|
|
119
|
+
for (const s of [0, 0.4, 0.8, 1]) {
|
|
120
|
+
for (const l of [0, 0.15, 0.35, 0.5, 0.65, 0.85, 1]) {
|
|
121
|
+
const hex = hslHex(h, s, l);
|
|
122
|
+
const out = look.lookAdjustAccent(hex);
|
|
123
|
+
if (!out) { fails.push(`${hex}: refused`); continue; }
|
|
124
|
+
const why = readsEverywhere(out.hex);
|
|
125
|
+
if (why.length) fails.push(`${hex} → ${out.hex}: ${why.join(', ')}`);
|
|
126
|
+
if (out.adjusted) moved++;
|
|
127
|
+
const before = hueOf(hex), after = hueOf(out.hex);
|
|
128
|
+
if (s >= 0.4 && l > 0 && l < 1 && before !== null && after !== null) {
|
|
129
|
+
const dh = Math.min(Math.abs(before - after), 360 - Math.abs(before - after));
|
|
130
|
+
if (dh > 8) fails.push(`${hex} → ${out.hex}: hue moved ${dh.toFixed(1)}°`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
assert.deepEqual(fails, []);
|
|
136
|
+
assert.ok(moved > 100, `sanity: the sweep must actually exercise the nudge (moved ${moved})`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await ta('pure black and pure white are nudged to a readable grey, not refused', async () => {
|
|
140
|
+
for (const hex of ['#000000', '#ffffff']) {
|
|
141
|
+
const out = look.lookAdjustAccent(hex);
|
|
142
|
+
assert.ok(out && out.adjusted, hex);
|
|
143
|
+
assert.deepEqual(readsEverywhere(out.hex), [], `${hex} → ${out.hex}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await ta('only a #rrggbb is a colour', async () => {
|
|
148
|
+
for (const v of ['', 'red', '#fff', '#12345g', null, undefined, 'rgb(1,2,3)']) assert.equal(look.lookAdjustAccent(v), null, String(v));
|
|
149
|
+
assert.deepEqual(look.lookAdjustAccent('B48CF2'), { hex: '#b48cf2', adjusted: false }, 'a missing # is forgiven');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await ta("the wizard's copy of lookAdjustAccent IS the server's, character for character", async () => {
|
|
153
|
+
const fnText = (src) => {
|
|
154
|
+
const start = src.indexOf('function lookAdjustAccent(');
|
|
155
|
+
assert.ok(start >= 0, 'function lookAdjustAccent( not found');
|
|
156
|
+
let depth = 0;
|
|
157
|
+
for (let i = src.indexOf('{', start); i < src.length; i++) {
|
|
158
|
+
if (src[i] === '{') depth++;
|
|
159
|
+
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1).replace(/\r\n/g, '\n').split('\n').map((l) => l.trim()).join('\n');
|
|
160
|
+
}
|
|
161
|
+
throw new Error('unbalanced');
|
|
162
|
+
};
|
|
163
|
+
const server = fnText(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'look.js'), 'utf8'));
|
|
164
|
+
const client = fnText(fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8'));
|
|
165
|
+
assert.equal(client, server, 'edit both copies together (ADR 0358 D4)');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
console.log('\nA logo is screened by its bytes:');
|
|
169
|
+
|
|
170
|
+
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(40, 1)]);
|
|
171
|
+
const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(40, 2)]);
|
|
172
|
+
const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('WEBP'), Buffer.alloc(40, 3)]);
|
|
173
|
+
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
|
174
|
+
|
|
175
|
+
await ta('PNG, JPEG and WebP pass, each named by its own magic number and hashed', async () => {
|
|
176
|
+
for (const [buf, type, ext] of [[PNG, 'image/png', 'png'], [JPG, 'image/jpeg', 'jpg'], [WEBP, 'image/webp', 'webp']]) {
|
|
177
|
+
const r = look.screenLogo(buf, type);
|
|
178
|
+
assert.equal(r.ok, true, type);
|
|
179
|
+
assert.equal(r.ext, ext);
|
|
180
|
+
assert.match(r.sha, /^[0-9a-f]{64}$/);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await ta('SVG is refused whatever it claims to be, and so is a renamed file, an empty one and an oversized one', async () => {
|
|
185
|
+
assert.equal(look.screenLogo(SVG, 'image/svg+xml').reason, 'bad_type');
|
|
186
|
+
assert.equal(look.screenLogo(SVG, 'image/png').reason, 'sniff_mismatch', 'an SVG labelled PNG');
|
|
187
|
+
assert.equal(look.screenLogo(JPG, 'image/png').reason, 'sniff_mismatch', 'a JPEG labelled PNG');
|
|
188
|
+
assert.equal(look.screenLogo(Buffer.alloc(0), 'image/png').reason, 'empty');
|
|
189
|
+
const big = Buffer.concat([PNG, Buffer.alloc(look.LOGO_MAX_BYTES)]);
|
|
190
|
+
assert.equal(look.screenLogo(big, 'image/png').reason, 'too_big');
|
|
191
|
+
assert.equal(look.screenLogo(Buffer.concat([PNG.subarray(0, 8), Buffer.alloc(look.LOGO_MAX_BYTES - 8)]), 'image/png').ok, true, 'exactly the cap passes');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── the routes, over a real mounted router ───────────────────────────────────
|
|
195
|
+
let instance = null;
|
|
196
|
+
let stored = [];
|
|
197
|
+
let events = [];
|
|
198
|
+
let enqueued = [];
|
|
199
|
+
let logos = new Map();
|
|
200
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
201
|
+
provisioning.updateInstanceSettings = async (_db, id, patch) => {
|
|
202
|
+
stored.push(patch);
|
|
203
|
+
instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
|
|
204
|
+
return { ...instance, id };
|
|
205
|
+
};
|
|
206
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
207
|
+
provisioning.enqueueSettingsApply = async (_db, id, by) => { enqueued.push({ id, by }); return { intent: { id: 3, action: 'settings-apply', state: 'pending' }, created: true }; };
|
|
208
|
+
provisioning.recordSettingsPushOwed = async () => {};
|
|
209
|
+
look.upsertLogo = async (_db, id, s, buf) => { logos.set(id, { sha: s.sha, ext: s.ext, mime: s.mime, bytes: buf }); };
|
|
210
|
+
look.deleteLogo = async (_db, id) => { logos.delete(id); };
|
|
211
|
+
look.readLogo = async (_db, sha, ext) => [...logos.values()].find((l) => l.sha === sha && l.ext === ext) || null;
|
|
212
|
+
|
|
213
|
+
const api = require('../src/module-api.js');
|
|
214
|
+
let builder = null;
|
|
215
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
216
|
+
api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
217
|
+
api.branding = () => ({ domains: { publicOrigin: 'https://platform.test' } });
|
|
218
|
+
|
|
219
|
+
const express = require('express');
|
|
220
|
+
const buildRouter = require('../modules/provisioning/routes/look.js');
|
|
221
|
+
const app = express();
|
|
222
|
+
app.use((req, res, next) => (/\/logo$/.test(req.path) ? next() : express.json()(req, res, next)));
|
|
223
|
+
app.use((_req, res, next) => {
|
|
224
|
+
res.fail = (code, opts) => {
|
|
225
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
226
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}) } });
|
|
227
|
+
};
|
|
228
|
+
next();
|
|
229
|
+
});
|
|
230
|
+
app.use('/api/bongos', buildRouter());
|
|
231
|
+
const server = app.listen(0);
|
|
232
|
+
await new Promise((r) => server.once('listening', r));
|
|
233
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
234
|
+
|
|
235
|
+
function call(method, p, body) {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
238
|
+
const req = http.request(`${base}${p}`, { method, headers: payload ? { 'Content-Type': 'application/json' } : {} }, (res) => {
|
|
239
|
+
const chunks = [];
|
|
240
|
+
res.on('data', (c) => chunks.push(c));
|
|
241
|
+
res.on('end', () => {
|
|
242
|
+
const raw = Buffer.concat(chunks);
|
|
243
|
+
let j = null; try { j = JSON.parse(raw.toString('utf8')); } catch { /* binary */ }
|
|
244
|
+
resolve({ status: res.statusCode, body: j, raw, headers: res.headers });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
req.on('error', reject);
|
|
248
|
+
if (payload) req.write(payload);
|
|
249
|
+
req.end();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const OWNER = { id: 7, rank: 'thetes' };
|
|
254
|
+
function reset(patch = {}) {
|
|
255
|
+
instance = { owner_builder_id: 7, slug: 'kestrel', status: 'active', hosting_shape: 'cloud-host', settings: {}, ...patch };
|
|
256
|
+
stored = []; events = []; enqueued = []; logos = new Map(); builder = OWNER;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
console.log('\nThe routes:');
|
|
260
|
+
|
|
261
|
+
await ta("another builder's project is a 404 on every route, and nothing is written", async () => {
|
|
262
|
+
reset();
|
|
263
|
+
builder = { id: 99, rank: 'metic' };
|
|
264
|
+
for (const [m, p, b] of [['GET', '/provisioning/instances/5/look'], ['PATCH', '/provisioning/instances/5/look', { look: 'grove' }],
|
|
265
|
+
['PUT', '/provisioning/instances/5/logo', { image_b64: PNG.toString('base64'), content_type: 'image/png' }], ['DELETE', '/provisioning/instances/5/logo']]) {
|
|
266
|
+
const r = await call(m, p, b);
|
|
267
|
+
assert.equal(r.status, 404, `${m} ${p}`);
|
|
268
|
+
}
|
|
269
|
+
assert.equal(stored.length + enqueued.length + logos.size, 0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await ta('a library look on a live project is stored, audited and pushed with a restart', async () => {
|
|
273
|
+
reset();
|
|
274
|
+
const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'grove', night_tint: 'off' });
|
|
275
|
+
assert.equal(r.status, 200, r.raw.toString());
|
|
276
|
+
assert.deepEqual(stored, [{ look: 'grove', night_tint: 'off' }]);
|
|
277
|
+
assert.equal(r.body.look.look, 'grove');
|
|
278
|
+
assert.equal(r.body.look.night_tint, 'off');
|
|
279
|
+
assert.equal(r.body.push.restart, true);
|
|
280
|
+
assert.equal(enqueued.length, 1, 'the settings-apply intent is queued');
|
|
281
|
+
assert.equal(events[0].actor, 'api:self');
|
|
282
|
+
assert.equal(r.body.adjusted, null);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await ta('a custom accent is stored ADJUSTED, and the reply names both colours', async () => {
|
|
286
|
+
reset();
|
|
287
|
+
const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom', accent: '#1A2B3C' });
|
|
288
|
+
assert.equal(r.status, 200, r.raw.toString());
|
|
289
|
+
const kept = look.lookAdjustAccent('#1a2b3c').hex;
|
|
290
|
+
assert.deepEqual(stored[0], { look: 'custom', look_accent: kept });
|
|
291
|
+
assert.deepEqual(r.body.adjusted, { from: '#1a2b3c', to: kept });
|
|
292
|
+
assert.equal(r.body.look.look_accent, kept);
|
|
293
|
+
assert.deepEqual(readsEverywhere(kept), []);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await ta('a custom look with no accent anywhere is refused; one already stored is enough', async () => {
|
|
297
|
+
reset();
|
|
298
|
+
let r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom' });
|
|
299
|
+
assert.equal(r.status, 400);
|
|
300
|
+
assert.equal(r.body.error.code, 'accent_required');
|
|
301
|
+
assert.equal(stored.length, 0);
|
|
302
|
+
reset({ settings: { look_accent: '#b48cf2' } });
|
|
303
|
+
r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom' });
|
|
304
|
+
assert.equal(r.status, 200, r.raw.toString());
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await ta('an accent that is not a colour, an unknown look and an empty body are 400s', async () => {
|
|
308
|
+
reset();
|
|
309
|
+
assert.equal((await call('PATCH', '/provisioning/instances/5/look', { look: 'custom', accent: 'tomato' })).body.error.code, 'bad_accent');
|
|
310
|
+
assert.equal((await call('PATCH', '/provisioning/instances/5/look', { look: 'neon' })).status, 400);
|
|
311
|
+
assert.equal((await call('PATCH', '/provisioning/instances/5/look', {})).body.error.code, 'bad_look');
|
|
312
|
+
assert.equal(stored.length, 0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await ta('the same look re-sent writes nothing and restarts nothing', async () => {
|
|
316
|
+
reset({ settings: { look: 'grove' } });
|
|
317
|
+
const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'grove' });
|
|
318
|
+
assert.equal(r.status, 200);
|
|
319
|
+
assert.equal(stored.length, 0);
|
|
320
|
+
assert.equal(enqueued.length, 0);
|
|
321
|
+
assert.match(r.body.push.message, /No change/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await ta('a project still standing up stores the look and takes it at standup — no restart queued', async () => {
|
|
325
|
+
reset({ status: 'provisioning' });
|
|
326
|
+
const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'blueprint' });
|
|
327
|
+
assert.equal(r.status, 200);
|
|
328
|
+
assert.equal(enqueued.length, 0);
|
|
329
|
+
assert.match(r.body.push.message, /finishes standing up/);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await ta('a logo is stored, the hall is told its https address, and the public read serves it as an image only', async () => {
|
|
333
|
+
reset();
|
|
334
|
+
const r = await call('PUT', '/provisioning/instances/5/logo', { image_b64: PNG.toString('base64'), content_type: 'image/png' });
|
|
335
|
+
assert.equal(r.status, 200, r.raw.toString());
|
|
336
|
+
const { sha } = look.screenLogo(PNG, 'image/png');
|
|
337
|
+
const url = `https://platform.test/api/bongos/provisioning/logos/${sha}.png`;
|
|
338
|
+
assert.equal(r.body.look.logo_url, url);
|
|
339
|
+
assert.equal(enqueued.length, 1);
|
|
340
|
+
const g = await call('GET', `/provisioning/logos/${sha}.png`);
|
|
341
|
+
assert.equal(g.status, 200);
|
|
342
|
+
assert.equal(g.headers['content-type'], 'image/png');
|
|
343
|
+
assert.equal(g.headers['x-content-type-options'], 'nosniff');
|
|
344
|
+
assert.match(g.headers['content-security-policy'], /sandbox/);
|
|
345
|
+
assert.match(g.headers['cache-control'], /immutable/);
|
|
346
|
+
assert.ok(g.raw.equals(PNG), 'the bytes come back as stored');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await ta('an SVG logo is refused and nothing is stored', async () => {
|
|
350
|
+
reset();
|
|
351
|
+
const r = await call('PUT', '/provisioning/instances/5/logo', { image_b64: SVG.toString('base64'), content_type: 'image/svg+xml' });
|
|
352
|
+
assert.equal(r.status, 400);
|
|
353
|
+
assert.equal(r.body.error.code, 'logo_bad_type');
|
|
354
|
+
assert.equal(logos.size + stored.length, 0);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await ta('removing the logo empties the address and pushes it', async () => {
|
|
358
|
+
reset({ settings: { logo_url: 'https://platform.test/api/bongos/provisioning/logos/x.png' } });
|
|
359
|
+
logos.set(5, { sha: 'x', ext: 'png', bytes: PNG });
|
|
360
|
+
const r = await call('DELETE', '/provisioning/instances/5/logo');
|
|
361
|
+
assert.equal(r.status, 200, r.raw.toString());
|
|
362
|
+
assert.deepEqual(stored, [{ logo_url: '' }]);
|
|
363
|
+
assert.equal(r.body.look.logo_url, null);
|
|
364
|
+
assert.equal(logos.size, 0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await ta('the public read answers only a content-addressed name', async () => {
|
|
368
|
+
reset();
|
|
369
|
+
for (const name of ['5.png', '../etc/passwd', `${'a'.repeat(64)}.svg`, `${'a'.repeat(64)}.png`]) {
|
|
370
|
+
assert.equal((await call('GET', `/provisioning/logos/${encodeURIComponent(name)}`)).status, 404, name);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
server.close();
|
|
375
|
+
console.log(`\nprovisioning_look: ${passed} passed, ${failed} failed`);
|
|
376
|
+
if (failed) process.exit(1);
|
|
@@ -164,7 +164,7 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
164
164
|
reset();
|
|
165
165
|
const r = await get('/provisioning/instances/18/settings');
|
|
166
166
|
assert.equal(r.status, 200, r.raw);
|
|
167
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
167
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
|
|
168
168
|
// the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
|
|
169
169
|
assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
|
|
170
170
|
assert.deepEqual(r.body.options, {
|
|
@@ -183,6 +183,9 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
|
|
|
183
183
|
// policy axis, offered to the form like the rest. What makes it different is
|
|
184
184
|
// its CONSEQUENCE: it is the only setting that can refuse a module outright.
|
|
185
185
|
spend_payer: ['none', 'project', 'builder'],
|
|
186
|
+
// the look a hall wears and its night tint (task 1004421, ADR 0358) — policy
|
|
187
|
+
// axes like the rest: they ride web.env and a restart applies them
|
|
188
|
+
look: ['own', 'chrome-world', 'expedition', 'grove', 'blueprint', 'custom'], night_tint: ['on', 'off'],
|
|
186
189
|
planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
|
|
187
190
|
// the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
|
|
188
191
|
// closed list, no default, no push — so it rides the same vocab
|
|
@@ -195,14 +198,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
|
|
|
195
198
|
const r = await get('/provisioning/instances/18/settings');
|
|
196
199
|
assert.equal(r.status, 200, r.raw);
|
|
197
200
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
|
|
198
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the planet is not a setting: the policy read is untouched');
|
|
201
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the planet is not a setting: the policy read is untouched');
|
|
199
202
|
});
|
|
200
203
|
|
|
201
204
|
await ta('stored keys overlay the defaults', async () => {
|
|
202
205
|
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
|
|
203
206
|
const r = await get('/provisioning/instances/18/settings');
|
|
204
207
|
assert.equal(r.status, 200, r.raw);
|
|
205
|
-
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
208
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
|
|
206
209
|
});
|
|
207
210
|
|
|
208
211
|
await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
|
|
@@ -230,7 +233,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
|
|
|
230
233
|
reset();
|
|
231
234
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
232
235
|
assert.equal(r.status, 200, r.raw);
|
|
233
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
236
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
234
237
|
assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
|
|
235
238
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
|
|
236
239
|
assert.equal(events.length, 1);
|
|
@@ -243,7 +246,7 @@ await ta('owner changes both keys in one call', async () => {
|
|
|
243
246
|
reset();
|
|
244
247
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
245
248
|
assert.equal(r.status, 200, r.raw);
|
|
246
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
249
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
|
|
247
250
|
});
|
|
248
251
|
|
|
249
252
|
await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
|
|
@@ -282,7 +285,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
|
|
|
282
285
|
reset();
|
|
283
286
|
const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
|
|
284
287
|
assert.equal(r.status, 200, r.raw);
|
|
285
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
288
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
|
|
286
289
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
|
|
287
290
|
assert.equal(events[0].actor, 'api:self');
|
|
288
291
|
assert.match(events[0].detail, /visibility=stealth/);
|
|
@@ -342,7 +345,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
|
|
|
342
345
|
const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
|
|
343
346
|
assert.equal(r.status, 200, r.raw);
|
|
344
347
|
assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
|
|
345
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the policy half is untouched');
|
|
348
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the policy half is untouched');
|
|
346
349
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
|
|
347
350
|
assert.equal(r.body.push.queued, false);
|
|
348
351
|
assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
|
|
@@ -272,7 +272,7 @@ await ta('ACTIVE cloud-host: the row is written FIRST, then a settings-apply int
|
|
|
272
272
|
reset();
|
|
273
273
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
274
274
|
assert.equal(r.status, 200, r.raw);
|
|
275
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
275
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
276
276
|
assert.equal(r.body.settings, undefined);
|
|
277
277
|
assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
|
|
278
278
|
assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
|
|
@@ -335,7 +335,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
|
|
|
335
335
|
enqueueThrows = new Error('db down');
|
|
336
336
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
337
337
|
assert.equal(r.status, 200, r.raw);
|
|
338
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
338
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
|
|
339
339
|
assert.equal(updateCalls.length, 1, 'the write landed');
|
|
340
340
|
receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
|
|
341
341
|
assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
|
|
@@ -348,7 +348,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
|
|
|
348
348
|
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
|
|
349
349
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
|
|
350
350
|
assert.equal(r.status, 200, r.raw);
|
|
351
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
|
|
351
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
|
|
352
352
|
assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
|
|
353
353
|
assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
|
|
354
354
|
assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
|
|
@@ -479,7 +479,7 @@ await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHA
|
|
|
479
479
|
assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
|
|
480
480
|
assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
|
|
481
481
|
assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
|
|
482
|
-
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
|
|
482
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' },
|
|
483
483
|
'the value that finally ships is the one the owner saved');
|
|
484
484
|
});
|
|
485
485
|
|
|
@@ -121,6 +121,9 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
121
121
|
// transport because src/modules.js reads it at CONFIG LOAD, which means the
|
|
122
122
|
// instance must already have it in web.env before the first require.
|
|
123
123
|
CLOUDBONGOS_SPEND_PAYER: 'none',
|
|
124
|
+
// The look and its night tint (ADR 0358, task 1004421), with their two companions:
|
|
125
|
+
// a custom look's accent and the logo address, empty when there is none.
|
|
126
|
+
CLOUDBONGOS_LOOK: 'own', CLOUDBONGOS_NIGHT_TINT: 'on', CLOUDBONGOS_LOOK_ACCENT: '', CLOUDBONGOS_LOGO_URL: '',
|
|
124
127
|
});
|
|
125
128
|
// varsOf splits on '=', so an empty value reads as '' on both sides — the point
|
|
126
129
|
// of the comparison is that the standup lines carry exactly the same key set.
|
|
@@ -128,6 +131,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
128
131
|
assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), {
|
|
129
132
|
X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
|
|
130
133
|
X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
|
|
134
|
+
X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
|
|
131
135
|
}, 'defaults, explicitly');
|
|
132
136
|
});
|
|
133
137
|
|
|
@@ -90,9 +90,9 @@ test('post-auth: the real step is rendered', () => {
|
|
|
90
90
|
|
|
91
91
|
test('a draft already at step 1 is unchanged, and an out-of-range step is not resurrected', () => {
|
|
92
92
|
assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
|
|
93
|
-
//
|
|
93
|
+
// 12 is the done panel (11 before task 1004421's Look step, 9 before task 1004417's front door, 8 before task 1004351), reached by its own branch
|
|
94
94
|
// above this slice; 0 is the gate. Neither is a resumable wizard position, so the placeholder is allowed to stand.
|
|
95
|
-
for (const bad of [0,
|
|
95
|
+
for (const bad of [0, 12, 99]) {
|
|
96
96
|
assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
|
|
97
97
|
}
|
|
98
98
|
});
|
|
@@ -53,7 +53,7 @@ test('the front door stands between sign-in and the name step: gate 0, terms 1,
|
|
|
53
53
|
|
|
54
54
|
test('the rail is the numbered steps\' — it steps aside on both front-door screens, statically too', () => {
|
|
55
55
|
const beats = [...HUB.matchAll(/<li[^>]*data-step="(\d+)"/g)].map((m) => Number(m[1]));
|
|
56
|
-
assert.deepEqual(beats, [3, 4, 5, 6, 7, 8, 9, 10], 'the rail numbers name…create, never the front door');
|
|
56
|
+
assert.deepEqual(beats, [3, 4, 5, 6, 7, 8, 9, 10, 11], 'the rail numbers name…create, never the front door');
|
|
57
57
|
assert.match(HUB, /<div class="card wizFront">/, 'a no-script visitor on the static first screen sees no rail');
|
|
58
58
|
assert.match(STYLE, /#view-new \.card\.wizFront \.steps\{display:none;\}/);
|
|
59
59
|
});
|
|
@@ -250,7 +250,7 @@ test('the create carries the tick, and is never filed without it', () => {
|
|
|
250
250
|
test('the tick is part of the draft and of the reset, and the draft key moved with the renumbering', () => {
|
|
251
251
|
assert.match(SCRIPT, /var state = \{ step: 0,[^}]*termsAck: false \};/, 'the initial state declares it');
|
|
252
252
|
assert.match(SCRIPT, /state = \{ step: 1,[^}]*termsAck: false \};/, 'and "start another project" clears it — each project records its own');
|
|
253
|
-
assert.match(SCRIPT, /var DRAFT_KEY = 'cb-create-draft-
|
|
253
|
+
assert.match(SCRIPT, /var DRAFT_KEY = 'cb-create-draft-v7';/, 'a draft saved under the old numbering is ignored, not replayed onto the wrong screen');
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
// ── the look-before-you-ship kit walks it ─────────────────────────────────────
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
// ruled out the task's other option (drop the record at a terminal state).
|
|
16
16
|
//
|
|
17
17
|
// The reset fires only when there is a SECOND DRAFT to lose, and the draft's own step is
|
|
18
|
-
// what says so. A landed create clears DRAFT_KEY — but show(
|
|
18
|
+
// what says so. A landed create clears DRAFT_KEY — but show(12) then persists the done
|
|
19
19
|
// panel's position straight back into it, so a plain "is there a draft?" test is always
|
|
20
20
|
// yes and would skip the reset forever, leaving the finished project's name sitting in
|
|
21
21
|
// the fresh form. (That is exactly what the first cut of this change shipped into a
|
|
22
22
|
// render: "still watching Mercury" above a name field still reading "Mercury".) Only
|
|
23
|
-
// steps 1-
|
|
23
|
+
// steps 1-11 are wizard steps (1-10 before task 1004421's Look step, 1-8 before task 1004417's front door took 1 and 2);
|
|
24
24
|
// anything else is the done panel's own bookkeeping.
|
|
25
25
|
//
|
|
26
26
|
// Harness: there is no jsdom in this repo, so this follows tests/wizard_draft_resume.mjs
|
|
@@ -58,8 +58,8 @@ test('both slices are still findable (a rename must fail loudly, not silently pa
|
|
|
58
58
|
assert.ok(offerSlice, 'paintResumeOffer() was not found in projects.html');
|
|
59
59
|
assert.match(enterSlice, /if \(focus && !resumeIntent\) \{/, 'the intent branch is part of the slice');
|
|
60
60
|
assert.match(enterSlice, /startProgress\(created\.id\);/, 'so is the resume branch it guards');
|
|
61
|
-
assert.match(enterSlice, /draft\.step >= 1 && draft\.step <=
|
|
62
|
-
'the STORED draft\'s step decides, not state.step — state.step is
|
|
61
|
+
assert.match(enterSlice, /draft\.step >= 1 && draft\.step <= 11/,
|
|
62
|
+
'the STORED draft\'s step decides, not state.step — state.step is 12 for the whole life of a finished standup');
|
|
63
63
|
assert.match(SCRIPT, /if \(created && created\.id\) \{ enterCreated\(created, !!focus\); return; \}/,
|
|
64
64
|
'enter() still hands the created record straight to it');
|
|
65
65
|
});
|
|
@@ -73,7 +73,7 @@ function runEnter({ focus, resumeIntent = false, draft = null, record }) {
|
|
|
73
73
|
const calls = { show: [], resets: 0, offer: [], done: 0, progress: [], polls: 0, focused: 0 };
|
|
74
74
|
// state.step is the DONE step for the whole life of a finished standup — the value
|
|
75
75
|
// a draft-blind reading would have trusted
|
|
76
|
-
const state = { step: draft ? draft.step :
|
|
76
|
+
const state = { step: draft ? draft.step : 12, name: 'Mercury', slug: 'mercury', domain: '', mode: 'greenfield', noAddress: false, modules: null };
|
|
77
77
|
const sandbox = {
|
|
78
78
|
record, focus, resumeIntent, state,
|
|
79
79
|
DRAFT_KEY: 'draft',
|
|
@@ -99,7 +99,7 @@ const REC = { id: 'inst-1', name: 'Acme', slug: 'acme', domain: 'acme.example',
|
|
|
99
99
|
|
|
100
100
|
test('a reload / Back / identity settle (focus false) still resumes the done panel, watch and all', () => {
|
|
101
101
|
const { calls, state } = runEnter({ focus: false, record: REC });
|
|
102
|
-
assert.deepEqual(calls.show, [[
|
|
102
|
+
assert.deepEqual(calls.show, [[12, false]], 'the done panel, and no focus steal on a non-deliberate arrival');
|
|
103
103
|
assert.equal(calls.resets, 0, 'nothing is reset — this is the same standup continuing');
|
|
104
104
|
assert.equal(calls.done, 1, 'the checklist is re-rendered');
|
|
105
105
|
assert.deepEqual(calls.progress, ['inst-1'], 'the progress watch is restarted from the record');
|
|
@@ -127,7 +127,7 @@ test('…and the standup stays one click away, named, on the resume link', () =>
|
|
|
127
127
|
|
|
128
128
|
test('the resume link gets the done panel back — the record was never cleared', () => {
|
|
129
129
|
const { calls, sandbox } = runEnter({ focus: true, resumeIntent: true, record: REC });
|
|
130
|
-
assert.deepEqual(calls.show, [[
|
|
130
|
+
assert.deepEqual(calls.show, [[12, true]], 'a deliberate resume moves focus to the done panel');
|
|
131
131
|
assert.deepEqual(calls.progress, ['inst-1'], 'the watch comes back with it');
|
|
132
132
|
assert.equal(sandbox.resumeIntent, false, 'the flag is ONE-SHOT — the next plain nav click must mean "new" again');
|
|
133
133
|
assert.deepEqual(calls.offer, [null], 'and the offer comes down behind it');
|
|
@@ -145,20 +145,21 @@ test('a second draft already under way is NOT wiped by navigating back to the wi
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
test('the done panel\'s own saved step is NOT a second draft — it must not block the reset', () => {
|
|
148
|
-
// Caught in a render, not in a unit test: show(
|
|
148
|
+
// Caught in a render, not in a unit test: show(12) calls saveDraft(), so DRAFT_KEY is
|
|
149
149
|
// non-empty again the moment a create lands. Reading "is there a draft?" left the
|
|
150
150
|
// finished project's name in the fresh form, under a line offering to resume it.
|
|
151
|
-
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step:
|
|
151
|
+
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 12, name: 'Mercury' } });
|
|
152
152
|
assert.equal(calls.resets, 1, 'the done step is bookkeeping, not a project being typed');
|
|
153
153
|
assert.deepEqual(calls.show, [[1, false]], 'out-of-range steps land on step 1, the front door');
|
|
154
154
|
assert.equal(state.name, '', 'and the finished project\'s name does not survive into it');
|
|
155
155
|
assert.equal(calls.done, 0, 'never back onto the done panel');
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
test('a draft on the app step or the review is live too — the last
|
|
159
|
-
// The app step moved the review from 7 to 8,
|
|
160
|
-
// guard left behind would reset a second project
|
|
161
|
-
|
|
158
|
+
test('a draft on the app step, the Look step or the review is live too — the last three numbered steps (task 1004351)', () => {
|
|
159
|
+
// The app step moved the review from 7 to 8, the front door (task 1004417) to 10 and the
|
|
160
|
+
// Look step (task 1004421) to 11. A guard left behind would reset a second project
|
|
161
|
+
// sitting on its review, silently wiping it.
|
|
162
|
+
for (const step of [9, 10, 11]) {
|
|
162
163
|
const { calls, state } = runEnter({ focus: true, record: REC, draft: { step } });
|
|
163
164
|
assert.equal(calls.resets, 0, `step ${step} is a live draft`);
|
|
164
165
|
assert.deepEqual(calls.show, [[step, true]]);
|
|
@@ -174,8 +175,8 @@ test('a draft on the front door is live too — a founder part-way through it ke
|
|
|
174
175
|
}
|
|
175
176
|
});
|
|
176
177
|
|
|
177
|
-
test('a draft whose step is missing or junk resets too — only 1-
|
|
178
|
-
for (const draft of [{ step: 0 }, { step:
|
|
178
|
+
test('a draft whose step is missing or junk resets too — only 1-11 preserves anything', () => {
|
|
179
|
+
for (const draft of [{ step: 0 }, { step: 12 }, { name: 'half-typed' }]) {
|
|
179
180
|
const { calls } = runEnter({ focus: true, record: REC, draft });
|
|
180
181
|
assert.equal(calls.resets, 1, `step ${JSON.stringify(draft.step)} is not a wizard step`);
|
|
181
182
|
assert.deepEqual(calls.show, [[1, false]]);
|