@bongos/core 1.21.77 → 1.21.79
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 +103 -73
- package/.claude/skills/goal-create/SKILL.md +2 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +285 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +48 -38
- package/docs/copy-registry.json +152 -61
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -2
- package/docs/page-readings.json +215 -206
- package/migrations/core_273_module_install_reports.sql +66 -0
- 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/city.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-page.js +13 -1
- package/modules/hall-ui/public/goals-render.js +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-usage-reports.js +96 -0
- package/modules/hall-ui/public/settings.html +19 -1
- package/modules/hall-ui/public/settings.states.json +1 -0
- package/modules/hall-ui/public/shell.js +61 -29
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task-lib.js +21 -2
- 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/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/module-assess-installs.js +143 -0
- package/scripts/gds/module.js +29 -5
- package/scripts/hall-preview/live-identity.js +1 -0
- package/src/bongos/module-usage-reports.js +112 -0
- package/src/bongos/routes/modules.js +96 -0
- package/src/module-api.js +1 -1
- package/tests/goal_map_page.mjs +1 -1
- package/tests/goal_plan_with_claude.mjs +41 -0
- package/tests/hall_commands.mjs +1 -1
- package/tests/hall_nav.mjs +42 -0
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/module_assess_installs.mjs +272 -0
- package/tests/module_install.mjs +3 -0
- package/tests/module_update.mjs +3 -0
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/bongos/module-usage-reports.js — take in the counts real installs report
|
|
2
|
+
// about a store module version: did the install work, and how many runtime
|
|
3
|
+
// errors it threw on a day (task 1003793, ADR 0343 D1; tables core_273).
|
|
4
|
+
//
|
|
5
|
+
// WHY a separate intake. Install and Reliability are the parts of a module's
|
|
6
|
+
// score an author cannot fake, because they come from other people's installs.
|
|
7
|
+
// That makes them telemetry about someone else's setup, so the owner set the
|
|
8
|
+
// privacy posture up front (Will, 2026-10-01): COUNTS ONLY, ON BY DEFAULT, WITH
|
|
9
|
+
// AN OPT-OUT. This file is where that posture is enforced, at the server, so it
|
|
10
|
+
// holds whatever client sends the report:
|
|
11
|
+
// - a report is a yes/no plus a short error CODE, or a whole-number daily
|
|
12
|
+
// count — there is no field for error text, paths or user data;
|
|
13
|
+
// - a holder that has switched reporting off is dropped at intake, before
|
|
14
|
+
// anything is written.
|
|
15
|
+
// Scoring the reports into signal rows is module-assess-installs.js's job.
|
|
16
|
+
//
|
|
17
|
+
// Every function takes { db } (a pg Pool or anything with query()) so tests run
|
|
18
|
+
// against a fake client, the way module-entitlements.js does.
|
|
19
|
+
|
|
20
|
+
const ERROR_CODE_RE = /^[a-z][a-z0-9_]{0,39}$/; // same rule as core_273's CHECK
|
|
21
|
+
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
|
+
const MAX_DAILY_ERRORS = 1_000_000;
|
|
23
|
+
// How far back a daily count may be sent. Matches the window Reliability is
|
|
24
|
+
// scored over (module-assess-installs.js), so nothing is stored that never counts.
|
|
25
|
+
const ERROR_DAY_WINDOW = 30;
|
|
26
|
+
|
|
27
|
+
function poolOf(db) { return db || require('./pool').pool; }
|
|
28
|
+
|
|
29
|
+
// Does this holder have usage reporting switched off? No row = on (the default).
|
|
30
|
+
async function isOptedOut({ holderKind, holderRef }, { db } = {}) {
|
|
31
|
+
const { rows } = await poolOf(db).query(
|
|
32
|
+
'SELECT 1 FROM module_usage_report_optouts WHERE holder_kind = $1 AND holder_ref = $2',
|
|
33
|
+
[holderKind, holderRef]);
|
|
34
|
+
return rows.length > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Switch a holder's reporting on or off. Idempotent either way.
|
|
38
|
+
async function setReporting({ holderKind, holderRef, enabled }, { db } = {}) {
|
|
39
|
+
const pool = poolOf(db);
|
|
40
|
+
if (enabled) {
|
|
41
|
+
await pool.query('DELETE FROM module_usage_report_optouts WHERE holder_kind = $1 AND holder_ref = $2',
|
|
42
|
+
[holderKind, holderRef]);
|
|
43
|
+
} else {
|
|
44
|
+
await pool.query(
|
|
45
|
+
`INSERT INTO module_usage_report_optouts (holder_kind, holder_ref) VALUES ($1, $2)
|
|
46
|
+
ON CONFLICT (holder_kind, holder_ref) DO NOTHING`, [holderKind, holderRef]);
|
|
47
|
+
}
|
|
48
|
+
return { enabled: !!enabled };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check a report's shape. Returns null when fine, else { code, message } — the
|
|
52
|
+
// route turns that into a 400. Pure.
|
|
53
|
+
function installReportError({ ok, errorCode }) {
|
|
54
|
+
if (typeof ok !== 'boolean') return { code: 'bad_report', message: 'ok is true or false.' };
|
|
55
|
+
if (ok && errorCode != null) return { code: 'bad_report', message: 'A working install carries no error code.' };
|
|
56
|
+
if (!ok && !ERROR_CODE_RE.test(String(errorCode || ''))) {
|
|
57
|
+
return { code: 'bad_error_code', message: 'A failed install carries a short lowercase code (letters, digits, _), never a message.' };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check a daily error count. `today` is a YYYY-MM-DD string (UTC) so tests can pin it. Pure.
|
|
63
|
+
function errorCountError({ day, count }, { today = new Date().toISOString().slice(0, 10) } = {}) {
|
|
64
|
+
if (!Number.isInteger(count) || count < 0 || count > MAX_DAILY_ERRORS) {
|
|
65
|
+
return { code: 'bad_count', message: `count is a whole number from 0 to ${MAX_DAILY_ERRORS}.` };
|
|
66
|
+
}
|
|
67
|
+
if (!DAY_RE.test(String(day || '')) || Number.isNaN(Date.parse(`${day}T00:00:00Z`))) {
|
|
68
|
+
return { code: 'bad_day', message: 'day is a date, YYYY-MM-DD.' };
|
|
69
|
+
}
|
|
70
|
+
const ageDays = (Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) / 86_400_000;
|
|
71
|
+
if (ageDays < 0) return { code: 'bad_day', message: 'day cannot be in the future.' };
|
|
72
|
+
if (ageDays > ERROR_DAY_WINDOW) return { code: 'bad_day', message: `day must be within the last ${ERROR_DAY_WINDOW} days.` };
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Record a holder's latest install or update attempt of a version — it REPLACES
|
|
77
|
+
// their previous one, so retries never grow the table or outweigh other holders.
|
|
78
|
+
// Resolves { recorded: true } or { recorded: false, reason: 'opted_out' }.
|
|
79
|
+
// Validate with installReportError first.
|
|
80
|
+
async function recordInstallReport({ versionId, holderKind, holderRef, ok, errorCode = null }, { db } = {}) {
|
|
81
|
+
const pool = poolOf(db);
|
|
82
|
+
if (await isOptedOut({ holderKind, holderRef }, { db: pool })) return { recorded: false, reason: 'opted_out' };
|
|
83
|
+
await pool.query(
|
|
84
|
+
`INSERT INTO module_install_reports (version_id, holder_kind, holder_ref, ok, error_code)
|
|
85
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
86
|
+
ON CONFLICT (version_id, holder_kind, holder_ref)
|
|
87
|
+
DO UPDATE SET ok = EXCLUDED.ok, error_code = EXCLUDED.error_code, reported_at = now()`,
|
|
88
|
+
[versionId, holderKind, holderRef, ok, ok ? null : errorCode]);
|
|
89
|
+
return { recorded: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Record a version's runtime error count for one holder on one day. A second
|
|
93
|
+
// report for the same day REPLACES the count (the sender counts the whole day),
|
|
94
|
+
// so a retried send never doubles it. Validate with errorCountError first.
|
|
95
|
+
async function recordErrorCount({ versionId, holderKind, holderRef, day, count }, { db } = {}) {
|
|
96
|
+
const pool = poolOf(db);
|
|
97
|
+
if (await isOptedOut({ holderKind, holderRef }, { db: pool })) return { recorded: false, reason: 'opted_out' };
|
|
98
|
+
await pool.query(
|
|
99
|
+
`INSERT INTO module_runtime_error_counts (version_id, holder_kind, holder_ref, day, error_count)
|
|
100
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
101
|
+
ON CONFLICT (version_id, holder_kind, holder_ref, day)
|
|
102
|
+
DO UPDATE SET error_count = EXCLUDED.error_count, reported_at = now()`,
|
|
103
|
+
[versionId, holderKind, holderRef, day, count]);
|
|
104
|
+
return { recorded: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
ERROR_CODE_RE, ERROR_DAY_WINDOW,
|
|
109
|
+
isOptedOut, setReporting,
|
|
110
|
+
installReportError, errorCountError,
|
|
111
|
+
recordInstallReport, recordErrorCount,
|
|
112
|
+
};
|
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
// POST /store/modules/:key/delist
|
|
39
39
|
// Metic+ takes a module out of the store; holders
|
|
40
40
|
// keep it (task 1003797, ADR 0338 D2).
|
|
41
|
+
// POST /store/modules/:key/versions/:version/install-report
|
|
42
|
+
// POST /store/modules/:key/versions/:version/error-count
|
|
43
|
+
// GET|POST /store/usage-reporting
|
|
44
|
+
// counts real installs report — did an install work,
|
|
45
|
+
// runtime errors per day — scored into the Install and
|
|
46
|
+
// Reliability parts, and the caller's own switch to
|
|
47
|
+
// stop reporting (task 1003793, ADR 0343 D1).
|
|
41
48
|
//
|
|
42
49
|
// Read is open to any signed-in builder (same gate as the atlas/primer pages);
|
|
43
50
|
// every write (enable/disable, submit) is metic+archon, same rank as
|
|
@@ -90,6 +97,10 @@ const {
|
|
|
90
97
|
resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
91
98
|
delistModule, delistedKeySet,
|
|
92
99
|
} = require('../module-store');
|
|
100
|
+
const {
|
|
101
|
+
isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
|
|
102
|
+
} = require('../module-usage-reports');
|
|
103
|
+
const { recordInstallSignals } = require('../../../scripts/gds/module-assess-installs');
|
|
93
104
|
const { KEY_RE } = require('../../module-loader/manifest-schema');
|
|
94
105
|
const { parseVersion } = require('../../module-loader/semver');
|
|
95
106
|
const log = require('../logger').logger.child({ src: 'modules' });
|
|
@@ -726,6 +737,91 @@ module.exports = function buildModulesRouter() {
|
|
|
726
737
|
res.json({ ok: true, entitlement: out.entitlement });
|
|
727
738
|
}));
|
|
728
739
|
|
|
740
|
+
// ---- usage reports: the Install and Reliability parts (task 1003793, ADR 0343 D1) ----
|
|
741
|
+
//
|
|
742
|
+
// Real installs report counts back so a module is scored on what happens when
|
|
743
|
+
// other people run it. Owner's privacy posture (2026-10-01): counts only, on by
|
|
744
|
+
// default, with an opt-out — enforced in src/bongos/module-usage-reports.js, which
|
|
745
|
+
// drops a report from a holder who switched reporting off before anything is
|
|
746
|
+
// written. Own-scoped like /acquired: the holder is always the caller, never
|
|
747
|
+
// named in the request, so requireBuilder is the gate, and only a LIVE holder of
|
|
748
|
+
// the module may report on it. Each accepted report re-scores the two parts.
|
|
749
|
+
async function reportTarget(req, res) {
|
|
750
|
+
const { key, version } = req.params;
|
|
751
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
752
|
+
res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const r = await resolveReadable(key, version);
|
|
756
|
+
if (!r.ok) { res.fail(r.code, { status: r.status, message: r.message }); return null; }
|
|
757
|
+
const holder = { holderKind: 'builder', holderRef: String(req.builder.id) };
|
|
758
|
+
const held = await checkEntitlement({ moduleKey: key, ...holder });
|
|
759
|
+
if (!held.entitled) {
|
|
760
|
+
res.fail('not_entitled', { status: 403, message: `You do not hold "${key}", so you cannot report on it.` });
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
return { versionId: r.version.id, ...holder };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Re-score after an accepted report, once the response is sent so the reporter
|
|
767
|
+
// never waits on it. A scoring failure never loses the report; the next report
|
|
768
|
+
// re-scores from the same stored rows.
|
|
769
|
+
function rescore(versionId) {
|
|
770
|
+
setImmediate(() => {
|
|
771
|
+
recordInstallSignals(versionId).catch((e) => {
|
|
772
|
+
log.warn({ err: e.message, versionId }, 'install/reliability re-score failed; the report is kept');
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
router.post('/store/modules/:key/versions/:version/install-report', auth.requireBuilder,
|
|
778
|
+
asyncHandler('POST /store/modules/:key/versions/:version/install-report', async (req, res) => {
|
|
779
|
+
if (validateOrRespond(req, res, {
|
|
780
|
+
ok: { type: 'boolean', required: true },
|
|
781
|
+
error_code: { type: 'string', maxLength: 40 },
|
|
782
|
+
})) return;
|
|
783
|
+
const report = { ok: req.body.ok, errorCode: req.body.error_code == null ? null : req.body.error_code };
|
|
784
|
+
const bad = installReportError(report);
|
|
785
|
+
if (bad) return res.fail(bad.code, { status: 400, message: bad.message });
|
|
786
|
+
const t = await reportTarget(req, res);
|
|
787
|
+
if (!t) return;
|
|
788
|
+
const out = await recordInstallReport({ ...t, ...report });
|
|
789
|
+
res.json({ ok: true, recorded: out.recorded, ...(out.reason ? { reason: out.reason } : {}) });
|
|
790
|
+
if (out.recorded) rescore(t.versionId);
|
|
791
|
+
}));
|
|
792
|
+
|
|
793
|
+
router.post('/store/modules/:key/versions/:version/error-count', auth.requireBuilder,
|
|
794
|
+
asyncHandler('POST /store/modules/:key/versions/:version/error-count', async (req, res) => {
|
|
795
|
+
if (validateOrRespond(req, res, {
|
|
796
|
+
day: { type: 'string', required: true, maxLength: 10 },
|
|
797
|
+
count: { type: 'integer', required: true },
|
|
798
|
+
})) return;
|
|
799
|
+
const report = { day: req.body.day, count: req.body.count };
|
|
800
|
+
const bad = errorCountError(report);
|
|
801
|
+
if (bad) return res.fail(bad.code, { status: 400, message: bad.message });
|
|
802
|
+
const t = await reportTarget(req, res);
|
|
803
|
+
if (!t) return;
|
|
804
|
+
const out = await recordErrorCount({ ...t, ...report });
|
|
805
|
+
res.json({ ok: true, recorded: out.recorded, ...(out.reason ? { reason: out.reason } : {}) });
|
|
806
|
+
if (out.recorded) rescore(t.versionId);
|
|
807
|
+
}));
|
|
808
|
+
|
|
809
|
+
// The caller's own reporting switch (Settings → Profile and privacy). On unless
|
|
810
|
+
// they turned it off.
|
|
811
|
+
router.get('/store/usage-reporting', auth.requireBuilder,
|
|
812
|
+
asyncHandler('GET /store/usage-reporting', async (req, res) => {
|
|
813
|
+
const off = await isOptedOut({ holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
814
|
+
res.set('Cache-Control', 'no-store');
|
|
815
|
+
res.json({ enabled: !off });
|
|
816
|
+
}));
|
|
817
|
+
|
|
818
|
+
router.post('/store/usage-reporting', auth.requireBuilder,
|
|
819
|
+
asyncHandler('POST /store/usage-reporting', async (req, res) => {
|
|
820
|
+
if (validateOrRespond(req, res, { enabled: { type: 'boolean', required: true } })) return;
|
|
821
|
+
const out = await setReporting({ holderKind: 'builder', holderRef: String(req.builder.id), enabled: req.body.enabled });
|
|
822
|
+
res.json({ ok: true, enabled: out.enabled });
|
|
823
|
+
}));
|
|
824
|
+
|
|
729
825
|
return router;
|
|
730
826
|
};
|
|
731
827
|
|
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.79'; // 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');
|
package/tests/goal_map_page.mjs
CHANGED
|
@@ -413,7 +413,7 @@ await test('a goal title never becomes markup — in the ledger, the panel or th
|
|
|
413
413
|
|
|
414
414
|
await test('Sky sits directly under Goals in the nav, member-gated, at the URL Goals links to', () => {
|
|
415
415
|
const shell = read('shell.js');
|
|
416
|
-
const item = shell.indexOf("{ id: 'goal-map', label: 'Sky', icon: '
|
|
416
|
+
const item = shell.indexOf("{ id: 'goal-map', label: 'Sky', icon: 'sky', href: '/goal-map', gate: 'member' }");
|
|
417
417
|
assert.ok(item > -1, 'the nav item, with the slot the map was denied (ADR 0227 §11)');
|
|
418
418
|
const goals = shell.indexOf("{ id: 'goals', label: 'Goals'"); const work = shell.indexOf("{ id: 'work', label: 'Tasks'");
|
|
419
419
|
assert.ok(goals > -1 && goals < item && item < work, 'under Goals, above Tasks');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// tests/goal_plan_with_claude.mjs — the goal page's "Plan with Claude" hand-off (task 1004587, ADR 0366 D4).
|
|
2
|
+
//
|
|
3
|
+
// Run: node --test tests/goal_plan_with_claude.mjs
|
|
4
|
+
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
globalThis.location = { hostname: 'builders.example.com', origin: 'https://builders.example.com' };
|
|
12
|
+
const { buildPlanPromptForGoal, buildPromptForTask } = require('../modules/hall-ui/public/task-lib.js');
|
|
13
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
14
|
+
|
|
15
|
+
const goal = { id: 42, title: 'Ship the thing' };
|
|
16
|
+
const spec = { id: 900, title: 'Spec goal 42: its scope', description: 'Do the interview.', source_ref: 'goal-42-spec' };
|
|
17
|
+
const other = { id: 901, title: 'Other', description: 'x', source_ref: null };
|
|
18
|
+
|
|
19
|
+
test('with an unshipped spec task the prompt is that task\'s', () => {
|
|
20
|
+
assert.equal(buildPlanPromptForGoal(goal, [other, spec]), buildPromptForTask(spec));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('another goal\'s spec task is not this goal\'s', () => {
|
|
24
|
+
const p = buildPlanPromptForGoal(goal, [{ ...spec, source_ref: 'goal-43-spec' }]);
|
|
25
|
+
assert.match(p, /\/goal-create/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('without a spec task the prompt asks for /goal-create against the goal', () => {
|
|
29
|
+
const p = buildPlanPromptForGoal(goal, [other]);
|
|
30
|
+
assert.match(p, /\/goal-create/);
|
|
31
|
+
assert.match(p, /goal 42/);
|
|
32
|
+
assert.match(p, /#\/goal\/42/);
|
|
33
|
+
assert.equal(buildPlanPromptForGoal(goal, undefined), p);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the button is on the goal page and copies the goal prompt', () => {
|
|
37
|
+
const r = src('modules/hall-ui/public/goals-render.js');
|
|
38
|
+
const pg = src('modules/hall-ui/public/goals-page.js');
|
|
39
|
+
assert.match(r, /id="goal-plan-btn"/);
|
|
40
|
+
assert.ok(pg.includes('buildPlanPromptForGoal(g, state.work.todo)'));
|
|
41
|
+
});
|
package/tests/hall_commands.mjs
CHANGED
|
@@ -156,7 +156,7 @@ test('the page is wired: nav entry in Docs, sign-in gated, both reads, no hand-t
|
|
|
156
156
|
assert.match(js, /\/api\/bongos\/cli\/commands/);
|
|
157
157
|
assert.ok(!/data-skill=|data-verb=|\/builder-ship|bongos claim/.test(page), 'the HTML names no command — the rows are generated');
|
|
158
158
|
const shell = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
159
|
-
assert.match(shell, /id: 'commands', label: 'Commands', icon: '
|
|
159
|
+
assert.match(shell, /id: 'commands', label: 'Commands', icon: 'commands', href: '\/commands', gate: 'signed'/);
|
|
160
160
|
const serveInternal = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
|
|
161
161
|
assert.match(serveInternal.match(/const SIGNEDIN_BUILDERS_PAGE_RE =\s*(\/.*\/);/)[1], /\|commands\)/);
|
|
162
162
|
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -683,3 +683,45 @@ test('the badge is styled for BOTH shapes of the rail', () => {
|
|
|
683
683
|
const drawer = css.slice(css.indexOf('html.drawer-open .app-sidebar'));
|
|
684
684
|
assert.match(drawer, /\.nav-item__badge \{ top: 50%/, 'the mobile drawer position');
|
|
685
685
|
});
|
|
686
|
+
|
|
687
|
+
// task 1004626: ONE GLYPH PER TAB. The rail is an icon strip until it opens,
|
|
688
|
+
// so at rest the icon is the tab's only name. Nine Government tabs once wore
|
|
689
|
+
// one shield and every Docs tab one page — the owner's word was "very
|
|
690
|
+
// confusing". Read off the RENDERED markup, so a fallback or a render bug that
|
|
691
|
+
// paints two tabs alike reds here even if the NAV keys differ.
|
|
692
|
+
test('every sidebar tab draws its own icon — no two tabs share a glyph', () => {
|
|
693
|
+
const html = boot().sidebarHtml;
|
|
694
|
+
const items = [...html.matchAll(/<a class="nav-item" id="nav-([^"]+)"[^>]*><svg class="nav-icon"[^>]*>([\s\S]*?)<\/svg>/g)]
|
|
695
|
+
.map((m) => ({ id: m[1], glyph: m[2] }));
|
|
696
|
+
assert.ok(items.length >= 20, `the nav items were actually located (${items.length})`);
|
|
697
|
+
const seen = new Map();
|
|
698
|
+
for (const { id, glyph } of items) {
|
|
699
|
+
assert.ok(glyph.trim(), `nav item ${id} draws a glyph (an unknown icon key renders an empty frame)`);
|
|
700
|
+
assert.ok(!seen.has(glyph), `nav items ${seen.get(glyph)} and ${id} draw the same icon`);
|
|
701
|
+
seen.set(glyph, id);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('every NAV icon key is declared in ICONS, used once, and no two ICONS draw alike', () => {
|
|
706
|
+
const nav = SRC.slice(SRC.indexOf('var NAV = ['), SRC.indexOf('var NAV_COLLAPSE_KEY'));
|
|
707
|
+
const keys = [...nav.matchAll(/icon: '([^']+)'/g)].map((m) => m[1]);
|
|
708
|
+
assert.ok(keys.length >= 20, 'the NAV icon keys were actually located');
|
|
709
|
+
const block = SRC.slice(SRC.indexOf('var ICONS = {'), SRC.indexOf('function icon('));
|
|
710
|
+
const icons = new Map([...block.matchAll(/^\s+([a-z]+): '(.*)',$/gm)].map((m) => [m[1], m[2]]));
|
|
711
|
+
assert.ok(icons.size >= keys.length, 'the ICONS entries were actually located');
|
|
712
|
+
for (const k of keys) assert.ok(icons.has(k), `NAV icon '${k}' exists in ICONS`);
|
|
713
|
+
assert.equal(new Set(keys).size, keys.length, `no icon key is used by two tabs: ${keys.filter((k, i) => keys.indexOf(k) !== i).join(', ')}`);
|
|
714
|
+
const markup = [...icons.values()];
|
|
715
|
+
assert.equal(new Set(markup).size, markup.length, 'no two ICONS entries draw the same markup');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// task 1004639: glyph path data is shipped verbatim, and the release packager's
|
|
719
|
+
// leak scan reads four numbers chained on dots as a leaked address. The first
|
|
720
|
+
// thinking glyph chained four that way and refused the whole core release, so a new
|
|
721
|
+
// glyph separates its numbers with spaces wherever four would otherwise chain.
|
|
722
|
+
test('no glyph path data reads as an IP address to the release leak scan', () => {
|
|
723
|
+
const block = SRC.slice(SRC.indexOf('var ICONS = {'), SRC.indexOf('function icon('));
|
|
724
|
+
assert.ok(block.includes('thinking:'), 'the ICONS block was actually located');
|
|
725
|
+
const hits = block.match(/\d+\.\d+\.\d+\.\d+/g) || [];
|
|
726
|
+
assert.deepEqual(hits, [], 'separate the numbers with spaces so no four chain on dots');
|
|
727
|
+
});
|
|
@@ -635,7 +635,7 @@ try {
|
|
|
635
635
|
assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
|
|
636
636
|
assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
|
|
637
637
|
assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
|
|
638
|
-
assert.match(HTML, /shell\.js\?v=2026-10-08-
|
|
638
|
+
assert.match(HTML, /shell\.js\?v=2026-10-08-glyph-fix/);
|
|
639
639
|
assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
|
|
640
640
|
for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
|
|
641
641
|
assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// tests/module_assess_installs.mjs — the Install and Reliability parts of a module's
|
|
2
|
+
// assessment, from what real installs report (task 1003793, ADR 0343 D1; core_273).
|
|
3
|
+
//
|
|
4
|
+
// No database, no network: the scorers are pure, the intake and the re-score run
|
|
5
|
+
// against a regex-dispatching fake pool that throws on any SQL it does not expect
|
|
6
|
+
// (ADR 0216 fail-loud), and the CLI runs against a fake store built from a REAL
|
|
7
|
+
// packed tarball. The owner's privacy posture — counts only, on by default, with an
|
|
8
|
+
// opt-out — is held from three sides: the intake's validators, the migration's
|
|
9
|
+
// columns and CHECKs, and the opt-out dropping a report before anything is written.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/module_assess_installs.mjs
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const { MIN_HOLDERS, assessInstall, assessReliability, recordInstallSignals } = require('../scripts/gds/module-assess-installs.js');
|
|
24
|
+
const reports = require('../src/bongos/module-usage-reports.js');
|
|
25
|
+
const { ERROR_CODE_RE } = require('../src/bongos/module-usage-reports.js');
|
|
26
|
+
const { cmdInstall } = require('../scripts/gds/module.js');
|
|
27
|
+
const { packModule, verifyModuleArtifact } = require('../scripts/gds/module-artifact.js');
|
|
28
|
+
|
|
29
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
|
|
30
|
+
|
|
31
|
+
// ---- the scorers (pure) ----
|
|
32
|
+
|
|
33
|
+
test('Install: no reports is no_data, never a zero', () => {
|
|
34
|
+
const s = assessInstall([]);
|
|
35
|
+
assert.equal(s.outcome, 'no_data');
|
|
36
|
+
assert.equal(s.score, null);
|
|
37
|
+
assert.equal(s.sample_size, 0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test(`Install: fewer than ${MIN_HOLDERS} holders is pending, so one friend cannot set the number`, () => {
|
|
41
|
+
const s = assessInstall([{ ok: true }, { ok: true }]);
|
|
42
|
+
assert.equal(s.outcome, 'pending');
|
|
43
|
+
assert.equal(s.score, null);
|
|
44
|
+
assert.equal(s.sample_size, 2);
|
|
45
|
+
assert.match(s.reason, /2 of the 3 installs needed/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('Install: the share of holders whose latest install worked, with the failure codes counted', () => {
|
|
49
|
+
const s = assessInstall([
|
|
50
|
+
{ ok: true }, { ok: true }, { ok: true },
|
|
51
|
+
{ ok: false, error_code: 'verify_failed' }, { ok: false, error_code: 'place_failed' }, { ok: false, error_code: 'place_failed' },
|
|
52
|
+
]);
|
|
53
|
+
assert.equal(s.outcome, 'scored');
|
|
54
|
+
assert.equal(s.score, 50);
|
|
55
|
+
assert.equal(s.sample_size, 6);
|
|
56
|
+
assert.equal(s.reason, '3 of 6 installs worked');
|
|
57
|
+
assert.deepEqual(s.detail.failed_codes, { verify_failed: 1, place_failed: 2 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('Reliability: the share of holder-days with zero errors; sample_size counts holders, not days', () => {
|
|
61
|
+
const days = [
|
|
62
|
+
{ holder_kind: 'builder', holder_ref: '1', error_count: 0 },
|
|
63
|
+
{ holder_kind: 'builder', holder_ref: '1', error_count: 4 },
|
|
64
|
+
{ holder_kind: 'builder', holder_ref: '2', error_count: 0 },
|
|
65
|
+
{ holder_kind: 'builder', holder_ref: '3', error_count: 0 },
|
|
66
|
+
];
|
|
67
|
+
const s = assessReliability(days);
|
|
68
|
+
assert.equal(s.outcome, 'scored');
|
|
69
|
+
assert.equal(s.score, 75);
|
|
70
|
+
assert.equal(s.sample_size, 3);
|
|
71
|
+
assert.equal(s.detail.errors, 4);
|
|
72
|
+
assert.equal(assessReliability([]).outcome, 'no_data');
|
|
73
|
+
assert.equal(assessReliability(days.slice(0, 3)).outcome, 'pending', 'two holders is not enough');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- the intake: counts only ----
|
|
77
|
+
|
|
78
|
+
test('an install report is a yes/no plus a short code — never a message', () => {
|
|
79
|
+
assert.equal(reports.installReportError({ ok: true, errorCode: null }), null);
|
|
80
|
+
assert.equal(reports.installReportError({ ok: false, errorCode: 'verify_failed' }), null);
|
|
81
|
+
assert.equal(reports.installReportError({ ok: 'yes' }).code, 'bad_report');
|
|
82
|
+
assert.equal(reports.installReportError({ ok: true, errorCode: 'x' }).code, 'bad_report', 'a working install carries no code');
|
|
83
|
+
assert.equal(reports.installReportError({ ok: false, errorCode: null }).code, 'bad_error_code');
|
|
84
|
+
for (const smuggled of ['Error: ENOENT /home/me/x', 'Verify Failed', 'a'.repeat(41), 'path/to', 'x y']) {
|
|
85
|
+
assert.equal(reports.installReportError({ ok: false, errorCode: smuggled }).code, 'bad_error_code', smuggled);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a daily error count is a whole number for a recent, non-future day', () => {
|
|
90
|
+
const today = '2026-10-08';
|
|
91
|
+
assert.equal(reports.errorCountError({ day: '2026-10-08', count: 0 }, { today }), null);
|
|
92
|
+
assert.equal(reports.errorCountError({ day: '2026-09-08', count: 3 }, { today }), null, '30 days back is the edge');
|
|
93
|
+
assert.equal(reports.errorCountError({ day: '2026-09-07', count: 3 }, { today }).code, 'bad_day');
|
|
94
|
+
assert.equal(reports.errorCountError({ day: '2026-10-09', count: 3 }, { today }).code, 'bad_day');
|
|
95
|
+
assert.equal(reports.errorCountError({ day: 'yesterday', count: 3 }, { today }).code, 'bad_day');
|
|
96
|
+
assert.equal(reports.errorCountError({ day: '2026-10-08', count: 1.5 }, { today }).code, 'bad_count');
|
|
97
|
+
assert.equal(reports.errorCountError({ day: '2026-10-08', count: -1 }, { today }).code, 'bad_count');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// A fake pool that answers only the SQL it is told to, and records every call.
|
|
101
|
+
function fakeDb(handlers) {
|
|
102
|
+
const calls = [];
|
|
103
|
+
return {
|
|
104
|
+
calls,
|
|
105
|
+
async query(sql, params) {
|
|
106
|
+
calls.push({ sql, params });
|
|
107
|
+
for (const [re, fn] of handlers) if (re.test(sql)) return fn(params);
|
|
108
|
+
throw new Error(`unexpected SQL: ${sql.slice(0, 80)}`);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test('opted out: the report is dropped before anything is written', async () => {
|
|
114
|
+
const db = fakeDb([[/FROM module_usage_report_optouts/, () => ({ rows: [{ '?column?': 1 }] })]]);
|
|
115
|
+
const holder = { holderKind: 'builder', holderRef: '7' };
|
|
116
|
+
assert.deepEqual(await reports.recordInstallReport({ versionId: 1, ...holder, ok: true }, { db }), { recorded: false, reason: 'opted_out' });
|
|
117
|
+
assert.deepEqual(await reports.recordErrorCount({ versionId: 1, ...holder, day: '2026-10-08', count: 2 }, { db }), { recorded: false, reason: 'opted_out' });
|
|
118
|
+
assert.ok(!db.calls.some((c) => /INSERT/.test(c.sql)), 'no INSERT ran');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('on by default: no opt-out row means the report is recorded; a same-day count replaces, never adds', async () => {
|
|
122
|
+
const db = fakeDb([
|
|
123
|
+
[/FROM module_usage_report_optouts/, () => ({ rows: [] })],
|
|
124
|
+
[/INSERT INTO module_install_reports/, () => ({ rows: [] })],
|
|
125
|
+
[/INSERT INTO module_runtime_error_counts/, () => ({ rows: [] })],
|
|
126
|
+
]);
|
|
127
|
+
const holder = { holderKind: 'builder', holderRef: '7' };
|
|
128
|
+
assert.deepEqual(await reports.recordInstallReport({ versionId: 1, ...holder, ok: false, errorCode: 'verify_failed' }, { db }), { recorded: true });
|
|
129
|
+
const install = db.calls.find((c) => /module_install_reports/.test(c.sql)).sql;
|
|
130
|
+
assert.match(install, /ON CONFLICT \(version_id, holder_kind, holder_ref\)\s*DO UPDATE SET ok = EXCLUDED\.ok/,
|
|
131
|
+
"a retry replaces the holder's row, never adds one");
|
|
132
|
+
await reports.recordErrorCount({ versionId: 1, ...holder, day: '2026-10-08', count: 2 }, { db });
|
|
133
|
+
const upsert = db.calls.find((c) => /module_runtime_error_counts/.test(c.sql)).sql;
|
|
134
|
+
assert.match(upsert, /DO UPDATE SET error_count = EXCLUDED\.error_count/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('the switch: off writes an opt-out row, on deletes it', async () => {
|
|
138
|
+
const db = fakeDb([
|
|
139
|
+
[/INSERT INTO module_usage_report_optouts/, () => ({ rows: [] })],
|
|
140
|
+
[/DELETE FROM module_usage_report_optouts/, () => ({ rows: [] })],
|
|
141
|
+
]);
|
|
142
|
+
assert.deepEqual(await reports.setReporting({ holderKind: 'builder', holderRef: '7', enabled: false }, { db }), { enabled: false });
|
|
143
|
+
assert.deepEqual(await reports.setReporting({ holderKind: 'builder', holderRef: '7', enabled: true }, { db }), { enabled: true });
|
|
144
|
+
assert.deepEqual(db.calls.map((c) => c.sql.trim().split(/\s+/)[0]), ['INSERT', 'DELETE']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---- the re-score ----
|
|
148
|
+
|
|
149
|
+
function scoringDb({ latest, days, current = [] }) {
|
|
150
|
+
return fakeDb([
|
|
151
|
+
[/FROM module_install_reports r/, () => ({ rows: latest })],
|
|
152
|
+
[/FROM module_runtime_error_counts r/, () => ({ rows: days })],
|
|
153
|
+
[/part IN \('install', 'reliability'\)/, () => ({ rows: current })],
|
|
154
|
+
[/INSERT INTO module_assessment_signals/, () => ({ rows: [] })],
|
|
155
|
+
[/SELECT DISTINCT ON \(part\) id, part, outcome, score, sample_size/, () => ({ rows: [] })],
|
|
156
|
+
[/FROM module_assessment_scores/, () => ({ rows: [] })],
|
|
157
|
+
[/INSERT INTO module_assessment_scores/, (p) => ({ rows: [{ id: 1, version_id: p[0], formula: p[4] }] })],
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
test('re-score: the module author is never counted, and each holder is one row (their latest attempt)', async () => {
|
|
162
|
+
const db = scoringDb({ latest: [], days: [] });
|
|
163
|
+
await recordInstallSignals(5, { db });
|
|
164
|
+
for (const table of ['module_install_reports', 'module_runtime_error_counts']) {
|
|
165
|
+
const sql = db.calls.find((c) => c.sql.includes(`FROM ${table} r`)).sql;
|
|
166
|
+
assert.match(sql, /NOT \(r\.holder_kind = 'builder' AND r\.holder_ref = m\.author_id::text\)/, `${table} excludes the author`);
|
|
167
|
+
}
|
|
168
|
+
const install = db.calls.find((c) => c.sql.includes('FROM module_install_reports r')).sql;
|
|
169
|
+
assert.doesNotMatch(install, /DISTINCT ON|ORDER BY/, 'one row per holder already, so no history scan');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('re-score: writes a signal row only for a part whose result moved, then re-composes', async () => {
|
|
173
|
+
const latest = [{ ok: true }, { ok: true }, { ok: false, error_code: 'place_failed' }];
|
|
174
|
+
const db = scoringDb({
|
|
175
|
+
latest, days: [],
|
|
176
|
+
current: [{ part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 }],
|
|
177
|
+
});
|
|
178
|
+
const res = await recordInstallSignals(5, { db });
|
|
179
|
+
assert.deepEqual(res.written, ['install'], 'reliability is unchanged, so no new row');
|
|
180
|
+
const insert = db.calls.find((c) => /INSERT INTO module_assessment_signals/.test(c.sql));
|
|
181
|
+
assert.deepEqual(insert.params.slice(0, 5), [5, 'install', 'scored', 67, 3]);
|
|
182
|
+
assert.ok(db.calls.some((c) => /INSERT INTO module_assessment_scores/.test(c.sql)), 'the overall score re-composed');
|
|
183
|
+
|
|
184
|
+
const again = scoringDb({ latest, days: [], current: [
|
|
185
|
+
{ part: 'install', outcome: 'scored', score: 67, sample_size: 3 },
|
|
186
|
+
{ part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 },
|
|
187
|
+
] });
|
|
188
|
+
const res2 = await recordInstallSignals(5, { db: again });
|
|
189
|
+
assert.deepEqual(res2.written, []);
|
|
190
|
+
assert.ok(!again.calls.some((c) => /INSERT/.test(c.sql)), 'nothing moved, nothing written');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ---- the migration: there is nowhere to put anything but counts ----
|
|
194
|
+
|
|
195
|
+
test('core_273: three additive tables whose columns hold counts and codes only', () => {
|
|
196
|
+
const sql = read('migrations/core_273_module_install_reports.sql');
|
|
197
|
+
const DDL = sql.replace(/--.*$/gm, '');
|
|
198
|
+
for (const t of ['module_install_reports', 'module_runtime_error_counts', 'module_usage_report_optouts']) {
|
|
199
|
+
assert.match(DDL, new RegExp(`CREATE TABLE IF NOT EXISTS ${t} \\(`), t);
|
|
200
|
+
}
|
|
201
|
+
assert.match(DDL, /error_code\s+text CHECK \(error_code ~ '\^\[a-z\]\[a-z0-9_\]\{0,39\}\$'\)/, 'the code is held to the same rule as the intake');
|
|
202
|
+
assert.equal(String(ERROR_CODE_RE), '/^[a-z][a-z0-9_]{0,39}$/', 'intake and CHECK agree');
|
|
203
|
+
assert.match(DDL, /CHECK \(\(ok AND error_code IS NULL\) OR \(NOT ok AND error_code IS NOT NULL\)\)/);
|
|
204
|
+
assert.match(DDL, /PRIMARY KEY \(version_id, holder_kind, holder_ref, day\)/, 'one count per holder per day');
|
|
205
|
+
assert.match(DDL, /PRIMARY KEY \(version_id, holder_kind, holder_ref\)\n\);/, 'one install row per holder per version: grows with holders, never with retries');
|
|
206
|
+
const textCols = [...DDL.matchAll(/^\s+(\w+)\s+text\b/gm)].map((m) => m[1]).sort();
|
|
207
|
+
assert.deepEqual([...new Set(textCols)], ['error_code', 'holder_kind', 'holder_ref'], 'no free-text column for a message or a path');
|
|
208
|
+
assert.doesNotMatch(DDL, /\b(DROP|ALTER)\b|^\s*(UPDATE|DELETE)\b/m, 'additive only');
|
|
209
|
+
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_273_module_install_reports'\)/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---- the routes and the settings switch, pinned in their source ----
|
|
213
|
+
|
|
214
|
+
test('the report routes are own-scoped, need a live holder, and re-score after an accepted report', () => {
|
|
215
|
+
const src = read('src/bongos/routes/modules.js');
|
|
216
|
+
for (const p of ['/store/modules/:key/versions/:version/install-report', '/store/modules/:key/versions/:version/error-count', '/store/usage-reporting']) {
|
|
217
|
+
assert.match(src, new RegExp(`router\\.(post|get)\\('${p.replace(/[/:]/g, (c) => `\\${c}`)}', auth\\.requireBuilder`), p);
|
|
218
|
+
}
|
|
219
|
+
assert.match(src, /holderKind: 'builder', holderRef: String\(req\.builder\.id\)/);
|
|
220
|
+
assert.match(src, /if \(!held\.entitled\) \{\s*res\.fail\('not_entitled'/);
|
|
221
|
+
assert.match(src, /res\.json\(\{ ok: true, recorded: out\.recorded[^\n]*\n\s*if \(out\.recorded\) rescore\(t\.versionId\);/, 're-scores after answering');
|
|
222
|
+
assert.match(src, /function rescore\(versionId\) \{\s*setImmediate\(/, 'the re-score is off the request path');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('Settings carries the opt-out, on by default, saved through the store route', () => {
|
|
226
|
+
const html = read('modules/hall-ui/public/settings.html');
|
|
227
|
+
const js = read('modules/hall-ui/public/settings-usage-reports.js');
|
|
228
|
+
assert.match(html, /<section class="scroll" id="usage-reports-scroll"[^>]*data-group="preferences" hidden>/);
|
|
229
|
+
assert.match(html, /<script src="\/builders\/settings-usage-reports\.js\?v=[^"]+"><\/script>/);
|
|
230
|
+
assert.match(js, /api\.request\('GET', `\$\{API\}\/store\/usage-reporting`\)/);
|
|
231
|
+
assert.match(js, /api\.request\('POST', `\$\{API\}\/store\/usage-reporting`, \{ body: \{ enabled: value === 'on' \} \}\)/);
|
|
232
|
+
assert.match(js, /render\(res\.data\.enabled !== false\)/, 'anything but an explicit false reads as on');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ---- the CLI: a failed install is reported with a code, never a message ----
|
|
236
|
+
|
|
237
|
+
test('install: a tarball that fails verification is reported as verify_failed', async () => {
|
|
238
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-installs-'));
|
|
239
|
+
const manifest = { key: 'weather', title: 'Weather', description: 'Shows the weather.', version: '1.2.0', coreVersion: '^1.0.0', contributes: {} };
|
|
240
|
+
fs.mkdirSync(path.join(src, 'weather'), { recursive: true });
|
|
241
|
+
fs.writeFileSync(path.join(src, 'weather', 'module.json'), JSON.stringify(manifest));
|
|
242
|
+
const { tgz } = packModule('weather', { modulesDir: src, now: () => new Date('2026-10-01T00:00:00Z'), modeOf: () => '644' });
|
|
243
|
+
const v = await verifyModuleArtifact(tgz, { key: 'weather' });
|
|
244
|
+
const calls = [];
|
|
245
|
+
const request = async (method, urlPath, opts = {}) => {
|
|
246
|
+
calls.push({ method, urlPath, json: opts.json });
|
|
247
|
+
if (urlPath.endsWith('/acquire')) {
|
|
248
|
+
return { ok: true, status: 200, data: { version: v.version, core_version: v.coreVersion, tarball_sha256: v.tarballSha256, tree_sha256: v.treeSha256 } };
|
|
249
|
+
}
|
|
250
|
+
if (urlPath.endsWith('/tarball')) return { ok: true, status: 200, buf: Buffer.from('not a tarball') };
|
|
251
|
+
if (urlPath.endsWith('/install-report')) return { ok: true, status: 200, data: { ok: true, recorded: true } };
|
|
252
|
+
throw new Error(`unexpected ${method} ${urlPath}`);
|
|
253
|
+
};
|
|
254
|
+
const out = [];
|
|
255
|
+
const code = await cmdInstall(['weather'], { log: (m) => out.push(m), errlog: (m) => out.push(m), modulesDir: fs.mkdtempSync(path.join(os.tmpdir(), 'mod-dest-')), roots: [], request, core: '1.19.0' });
|
|
256
|
+
assert.equal(code, 1);
|
|
257
|
+
const report = calls.find((c) => c.urlPath.endsWith('/install-report'));
|
|
258
|
+
assert.equal(report.urlPath, '/store/modules/weather/versions/1.2.0/install-report');
|
|
259
|
+
assert.deepEqual(report.json, { ok: false, error_code: 'verify_failed' }, 'a code, never the message');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('install: a store that refuses or cannot be reached is not the module\'s fault, so nothing is reported', async () => {
|
|
263
|
+
const calls = [];
|
|
264
|
+
const request = async (method, urlPath) => {
|
|
265
|
+
calls.push(urlPath);
|
|
266
|
+
if (urlPath.endsWith('/acquire')) return { ok: false, status: 402, data: { error: { code: 'payment_required' } } };
|
|
267
|
+
throw new Error(`unexpected ${urlPath}`);
|
|
268
|
+
};
|
|
269
|
+
const code = await cmdInstall(['weather'], { log() {}, errlog() {}, modulesDir: fs.mkdtempSync(path.join(os.tmpdir(), 'mod-dest-')), roots: [], request });
|
|
270
|
+
assert.equal(code, 1);
|
|
271
|
+
assert.deepEqual(calls, ['/store/modules/weather/acquire']);
|
|
272
|
+
});
|