@bongos/core 1.21.125 → 1.21.127
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 +106 -51
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
- package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
- package/docs/api/openapi.json +4 -4
- package/docs/copy-inventory.md +128 -126
- package/docs/copy-registry.json +188 -170
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +152 -143
- package/modules/hall-ui/public/goal-forest-lib.js +36 -0
- package/modules/hall-ui/public/goal-forest-render.js +50 -10
- package/modules/hall-ui/public/goals-page.js +19 -22
- package/modules/hall-ui/public/goals-render.js +6 -16
- package/modules/hall-ui/public/goals.css +13 -4
- package/modules/hall-ui/public/goals.html +22 -24
- package/modules/hall-ui/public/goals.states.json +19 -1
- package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
- package/modules/platform-identity/invite-authz.js +1 -1
- package/modules/provisioning/catalog-bridge.js +1 -1
- package/modules/provisioning/provisioning.js +1 -1
- package/modules/provisioning/route-helpers.js +50 -0
- package/modules/provisioning/routes/disconnect.js +6 -5
- package/modules/provisioning/routes/env-manifest.js +11 -27
- package/modules/provisioning/routes/provisioning.js +58 -75
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/module-assess-tests.js +40 -11
- package/scripts/gds/module-assess-version.js +10 -2
- package/scripts/gds/module-error-report.js +148 -0
- package/scripts/gds/module-test-runner/README.md +44 -0
- package/scripts/gds/module-test-runner/module-tests.yml +87 -0
- package/scripts/gds/module-test-runner.js +358 -0
- package/scripts/gds/module.js +1 -0
- package/scripts/gds/provision-caps.js +63 -0
- package/scripts/gds/provision-process.js +3 -0
- package/scripts/gds/provision-units.js +3 -0
- package/scripts/gds/provision.js +3 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/error-tally.js +188 -0
- package/src/module-loader/loader.js +52 -4
- package/src/platform-server.js +7 -0
- package/tests/goal_forest_lib.mjs +28 -0
- package/tests/goal_forest_page.mjs +106 -11
- package/tests/module_assess_price_parity.mjs +2 -2
- package/tests/module_assess_score.mjs +13 -3
- package/tests/module_error_counts.mjs +265 -0
- package/tests/module_test_runner.mjs +294 -0
- package/tests/project_catalog_routes.mjs +1 -1
- package/tests/provision.mjs +2 -0
- package/tests/provision_resource_caps.mjs +117 -0
- package/tests/provisioning_module_selection.mjs +1 -1
- package/tests/provisioning_project_owner_gate.mjs +271 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// scripts/gds/provision-caps.js — the resource caps every hosted project's service carries
|
|
2
|
+
// (task 1004414). A cloud-host project runs its owner's code on the box that also serves the
|
|
3
|
+
// hub, and ADR 0354 lets any signed-in builder host one, so a runaway project must not be
|
|
4
|
+
// able to starve the others or the hub.
|
|
5
|
+
//
|
|
6
|
+
// The numbers are the owner's (2026-10-15), sized from ADR 0285's measurement: a project
|
|
7
|
+
// idles at ~45-70 MB and peaks at 118-134 MB at boot (page cache included), and uses a
|
|
8
|
+
// fraction of a percent of one core. MemoryHigh throttles and reclaims before MemoryMax
|
|
9
|
+
// kills; a killed project comes back through the unit's Restart=always. Too tight a cap
|
|
10
|
+
// takes a healthy project down, so change these only as a decision.
|
|
11
|
+
//
|
|
12
|
+
// A NEW unit renders them (serviceUnit). An EXISTING unit takes them with `systemctl
|
|
13
|
+
// set-property`, which applies live and persists a drop-in under /etc/systemd/system.control/,
|
|
14
|
+
// so nothing restarts and no unit file is rewritten — rewriting would move a project made
|
|
15
|
+
// before the per-instance accounts (task 1003369) onto an account that does not exist.
|
|
16
|
+
// systemd only: the supervisord image has no cgroups to cap.
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const RESOURCE_CAPS = Object.freeze({
|
|
21
|
+
MemoryHigh: '160M',
|
|
22
|
+
MemoryMax: '256M',
|
|
23
|
+
CPUQuota: '50%',
|
|
24
|
+
TasksMax: '256',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function capDirectives() {
|
|
28
|
+
return Object.entries(RESOURCE_CAPS).map(([key, value]) => `${key}=${value}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The slug is interpolated into a root shell command, so it is held to the create route's
|
|
32
|
+
// charset here too rather than trusted from the row.
|
|
33
|
+
const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
34
|
+
|
|
35
|
+
function setCapsCmd(inst, { sudoP = '' } = {}) {
|
|
36
|
+
if (!SAFE_SLUG.test(String(inst.slug))) throw new Error(`refusing to cap an unsafe slug: ${JSON.stringify(inst.slug)}`);
|
|
37
|
+
return `${sudoP}systemctl set-property ${inst.slug}.service ${capDirectives().join(' ')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `provision.js apply-caps [--apply]` — cap every running hosted project now. A cloud-host
|
|
41
|
+
// always runs on the control plane (capacity.placementBox), so this uses controlExec. One
|
|
42
|
+
// project that refuses never stops the rest; the result names it.
|
|
43
|
+
async function cmdApplyCaps(deps) {
|
|
44
|
+
const { db, log, privileged } = deps;
|
|
45
|
+
const exec = deps.controlExec || deps.exec;
|
|
46
|
+
const sudoP = privileged ? 'sudo ' : '';
|
|
47
|
+
const { rows } = await db.query(
|
|
48
|
+
`SELECT slug FROM provisioning_instances
|
|
49
|
+
WHERE hosting_shape = 'cloud-host' AND status = 'active'
|
|
50
|
+
ORDER BY slug`);
|
|
51
|
+
const capped = [], failed = [];
|
|
52
|
+
for (const { slug } of rows) {
|
|
53
|
+
// makeExec answers an allowFail'd failure ok:true + softFailed:true (softFailResult)
|
|
54
|
+
let r;
|
|
55
|
+
try { r = exec(setCapsCmd({ slug }, { sudoP }), { allowFail: true }); } catch { r = null; }
|
|
56
|
+
(!r || r.ok === false || r.softFailed ? failed : capped).push(slug);
|
|
57
|
+
}
|
|
58
|
+
log(`${deps.apply ? 'capped' : 'would cap (dry-run — add --apply)'} ${capped.length} of ${rows.length} active hosted project(s) at ${capDirectives().join(' ')}`);
|
|
59
|
+
if (failed.length) log(` ✖ could not cap: ${failed.join(', ')} — re-run once each unit is loaded`);
|
|
60
|
+
return { ok: failed.length === 0, capped, failed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { RESOURCE_CAPS, capDirectives, setCapsCmd, cmdApplyCaps };
|
|
@@ -59,6 +59,9 @@ const systemd = {
|
|
|
59
59
|
exec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
|
|
60
60
|
exec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
|
|
61
61
|
exec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
|
|
62
|
+
// the caps drop-in `provision.js apply-caps` wrote (task 1004414, provision-caps.js)
|
|
63
|
+
exec(`${sudoP}rm -f /etc/systemd/system.control/${inst.slug}.service.d/50-*.conf`, { allowFail: true });
|
|
64
|
+
exec(`${sudoP}rmdir /etc/systemd/system.control/${inst.slug}.service.d`, { allowFail: true });
|
|
62
65
|
exec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
|
|
63
66
|
exec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
|
|
64
67
|
},
|
|
@@ -12,6 +12,7 @@ const fs = require('node:fs');
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
14
14
|
const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
|
|
15
|
+
const { capDirectives } = require('./provision-caps.js');
|
|
15
16
|
|
|
16
17
|
function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
17
18
|
const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
|
|
@@ -61,6 +62,8 @@ ProtectSystem=strict
|
|
|
61
62
|
ProtectHome=read-only
|
|
62
63
|
PrivateTmp=true
|
|
63
64
|
ReadWritePaths=${stateDir} -${workDir}/config
|
|
65
|
+
# Resource caps (task 1004414; the numbers and their reasons are in provision-caps.js).
|
|
66
|
+
${capDirectives().join('\n')}
|
|
64
67
|
|
|
65
68
|
[Install]
|
|
66
69
|
WantedBy=multi-user.target
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1348,6 +1348,7 @@ Commands:
|
|
|
1348
1348
|
teardown <slug> tear down one instance by slug
|
|
1349
1349
|
reconcile re-enqueue instances stuck mid-flight with no open intent
|
|
1350
1350
|
status list provisioned instances + estimated run-rate
|
|
1351
|
+
apply-caps cap every active hosted project's memory/CPU/tasks live (task 1004414)
|
|
1351
1352
|
dns-check <domain> PREFLIGHT: verify the control-plane Cloudflare token can reach
|
|
1352
1353
|
the domain's zone BEFORE the address step (network-only, no DB)
|
|
1353
1354
|
|
|
@@ -1397,7 +1398,7 @@ async function main() {
|
|
|
1397
1398
|
process.exit(1);
|
|
1398
1399
|
}
|
|
1399
1400
|
|
|
1400
|
-
const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status'];
|
|
1401
|
+
const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status', 'apply-caps'];
|
|
1401
1402
|
if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
|
|
1402
1403
|
const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1403
1404
|
if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
|
|
@@ -1432,6 +1433,7 @@ async function main() {
|
|
|
1432
1433
|
else if (cmd === 'teardown') { const inst = await provisioning.getInstanceBySlug(pool, slug); if (!inst) { console.error(`no instance with slug '${slug}'`); await pool.end().catch(() => {}); process.exit(1); } result = await teardownInstance(inst, deps); }
|
|
1433
1434
|
else if (cmd === 'reconcile') result = await cmdReconcile(deps);
|
|
1434
1435
|
else if (cmd === 'status') result = await cmdStatus(deps);
|
|
1436
|
+
else if (cmd === 'apply-caps') result = await require('./provision-caps.js').cmdApplyCaps(deps);
|
|
1435
1437
|
} catch (e) {
|
|
1436
1438
|
console.error('fatal:', e && e.message ? e.message : e);
|
|
1437
1439
|
await pool.end().catch(() => {});
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.127'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// src/module-loader/error-tally.js — count each mounted module's runtime errors
|
|
2
|
+
// per day, so a store module's Reliability score has real installs behind it
|
|
3
|
+
// (task 1004637, follow-up to task 1003793; ADR 0343 D1).
|
|
4
|
+
//
|
|
5
|
+
// WHY. The store scores Reliability from a daily runtime-error count per module
|
|
6
|
+
// version, but nothing on an instance counted one: the loader console.errored a
|
|
7
|
+
// poller that failed to load and attributed nothing else to a module at all. This
|
|
8
|
+
// file is the instance-side tally the loader feeds; scripts/gds/module-error-report.js
|
|
9
|
+
// is the sender that turns it into reports.
|
|
10
|
+
//
|
|
11
|
+
// WHAT COUNTS. A request a module's router took and answered 500 (a thrown error or
|
|
12
|
+
// an unhandled failure — not a 4xx, which is the caller's mistake, and not a 503,
|
|
13
|
+
// which modules use to say "not set up here"); a poller that failed to load, threw
|
|
14
|
+
// or rejected on start; and anything a poller hands to the `reportError` the loader
|
|
15
|
+
// gives it. Only a module whose manifest carries an exact X.Y.Z version is counted,
|
|
16
|
+
// because the store files counts against an exact version.
|
|
17
|
+
//
|
|
18
|
+
// COUNTS ONLY (owner's privacy posture, 2026-10-01). The tally holds module key,
|
|
19
|
+
// version, day and a whole number — never an error's text, a path or a request.
|
|
20
|
+
//
|
|
21
|
+
// WHO SENDS (owner, 2026-10-15). A builder's sign-in on the box: the store's report
|
|
22
|
+
// routes take a builder, and no project-to-store credential exists (parked as
|
|
23
|
+
// task 1004740). So this file never talks to the network. It writes the tally to the
|
|
24
|
+
// instance config dir, and startDailySend() runs the sender as a CHILD process —
|
|
25
|
+
// cli-lib's session plumbing must never load in the server process.
|
|
26
|
+
//
|
|
27
|
+
// Counting is always in memory; nothing touches disk until start() is called, and
|
|
28
|
+
// only the server entrypoint calls it — so a test or tool that mounts modules never
|
|
29
|
+
// writes a file.
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const FILE_NAME = 'module-error-counts.json';
|
|
35
|
+
// Days kept on disk. One more than the store accepts (ERROR_DAY_WINDOW = 30), so a
|
|
36
|
+
// day the sender has not reached yet is never pruned before it can go.
|
|
37
|
+
const KEEP_DAYS = 31;
|
|
38
|
+
const FLUSH_DELAY_MS = 5_000;
|
|
39
|
+
const ROLL_CHECK_MS = 60 * 60 * 1000;
|
|
40
|
+
const SEND_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
41
|
+
const FIRST_SEND_DELAY_MS = 10 * 60 * 1000;
|
|
42
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
|
|
43
|
+
// The sender, rooted at this file so what the server runs is answerable by reading
|
|
44
|
+
// this line (exec-path-guard, task 1003371).
|
|
45
|
+
const SENDER = path.join(__dirname, '..', '..', 'scripts', 'gds', 'module-error-report.js');
|
|
46
|
+
|
|
47
|
+
function utcDay(ms = Date.now()) { return new Date(ms).toISOString().slice(0, 10); }
|
|
48
|
+
|
|
49
|
+
let _now = Date.now;
|
|
50
|
+
let _days = {}; // day → { "key@version": count }
|
|
51
|
+
let _mounted = new Map(); // key → version, for every module seeded today and each new day
|
|
52
|
+
let _file = null; // set by start(); null = nothing written to disk
|
|
53
|
+
let _flushTimer = null;
|
|
54
|
+
let _timers = [];
|
|
55
|
+
|
|
56
|
+
function entryOf(key, version) { return `${key}@${version}`; }
|
|
57
|
+
|
|
58
|
+
// Make sure today has a 0 for every mounted module — a clean day is exactly what
|
|
59
|
+
// Reliability measures, so a module that ran and threw nothing must still appear.
|
|
60
|
+
function seedToday() {
|
|
61
|
+
const today = utcDay(_now());
|
|
62
|
+
const row = (_days[today] ||= {});
|
|
63
|
+
for (const [key, version] of _mounted) {
|
|
64
|
+
const e = entryOf(key, version);
|
|
65
|
+
if (!(e in row)) row[e] = 0;
|
|
66
|
+
}
|
|
67
|
+
return row;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Register the modules this process runs. Call once the enabled set is known; a
|
|
71
|
+
// module without an exact version is ignored. Returns how many were registered.
|
|
72
|
+
function seed(modules) {
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (const { key, manifest } of modules) {
|
|
75
|
+
const v = manifest && manifest.version;
|
|
76
|
+
if (typeof v !== 'string' || !EXACT_VERSION.test(v)) continue;
|
|
77
|
+
_mounted.set(key, v);
|
|
78
|
+
n += 1;
|
|
79
|
+
}
|
|
80
|
+
seedToday();
|
|
81
|
+
scheduleFlush();
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Count one runtime error against a mounted module. Unknown or unversioned keys
|
|
86
|
+
// are ignored, so a stray call can never invent a module.
|
|
87
|
+
function record(key) {
|
|
88
|
+
const version = _mounted.get(key);
|
|
89
|
+
if (!version) return false;
|
|
90
|
+
const row = seedToday();
|
|
91
|
+
row[entryOf(key, version)] += 1;
|
|
92
|
+
scheduleFlush();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function prune() {
|
|
97
|
+
const cutoff = utcDay(_now() - KEEP_DAYS * 86_400_000);
|
|
98
|
+
for (const d of Object.keys(_days)) if (d < cutoff) delete _days[d];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scheduleFlush() {
|
|
102
|
+
if (!_file || _flushTimer) return;
|
|
103
|
+
_flushTimer = setTimeout(() => { _flushTimer = null; flush(); }, FLUSH_DELAY_MS);
|
|
104
|
+
if (_flushTimer.unref) _flushTimer.unref();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Write the tally atomically (temp file, then rename), so the sender never reads
|
|
108
|
+
// half a file. Best-effort: a failed write costs a count, never the server.
|
|
109
|
+
function flush() {
|
|
110
|
+
if (!_file) return false;
|
|
111
|
+
prune();
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(path.dirname(_file), { recursive: true });
|
|
114
|
+
const tmp = `${_file}.${process.pid}.tmp`;
|
|
115
|
+
fs.writeFileSync(tmp, JSON.stringify({ days: _days }, null, 2));
|
|
116
|
+
fs.renameSync(tmp, _file);
|
|
117
|
+
return true;
|
|
118
|
+
} catch (e) {
|
|
119
|
+
process.emitWarning(`module error tally: could not write ${_file}: ${e && e.message ? e.message : e}`);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Read a tally file. Never throws: a missing or unreadable file is an empty tally.
|
|
125
|
+
function readTally(file) {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
128
|
+
return parsed && typeof parsed.days === 'object' && parsed.days ? parsed.days : {};
|
|
129
|
+
} catch { return {}; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function defaultFile() {
|
|
133
|
+
return require('../instance-config').configPath(FILE_NAME);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Persist from now on. Merges what is already on disk (a restart mid-day keeps the
|
|
137
|
+
// morning's count: the higher of the two wins per entry), seeds today, and checks
|
|
138
|
+
// hourly for a new day so a server that runs for a week still files a 0 each day.
|
|
139
|
+
function start({ file = defaultFile(), now } = {}) {
|
|
140
|
+
if (now) _now = now;
|
|
141
|
+
_file = file;
|
|
142
|
+
const onDisk = readTally(file);
|
|
143
|
+
for (const [day, row] of Object.entries(onDisk)) {
|
|
144
|
+
const mine = (_days[day] ||= {});
|
|
145
|
+
for (const [e, n] of Object.entries(row || {})) {
|
|
146
|
+
if (Number.isInteger(n) && n >= 0) mine[e] = Math.max(mine[e] || 0, n);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
seedToday();
|
|
150
|
+
flush();
|
|
151
|
+
const roll = setInterval(() => { seedToday(); flush(); }, ROLL_CHECK_MS);
|
|
152
|
+
if (roll.unref) roll.unref();
|
|
153
|
+
_timers.push(roll);
|
|
154
|
+
return file;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Run the sender now and then (scripts/gds/module-error-report.js) as a child
|
|
158
|
+
// process. It sends nothing without a builder sign-in on this box, and only days
|
|
159
|
+
// before today, so running it often is cheap and safe. Returns the timers.
|
|
160
|
+
function startDailySend({ spawnImpl } = {}) {
|
|
161
|
+
const { spawn } = require('node:child_process');
|
|
162
|
+
const run = spawnImpl || (() => {
|
|
163
|
+
const child = spawn(process.execPath, [SENDER, '--quiet'], { stdio: 'ignore', detached: false });
|
|
164
|
+
child.on('error', () => {});
|
|
165
|
+
if (child.unref) child.unref();
|
|
166
|
+
});
|
|
167
|
+
if (!fs.existsSync(SENDER)) return [];
|
|
168
|
+
const go = () => { try { run(SENDER); } catch { /* best-effort */ } };
|
|
169
|
+
const first = setTimeout(go, FIRST_SEND_DELAY_MS);
|
|
170
|
+
const every = setInterval(go, SEND_EVERY_MS);
|
|
171
|
+
for (const t of [first, every]) if (t.unref) t.unref();
|
|
172
|
+
_timers.push(first, every);
|
|
173
|
+
return [first, every];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The in-memory tally, for tests and a /watch tile.
|
|
177
|
+
function snapshot() { return JSON.parse(JSON.stringify(_days)); }
|
|
178
|
+
|
|
179
|
+
function _reset() {
|
|
180
|
+
for (const t of _timers) { clearTimeout(t); clearInterval(t); }
|
|
181
|
+
if (_flushTimer) clearTimeout(_flushTimer);
|
|
182
|
+
_timers = []; _flushTimer = null; _days = {}; _mounted = new Map(); _file = null; _now = Date.now;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
FILE_NAME,
|
|
187
|
+
utcDay, seed, record, readTally, start, startDailySend, snapshot, _reset,
|
|
188
|
+
};
|
|
@@ -112,7 +112,7 @@ function discoverModules({ roots = DEFAULT_ROOTS, core = coreVersion() } = {}) {
|
|
|
112
112
|
// Cached singleton for app code (lazy, like src/modules.js's modules()).
|
|
113
113
|
let _cache = null;
|
|
114
114
|
function discovered(opts) { return (_cache ||= discoverModules(opts)); }
|
|
115
|
-
function _reset() { _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
|
|
115
|
+
function _reset() { resetErrorTally(); _cache = null; _startedPollers = []; _boot = { routes: null, pollers: null }; _bootShape = null; }
|
|
116
116
|
|
|
117
117
|
// The discovered manifests as MODULE_REGISTRY-shaped entries, so src/modules.js
|
|
118
118
|
// can merge them with its built-in registry. Empty until a module exists.
|
|
@@ -197,10 +197,13 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
|
|
|
197
197
|
for (const rk of routeKeys) {
|
|
198
198
|
const factory = require(path.join(dir, 'routes', `${rk}.js`));
|
|
199
199
|
const sub = typeof factory === 'function' ? factory() : factory;
|
|
200
|
-
router.use(sub);
|
|
200
|
+
router.use(attributeErrors(key, sub));
|
|
201
201
|
mounted += 1;
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
|
+
// Every enabled module gets a 0 for today, routes or not: a clean day is what the
|
|
205
|
+
// store's Reliability score measures (task 1004637).
|
|
206
|
+
seedErrorTally(enabled);
|
|
204
207
|
return mounted;
|
|
205
208
|
}
|
|
206
209
|
|
|
@@ -352,19 +355,26 @@ function startModulePollers(deps = {}, { isEnabled } = {}, opts) {
|
|
|
352
355
|
// here. A file that EXISTS but throws is a real error (the catch below).
|
|
353
356
|
if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(pollerPath)) continue;
|
|
354
357
|
console.error(`[module-loader] poller ${key}/${pk} failed to load: ${e && e.message ? e.message : e}`);
|
|
358
|
+
recordModuleError(key);
|
|
355
359
|
continue;
|
|
356
360
|
}
|
|
357
361
|
try {
|
|
358
362
|
if (poller && typeof poller.start === 'function') {
|
|
359
|
-
|
|
363
|
+
// reportError: how a poller counts a failure in its own runs against its
|
|
364
|
+
// module (task 1004637) — counts only, it takes no message.
|
|
365
|
+
const r = poller.start({ ...deps, reportError: () => recordModuleError(key) });
|
|
360
366
|
if (r && typeof r.then === 'function') {
|
|
361
|
-
r.catch((e) =>
|
|
367
|
+
r.catch((e) => {
|
|
368
|
+
console.error(`[module-loader] poller ${key}/${pk} start error: ${e && e.message ? e.message : e}`);
|
|
369
|
+
recordModuleError(key);
|
|
370
|
+
});
|
|
362
371
|
}
|
|
363
372
|
started.push({ key, poller: pk, handle: poller });
|
|
364
373
|
_boot.pollers.add(key);
|
|
365
374
|
}
|
|
366
375
|
} catch (e) {
|
|
367
376
|
console.error(`[module-loader] poller ${key}/${pk} start threw: ${e && e.message ? e.message : e}`);
|
|
377
|
+
recordModuleError(key);
|
|
368
378
|
}
|
|
369
379
|
}
|
|
370
380
|
}
|
|
@@ -397,9 +407,47 @@ function loaderSnapshot(opts) {
|
|
|
397
407
|
loaded: d.loaded.map((m) => m.key),
|
|
398
408
|
skipped: d.skipped,
|
|
399
409
|
errors: d.errors,
|
|
410
|
+
// per-day runtime-error counts per module@version, kept for the store (task 1004637)
|
|
411
|
+
errorCounts: snapshotErrorTally(),
|
|
400
412
|
};
|
|
401
413
|
}
|
|
402
414
|
|
|
415
|
+
// Which module took this request, and whether its 500 has been hooked yet.
|
|
416
|
+
const OWNER = Symbol('moduleOwner');
|
|
417
|
+
const HOOKED = Symbol('moduleErrorHooked');
|
|
418
|
+
|
|
419
|
+
// Wrap a module's router so a request it answers with a 500 counts against that
|
|
420
|
+
// module (task 1004637). The module is the owner from the moment the request enters
|
|
421
|
+
// its router until it falls through (the router calls next() with no error): a request
|
|
422
|
+
// the module never matched is not its fault, and the next module or the kernel takes
|
|
423
|
+
// it from there. An error the module passes on (next(err)) keeps it the owner, so the
|
|
424
|
+
// kernel's error handler answering 500 for it still counts here. Only a 500 counts:
|
|
425
|
+
// a 4xx is the caller's mistake and a 503 is a module saying it is not set up.
|
|
426
|
+
//
|
|
427
|
+
// A plain function rather than a nested express Router, so nothing about mounting
|
|
428
|
+
// changes for the kernel; `stack` is passed through so tooling that reads a mounted
|
|
429
|
+
// router's routes still sees the module's own.
|
|
430
|
+
function attributeErrors(key, sub) {
|
|
431
|
+
const scoped = function moduleScoped(req, res, next) {
|
|
432
|
+
const prev = req[OWNER];
|
|
433
|
+
req[OWNER] = key;
|
|
434
|
+
if (!req[HOOKED] && res && typeof res.on === 'function') {
|
|
435
|
+
req[HOOKED] = true;
|
|
436
|
+
res.on('finish', () => { if (res.statusCode === 500 && req[OWNER]) recordModuleError(req[OWNER]); });
|
|
437
|
+
}
|
|
438
|
+
sub(req, res, (err) => {
|
|
439
|
+
if (!err || typeof err === 'string') req[OWNER] = prev; // fell through ('route'/'router' too)
|
|
440
|
+
next(err);
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
if (sub && sub.stack) scoped.stack = sub.stack;
|
|
444
|
+
return scoped;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Per-module runtime-error counts (task 1004637). Pure fs + timers, no network and no
|
|
448
|
+
// core requires at load, so it is cycle-safe here.
|
|
449
|
+
const { seed: seedErrorTally, record: recordModuleError, _reset: resetErrorTally, snapshot: snapshotErrorTally } = require('./error-tally');
|
|
450
|
+
|
|
403
451
|
module.exports = {
|
|
404
452
|
discoverModules,
|
|
405
453
|
discovered,
|
package/src/platform-server.js
CHANGED
|
@@ -47,6 +47,7 @@ const express = require('express');
|
|
|
47
47
|
const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal } = require('./bongos/serve-internal');
|
|
48
48
|
const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
|
|
49
49
|
const moduleLoader = require('./module-loader/loader');
|
|
50
|
+
const { start: startErrorTally, startDailySend: startErrorReports } = require('./module-loader/error-tally');
|
|
50
51
|
const { isModuleEnabled } = require('./modules');
|
|
51
52
|
// Hoisted out of an inline require inside the boot IIFE below (task 1004052 strand walk).
|
|
52
53
|
// knip does not follow an inline require(), so every export of module-overrides.js read as
|
|
@@ -413,6 +414,12 @@ if (require.main === module) {
|
|
|
413
414
|
// process starts, and /version carries the count within one query of a restart.
|
|
414
415
|
schemaPending();
|
|
415
416
|
startBackgroundServices();
|
|
417
|
+
// Store modules' daily runtime-error counts (task 1004637): keep the tally on
|
|
418
|
+
// disk, and now and then run the sender, which goes out only with a builder
|
|
419
|
+
// sign-in on this box. Here, not in startBackgroundServices(), so a test that
|
|
420
|
+
// starts the pollers never writes the tally or spawns the sender.
|
|
421
|
+
try { startErrorTally(); startErrorReports(); }
|
|
422
|
+
catch (e) { logger.warn({ err: e.message }, 'module error tally did not start; module error counts are not kept'); }
|
|
416
423
|
});
|
|
417
424
|
})().catch((err) => {
|
|
418
425
|
// THE AWAIT ABOVE MUST NOT SOFTEN A FATAL BOOT (task 1002857, caught by
|
|
@@ -141,6 +141,34 @@ await test('describe states the numbers plainly, and an empty forest says so', (
|
|
|
141
141
|
assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
// ---- the band card's summary (task 1004738) ---------------------------------------
|
|
145
|
+
|
|
146
|
+
await test('brief is the short form of describe, and falls back to it for an empty forest', () => {
|
|
147
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(5, 4)] });
|
|
148
|
+
assert.equal(L.brief(a), '5 tasks linked by dependencies, in 2 chains.');
|
|
149
|
+
assert.equal(L.brief(L.analyse({})), L.describe(L.analyse({})));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await test('shipFirst ranks the open tasks nothing open blocks by how many open tasks wait on them, down the chain', () => {
|
|
153
|
+
// 1 ← 2 ← 3 and 1 ← 4: task 1 unblocks three. 5 ← 6: task 5 unblocks one.
|
|
154
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 1), edge(6, 5)] });
|
|
155
|
+
assert.deepEqual(L.shipFirst(a, 3).map((r) => [r.id, r.unblocks]), [['1', 3], ['5', 1]],
|
|
156
|
+
'a task still waiting on an open one is never "first"; one that unblocks nothing is left off');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await test('shipFirst: a shipped dependency frees what waits on it, and closed work is never listed or counted', () => {
|
|
160
|
+
const a = L.analyse({
|
|
161
|
+
nodes: [node(1, { status: 'shipped' }), node(2), node(3), node(4, { status: 'abandoned' })],
|
|
162
|
+
edges: [edge(2, 1), edge(3, 2), edge(4, 2)],
|
|
163
|
+
});
|
|
164
|
+
assert.deepEqual(L.shipFirst(a, 3).map((r) => [r.id, r.unblocks]), [['2', 1]], 'task 2 is up next and unblocks only the open task 3');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await test('shipFirst stops at the limit and breaks ties by task id', () => {
|
|
168
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7, 8].map((i) => node(i)), edges: [edge(5, 4), edge(6, 3), edge(7, 2), edge(8, 1)] });
|
|
169
|
+
assert.deepEqual(L.shipFirst(a, 3).map((r) => r.id), ['1', '2', '3']);
|
|
170
|
+
});
|
|
171
|
+
|
|
144
172
|
// ---- purity -----------------------------------------------------------------------
|
|
145
173
|
|
|
146
174
|
await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
|
|
@@ -36,10 +36,11 @@ const settle = async () => { for (let i = 0; i < 20; i++) await tick(); };
|
|
|
36
36
|
const t = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
|
|
37
37
|
const e = (from, to) => ({ from: String(from), to: String(to) });
|
|
38
38
|
|
|
39
|
-
async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] } } = {}) {
|
|
39
|
+
async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] }, conflictPairs = null, view = 'dependencies' } = {}) {
|
|
40
40
|
const root = makeEl('div');
|
|
41
41
|
const reg = (id) => { const el = makeEl('div', { id }); root.appendChild(el); return el; };
|
|
42
|
-
['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body'
|
|
42
|
+
['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body', 'goal-deps-back',
|
|
43
|
+
'goal-deps-card-count', 'goal-deps-card-lede', 'goal-deps-card-body', 'goal-deps-card-foot'].forEach(reg);
|
|
43
44
|
|
|
44
45
|
const documentObj = {
|
|
45
46
|
getElementById: (id) => walk(root, (el) => (el.id === id ? el : null)),
|
|
@@ -83,7 +84,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
|
|
|
83
84
|
vm.runInContext(read('bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
|
|
84
85
|
vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
|
|
85
86
|
|
|
86
|
-
const state = { current: 7, forest: null, forestError: false };
|
|
87
|
+
const state = { current: 7, view, forest: null, forestError: false, conflictPairs };
|
|
87
88
|
const ctx = {
|
|
88
89
|
API: '/api/bongos',
|
|
89
90
|
state,
|
|
@@ -96,7 +97,10 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
|
|
|
96
97
|
},
|
|
97
98
|
};
|
|
98
99
|
const renderers = windowObj.OTBGoalsRender(ctx);
|
|
99
|
-
|
|
100
|
+
// The band card's renderer is the forest module's own export — goals-render.js
|
|
101
|
+
// reaches it through its private `forestRender`, so the test makes its own.
|
|
102
|
+
const card = windowObj.OTBGoalForestRender(ctx);
|
|
103
|
+
return { $: documentObj.getElementById, requests, state, renderers, card };
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
// ---- the honest states (ADR 0227) ---------------------------------------------
|
|
@@ -217,14 +221,105 @@ await test('renderDetail calls loadDependencyForest, beside loadGoalTasks, for t
|
|
|
217
221
|
assert.match(RENDER_JS, /loadGoalTasks\(g\.id\);\s*\n\s*loadDependencyForest\(g\.id\);/);
|
|
218
222
|
});
|
|
219
223
|
|
|
220
|
-
|
|
224
|
+
// ---- the band card + the full view (task 1004738) ----------------------------
|
|
225
|
+
|
|
226
|
+
const sectionOf = (html, id) => {
|
|
227
|
+
const start = html.indexOf(`<section id="${id}"`);
|
|
228
|
+
return start < 0 ? '' : html.slice(start, html.indexOf('</section>', start));
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
await test('the band\'s third column is the compact Dependencies card, where Conflicts was — no long block before Tasks', () => {
|
|
221
232
|
const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
assert.
|
|
226
|
-
assert.
|
|
227
|
-
assert.equal(
|
|
233
|
+
const detail = sectionOf(html, 'goal-detail-view');
|
|
234
|
+
const cols = detail.split('class="goal-band__col"').slice(1);
|
|
235
|
+
assert.equal(cols.length, 3, 'still three columns');
|
|
236
|
+
assert.match(cols[2], /^\s*id="goal-deps-card"/, 'Dependencies takes the third slot');
|
|
237
|
+
assert.equal(/>Conflicts</.test(cols.join('')), false, 'no Conflicts column left in the band');
|
|
238
|
+
assert.equal(detail.includes('id="goal-deps-scroll"'), false, 'the forest is off the goal page');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await test('the full picture is #/goal/N/dependencies: the forest, then the whole conflict set; the old /conflicts lands there', () => {
|
|
242
|
+
const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
|
|
243
|
+
const view = sectionOf(html, 'goal-dependencies-view');
|
|
244
|
+
const depsAt = view.indexOf('id="goal-deps-scroll"');
|
|
245
|
+
const conflictsAt = view.indexOf('id="goal-conflicts-full"');
|
|
246
|
+
assert.ok(view.includes('id="goal-deps-back"') && depsAt > -1 && depsAt < conflictsAt, 'back link, forest, then conflicts');
|
|
247
|
+
assert.equal(sectionOf(html, 'goal-conflicts-view'), '', 'no separate conflicts view any more');
|
|
248
|
+
const page = read('goals-page.js');
|
|
249
|
+
assert.match(page, /dependencies: '#goal-dependencies-view'/);
|
|
250
|
+
assert.match(page, /\(dependencies\|conflicts\|members\|completion\)/);
|
|
251
|
+
assert.match(page, /mSub\[2\] === 'conflicts' \? 'dependencies'/, 'the inbox\'s /conflicts links keep working');
|
|
252
|
+
assert.match(RENDER_JS, /function renderConflicts\(\) \{[\s\S]*?forestRender\.renderDependencyCard\(\);/, 'the card re-renders when the conflicts land');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await test('the card states the shape, lists what to ship first, counts conflicts, and links to the full view', async () => {
|
|
256
|
+
const h = await boot({
|
|
257
|
+
payload: { nodes: [t(1, { title: 'Base' }), t(2), t(3), t(4, { title: 'Other root' }), t(5)], edges: [e(2, 1), e(3, 2), e(5, 4)] },
|
|
258
|
+
conflictPairs: [{ a: 2, b: 5 }, { a: 3, b: 5 }],
|
|
259
|
+
});
|
|
260
|
+
await h.renderers.loadDependencyForest(7);
|
|
261
|
+
assert.equal(h.$('goal-deps-card-count').hidden, false);
|
|
262
|
+
assert.equal(h.$('goal-deps-card-count').textContent, '5');
|
|
263
|
+
assert.equal(h.$('goal-deps-card-lede').textContent, '5 tasks linked by dependencies, in 2 chains.');
|
|
264
|
+
const body = h.$('goal-deps-card-body');
|
|
265
|
+
const rows = body.querySelectorAll('.gf-brief__row');
|
|
266
|
+
assert.deepEqual(rows.map((r) => r.querySelector('a').getAttribute('href')), ['#/task/1', '#/task/4']);
|
|
267
|
+
assert.deepEqual(rows.map((r) => r.querySelector('.gf-brief__n').textContent), ['unblocks 2', 'unblocks 1']);
|
|
268
|
+
const line = body.querySelector('.gf-brief__conflicts');
|
|
269
|
+
assert.equal(line.textContent, '2 file conflicts between open tasks.');
|
|
270
|
+
assert.match(line.className, /gf-brief__conflicts--live/);
|
|
271
|
+
const foot = h.$('goal-deps-card-foot');
|
|
272
|
+
assert.equal(foot.hidden, false);
|
|
273
|
+
assert.equal(foot.querySelector('a').getAttribute('href'), '#/goal/7/dependencies');
|
|
274
|
+
assert.equal(h.$('goal-deps-back').getAttribute('href'), '#/goal/7');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await test('on the goal page only the card is drawn — the forest waits for its own view, where its titles can be measured', async () => {
|
|
278
|
+
const h = await boot({ view: 'detail', payload: { nodes: [t(1), t(2)], edges: [e(2, 1)] } });
|
|
279
|
+
await h.renderers.loadDependencyForest(7);
|
|
280
|
+
assert.equal(h.$('goal-deps-body').querySelector('svg'), null, 'no forest drawn into the hidden view');
|
|
281
|
+
assert.equal(h.$('goal-deps-body').innerHTML, '');
|
|
282
|
+
assert.equal(h.$('goal-deps-card-lede').textContent, '2 tasks linked by dependencies, in 1 chain.');
|
|
283
|
+
h.state.view = 'dependencies';
|
|
284
|
+
h.renderers.renderDependencyForest();
|
|
285
|
+
assert.ok(h.$('goal-deps-body').querySelector('svg'), 'opening the view draws it');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await test('the card: no conflicts says so, unknown conflicts says nothing, and an empty goal has no door to an empty page', async () => {
|
|
289
|
+
const none = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: [] });
|
|
290
|
+
await none.renderers.loadDependencyForest(7);
|
|
291
|
+
assert.match(none.$('goal-deps-card-lede').textContent, /No task in this goal depends on another/);
|
|
292
|
+
assert.equal(none.$('goal-deps-card-count').hidden, true);
|
|
293
|
+
assert.equal(none.$('goal-deps-card-body').querySelector('.gf-brief__conflicts').textContent, 'No file conflicts between open tasks.');
|
|
294
|
+
assert.equal(none.$('goal-deps-card-foot').hidden, true);
|
|
295
|
+
|
|
296
|
+
const unknown = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: null });
|
|
297
|
+
await unknown.renderers.loadDependencyForest(7);
|
|
298
|
+
assert.equal(unknown.$('goal-deps-card-body').querySelector('.gf-brief__conflicts'), null, 'a failed or pending conflicts read draws no line, not a zero');
|
|
299
|
+
|
|
300
|
+
const onlyConflicts = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: [{ a: 1, b: 2 }] });
|
|
301
|
+
await onlyConflicts.renderers.loadDependencyForest(7);
|
|
302
|
+
assert.equal(onlyConflicts.$('goal-deps-card-foot').hidden, false, 'conflicts alone still earn the full view');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await test('the card: while reading, and after a failed read, it says so — and a late conflicts list re-renders it', async () => {
|
|
306
|
+
const h = await boot({ fail: true });
|
|
307
|
+
const p = h.renderers.loadDependencyForest(7);
|
|
308
|
+
assert.equal(h.$('goal-deps-card-lede').textContent, 'Reading…');
|
|
309
|
+
await p;
|
|
310
|
+
assert.match(h.$('goal-deps-card-lede').textContent, /Couldn.t load this goal.s task dependencies/);
|
|
311
|
+
h.state.conflictPairs = [{ a: 1, b: 2 }];
|
|
312
|
+
h.card.renderDependencyCard();
|
|
313
|
+
assert.equal(h.$('goal-deps-card-body').querySelector('.gf-brief__conflicts').textContent, '1 file conflict between open tasks.');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await test('a "ship first" title is builder-authored text and never becomes markup in the card either', async () => {
|
|
317
|
+
const evil = '<img src=x onerror=alert(1)>';
|
|
318
|
+
const h = await boot({ payload: { nodes: [t(1, { title: evil }), t(2)], edges: [e(2, 1)] } });
|
|
319
|
+
await h.renderers.loadDependencyForest(7);
|
|
320
|
+
const body = h.$('goal-deps-card-body');
|
|
321
|
+
assert.equal(body.querySelector('img'), null);
|
|
322
|
+
assert.ok(body.textContent.includes(evil));
|
|
228
323
|
});
|
|
229
324
|
|
|
230
325
|
summary();
|
|
@@ -87,7 +87,7 @@ function fakeDb() {
|
|
|
87
87
|
async query(sql, params = []) {
|
|
88
88
|
sqlSeen.push(sql);
|
|
89
89
|
if (PRICE_WORD.test(sql)) throw new Error(`the assessment read price: ${sql.slice(0, 120)}`);
|
|
90
|
-
if (/^SELECT id, module_key, version FROM store_module_versions WHERE id/.test(sql)) {
|
|
90
|
+
if (/^SELECT id, module_key, version(, artifact_path)? FROM store_module_versions WHERE id/.test(sql)) {
|
|
91
91
|
const v = CASES.find((c) => String(c.id) === String(params[0]));
|
|
92
92
|
return { rows: v ? [{ id: v.id, module_key: v.module_key, version: v.version }] : [] };
|
|
93
93
|
}
|
|
@@ -162,7 +162,7 @@ test('assessVersion on publish runs the same steps for every case, and never rea
|
|
|
162
162
|
const recordDocs = async (id, { db: d }) => { steps.push(['docs', id]); return { ok: true, signal: d.add(id, 'docs', 'scored', 50) }; };
|
|
163
163
|
const out = [];
|
|
164
164
|
for (const c of CASES) {
|
|
165
|
-
const res = await assessVersion(c.id, { db, recordSecurity, recordDocs });
|
|
165
|
+
const res = await assessVersion(c.id, { db, dispatchTests: async () => null, recordSecurity, recordDocs });
|
|
166
166
|
assert.equal(res.ok, true, c.module_key);
|
|
167
167
|
out.push(shape(res.score));
|
|
168
168
|
}
|