@bongos/core 1.21.78 → 1.21.80

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.
Files changed (77) hide show
  1. package/.bongos-core.json +108 -78
  2. package/.claude/skills/goal-create/SKILL.md +2 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +8 -0
  5. package/clients/bongos-client/index.cjs +8 -0
  6. package/clients/bongos-client/index.d.ts +13 -0
  7. package/clients/bongos-client/index.mjs +8 -0
  8. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +1 -0
  9. package/docs/adr/0370-a-repo-we-create-is-private.md +29 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +285 -3
  12. package/docs/api-reference.md +6 -2
  13. package/docs/copy-inventory.md +40 -33
  14. package/docs/copy-registry.json +125 -61
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +4 -2
  17. package/docs/page-readings.json +216 -207
  18. package/migrations/core_273_module_install_reports.sql +66 -0
  19. package/modules/hall-ui/public/atlas.html +1 -1
  20. package/modules/hall-ui/public/blockers.html +1 -1
  21. package/modules/hall-ui/public/board-room.html +1 -1
  22. package/modules/hall-ui/public/city.html +1 -1
  23. package/modules/hall-ui/public/collab.html +1 -1
  24. package/modules/hall-ui/public/commands.html +1 -1
  25. package/modules/hall-ui/public/copy-desk.html +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/idea.html +1 -1
  35. package/modules/hall-ui/public/ideas.html +1 -1
  36. package/modules/hall-ui/public/index.html +1 -1
  37. package/modules/hall-ui/public/modules.html +1 -1
  38. package/modules/hall-ui/public/primer.html +1 -1
  39. package/modules/hall-ui/public/profile.html +1 -1
  40. package/modules/hall-ui/public/project-settings.html +1 -1
  41. package/modules/hall-ui/public/ranks.html +1 -1
  42. package/modules/hall-ui/public/roadmap.html +1 -1
  43. package/modules/hall-ui/public/roster.html +1 -1
  44. package/modules/hall-ui/public/sessions.html +1 -1
  45. package/modules/hall-ui/public/settings-usage-reports.js +96 -0
  46. package/modules/hall-ui/public/settings.html +19 -1
  47. package/modules/hall-ui/public/settings.states.json +1 -0
  48. package/modules/hall-ui/public/shell.js +61 -29
  49. package/modules/hall-ui/public/street.html +1 -1
  50. package/modules/hall-ui/public/studio.html +1 -1
  51. package/modules/hall-ui/public/task.html +1 -1
  52. package/modules/hall-ui/public/thinking.html +1 -1
  53. package/modules/hall-ui/public/tweak-editor-lib.js +22 -3
  54. package/modules/hall-ui/public/tweak-editor.html +1 -1
  55. package/modules/hall-ui/public/watch.html +1 -1
  56. package/modules/hall-ui/public/work.html +1 -1
  57. package/modules/hall-ui/records/tweak-editor.md +1 -1
  58. package/modules/public-landing/public/projects.html +5 -5
  59. package/package-lock.json +2 -2
  60. package/package.json +1 -1
  61. package/release-notes.json +28 -0
  62. package/scripts/gds/module-assess-installs.js +143 -0
  63. package/scripts/gds/module.js +29 -5
  64. package/scripts/hall-preview/live-identity.js +1 -0
  65. package/src/bongos/github-repos.js +7 -6
  66. package/src/bongos/module-usage-reports.js +112 -0
  67. package/src/bongos/routes/modules.js +96 -0
  68. package/src/module-api.js +1 -1
  69. package/tests/github_repos_helpers.mjs +5 -5
  70. package/tests/goal_map_page.mjs +1 -1
  71. package/tests/hall_commands.mjs +1 -1
  72. package/tests/hall_nav.mjs +42 -0
  73. package/tests/hall_tweak_editor.mjs +13 -1
  74. package/tests/module_assess_installs.mjs +272 -0
  75. package/tests/module_install.mjs +3 -0
  76. package/tests/module_update.mjs +3 -0
  77. package/tests/nav_permission_atoms.mjs +1 -1
@@ -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
+ });
@@ -49,6 +49,7 @@ function fakeStore({ tgz, v }, over = {}) {
49
49
  if (urlPath.endsWith('/acquire')) return over.acquire || { ok: true, status: 200, data: { ...describe, ...(over.describe || {}) } };
50
50
  if (urlPath.endsWith('/tarball')) return over.download || { ok: true, status: 200, buf: over.buf || tgz };
51
51
  if (urlPath.endsWith('/acquired')) return over.acquired || { ok: true, status: 200, data: { ok: true } };
52
+ if (urlPath.endsWith('/install-report')) return { ok: true, status: 200, data: { ok: true, recorded: true } };
52
53
  throw new Error(`unexpected ${method} ${urlPath}`);
53
54
  };
54
55
  return { calls, request };
@@ -75,8 +76,10 @@ test('installs a free module: verified, placed, recorded, and NOT enabled', asyn
75
76
  'POST /store/modules/weather/acquire',
76
77
  'GET /store/modules/weather/versions/1.2.0/tarball',
77
78
  'POST /store/modules/weather/acquired',
79
+ 'POST /store/modules/weather/versions/1.2.0/install-report',
78
80
  ]);
79
81
  assert.deepEqual(store.calls[2].json, { version: '1.2.0' }, 'records the exact version placed');
82
+ assert.deepEqual(store.calls[3].json, { ok: true }, 'reports that the install worked — counts only (task 1003793)');
80
83
  assert.match(q.out.join('\n'), /not switched on/);
81
84
  });
82
85
 
@@ -68,6 +68,7 @@ function fakeStore({ tgz, v }, { source = 'free', over = {} } = {}) {
68
68
  }
69
69
  if (urlPath.endsWith('/tarball')) return { ok: true, status: 200, buf: over.buf || tgz };
70
70
  if (urlPath.endsWith('/acquired')) return { ok: true, status: 200, data: { ok: true } };
71
+ if (urlPath.endsWith('/install-report')) return { ok: true, status: 200, data: { ok: true, recorded: true } };
71
72
  throw new Error(`unexpected ${method} ${urlPath}`);
72
73
  };
73
74
  return { calls, request };
@@ -91,8 +92,10 @@ test('updates an installed module to the newest version: files replaced, old-onl
91
92
  'POST /store/modules/weather/acquire',
92
93
  'GET /store/modules/weather/versions/1.3.0/tarball',
93
94
  'POST /store/modules/weather/acquired',
95
+ 'POST /store/modules/weather/versions/1.3.0/install-report',
94
96
  ]);
95
97
  assert.deepEqual(store.calls[2].json, { version: '1.3.0' });
98
+ assert.deepEqual(store.calls[3].json, { ok: true }, 'an update reports like an install (task 1003793)');
96
99
  assert.match(q.out.join('\n'), /1\.2\.0 → 1\.3\.0\. Its on\/off switch is unchanged/);
97
100
  });
98
101
 
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = '02e600a176c32f6a';
90
+ const SHELL_SHA = 'd080c7a69a310077';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);