@bongos/core 1.25.17 → 1.25.19
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 +135 -65
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +17 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +2 -1
- package/docs/api/openapi.json +288 -6
- package/docs/api-reference.md +14 -4
- package/docs/copy-inventory.md +132 -121
- package/docs/copy-registry.json +347 -195
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +63 -63
- package/modules/app-host/CLAUDE.md +15 -0
- package/modules/app-host/module.json +20 -0
- package/modules/app-host/public/settings-widget.js +143 -0
- package/modules/app-host/routes/app-host.js +88 -0
- package/modules/government/charter-advice.js +103 -0
- package/modules/government/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/hall-ui/public/genesis-home.js +28 -3
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +47 -9
- package/modules/lifecycle/first-read.js +88 -23
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/routes/first-read.js +27 -5
- package/modules/onboarding/genesis-home.js +68 -7
- package/modules/onboarding/genesis-work.js +169 -4
- package/modules/onboarding/routes/genesis-work.js +5 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso-app-status.js +65 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/seams.js +32 -1
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/public-landing/public/assets/guide-core.js +475 -0
- package/modules/public-landing/public/assets/guide-hall.css +121 -0
- package/modules/public-landing/public/assets/guide-hall.js +164 -0
- package/modules/public-landing/public/assets/guide-scene.css +9 -0
- package/modules/public-landing/public/assets/guide-scene.js +58 -381
- package/modules/public-landing/public/index.html +46 -1
- package/modules/public-landing/public/projects.html +11 -2
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/bongos/first-read.js +45 -11
- package/scripts/bongos/genesis.js +29 -2
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- package/scripts/hall-preview/server.js +2 -0
- package/src/bongos/auth-admission.js +64 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes/core-update.js +3 -16
- package/src/bongos/routes/modules.js +11 -0
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +6 -1
- package/src/module-loader/loader.js +2 -2
- package/tests/app_host.mjs +413 -0
- package/tests/first_read.mjs +184 -8
- package/tests/genesis_home.mjs +81 -1
- package/tests/genesis_work.mjs +103 -3
- package/tests/genesis_work_routes.mjs +9 -0
- package/tests/government_charter_advice.mjs +150 -0
- package/tests/landing_guide_hall.mjs +364 -0
- package/tests/landing_guide_scene.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_where.mjs +13 -3
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// tests/app_host.mjs — where the project's app runs, in the hall's options menu (task 1004571).
|
|
2
|
+
//
|
|
3
|
+
// The whole chain, DB-free and with no Render call anywhere:
|
|
4
|
+
// 1. the provisioning port (projectAppStatus) — the row, by both halves of the projection,
|
|
5
|
+
// read through app-status.js's own shape;
|
|
6
|
+
// 2. the hub route (POST /sso/app-status) — only an authenticated project, only its own row;
|
|
7
|
+
// 3. the core asker (readAppStatusFromHub) — the project's credentials, never throws;
|
|
8
|
+
// 4. the hall route (GET /app-host) — each state, owner vs everyone else, new fields kept;
|
|
9
|
+
// 5. the module switch — the panel's script is injected only while the module is on, and
|
|
10
|
+
// loads before settings.js the way settings.js loads;
|
|
11
|
+
// 6. the panel itself — what a builder reads in each state.
|
|
12
|
+
//
|
|
13
|
+
// Run: node --test tests/app_host.mjs
|
|
14
|
+
|
|
15
|
+
import test from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const express = require('express');
|
|
27
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
28
|
+
|
|
29
|
+
// The doorway with a quiet logger and a stand-in sign-in gate, BEFORE any route file loads.
|
|
30
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
31
|
+
const realApi = require(apiPath);
|
|
32
|
+
let viewer = { id: 7 };
|
|
33
|
+
const fakeApi = {
|
|
34
|
+
...realApi,
|
|
35
|
+
logger: () => ({ error() {}, warn() {}, info() {} }),
|
|
36
|
+
requireBuilder: (req, _res, next) => { req.builder = viewer; next(); },
|
|
37
|
+
};
|
|
38
|
+
require.cache[apiPath].exports = fakeApi;
|
|
39
|
+
|
|
40
|
+
async function serve(router) {
|
|
41
|
+
const app = express();
|
|
42
|
+
app.use(express.json());
|
|
43
|
+
app.use(attachFail);
|
|
44
|
+
app.use('/api/bongos', router);
|
|
45
|
+
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
|
|
46
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
47
|
+
const call = (method, route, body) => new Promise((resolve, reject) => {
|
|
48
|
+
const payload = body ? JSON.stringify(body) : '';
|
|
49
|
+
const req = http.request(`${base}${route}`, {
|
|
50
|
+
method, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
51
|
+
}, (res) => {
|
|
52
|
+
let raw = '';
|
|
53
|
+
res.on('data', (c) => { raw += c; });
|
|
54
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: raw ? JSON.parse(raw) : null }));
|
|
55
|
+
});
|
|
56
|
+
req.on('error', reject);
|
|
57
|
+
req.end(payload);
|
|
58
|
+
});
|
|
59
|
+
return { call, close: () => server.close() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── 1. the provisioning port ─────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const { projectAppStatus } = require('../modules/provisioning/seams.js');
|
|
65
|
+
|
|
66
|
+
function rowDb(row) {
|
|
67
|
+
const seen = [];
|
|
68
|
+
return { seen, async query(sql, params) { seen.push({ sql: String(sql), params }); return { rows: row ? [row] : [] }; } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('port: the project is named by slug AND host, the host lowercased', async () => {
|
|
72
|
+
const db = rowDb(null);
|
|
73
|
+
assert.equal(await projectAppStatus(db, { slug: 'mercury', host: 'Mercury.Test' }), null);
|
|
74
|
+
assert.deepEqual(db.seen[0].params, ['mercury', 'mercury.test']);
|
|
75
|
+
assert.match(db.seen[0].sql, /i\.slug = \$1 AND lower\(i\.domain\) = \$2/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('port: a half-named project is no answer, and no query runs', async () => {
|
|
79
|
+
const db = rowDb({ app_host: null, waiting: false });
|
|
80
|
+
assert.equal(await projectAppStatus(db, { slug: 'mercury' }), null);
|
|
81
|
+
assert.equal(await projectAppStatus(db, { host: 'mercury.test' }), null);
|
|
82
|
+
assert.equal(db.seen.length, 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('port: no platform yet is { app: null }; Render reads through app-status.js', async () => {
|
|
86
|
+
assert.deepEqual(await projectAppStatus(rowDb({ app_host: null, waiting: false }), { slug: 'm', host: 'm.test' }), { app: null, sandbox: null });
|
|
87
|
+
const live = await projectAppStatus(rowDb({
|
|
88
|
+
app_host: { platform: 'render', state: 'live', serviceUrl: 'https://mercury-app.onrender.com', serviceId: 'srv-abcdefgh12' },
|
|
89
|
+
waiting: false,
|
|
90
|
+
}), { slug: 'm', host: 'm.test' });
|
|
91
|
+
assert.equal(live.app.stage, 'live');
|
|
92
|
+
assert.equal(live.app.url, 'https://mercury-app.onrender.com');
|
|
93
|
+
assert.equal(live.app.links.service, 'https://dashboard.render.com/web/srv-abcdefgh12');
|
|
94
|
+
const failed = await projectAppStatus(rowDb({ app_host: { platform: 'render', state: 'failed', error: 'the build command failed' }, waiting: false }), { slug: 'm', host: 'm.test' });
|
|
95
|
+
assert.equal(failed.app.state, 'failed');
|
|
96
|
+
assert.equal(failed.app.error, 'The build command failed.');
|
|
97
|
+
const waiting = await projectAppStatus(rowDb({ app_host: { platform: 'render', state: 'requested' }, waiting: true }), { slug: 'm', host: 'm.test' });
|
|
98
|
+
assert.equal(waiting.app.waiting, true);
|
|
99
|
+
assert.equal(waiting.app.stage, 'creating');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── 2. the hub route ─────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
105
|
+
const SECRET = 'project-secret';
|
|
106
|
+
const CLIENTS = {
|
|
107
|
+
mercury: { client_id: 'mercury', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://Mercury.test' },
|
|
108
|
+
frozen: { client_id: 'frozen', secret_hash: pi.sha256hex(SECRET), status: 'suspended', origin: 'https://frozen.test' },
|
|
109
|
+
shell: { client_id: 'shell', secret_hash: '', status: 'active', origin: 'https://shell.test' },
|
|
110
|
+
};
|
|
111
|
+
const clientDb = { async query(_sql, params) { return { rows: CLIENTS[params[0]] ? [CLIENTS[params[0]]] : [] }; } };
|
|
112
|
+
const buildHubRoute = require('../modules/platform-identity/routes/sso-app-status.js');
|
|
113
|
+
|
|
114
|
+
test('hub: a wrong secret, a suspended client and a catalog-only row are all refused, and the port is never asked', async () => {
|
|
115
|
+
const asked = [];
|
|
116
|
+
const hub = await serve(buildHubRoute({ db: clientDb, resolvePort: () => async (t) => { asked.push(t); return { app: null }; } }));
|
|
117
|
+
try {
|
|
118
|
+
for (const body of [
|
|
119
|
+
{ client_id: 'mercury', client_secret: 'nope' },
|
|
120
|
+
{ client_id: 'frozen', client_secret: SECRET },
|
|
121
|
+
{ client_id: 'shell', client_secret: '' },
|
|
122
|
+
{ client_id: 'nobody', client_secret: SECRET },
|
|
123
|
+
]) {
|
|
124
|
+
const r = await hub.call('POST', '/sso/app-status', body);
|
|
125
|
+
assert.equal(r.status, 401, JSON.stringify(body));
|
|
126
|
+
}
|
|
127
|
+
assert.equal(asked.length, 0);
|
|
128
|
+
} finally { hub.close(); }
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('hub: the project reads ITS OWN row — slug from the client, host from its registered origin', async () => {
|
|
132
|
+
const asked = [];
|
|
133
|
+
const app = { platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com', sandbox: { url: 'https://m-sandbox.onrender.com' } };
|
|
134
|
+
const hub = await serve(buildHubRoute({ db: clientDb, resolvePort: (n) => (n === 'provisioning.projectAppStatus' ? async (t) => { asked.push(t); return { app }; } : null) }));
|
|
135
|
+
try {
|
|
136
|
+
const r = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET });
|
|
137
|
+
assert.equal(r.status, 200);
|
|
138
|
+
assert.deepEqual(asked, [{ slug: 'mercury', host: 'mercury.test' }]);
|
|
139
|
+
assert.deepEqual(r.body, { ok: true, known: true, app, sandbox: null }, 'the shape passes through, new fields and all');
|
|
140
|
+
const extra = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET, slug: 'venus' });
|
|
141
|
+
assert.equal(extra.status, 400, 'nothing in the body can name another project');
|
|
142
|
+
} finally { hub.close(); }
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('hub: no provisioning, or no row, is known:false', async () => {
|
|
146
|
+
for (const resolvePort of [() => null, () => async () => null]) {
|
|
147
|
+
const hub = await serve(buildHubRoute({ db: clientDb, resolvePort }));
|
|
148
|
+
try {
|
|
149
|
+
const r = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET });
|
|
150
|
+
assert.deepEqual(r.body, { ok: true, known: false, app: null, sandbox: null });
|
|
151
|
+
} finally { hub.close(); }
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── 3. the core asker ────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const pin = (rel, exports) => {
|
|
158
|
+
const p = require.resolve(rel);
|
|
159
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
|
160
|
+
};
|
|
161
|
+
const IDP = { origin: 'https://hub.test', clientId: 'mercury', clientSecret: SECRET };
|
|
162
|
+
function loadAsker(idp) {
|
|
163
|
+
pin('../src/bongos/auth-config.js', { ...require('../src/bongos/auth-config.js'), loadIdpConfig: () => idp });
|
|
164
|
+
delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
|
|
165
|
+
return require('../src/bongos/auth-admission.js').readAppStatusFromHub;
|
|
166
|
+
}
|
|
167
|
+
async function withFetch(impl, fn) {
|
|
168
|
+
const real = globalThis.fetch;
|
|
169
|
+
const sent = [];
|
|
170
|
+
globalThis.fetch = async (url, init) => { sent.push({ url, init }); return impl(url, init); };
|
|
171
|
+
try { await fn(sent); } finally { globalThis.fetch = real; }
|
|
172
|
+
}
|
|
173
|
+
const MANAGE = { manage_url: 'https://hub.test/projects?view=manage&project=mercury', hub_host: 'hub.test' };
|
|
174
|
+
|
|
175
|
+
test('asker: no hub answers hub:false and sends nothing', async () => {
|
|
176
|
+
const ask = loadAsker(null);
|
|
177
|
+
await withFetch(async () => ({ ok: true }), async (sent) => {
|
|
178
|
+
assert.deepEqual(await ask(), { hub: false });
|
|
179
|
+
assert.equal(sent.length, 0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('asker: it sends only this project\'s credentials, bounded, and returns the hub\'s answer with the manage page', async () => {
|
|
184
|
+
const ask = loadAsker(IDP);
|
|
185
|
+
const app = { platform: 'render', state: 'creating', stage: 'creating' };
|
|
186
|
+
await withFetch(async () => ({ ok: true, json: async () => ({ ok: true, known: true, app }) }), async (sent) => {
|
|
187
|
+
assert.deepEqual(await ask(), { hub: true, reachable: true, known: true, app, sandbox: null, manage: MANAGE });
|
|
188
|
+
assert.equal(sent[0].url, 'https://hub.test/api/bongos/sso/app-status');
|
|
189
|
+
assert.deepEqual(JSON.parse(sent[0].init.body), { client_id: 'mercury', client_secret: SECRET });
|
|
190
|
+
assert.ok(sent[0].init.signal);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('asker: a hub that is down or older never throws — reachable:false', async () => {
|
|
195
|
+
const ask = loadAsker(IDP);
|
|
196
|
+
await withFetch(async () => { throw new Error('ECONNREFUSED'); }, async () => {
|
|
197
|
+
assert.deepEqual(await ask(), { hub: true, reachable: false, manage: MANAGE });
|
|
198
|
+
});
|
|
199
|
+
await withFetch(async () => ({ ok: false, status: 404, json: async () => ({}) }), async () => {
|
|
200
|
+
assert.deepEqual(await ask(), { hub: true, reachable: false, manage: MANAGE });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── 4. the hall route ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
const buildHall = require('../modules/app-host/routes/app-host.js');
|
|
207
|
+
const { viewFor } = buildHall;
|
|
208
|
+
const RENDER_LIVE = {
|
|
209
|
+
platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com',
|
|
210
|
+
links: { service: 'https://dashboard.render.com/web/srv-abcdefgh12' }, workspace: 'tea-x', retry: null,
|
|
211
|
+
sandbox: { url: 'https://m-sandbox.onrender.com', stage: 'live' },
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
test('hall: each state, as the owner and as everyone else', () => {
|
|
215
|
+
const read = (app, extra = {}) => ({ hub: true, reachable: true, known: true, app, manage: MANAGE, ...extra });
|
|
216
|
+
// no platform yet
|
|
217
|
+
let v = viewFor(read(null), { owner: true });
|
|
218
|
+
assert.equal(v.app, null);
|
|
219
|
+
assert.equal(v.connect_url, `${MANAGE.manage_url}#mAppCard`);
|
|
220
|
+
v = viewFor(read(null), { owner: false });
|
|
221
|
+
assert.equal(v.connect_url, null, 'no action a non-owner cannot complete');
|
|
222
|
+
assert.equal(v.owner, false);
|
|
223
|
+
// live, owner: links kept; non-owner: owner-only fields gone, the rest (and new fields) kept
|
|
224
|
+
v = viewFor(read(RENDER_LIVE), { owner: true });
|
|
225
|
+
assert.ok(v.app.links);
|
|
226
|
+
assert.equal('retry' in v.app, false);
|
|
227
|
+
v = viewFor(read(RENDER_LIVE), { owner: false });
|
|
228
|
+
for (const k of buildHall.OWNER_ONLY) assert.equal(k in v.app, false, k);
|
|
229
|
+
assert.equal(v.app.url, RENDER_LIVE.url);
|
|
230
|
+
assert.deepEqual(v.app.sandbox, RENDER_LIVE.sandbox, 'a field the hub adds later arrives untouched');
|
|
231
|
+
// failed: the sentence reaches everyone; only the owner gets where to retry
|
|
232
|
+
const failed = { platform: 'render', state: 'failed', stage: null, error: 'The build command failed.', retry: { owner_id: 'tea-x' } };
|
|
233
|
+
assert.equal(viewFor(read(failed), { owner: false }).app.error, 'The build command failed.');
|
|
234
|
+
assert.equal(viewFor(read(failed), { owner: false }).connect_url, null);
|
|
235
|
+
assert.ok(viewFor(read(failed), { owner: true }).connect_url);
|
|
236
|
+
// not reachable, not known, no hub
|
|
237
|
+
assert.equal(viewFor({ hub: true, reachable: false, manage: MANAGE }, { owner: true }).reachable, false);
|
|
238
|
+
assert.equal(viewFor(read(null, { known: false }), { owner: true }).known, false);
|
|
239
|
+
assert.deepEqual(viewFor({ hub: false }, { owner: true }), { hub: false, reachable: false, known: false, app: null, sandbox: null, owner: false, connect_url: null, hub_host: null });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('hall: GET /app-host decides owner on the server, never from the request', async () => {
|
|
243
|
+
const read = async () => ({ hub: true, reachable: true, known: true, app: RENDER_LIVE, manage: MANAGE });
|
|
244
|
+
let ownerIds = new Set([1]);
|
|
245
|
+
const hall = await serve(buildHall({ readHub: read, isOwner: async (req) => ownerIds.has(req.builder.id) }));
|
|
246
|
+
try {
|
|
247
|
+
viewer = { id: 1 };
|
|
248
|
+
let r = await hall.call('GET', '/app-host?owner=1');
|
|
249
|
+
assert.equal(r.status, 200);
|
|
250
|
+
assert.equal(r.body.owner, true);
|
|
251
|
+
assert.ok(r.body.app.links);
|
|
252
|
+
assert.equal(r.headers['cache-control'], 'no-store');
|
|
253
|
+
viewer = { id: 2 };
|
|
254
|
+
r = await hall.call('GET', '/app-host?owner=1');
|
|
255
|
+
assert.equal(r.body.owner, false);
|
|
256
|
+
assert.equal(r.body.connect_url, null);
|
|
257
|
+
assert.equal(r.body.app.links, undefined);
|
|
258
|
+
} finally { hall.close(); viewer = { id: 7 }; }
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('hall: the owner atom is core.pin.move, and an unreadable authority is no', async () => {
|
|
262
|
+
// The default isOwner, through the government port.
|
|
263
|
+
const realResolve = fakeApi.resolveOptional;
|
|
264
|
+
const read = async () => ({ hub: true, reachable: true, known: true, app: null, manage: MANAGE });
|
|
265
|
+
try {
|
|
266
|
+
for (const [gov, expected] of [
|
|
267
|
+
[{ resolveBuilderPermissions: async () => ['core.pin.move'] }, true],
|
|
268
|
+
[{ resolveBuilderPermissions: async () => ['task.claim'] }, false],
|
|
269
|
+
[{ resolveBuilderPermissions: async () => { throw new Error('db down'); } }, false],
|
|
270
|
+
[null, false],
|
|
271
|
+
]) {
|
|
272
|
+
fakeApi.resolveOptional = (n) => (n === 'government' ? gov : null);
|
|
273
|
+
const hall = await serve(buildHall({ readHub: read }));
|
|
274
|
+
try { assert.equal((await hall.call('GET', '/app-host')).body.owner, expected); } finally { hall.close(); }
|
|
275
|
+
}
|
|
276
|
+
} finally { fakeApi.resolveOptional = realResolve; }
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── 5. the module switch ─────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
const { hallWidgetScripts } = require('../src/module-loader/loader.js');
|
|
282
|
+
const { injectModuleHallWidgets } = require('../src/bongos/serve-internal.js');
|
|
283
|
+
|
|
284
|
+
test('module: on, its panel script is listed for the options menu; off, it is not', () => {
|
|
285
|
+
const on = hallWidgetScripts({ isEnabled: () => true, file: 'settings-widget.js' });
|
|
286
|
+
assert.ok(on.some((s) => s.key === 'app-host' && s.src === '/modules/app-host/public/settings-widget.js'));
|
|
287
|
+
const off = hallWidgetScripts({ isEnabled: (k) => k !== 'app-host', file: 'settings-widget.js' });
|
|
288
|
+
assert.equal(off.some((s) => s.key === 'app-host'), false);
|
|
289
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/app-host/module.json'), 'utf8'));
|
|
290
|
+
assert.equal(manifest.default, true);
|
|
291
|
+
assert.deepEqual(manifest.contributes.routes, ['app-host']);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('module: the panel script lands before settings.js and loads the way it does (not deferred)', () => {
|
|
295
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
|
|
296
|
+
const out = injectModuleHallWidgets(html, [{ key: 'app-host', src: '/modules/app-host/public/settings-widget.js' }], 'settings');
|
|
297
|
+
const at = out.indexOf('<script src="/modules/app-host/public/settings-widget.js"></script>');
|
|
298
|
+
assert.ok(at > 0, 'injected as a plain script');
|
|
299
|
+
assert.ok(at < out.indexOf('<script src="/builders/settings.js'));
|
|
300
|
+
assert.ok(at > out.indexOf('<script src="/builders/dom-utils.js'), 'after the seam exists');
|
|
301
|
+
assert.equal(injectModuleHallWidgets(html, [], 'settings'), html, 'off: the page is unchanged');
|
|
302
|
+
// The hall index keeps its deferred widgets.
|
|
303
|
+
const idx = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/index.html'), 'utf8');
|
|
304
|
+
assert.match(injectModuleHallWidgets(idx, [{ key: 'x', src: '/modules/x/public/hall-widget.js' }]), /hall-widget\.js" defer><\/script>/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── 6. the panel ─────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
function loadPanel() {
|
|
310
|
+
const panels = [];
|
|
311
|
+
// The hall's shared escaper, as window.OTB carries it (dom-utils.js).
|
|
312
|
+
const { escapeHtml } = require('../src/html-escape.js');
|
|
313
|
+
const window = { OTB: { escapeHtml, contributeSettingsPanel: (p) => panels.push(p) } };
|
|
314
|
+
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'modules/app-host/public/settings-widget.js'), 'utf8'), { window });
|
|
315
|
+
return { panels, render: window.OTBAppHost.render };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
test('panel: registers one options-menu panel', () => {
|
|
319
|
+
const { panels } = loadPanel();
|
|
320
|
+
assert.equal(panels.length, 1);
|
|
321
|
+
assert.equal(panels[0].id, 'app-host');
|
|
322
|
+
assert.equal(panels[0].title, 'Where the app runs');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('panel: what a builder reads in each state', () => {
|
|
326
|
+
const { render } = loadPanel();
|
|
327
|
+
const base = { hub: true, reachable: true, known: true, hub_host: 'hub.test' };
|
|
328
|
+
const owner = { ...base, owner: true, connect_url: `${MANAGE.manage_url}#mAppCard` };
|
|
329
|
+
const member = { ...base, owner: false, connect_url: null };
|
|
330
|
+
|
|
331
|
+
let h = render({ ...owner, app: null });
|
|
332
|
+
assert.match(h, /No platform yet/);
|
|
333
|
+
assert.match(h, /href="https:\/\/hub\.test\/projects\?view=manage&project=mercury#mAppCard">Connect Render</);
|
|
334
|
+
h = render({ ...member, app: null });
|
|
335
|
+
assert.match(h, /No platform yet/);
|
|
336
|
+
assert.doesNotMatch(h, /<a /, 'no action a non-owner cannot complete');
|
|
337
|
+
assert.match(h, /owner can connect Render/);
|
|
338
|
+
|
|
339
|
+
assert.match(render({ ...member, app: { platform: 'render', state: 'creating', stage: 'creating' } }), /creating.*Step 1 of 3|Step 1 of 3[\s\S]*creating/);
|
|
340
|
+
assert.match(render({ ...member, app: { platform: 'render', state: 'deploying', stage: 'building' } }), /Step 2 of 3/);
|
|
341
|
+
assert.match(render({ ...member, app: { platform: 'render', state: 'deploying', stage: 'quiet', quiet: true } }), /hasn’t answered/);
|
|
342
|
+
h = render({ ...member, app: { platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com' } });
|
|
343
|
+
assert.match(h, /status-pill--ok">live/);
|
|
344
|
+
assert.match(h, /href="https:\/\/m\.onrender\.com"/);
|
|
345
|
+
assert.doesNotMatch(render({ ...member, app: { platform: 'render', state: 'live', stage: 'live', url: 'https://evil.example' } }), /evil/, 'only an onrender.com address is linked');
|
|
346
|
+
|
|
347
|
+
h = render({ ...owner, app: { platform: 'render', state: 'failed', stage: null, error: 'The build command <b>failed</b>.' } });
|
|
348
|
+
assert.match(h, /status-pill--danger">failed/);
|
|
349
|
+
assert.match(h, /The build command <b>failed<\/b>\./, 'the setup\'s own words, escaped');
|
|
350
|
+
assert.match(h, />Retry on the project page</);
|
|
351
|
+
h = render({ ...member, app: { platform: 'render', state: 'failed', stage: null, error: 'The build command failed.' } });
|
|
352
|
+
assert.match(h, /owner can retry it from this project's page on hub\.test/);
|
|
353
|
+
assert.doesNotMatch(h, /<a /);
|
|
354
|
+
|
|
355
|
+
assert.match(render({ ...owner, reachable: false }), /Check again/);
|
|
356
|
+
assert.match(render({ ...owner, known: false }), /isn’t set up through hub\.test/);
|
|
357
|
+
assert.match(render({ hub: false }), /runs on its own server/);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── 7. the sandbox beside the app (task 1005289's shape, carried as is) ──────────
|
|
361
|
+
|
|
362
|
+
test('sandbox: the port reads it from the same row, only for a Render app', async () => {
|
|
363
|
+
const row = {
|
|
364
|
+
status: 'active', waiting: false,
|
|
365
|
+
app_host: {
|
|
366
|
+
platform: 'render', state: 'live', serviceUrl: 'https://m.onrender.com', serviceId: 'srv-abcdefgh12',
|
|
367
|
+
sandbox: { state: 'live', serviceId: 'srv-sandbox1234', serviceUrl: 'https://m-sandbox.onrender.com' },
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
const got = await projectAppStatus(rowDb(row), { slug: 'm', host: 'm.test' });
|
|
371
|
+
assert.equal(got.sandbox.stage, 'live');
|
|
372
|
+
assert.equal(got.sandbox.url, 'https://m-sandbox.onrender.com');
|
|
373
|
+
assert.equal((await projectAppStatus(rowDb({ app_host: null, status: 'active', waiting: false }), { slug: 'm', host: 'm.test' })).sandbox, null);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('sandbox: the hub and the asker pass it through; an older hub sends none', async () => {
|
|
377
|
+
const sandbox = { state: 'live', stage: 'live', url: 'https://m-sandbox.onrender.com' };
|
|
378
|
+
const hub = await serve(buildHubRoute({ db: clientDb, resolvePort: () => async () => ({ app: { platform: 'render', stage: 'live' }, sandbox }) }));
|
|
379
|
+
try {
|
|
380
|
+
const r = await hub.call('POST', '/sso/app-status', { client_id: 'mercury', client_secret: SECRET });
|
|
381
|
+
assert.deepEqual(r.body.sandbox, sandbox);
|
|
382
|
+
} finally { hub.close(); }
|
|
383
|
+
const ask = loadAsker(IDP);
|
|
384
|
+
await withFetch(async () => ({ ok: true, json: async () => ({ ok: true, known: true, app: null }) }), async () => {
|
|
385
|
+
assert.equal((await ask()).sandbox, null);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('sandbox: everyone sees it, only the owner gets its links, and "none" is not drawn', () => {
|
|
390
|
+
const app = { platform: 'render', state: 'live', stage: 'live' };
|
|
391
|
+
const sandbox = { state: 'live', stage: 'live', url: 'https://m-sandbox.onrender.com', links: { service: 'https://dashboard.render.com/web/srv-sandbox1234' }, can_add: false, plan: 'free' };
|
|
392
|
+
const read = (sb) => ({ hub: true, reachable: true, known: true, app, sandbox: sb, manage: MANAGE });
|
|
393
|
+
assert.ok(viewFor(read(sandbox), { owner: true }).sandbox.links);
|
|
394
|
+
const other = viewFor(read(sandbox), { owner: false }).sandbox;
|
|
395
|
+
assert.equal(other.links, undefined);
|
|
396
|
+
assert.equal(other.can_add, undefined);
|
|
397
|
+
assert.equal(other.url, sandbox.url);
|
|
398
|
+
assert.equal(other.plan, 'free', 'other fields pass through');
|
|
399
|
+
assert.equal(viewFor(read({ state: 'none', message: 'This project has no sandbox yet.', can_add: true }), { owner: true }).sandbox, null);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('sandbox: the panel draws a second row with its own stage and address', () => {
|
|
403
|
+
const { render } = loadPanel();
|
|
404
|
+
const d = { hub: true, reachable: true, known: true, hub_host: 'hub.test', owner: false, connect_url: null,
|
|
405
|
+
app: { platform: 'render', state: 'live', stage: 'live', url: 'https://m.onrender.com' } };
|
|
406
|
+
let h = render({ ...d, sandbox: { state: 'live', stage: 'live', url: 'https://m-sandbox.onrender.com' } });
|
|
407
|
+
assert.match(h, /Sandbox[\s\S]*href="https:\/\/m-sandbox\.onrender\.com"/);
|
|
408
|
+
h = render({ ...d, sandbox: { state: 'requested', stage: 'creating', message: 'Your sandbox is set up right after your app.' } });
|
|
409
|
+
assert.match(h, /Sandbox[\s\S]*set up right after your app[\s\S]*creating/);
|
|
410
|
+
h = render({ ...d, sandbox: { state: 'failed', stage: null, error: 'The free plan is full.' } });
|
|
411
|
+
assert.match(h, /Setting up the sandbox stopped\. The free plan is full\./);
|
|
412
|
+
assert.doesNotMatch(render({ ...d, sandbox: null }), /Sandbox/);
|
|
413
|
+
});
|
package/tests/first_read.mjs
CHANGED
|
@@ -41,10 +41,16 @@ function fakePool() {
|
|
|
41
41
|
if (/^SELECT ref, kind, entity_id FROM lifecycle_repo_map_filed/.test(q)) {
|
|
42
42
|
return { rows: p[0].filter((r) => s.filed.has(r)).map((r) => ({ ref: r, ...s.filed.get(r) })) };
|
|
43
43
|
}
|
|
44
|
+
if (/^INSERT INTO lifecycle_repo_map_filed .*'dismissed', NULL/.test(q)) {
|
|
45
|
+
// the owner's no: ON CONFLICT DO NOTHING
|
|
46
|
+
for (const ref of p[0]) if (!s.filed.has(ref)) s.filed.set(ref, { kind: 'dismissed', entity_id: null, map_id: p[1] });
|
|
47
|
+
return { rows: [] };
|
|
48
|
+
}
|
|
44
49
|
if (/^INSERT INTO lifecycle_repo_map_filed .* unnest/.test(q)) {
|
|
45
50
|
s.filedWrites = (s.filedWrites || 0) + 1;
|
|
46
51
|
p[0].forEach((ref, i) => {
|
|
47
|
-
|
|
52
|
+
// ON CONFLICT ... DO UPDATE ... WHERE kind = 'dismissed': only a dismissed row is replaced
|
|
53
|
+
if (s.filed.has(ref) && s.filed.get(ref).kind !== 'dismissed') throw Object.assign(new Error('filed twice'), { code: '23505' });
|
|
48
54
|
s.filed.set(ref, { kind: p[1][i], entity_id: p[2][i], map_id: p[3] });
|
|
49
55
|
});
|
|
50
56
|
return { rows: [] };
|
|
@@ -74,12 +80,13 @@ test('the prompt sends the owner\'s own session to read locally, map in the defi
|
|
|
74
80
|
for (const must of [
|
|
75
81
|
'own AI session', 'Bongos itself never reads this repository', 'Do not upload, paste or send any source code',
|
|
76
82
|
fr.MAP_FILE, fr.SCHEMA, '"goals"', '"next"', '"why"', '"seen_at"', 'errors or mishaps',
|
|
77
|
-
'first-read check', 'first-read submit', 'first-read accept', 'first-read reject', 'Never accept on your own',
|
|
83
|
+
'first-read check', 'first-read submit', 'first-read accept', 'first-read reject', 'first-read dismiss', 'Never accept or dismiss on your own',
|
|
78
84
|
'not filed twice',
|
|
79
85
|
]) assert.ok(p.includes(must), `the prompt says: ${must}`);
|
|
80
86
|
// The example it prints is itself a valid map, so a session that copies it is on the shape.
|
|
81
87
|
assert.equal(fr.validateRepoMap(fr.exampleMap()).ok, true);
|
|
82
|
-
const
|
|
88
|
+
const from = p.indexOf('Use exactly this shape:') + 'Use exactly this shape:'.length;
|
|
89
|
+
const printed = JSON.parse(p.slice(p.indexOf('{', from), p.indexOf('\n - Optional', from)));
|
|
83
90
|
assert.deepEqual(printed, fr.exampleMap());
|
|
84
91
|
});
|
|
85
92
|
|
|
@@ -107,11 +114,11 @@ test('a good map validates into a flat review list with stable refs', () => {
|
|
|
107
114
|
assert.equal(v.items[3].goal_ref, fr.NEXT_GOAL.ref, 'a next item naming no goal lands under the fix-what-was-found goal');
|
|
108
115
|
});
|
|
109
116
|
|
|
110
|
-
test('a
|
|
111
|
-
const raw = { ...fr.exampleMap(),
|
|
117
|
+
test('a section this version does not read rides along without breaking the shape', () => {
|
|
118
|
+
const raw = { ...fr.exampleMap(), later_section: [{ key: 'x', why: 'room to grow' }] };
|
|
112
119
|
const v = fr.validateRepoMap(raw);
|
|
113
120
|
assert.equal(v.ok, true);
|
|
114
|
-
assert.deepEqual(v.map.
|
|
121
|
+
assert.deepEqual(v.map.later_section, raw.later_section);
|
|
115
122
|
});
|
|
116
123
|
|
|
117
124
|
test('a prototype-shaped key in the map is never copied onto it', () => {
|
|
@@ -253,7 +260,7 @@ api.requirePermission = (...p) => { perms.push(p.join(',')); return (_req, _res,
|
|
|
253
260
|
api.requireActAlone = (area) => { perms.push('act-alone:' + area); return (_req, _res, next) => next(); };
|
|
254
261
|
|
|
255
262
|
const route = fakePool();
|
|
256
|
-
for (const fn of ['submitMap', 'reviewMap', 'acceptMap', 'rejectMap']) {
|
|
263
|
+
for (const fn of ['submitMap', 'reviewMap', 'acceptMap', 'rejectMap', 'dismissItems']) {
|
|
257
264
|
storeModule[fn] = (a) => store[fn](a, route.deps);
|
|
258
265
|
}
|
|
259
266
|
const app = express();
|
|
@@ -277,7 +284,8 @@ test('the routes: filing is gated like creating a goal, and the flow runs end to
|
|
|
277
284
|
assert.ok(perms.includes('act-alone:planning'));
|
|
278
285
|
const prompt = await call('GET', '/first-read/prompt');
|
|
279
286
|
assert.equal(prompt.status, 200);
|
|
280
|
-
|
|
287
|
+
// No module list is registered in this test, so the prompt says so (task 1005281).
|
|
288
|
+
assert.equal(prompt.body.prompt, fr.firstReadPrompt({ catalog: null }));
|
|
281
289
|
|
|
282
290
|
const bad = await call('POST', '/first-read/maps', { map: { schema: fr.SCHEMA, summary: 'x', goals: [{ title: 'A' }] } });
|
|
283
291
|
assert.equal(bad.status, 400);
|
|
@@ -301,3 +309,171 @@ test('the routes: filing is gated like creating a goal, and the flow runs end to
|
|
|
301
309
|
assert.equal((await call('GET', '/first-read/maps/999')).status, 404);
|
|
302
310
|
assert.equal((await call('POST', `/first-read/maps/${ok.body.map_id}/reject`)).status, 409, 'an accepted map is not then rejected');
|
|
303
311
|
});
|
|
312
|
+
|
|
313
|
+
// ── recommended modules (task 1005281) ───────────────────────────────────────
|
|
314
|
+
//
|
|
315
|
+
// The owner's own session compares what the code does with this project's live module
|
|
316
|
+
// list and may recommend from it. Only keys on that list are accepted; a recommendation
|
|
317
|
+
// is a suggestion the owner accepts (a draft task to switch it on) or dismisses.
|
|
318
|
+
|
|
319
|
+
const fc = require('../modules/lifecycle/first-read-catalog.js');
|
|
320
|
+
|
|
321
|
+
const LISTED = [
|
|
322
|
+
{ key: 'lifecycle', title: 'Lifecycle', description: 'Tasks and claims.', enabled: true },
|
|
323
|
+
{ key: 'billing', title: 'Billing (internal name)', description: 'Takes card payments through the processor and keeps the ledger. Engineers: see ADR.', enabled: false },
|
|
324
|
+
{ key: 'raise', title: 'Raise', description: 'A fundraising page.', enabled: false },
|
|
325
|
+
];
|
|
326
|
+
const PICKER = { optional: [{ key: 'billing', label: 'Billing', summary: 'Take payments and send receipts.' }] };
|
|
327
|
+
const CATALOG = fc.buildCatalog(LISTED, PICKER);
|
|
328
|
+
|
|
329
|
+
// A repository whose billing code keeps failing: a known weak area.
|
|
330
|
+
function weakBillingMap() {
|
|
331
|
+
return {
|
|
332
|
+
schema: fr.SCHEMA,
|
|
333
|
+
summary: 'A shop whose own card-payment code keeps failing.',
|
|
334
|
+
next: [{ title: 'Payments fail at checkout', kind: 'error', why: 'Half the test charges fail with a timeout.', seen_at: ['src/billing/charge.js:30'] }],
|
|
335
|
+
recommended_modules: [{
|
|
336
|
+
module: 'billing', title: 'Session-chosen words, ignored',
|
|
337
|
+
found: 'The hand-written billing code retries forever and loses receipts.',
|
|
338
|
+
why: 'The Billing module takes payments and sends receipts, so this code can go.',
|
|
339
|
+
seen_at: ['src/billing/'],
|
|
340
|
+
}],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
test('the catalog is this project\'s modules not yet on, in the picker\'s words where it has them', () => {
|
|
345
|
+
assert.deepEqual(CATALOG.map((m) => m.key), ['billing', 'raise'], 'an enabled module is not offered');
|
|
346
|
+
assert.equal(CATALOG[0].title, 'Billing');
|
|
347
|
+
assert.equal(CATALOG[0].summary, 'Take payments and send receipts.');
|
|
348
|
+
assert.equal(CATALOG[1].summary, 'A fundraising page.', 'otherwise the module\'s own first sentence');
|
|
349
|
+
assert.equal(fc.buildCatalog(null, null).length, 0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('the catalog is read live from the kernel port, and is null when no list can be read', async () => {
|
|
353
|
+
const seams = require('../src/module-seams.js');
|
|
354
|
+
assert.equal(await fc.switchOnCatalog(), null, 'no port, no list: nothing may be recommended');
|
|
355
|
+
// Left registered: the route test below reads the same list.
|
|
356
|
+
seams.registerProvider('modules.catalog', () => LISTED);
|
|
357
|
+
assert.deepEqual((await fc.switchOnCatalog()).map((m) => m.key), ['billing', 'raise']);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('the prompt carries the live list; the Genesis copy tells the session how to get it', () => {
|
|
361
|
+
const p = fr.firstReadPrompt({ catalog: CATALOG });
|
|
362
|
+
assert.ok(p.includes('- billing: Billing. Take payments and send receipts.'));
|
|
363
|
+
assert.ok(p.includes('"recommended_modules"') && p.includes('Recommend only from the list'));
|
|
364
|
+
assert.ok(p.includes('no recommendation is a good answer'));
|
|
365
|
+
assert.ok(fr.firstReadPrompt().includes('first-read catalog'), 'the fixed Genesis text names the live list');
|
|
366
|
+
assert.ok(fr.firstReadPrompt({ catalog: null }).includes('could not be read'));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('a repository with a known weak area gets the matching module, with its reason', async () => {
|
|
370
|
+
const v = fr.validateRepoMap(weakBillingMap(), { catalog: CATALOG });
|
|
371
|
+
assert.equal(v.ok, true, JSON.stringify(v.errors));
|
|
372
|
+
const mod = v.items.find((i) => i.type === 'module');
|
|
373
|
+
assert.equal(mod.ref, 'module:billing');
|
|
374
|
+
assert.equal(mod.title, 'Billing', 'the catalog\'s words, never the session\'s');
|
|
375
|
+
assert.match(mod.found, /retries forever/);
|
|
376
|
+
assert.match(mod.why, /takes payments/);
|
|
377
|
+
assert.deepEqual(v.map.recommended_modules[0].title, 'Billing');
|
|
378
|
+
|
|
379
|
+
const { deps, s } = fakePool();
|
|
380
|
+
const { map_id } = await store.submitMap({ raw: weakBillingMap(), catalog: CATALOG }, deps);
|
|
381
|
+
const out = await store.acceptMap({ mapId: map_id, select: 'all' }, deps);
|
|
382
|
+
assert.equal(out.ok, true);
|
|
383
|
+
assert.ok(s.goals.some((g) => g.title === fr.MODULES_GOAL.title));
|
|
384
|
+
const task = s.tasks.find((t) => t.sourceRef === 'module:billing');
|
|
385
|
+
assert.equal(task.title, 'Switch on the Billing module');
|
|
386
|
+
assert.equal(task.status, 'backlog', 'a draft: nothing is switched on, nothing is claimable');
|
|
387
|
+
assert.match(task.description, /Found: The hand-written billing code/);
|
|
388
|
+
assert.match(task.description, /Why the Billing module/);
|
|
389
|
+
assert.match(task.description, /Seen at: src\/billing\//);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('a repository with nothing to replace gets no recommendation', async () => {
|
|
393
|
+
const raw = { ...fr.exampleMap() };
|
|
394
|
+
const v = fr.validateRepoMap(raw, { catalog: CATALOG });
|
|
395
|
+
assert.equal(v.ok, true);
|
|
396
|
+
assert.equal(v.items.filter((i) => i.type === 'module').length, 0);
|
|
397
|
+
assert.equal(v.map.recommended_modules, undefined);
|
|
398
|
+
const { deps, s } = fakePool();
|
|
399
|
+
const { map_id } = await store.submitMap({ raw, catalog: CATALOG }, deps);
|
|
400
|
+
await store.acceptMap({ mapId: map_id, select: 'all' }, deps);
|
|
401
|
+
assert.ok(!s.goals.some((g) => g.title === fr.MODULES_GOAL.title), 'no modules goal is filed for an empty section');
|
|
402
|
+
assert.ok(!s.tasks.some((t) => /^Switch on the/.test(t.title)));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('an unknown module, a repeated one, or any module when no list can be read, is refused', async () => {
|
|
406
|
+
const unknown = weakBillingMap();
|
|
407
|
+
unknown.recommended_modules[0].module = 'stripe-magic';
|
|
408
|
+
let v = fr.validateRepoMap(unknown, { catalog: CATALOG });
|
|
409
|
+
assert.equal(v.ok, false);
|
|
410
|
+
assert.ok(v.errors.some((e) => /"stripe-magic" is not a module this project can switch on/.test(e)));
|
|
411
|
+
|
|
412
|
+
const on = weakBillingMap();
|
|
413
|
+
on.recommended_modules[0].module = 'lifecycle';
|
|
414
|
+
assert.equal(fr.validateRepoMap(on, { catalog: CATALOG }).ok, false, 'a module already on is not on the list');
|
|
415
|
+
|
|
416
|
+
const twice = weakBillingMap();
|
|
417
|
+
twice.recommended_modules.push({ ...twice.recommended_modules[0] });
|
|
418
|
+
assert.ok(fr.validateRepoMap(twice, { catalog: CATALOG }).errors.some((e) => /repeats a module/.test(e)));
|
|
419
|
+
|
|
420
|
+
v = fr.validateRepoMap(weakBillingMap(), { catalog: null });
|
|
421
|
+
assert.ok(v.errors.some((e) => /could not be read here/.test(e)));
|
|
422
|
+
|
|
423
|
+
const missing = weakBillingMap();
|
|
424
|
+
delete missing.recommended_modules[0].found;
|
|
425
|
+
assert.ok(fr.validateRepoMap(missing, { catalog: CATALOG }).errors.some((e) => /recommended_modules\[0\]\.found is missing/.test(e)));
|
|
426
|
+
|
|
427
|
+
const { deps, s } = fakePool();
|
|
428
|
+
const out = await store.submitMap({ raw: unknown, catalog: CATALOG }, deps);
|
|
429
|
+
assert.equal(out.ok, false);
|
|
430
|
+
assert.equal(s.maps.length, 0, 'a refused map is not stored');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('dismissing a recommendation files nothing, and a re-run does not raise it again unless named', async () => {
|
|
434
|
+
const { deps, s } = fakePool();
|
|
435
|
+
const first = await store.submitMap({ raw: weakBillingMap(), catalog: CATALOG }, deps);
|
|
436
|
+
const d = await store.dismissItems({ mapId: first.map_id, refs: ['module:billing'] }, deps);
|
|
437
|
+
assert.equal(d.ok, true);
|
|
438
|
+
assert.deepEqual(d.dismissed, ['module:billing']);
|
|
439
|
+
assert.equal(s.tasks.length, 0, 'a no files nothing');
|
|
440
|
+
assert.equal((await store.dismissItems({ mapId: first.map_id, refs: ['module:nope'] }, deps)).code, 'unknown_items');
|
|
441
|
+
|
|
442
|
+
const second = await store.submitMap({ raw: weakBillingMap(), catalog: CATALOG }, deps);
|
|
443
|
+
const review = await store.reviewMap(second.map_id, deps);
|
|
444
|
+
assert.equal(review.items.find((i) => i.ref === 'module:billing').filed.kind, 'dismissed');
|
|
445
|
+
const all = await store.acceptMap({ mapId: second.map_id, select: 'all' }, deps);
|
|
446
|
+
assert.ok(all.skipped.some((x) => x.ref === 'module:billing' && x.reason === 'dismissed'));
|
|
447
|
+
assert.ok(!s.tasks.some((t) => t.sourceRef === 'module:billing'));
|
|
448
|
+
|
|
449
|
+
const named = await store.acceptMap({ mapId: second.map_id, select: ['module:billing'] }, deps);
|
|
450
|
+
assert.equal(named.filed.tasks.length, 1, 'naming a dismissed item files it: the owner changed their mind');
|
|
451
|
+
assert.equal(s.filed.get('module:billing').kind, 'task');
|
|
452
|
+
const again = await store.dismissItems({ mapId: second.map_id, refs: ['module:billing'] }, deps);
|
|
453
|
+
assert.ok(again.skipped.some((x) => x.reason === 'already_filed'), 'a filed item is not un-filed by a dismiss');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('the routes: the catalog route, a submit checked against the live list, and dismiss', async () => {
|
|
457
|
+
const seams = require('../src/module-seams.js');
|
|
458
|
+
if (!seams.hasProvider('modules.catalog')) seams.registerProvider('modules.catalog', () => LISTED);
|
|
459
|
+
const cat = await call('GET', '/first-read/catalog');
|
|
460
|
+
assert.equal(cat.status, 200);
|
|
461
|
+
assert.equal(cat.body.available, true);
|
|
462
|
+
assert.deepEqual(cat.body.modules.map((m) => m.key), ['billing', 'raise']);
|
|
463
|
+
const prompt = await call('GET', '/first-read/prompt');
|
|
464
|
+
assert.ok(prompt.body.prompt.includes('- billing: Billing (internal name). Takes card payments through the processor and keeps the ledger.'));
|
|
465
|
+
|
|
466
|
+
const bad = weakBillingMap();
|
|
467
|
+
bad.recommended_modules[0].module = 'stripe-magic';
|
|
468
|
+
const refused = await call('POST', '/first-read/maps', { map: bad });
|
|
469
|
+
assert.equal(refused.status, 400);
|
|
470
|
+
assert.ok(refused.body.errors.some((e) => /stripe-magic/.test(e)));
|
|
471
|
+
|
|
472
|
+
const ok = await call('POST', '/first-read/maps', { map: weakBillingMap() });
|
|
473
|
+
assert.equal(ok.status, 201);
|
|
474
|
+
assert.ok(ok.body.items.some((i) => i.ref === 'module:billing' && i.type === 'module'));
|
|
475
|
+
const dismissed = await call('POST', `/first-read/maps/${ok.body.map_id}/dismiss`, { items: ['module:billing'] });
|
|
476
|
+
assert.equal(dismissed.status, 200);
|
|
477
|
+
assert.equal((await call('POST', `/first-read/maps/${ok.body.map_id}/dismiss`, { items: 'all' })).status, 400);
|
|
478
|
+
assert.ok(perms.filter((x) => x === 'goal.create').length >= 4, 'dismiss is gated like the rest of the review');
|
|
479
|
+
});
|