@bongos/core 1.21.152 → 1.21.154
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 +44 -29
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +315 -307
- package/docs/copy-registry.json +431 -359
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -0
- package/docs/page-readings.json +224 -216
- package/modules/hall-ui/public/modules.html +16 -0
- package/modules/hall-ui/public/modules.js +116 -0
- package/modules/hall-ui/public/modules.states.json +2 -1
- package/modules/hall-ui/records/catalog.md +4 -0
- package/modules/lifecycle/github-failure.js +50 -0
- package/modules/lifecycle/routes/tasks.js +2 -2
- package/modules/provisioning/github-setup.js +51 -1
- package/modules/provisioning/routes/provisioning.js +7 -2
- package/modules/public-landing/public/projects.html +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/bongos/module-listing.js +116 -6
- package/src/bongos/routes/modules.js +11 -3
- package/src/module-api.js +1 -1
- package/tests/github_failure.mjs +79 -0
- package/tests/github_install_nudge.mjs +219 -0
- package/tests/hall_catalog_world.mjs +31 -2
- package/tests/module_store_listing.mjs +156 -3
|
@@ -110,7 +110,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
|
|
|
110
110
|
// property access on a CommonJS namespace, so reaching these through one made them read
|
|
111
111
|
// as unused exports and tripped the dead-code ratchet that held this branch out of main.
|
|
112
112
|
const { listOverrides, setOverride } = require('../module-overrides');
|
|
113
|
-
const { listCatalog } = require('../module-listing');
|
|
113
|
+
const { listCatalog, SORT_PARTS } = require('../module-listing');
|
|
114
114
|
|
|
115
115
|
// The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
|
|
116
116
|
// module's pages, 'pollers' its background jobs. PURE.
|
|
@@ -733,12 +733,20 @@ module.exports = function buildModulesRouter() {
|
|
|
733
733
|
// listed module's newest version, if it passed the Security gate and scores at or
|
|
734
734
|
// above the floor, or under "new" if it has no real-world data yet. Below the bar
|
|
735
735
|
// is not listed at all. The floor is fixed in module-listing.js and returned here.
|
|
736
|
+
// `?sort=<part>` orders the listed modules by the part a buyer cares about
|
|
737
|
+
// (task 1003800, sortByPart); the parts it accepts come back as sort_parts.
|
|
736
738
|
//
|
|
737
739
|
// rank: any-builder — a read of public store metadata, like /store/modules/latest:
|
|
738
740
|
// no entitlement taken, nothing changed.
|
|
739
741
|
router.get('/store/modules', auth.requireBuilder,
|
|
740
|
-
asyncHandler('GET /store/modules', async (
|
|
741
|
-
const
|
|
742
|
+
asyncHandler('GET /store/modules', async (req, res) => {
|
|
743
|
+
const raw = req.query && req.query.sort;
|
|
744
|
+
const sort = typeof raw === 'string' && raw ? raw : null;
|
|
745
|
+
if (sort && !SORT_PARTS.some((p) => p.part === sort)) {
|
|
746
|
+
const names = SORT_PARTS.map((p) => `${p.title} (${p.part})`).join(', ');
|
|
747
|
+
return res.fail('bad_request', { status: 400, message: `The store can be sorted by one part of the score: ${names}.` });
|
|
748
|
+
}
|
|
749
|
+
const listing = await listCatalog({ sort });
|
|
742
750
|
res.set('Cache-Control', 'no-store');
|
|
743
751
|
res.json(listing);
|
|
744
752
|
}));
|
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.154'; // 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,79 @@
|
|
|
1
|
+
// tests/github_failure.mjs — a failed GitHub call says WHICH step broke and what
|
|
2
|
+
// GitHub answered (task 1004766).
|
|
3
|
+
//
|
|
4
|
+
// On the toast instance every GET /tasks/:id/publish-status answered a bare 502
|
|
5
|
+
// `status_failed` with no message, and the server log carried one word. The thrown
|
|
6
|
+
// errors already held the step (code) and GitHub's reply (status, detail).
|
|
7
|
+
//
|
|
8
|
+
// Run: node tests/github_failure.mjs
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import url from 'node:url';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
18
|
+
const { describeGithubFailure } = require(path.join(ROOT, 'modules/lifecycle/github-failure.js'));
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
function t(name, fn) {
|
|
23
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
24
|
+
catch (e) { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
|
|
25
|
+
}
|
|
26
|
+
const thrown = (code, extra = {}) => Object.assign(new Error(code), { code }, extra);
|
|
27
|
+
|
|
28
|
+
t('an App token refusal names the step and GitHub\'s answer', () => {
|
|
29
|
+
const r = describeGithubFailure(thrown('APP_TOKEN_FAILED', { status: 401, detail: '{"message":"A JSON web token could not be decoded"}' }));
|
|
30
|
+
assert.match(r.message, /GitHub App token/);
|
|
31
|
+
assert.match(r.message, /HTTP 401: \{"message":"A JSON web token could not be decoded"\}/);
|
|
32
|
+
assert.equal(r.details.cause, 'APP_TOKEN_FAILED');
|
|
33
|
+
assert.equal(r.details.github_status, 401);
|
|
34
|
+
assert.match(r.log, /APP_TOKEN_FAILED \(GitHub HTTP 401/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
t('a refused repository read is told apart from a token failure', () => {
|
|
38
|
+
const r = describeGithubFailure(thrown('STATUS_FETCH_FAILED', { status: 403 }));
|
|
39
|
+
assert.match(r.message, /refused a read of the repository/);
|
|
40
|
+
assert.match(r.message, /HTTP 403\./);
|
|
41
|
+
assert.equal(r.details.github_detail, null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
t('an unknown error still says something, and never invents a GitHub status', () => {
|
|
45
|
+
const r = describeGithubFailure(new Error('fetch failed'));
|
|
46
|
+
assert.match(r.message, /unexpected error/);
|
|
47
|
+
assert.doesNotMatch(r.message, /HTTP/);
|
|
48
|
+
assert.equal(r.details.cause, null);
|
|
49
|
+
assert.equal(r.log, 'fetch failed');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
t('GitHub\'s detail is capped', () => {
|
|
53
|
+
const r = describeGithubFailure(thrown('APP_TOKEN_FAILED', { status: 500, detail: 'x'.repeat(5000) }));
|
|
54
|
+
assert.ok(r.details.github_detail.length <= 300);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
t('publish-status sends the description, not a bare 502', () => {
|
|
58
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
59
|
+
const i = src.indexOf("router.get('/tasks/:id/publish-status'");
|
|
60
|
+
const handler = src.slice(i, src.indexOf('router.', i + 20));
|
|
61
|
+
assert.match(handler, /failGithubStatus\(res, log, e\)/);
|
|
62
|
+
assert.doesNotMatch(handler, /res\.fail\('status_failed', 502\)/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
t('the route answer carries the message and logs the step', () => {
|
|
66
|
+
const { failGithubStatus } = require(path.join(ROOT, 'modules/lifecycle/github-failure.js'));
|
|
67
|
+
let sent = null;
|
|
68
|
+
const logged = [];
|
|
69
|
+
const res = { fail: (code, opts) => { sent = { code, ...opts }; return 'sent'; } };
|
|
70
|
+
const out = failGithubStatus(res, { error: (...a) => logged.push(a.join(' ')) }, thrown('APP_TOKEN_FAILED', { status: 401 }));
|
|
71
|
+
assert.equal(out, 'sent');
|
|
72
|
+
assert.equal(sent.code, 'status_failed');
|
|
73
|
+
assert.equal(sent.status, 502);
|
|
74
|
+
assert.match(sent.message, /GitHub App token.*HTTP 401/);
|
|
75
|
+
assert.match(logged[0], /APP_TOKEN_FAILED \(GitHub HTTP 401\)/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
79
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// tests/github_install_nudge.mjs — a project whose GitHub App is not installed keeps showing
|
|
2
|
+
// its owner the install link (task 1004763).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Toast (instance 52) was made before the wizard asked anyone to install the app (task
|
|
5
|
+
// 1004649), so nobody did, and every ship ended land-not-proven: the platform could not read
|
|
6
|
+
// the repo to see the merge. The install link lived only inside the creation wizard's
|
|
7
|
+
// checklist. Now the owner's two reads carry `github_install`, and the project list card and
|
|
8
|
+
// the project's own page say "Install the GitHub App" until the runner has seen the install.
|
|
9
|
+
//
|
|
10
|
+
// Three layers, DB-free and network-free:
|
|
11
|
+
// 1. modules/provisioning/github-setup.js — installNudge (pure) and installNudgesFor (two reads for a whole list).
|
|
12
|
+
// 2. the REAL list and instance routes over a mounted router: the field rides both.
|
|
13
|
+
// 3. projects.html's ghInstallRow, groupFor and the click handler, run in a vm: the link on
|
|
14
|
+
// the card and the manage page, "Needs you", and the click asking for a re-check.
|
|
15
|
+
//
|
|
16
|
+
// Run: node --test tests/github_install_nudge.mjs
|
|
17
|
+
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test, after } from 'node:test';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import vm from 'node:vm';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const GS = require(path.join(ROOT, 'modules', 'provisioning', 'github-setup.js'));
|
|
29
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
30
|
+
|
|
31
|
+
const INSTALL = 'https://github.com/apps/uattest-app/installations/new';
|
|
32
|
+
const APP_ROW = { id: 9, instance_id: 56, status: 'done', app_id: 5, app_slug: 'uattest-app', state: 'SECRETSTATE', attempts: 1,
|
|
33
|
+
created_at: '2026-10-01T00:00:00Z', updated_at: '2026-10-01T00:05:00Z', resolved_at: '2026-10-01T00:05:00Z' };
|
|
34
|
+
const INST = { id: 56, slug: 'uattest', status: 'active', target_ref: 'acme/uattest' };
|
|
35
|
+
|
|
36
|
+
// ── 1. the helper ───────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
test('installNudge: the link and repo while the install is unconfirmed, null otherwise', () => {
|
|
39
|
+
const setup = (install, url = INSTALL) => ({ checklist: { install }, install_url: url, repo: 'acme/uattest' });
|
|
40
|
+
assert.deepEqual(GS.installNudge(setup('now')), { install_url: INSTALL, repo: 'acme/uattest' });
|
|
41
|
+
assert.equal(GS.installNudge(setup('done')), null, 'GitHub confirmed it: nothing to say');
|
|
42
|
+
assert.equal(GS.installNudge(setup('todo', null)), null, 'no app of ours: nothing to install');
|
|
43
|
+
assert.equal(GS.installNudge(null), null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A fake pool answering the two batched reads from plain tables, counting the queries.
|
|
47
|
+
function fakeDb({ attempts = [], installedEvents = [], fail = false } = {}) {
|
|
48
|
+
const db = { queries: 0 };
|
|
49
|
+
db.query = async (sql, params) => {
|
|
50
|
+
db.queries += 1;
|
|
51
|
+
if (fail) throw new Error('db down');
|
|
52
|
+
if (/FROM provisioning_oauth_manifests/.test(sql)) {
|
|
53
|
+
const ids = params[0].map(String);
|
|
54
|
+
return { rows: attempts.filter((r) => ids.includes(String(r.instance_id))) };
|
|
55
|
+
}
|
|
56
|
+
if (/FROM provisioning_events/.test(sql)) {
|
|
57
|
+
assert.equal(params[2], GS.SETUP_EVENTS.installed, 'only the verified install counts, never a report');
|
|
58
|
+
const since = new Map(params[0].map((id, i) => [String(id), Date.parse(params[1][i])]));
|
|
59
|
+
return { rows: installedEvents.filter((e) => since.has(String(e.instance_id)) && Date.parse(e.created_at) >= since.get(String(e.instance_id))) };
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
62
|
+
};
|
|
63
|
+
return db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('installNudgesFor: two reads for the whole list; silent with no app, once installed, switched off, or on a failed read', async () => {
|
|
67
|
+
const insts = [
|
|
68
|
+
INST,
|
|
69
|
+
{ id: 52, slug: 'toast', status: 'active', target_ref: 'acme/toast' },
|
|
70
|
+
{ id: 13, slug: 'charter', status: 'active', target_ref: 'acme/charter' },
|
|
71
|
+
{ id: 70, slug: 'gone', status: 'torn_down', target_ref: 'acme/gone' },
|
|
72
|
+
{ id: 71, slug: 'old', status: 'active', target_ref: 'acme/old' },
|
|
73
|
+
];
|
|
74
|
+
const db = fakeDb({
|
|
75
|
+
attempts: [
|
|
76
|
+
{ instance_id: '56', app_slug: 'uattest-app', created_at: '2026-10-01T00:00:00Z' },
|
|
77
|
+
{ instance_id: '52', app_slug: 'toast-app', created_at: '2026-10-01T00:00:00Z' },
|
|
78
|
+
{ instance_id: '13', app_slug: null, created_at: '2026-10-01T00:00:00Z' },
|
|
79
|
+
{ instance_id: '70', app_slug: 'gone-app', created_at: '2026-10-01T00:00:00Z' },
|
|
80
|
+
{ instance_id: '71', app_slug: 'old-app', created_at: '2026-10-05T00:00:00Z' },
|
|
81
|
+
],
|
|
82
|
+
installedEvents: [
|
|
83
|
+
{ instance_id: '52', created_at: '2026-10-20T10:00:00Z' },
|
|
84
|
+
{ instance_id: '71', created_at: '2026-10-02T00:00:00Z' },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
const n = await GS.installNudgesFor(db, insts);
|
|
88
|
+
assert.equal(db.queries, 2, 'two reads, however many projects');
|
|
89
|
+
assert.deepEqual(n.get('56'), { install_url: 'https://github.com/apps/uattest-app/installations/new', repo: 'acme/uattest' }, 'the stateless link: never the state token');
|
|
90
|
+
assert.equal(n.get('52'), null, 'the runner saw the install');
|
|
91
|
+
assert.equal(n.get('13'), null, 'no app was made: nothing to install');
|
|
92
|
+
assert.equal(n.get('70'), null, 'a switched-off project is silent');
|
|
93
|
+
assert.deepEqual(n.get('71'), { install_url: 'https://github.com/apps/old-app/installations/new', repo: 'acme/old' }, 'an install seen before the latest attempt began does not count for it');
|
|
94
|
+
const empty = fakeDb();
|
|
95
|
+
assert.equal((await GS.installNudgesFor(empty, [{ ...INST, status: 'torn_down' }])).get('56'), null);
|
|
96
|
+
assert.equal(empty.queries, 0, 'nothing live: no read at all');
|
|
97
|
+
const broken = await GS.installNudgesFor(fakeDb({ fail: true }), insts);
|
|
98
|
+
assert.ok([...broken.values()].every((v) => v === null), 'a failed read is no nudge, never a failed list');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('the instance read: the nudge comes from the same checklist the wizard reads', () => {
|
|
102
|
+
const read = (install, url) => ({ checklist: { install }, install_url: url, repo: 'acme/uattest' });
|
|
103
|
+
assert.equal(GS.installNudge(read('now', INSTALL)).install_url, INSTALL);
|
|
104
|
+
assert.equal(GS.installNudge(read('todo', null)), null, 'no app: githubSetupRead gives no link, so no nudge');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── 2. the owner's two reads ────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
test('GET /provisioning/instances and /provisioning/instances/:id carry github_install', async () => {
|
|
110
|
+
const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
|
|
111
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
112
|
+
const catalogBridge = require(path.join(ROOT, 'modules', 'provisioning', 'catalog-bridge.js'));
|
|
113
|
+
const saved = {
|
|
114
|
+
list: provisioning.listInstancesForOwner, byId: provisioning.getInstanceById, open: provisioning.getOpenIntent,
|
|
115
|
+
latest: provisioning.getLatestOAuthManifestForInstance, rb: api.requireBuilder, heal: catalogBridge.healInstanceCatalog, facts: GS.githubSetupFacts,
|
|
116
|
+
};
|
|
117
|
+
const owned = [{ ...INST, owner_builder_id: 7 }, { id: 52, slug: 'toast', status: 'active', target_ref: 'acme/toast', owner_builder_id: 7 }];
|
|
118
|
+
provisioning.listInstancesForOwner = async () => owned;
|
|
119
|
+
provisioning.getInstanceById = async (_db, id) => owned.find((i) => Number(i.id) === Number(id)) || null;
|
|
120
|
+
provisioning.getOpenIntent = async () => null;
|
|
121
|
+
provisioning.getLatestOAuthManifestForInstance = async (_db, id) => ({ ...APP_ROW, instance_id: id, app_slug: `app-${id}` });
|
|
122
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank: 'metic' }; next(); };
|
|
123
|
+
catalogBridge.healInstanceCatalog = async () => {};
|
|
124
|
+
GS.githubSetupFacts = async () => ({});
|
|
125
|
+
const realBatch = GS.installNudgesFor;
|
|
126
|
+
const listDb = fakeDb({
|
|
127
|
+
attempts: owned.map((i) => ({ instance_id: String(i.id), app_slug: `app-${i.id}`, created_at: '2026-10-01T00:00:00Z' })),
|
|
128
|
+
installedEvents: [{ instance_id: '52', created_at: '2026-10-20T10:00:00Z' }],
|
|
129
|
+
});
|
|
130
|
+
GS.installNudgesFor = (_pool, rows) => realBatch(listDb, rows);
|
|
131
|
+
|
|
132
|
+
const express = require('express');
|
|
133
|
+
const app = express();
|
|
134
|
+
app.use((_req, res, next) => { res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code } }); next(); });
|
|
135
|
+
app.use('/api/bongos', require(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'))());
|
|
136
|
+
const server = app.listen(0);
|
|
137
|
+
await new Promise((r) => server.once('listening', r));
|
|
138
|
+
after(() => {
|
|
139
|
+
server.close();
|
|
140
|
+
Object.assign(provisioning, { listInstancesForOwner: saved.list, getInstanceById: saved.byId, getOpenIntent: saved.open, getLatestOAuthManifestForInstance: saved.latest });
|
|
141
|
+
api.requireBuilder = saved.rb; catalogBridge.healInstanceCatalog = saved.heal; GS.githubSetupFacts = saved.facts; GS.installNudgesFor = realBatch;
|
|
142
|
+
});
|
|
143
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
144
|
+
|
|
145
|
+
const list = await (await fetch(`${base}/provisioning/instances`)).json();
|
|
146
|
+
const by = Object.fromEntries(list.instances.map((i) => [i.slug, i]));
|
|
147
|
+
assert.deepEqual(by.uattest.github_install, { install_url: 'https://github.com/apps/app-56/installations/new', repo: 'acme/uattest' });
|
|
148
|
+
assert.equal(by.toast.github_install, null, 'installed: no nudge');
|
|
149
|
+
assert.equal(listDb.queries, 2, 'the list reads the nudges in two queries');
|
|
150
|
+
|
|
151
|
+
const one = await (await fetch(`${base}/provisioning/instances/56`)).json();
|
|
152
|
+
assert.deepEqual(one.instance.github_install, { install_url: 'https://github.com/apps/app-56/installations/new', repo: 'acme/uattest' });
|
|
153
|
+
assert.ok(!/SECRETSTATE/.test(JSON.stringify(one)) && !/SECRETSTATE/.test(JSON.stringify(list)), 'the state token never rides an owner read');
|
|
154
|
+
assert.equal(one.github_setup.checklist.install, 'now', 'the wizard checklist still rides the same read');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── 3. the page ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function grab(head) {
|
|
160
|
+
const at = HUB.indexOf(`\n ${head}`);
|
|
161
|
+
assert.ok(at >= 0, `${head} must exist`);
|
|
162
|
+
const end = HUB.indexOf('\n }', at + 1);
|
|
163
|
+
const close = HUB.slice(end, end + 5) === '\n };' ? end + 5 : end + 4;
|
|
164
|
+
return HUB.slice(at, close);
|
|
165
|
+
}
|
|
166
|
+
function page() {
|
|
167
|
+
const ctx = { STATE_GROUP: { active: 'live', error: 'attention', torn_down: 'gone' } };
|
|
168
|
+
vm.createContext(ctx);
|
|
169
|
+
vm.runInContext(['function esc(', 'function ghInstallRow(', 'function groupFor('].map(grab).join('\n'), ctx);
|
|
170
|
+
return ctx;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test('ghInstallRow: the install link on its own, for an unconfirmed install only', () => {
|
|
174
|
+
const p = page();
|
|
175
|
+
const html = p.ghInstallRow({ id: 56, status: 'active', github_install: { install_url: INSTALL, repo: 'acme/uattest' } });
|
|
176
|
+
assert.match(html, /Install the GitHub App ↗/);
|
|
177
|
+
assert.match(html, new RegExp(`href="${INSTALL.replace(/[.?/]/g, '\\$&')}" target="_blank" rel="noopener" data-gh-install="56"`));
|
|
178
|
+
assert.match(html, /<code>acme\/uattest<\/code>/);
|
|
179
|
+
assert.equal(p.ghInstallRow({ id: 52, status: 'active', github_install: null }), '');
|
|
180
|
+
assert.equal(p.ghInstallRow({ id: 56, status: 'torn_down', github_install: { install_url: INSTALL } }), '', 'switched off: silent');
|
|
181
|
+
assert.equal(p.ghInstallRow({ id: 56, status: 'active', github_install: { install_url: 'https://evil.example/x' } }), '', 'only GitHub\'s own app pages are linked');
|
|
182
|
+
assert.match(p.ghInstallRow({ id: 56, status: 'active', github_install: { install_url: INSTALL, repo: '<b>x</b>' } }), /<b>/, 'the repo is escaped');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('groupFor: a project waiting on the install counts under Needs you', () => {
|
|
186
|
+
const p = page();
|
|
187
|
+
assert.equal(p.groupFor({ status: 'active', last_verified_up: true, github_install: { install_url: INSTALL } }), 'attention');
|
|
188
|
+
assert.equal(p.groupFor({ status: 'active', last_verified_up: true, github_install: null }), 'live');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('the card and the project\'s own page both draw it', () => {
|
|
192
|
+
assert.match(grab('function renderCard('), /var gh = ghInstallRow\(inst\);\n if \(gh\) body \+= '<div class="projMsg">' \+ gh \+ '<\/div>';/);
|
|
193
|
+
assert.match(grab('function renderManage('), /var gh = ghInstallRow\(inst\);\n \$\('mGh'\)\.innerHTML = gh;\n \$\('mGh'\)\.hidden = !gh;/);
|
|
194
|
+
assert.match(HUB, /<div class="projMsg" id="mGh" hidden><\/div>/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('the click opens GitHub and asks the platform to look again', async () => {
|
|
198
|
+
const at = HUB.indexOf("\n document.addEventListener('click', function (ev) {\n var a = ev.target.closest && ev.target.closest('a[data-gh-install]');");
|
|
199
|
+
assert.ok(at >= 0, 'the install click handler must exist');
|
|
200
|
+
const src = HUB.slice(at, HUB.indexOf('\n });', at) + 6);
|
|
201
|
+
let listener = null;
|
|
202
|
+
const calls = [];
|
|
203
|
+
const ctx = {
|
|
204
|
+
API: '/api/bongos',
|
|
205
|
+
document: { addEventListener: (type, fn) => { if (type === 'click') listener = fn; } },
|
|
206
|
+
fetch: (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); },
|
|
207
|
+
};
|
|
208
|
+
vm.createContext(ctx);
|
|
209
|
+
vm.runInContext(src, ctx);
|
|
210
|
+
let prevented = false;
|
|
211
|
+
const link = { getAttribute: (k) => (k === 'data-gh-install' ? '56' : null) };
|
|
212
|
+
listener({ target: { closest: (sel) => (sel === 'a[data-gh-install]' ? link : null) }, preventDefault: () => { prevented = true; } });
|
|
213
|
+
assert.equal(prevented, false, 'the link still opens GitHub');
|
|
214
|
+
assert.equal(calls.length, 1);
|
|
215
|
+
assert.equal(calls[0].url, '/api/bongos/provisioning/instances/56/github-app/pick-up');
|
|
216
|
+
assert.equal(calls[0].opts.method, 'POST');
|
|
217
|
+
listener({ target: { closest: () => null }, preventDefault() {} });
|
|
218
|
+
assert.equal(calls.length, 1, 'any other click asks nothing');
|
|
219
|
+
});
|
|
@@ -39,7 +39,7 @@ const noAlias = (rawCss, where) => {
|
|
|
39
39
|
|
|
40
40
|
// ---- modules ----------------------------------------------------------------
|
|
41
41
|
|
|
42
|
-
test('modules: ONE panel of family rows — the filter bar, the figure band and two groups on one .scroll', () => {
|
|
42
|
+
test('modules: ONE panel of family rows — the filter bar, the figure band and two groups on one .scroll (a third, the store, since task 1003799)', () => {
|
|
43
43
|
const html = read('modules.html');
|
|
44
44
|
assert.match(html, /<link rel="stylesheet" href="\/builders\/oversight\.css">\s*<link rel="stylesheet" href="\/builders\/modules\.css">/, 'the family sheet loads before the page sheet');
|
|
45
45
|
assert.equal((html.match(/class="scroll[ "]/g) || []).length, 1, 'one panel, not three');
|
|
@@ -47,7 +47,7 @@ test('modules: ONE panel of family rows — the filter bar, the figure band and
|
|
|
47
47
|
assert.match(html, /class="ov-figs" id="mod-figs"/, 'the figure band is the family\'s');
|
|
48
48
|
assert.match(html, /<ul class="ov-rows" id="core-grid">/, 'the core catalog is a run of rows');
|
|
49
49
|
assert.match(html, /<ul class="ov-rows" id="own-grid">/, 'the project\'s own modules are a run of rows');
|
|
50
|
-
assert.equal((html.match(/class="ov-group__head"/g) || []).length,
|
|
50
|
+
assert.equal((html.match(/class="ov-group__head"/g) || []).length, 3, 'three group heads: the core, this project, the store');
|
|
51
51
|
assert.doesNotMatch(decomment(html), /mod-grid|mod-card|mod-browse|submit-scroll|class="settings-status"/, 'the card grid, the boxed search and the third panel are gone');
|
|
52
52
|
assert.doesNotMatch(html, /\d+ module\(s\)/, 'the page head no longer says the numbers as prose');
|
|
53
53
|
});
|
|
@@ -89,6 +89,35 @@ test('modules: upkeep colours the exception — undeclared is the warn pill, mai
|
|
|
89
89
|
assert.match(js, /cat\.parentTitle, cat\.sub\]/, 'the hall search covers the category, as searchCatalog does');
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
test('modules: the store group reads strengths and weaknesses off the server row (task 1003799)', () => {
|
|
93
|
+
const js = read('modules.js');
|
|
94
|
+
const html = read('modules.html');
|
|
95
|
+
assert.match(html, /<div class="ov-group" id="store-group" hidden>/, 'the store group is hidden until the store answers');
|
|
96
|
+
assert.match(html, /<ul class="ov-rows" id="store-grid"><\/ul>/);
|
|
97
|
+
assert.match(js, /api\.request\('GET', `\$\{API\}\/store\/modules\$\{q\}`\)/, 'it reads GET /store/modules');
|
|
98
|
+
assert.match(js, /<span class="ov-fact">strong at \$\{partsList\(strong\)\}<\/span>/, 'a strength is a plain fact');
|
|
99
|
+
assert.match(js, /<span class="fact-pill fact-pill--warn">weak at \$\{partsList\(weak\)\}<\/span>/, 'a weakness is the warn pill');
|
|
100
|
+
assert.match(js, /not measured yet/, 'a part with no data is named, never counted as weak');
|
|
101
|
+
assert.match(js, /<details class="mod-more"[^`]*Strengths and weaknesses of/, 'the detail sentence is one click away');
|
|
102
|
+
assert.doesNotMatch(decomment(js), /score\s*>=\s*80|STRONG_AT/, 'the page does not re-derive the reading — the server owns the thresholds');
|
|
103
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'store__modules.json'), 'utf8'));
|
|
104
|
+
const two = fixture.listed.filter((m) => m.overall === 74);
|
|
105
|
+
assert.equal(two.length, 2, 'the harness shows the criterion: two modules on the same score');
|
|
106
|
+
assert.notDeepEqual(two[0].strengths.map((s) => s.part), two[1].strengths.map((s) => s.part), '…that are strong at different things');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('modules: the buyer sorts the store by the part that matters to them (task 1003800)', () => {
|
|
110
|
+
const js = read('modules.js');
|
|
111
|
+
const html = read('modules.html');
|
|
112
|
+
assert.match(html, /<select id="store-sort"><option value="">Name<\/option><\/select>/, 'the menu starts at name order');
|
|
113
|
+
assert.match(js, /\?sort=\$\{encodeURIComponent\(storeSort\)\}/, 'the server orders the list; the page asks for a part');
|
|
114
|
+
assert.match(js, /fillStoreSort\(body\.sort_parts\)/, 'the parts on the menu are the server\'s list');
|
|
115
|
+
assert.doesNotMatch(decomment(js), /\.sort\(\(a, b\)/, 'the page does not re-sort the store rows itself');
|
|
116
|
+
assert.match(js, /not measured yet<\/span>/, 'a row with no score for the sorted part says so, not a zero');
|
|
117
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'store__modules.json'), 'utf8'));
|
|
118
|
+
assert.deepEqual(fixture.sort_parts.map((p) => p.part), ['tests', 'install', 'reliability', 'tester_feedback', 'docs'], 'the harness carries the menu');
|
|
119
|
+
});
|
|
120
|
+
|
|
92
121
|
test('modules: the kit filter bar carries the search and a state lens, and the empty state is the kit\'s', () => {
|
|
93
122
|
const js = read('modules.js');
|
|
94
123
|
assert.match(js, /kit\.makeFilterBar\(\{/, 'the bar is the kit\'s');
|
|
@@ -15,7 +15,7 @@ import { createRequire } from 'node:module';
|
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
|
|
17
17
|
// ---- the fake registry the route reads ----
|
|
18
|
-
const state = { modules: {}, versions: [], scores: [] };
|
|
18
|
+
const state = { modules: {}, versions: [], scores: [], signals: [] };
|
|
19
19
|
const pool = {
|
|
20
20
|
async query(sql, params = []) {
|
|
21
21
|
if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
|
|
@@ -35,6 +35,17 @@ const pool = {
|
|
|
35
35
|
}
|
|
36
36
|
return { rows: [...newest.values()] };
|
|
37
37
|
}
|
|
38
|
+
if (/FROM module_assessment_signals/.test(sql)) {
|
|
39
|
+
const ids = params[0].map(String);
|
|
40
|
+
const newest = new Map();
|
|
41
|
+
for (const s of state.signals) {
|
|
42
|
+
if (!ids.includes(String(s.version_id))) continue;
|
|
43
|
+
const k = `${s.version_id}:${s.part}`;
|
|
44
|
+
const have = newest.get(k);
|
|
45
|
+
if (!have || s.measured_at > have.measured_at || (s.measured_at === have.measured_at && s.id > have.id)) newest.set(k, s);
|
|
46
|
+
}
|
|
47
|
+
return { rows: [...newest.values()] };
|
|
48
|
+
}
|
|
38
49
|
return { rows: [] };
|
|
39
50
|
},
|
|
40
51
|
};
|
|
@@ -42,7 +53,7 @@ const poolPath = require.resolve('../src/bongos/pool.js');
|
|
|
42
53
|
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
43
54
|
|
|
44
55
|
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
45
|
-
const { QUALITY_FLOOR, placeVersion } = require('../src/bongos/module-listing.js');
|
|
56
|
+
const { QUALITY_FLOOR, SORT_PARTS, placeVersion, sortByPart } = require('../src/bongos/module-listing.js');
|
|
46
57
|
|
|
47
58
|
function handlerFor(router, method, p) {
|
|
48
59
|
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
@@ -152,5 +163,147 @@ test('GET /store/modules: an empty store answers empty lists and the floor', asy
|
|
|
152
163
|
state.modules = {}; state.versions = []; state.scores = [];
|
|
153
164
|
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
154
165
|
const res = await invoke(handle, {});
|
|
155
|
-
assert.deepEqual(res.body, { floor: 50, listed: [], new: [] });
|
|
166
|
+
assert.deepEqual(res.body, { floor: 50, sort: null, sort_parts: SORT_PARTS, listed: [], new: [] });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// sort by the part a buyer cares about (task 1003800, owner decision 2026-10-22)
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
const entry = (key, overall, partScores) => ({
|
|
174
|
+
key, overall,
|
|
175
|
+
parts: Object.fromEntries(Object.entries(partScores).map(([p, s]) => [p, { outcome: s == null ? 'no_data' : 'scored', score: s, sample_size: null }])),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('sortByPart: highest first for the chosen part; a tie goes to the overall score, then the key', () => {
|
|
179
|
+
const out = sortByPart([
|
|
180
|
+
entry('b-mod', 70, { reliability: 90 }),
|
|
181
|
+
entry('a-mod', 70, { reliability: 90 }),
|
|
182
|
+
entry('steady', 60, { reliability: 95 }),
|
|
183
|
+
entry('higher', 80, { reliability: 90 }),
|
|
184
|
+
], 'reliability');
|
|
185
|
+
assert.deepEqual(out.map((e) => e.key), ['steady', 'higher', 'a-mod', 'b-mod']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('sortByPart: a module with no score for the part goes last, never first, and the input is untouched', () => {
|
|
189
|
+
const input = [entry('z-unmeasured', 99, { reliability: null }), entry('low', 55, { reliability: 10 }), entry('a-missing', 99, {})];
|
|
190
|
+
const out = sortByPart(input, 'reliability');
|
|
191
|
+
assert.deepEqual(out.map((e) => e.key), ['low', 'a-missing', 'z-unmeasured']);
|
|
192
|
+
assert.equal(input[0].key, 'z-unmeasured', 'a new array; the listing it came from keeps its order');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('SORT_PARTS: every part a buyer compares, Security not among them', () => {
|
|
196
|
+
assert.deepEqual(SORT_PARTS.map((p) => p.part), ['tests', 'install', 'reliability', 'tester_feedback', 'docs']);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('GET /store/modules?sort=: orders the listed modules by the part, leaves New in key order', async () => {
|
|
200
|
+
state.modules = {
|
|
201
|
+
tested: { status: 'listed', title: 'Tested' }, steady: { status: 'listed', title: 'Steady' },
|
|
202
|
+
'b-new': { status: 'listed', title: 'B new' }, 'a-new': { status: 'listed', title: 'A new' },
|
|
203
|
+
};
|
|
204
|
+
state.versions = [
|
|
205
|
+
{ id: 21, module_key: 'tested', version: '1.0.0', published_at: 't' },
|
|
206
|
+
{ id: 22, module_key: 'steady', version: '1.0.0', published_at: 't' },
|
|
207
|
+
{ id: 23, module_key: 'b-new', version: '1.0.0', published_at: 't' },
|
|
208
|
+
{ id: 24, module_key: 'a-new', version: '1.0.0', published_at: 't' },
|
|
209
|
+
];
|
|
210
|
+
state.scores = [
|
|
211
|
+
{ id: 21, version_id: 21, computed_at: 1, ...score({ overall: 74 }) },
|
|
212
|
+
{ id: 22, version_id: 22, computed_at: 1, ...score({ overall: 74 }) },
|
|
213
|
+
{ id: 23, version_id: 23, computed_at: 1, ...score({ is_new: true }) },
|
|
214
|
+
{ id: 24, version_id: 24, computed_at: 1, ...score({ is_new: true }) },
|
|
215
|
+
];
|
|
216
|
+
state.signals = [
|
|
217
|
+
{ id: 21, version_id: 21, measured_at: 1, ...sig('tests', 96) },
|
|
218
|
+
{ id: 22, version_id: 21, measured_at: 1, ...sig('reliability', 52) },
|
|
219
|
+
{ id: 23, version_id: 22, measured_at: 1, ...sig('tests', 58) },
|
|
220
|
+
{ id: 24, version_id: 22, measured_at: 1, ...sig('reliability', 91) },
|
|
221
|
+
{ id: 25, version_id: 23, measured_at: 1, ...sig('reliability', 99) },
|
|
222
|
+
];
|
|
223
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
224
|
+
const byRel = await invoke(handle, { query: { sort: 'reliability' } });
|
|
225
|
+
assert.equal(byRel.body.sort, 'reliability');
|
|
226
|
+
assert.deepEqual(byRel.body.listed.map((m) => m.key), ['steady', 'tested'], 'the buyer who cares about reliability sees Steady first');
|
|
227
|
+
assert.deepEqual(byRel.body.new.map((m) => m.key), ['a-new', 'b-new'], 'New is never ranked, whatever the buyer picks');
|
|
228
|
+
const byTests = await invoke(handle, { query: { sort: 'tests' } });
|
|
229
|
+
assert.deepEqual(byTests.body.listed.map((m) => m.key), ['tested', 'steady']);
|
|
230
|
+
const plain = await invoke(handle, { query: {} });
|
|
231
|
+
assert.equal(plain.body.sort, null);
|
|
232
|
+
assert.deepEqual(plain.body.listed.map((m) => m.key), ['steady', 'tested'], 'no sort is key order, as before');
|
|
233
|
+
state.signals = [];
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('GET /store/modules?sort=: a part it does not know is refused, Security included', async () => {
|
|
237
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
238
|
+
for (const sort of ['security', 'price', 'overall']) {
|
|
239
|
+
const res = await invoke(handle, { query: { sort } });
|
|
240
|
+
assert.equal(res.statusCode, 400, `${sort} is not a part to sort by`);
|
|
241
|
+
assert.match(res.body.message, /Tests \(tests\), Clean installs \(install\), Reliability \(reliability\), Tester feedback \(tester_feedback\), How-to \(docs\)/, 'the refusal names each part as a person says it, with the value to send');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// strengths and weaknesses (task 1003799, ADR 0343 D4)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
const { readParts, STRONG_AT } = require('../src/bongos/module-listing.js');
|
|
250
|
+
const sig = (part, score, extra = {}) => ({ part, outcome: 'scored', score, sample_size: null, ...extra });
|
|
251
|
+
|
|
252
|
+
test('readParts: a part at or above STRONG_AT is a strength, below the floor a weakness, between is neither', () => {
|
|
253
|
+
assert.equal(STRONG_AT, 80);
|
|
254
|
+
const r = readParts([sig('tests', 80), sig('install', 79), sig('reliability', 49), sig('docs', 50)]);
|
|
255
|
+
assert.deepEqual(r.strengths.map((s) => s.part), ['tests']);
|
|
256
|
+
assert.deepEqual(r.weaknesses.map((s) => s.part), ['reliability']);
|
|
257
|
+
assert.deepEqual(r.unmeasured.map((s) => s.part), ['tester_feedback'], 'a part with no row is unmeasured');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('readParts: an unmeasured part is never a weakness, and Security is never listed', () => {
|
|
261
|
+
const r = readParts([
|
|
262
|
+
{ part: 'security', outcome: 'passed', score: null },
|
|
263
|
+
{ part: 'install', outcome: 'no_data', score: null },
|
|
264
|
+
{ part: 'reliability', outcome: 'pending', score: null },
|
|
265
|
+
{ part: 'tester_feedback', outcome: 'not_scored', score: null },
|
|
266
|
+
sig('tests', 10),
|
|
267
|
+
]);
|
|
268
|
+
assert.deepEqual(r.weaknesses.map((s) => s.part), ['tests']);
|
|
269
|
+
assert.deepEqual(r.unmeasured.map((s) => s.part), ['install', 'reliability', 'tester_feedback', 'docs']);
|
|
270
|
+
for (const list of [r.strengths, r.weaknesses, r.unmeasured]) assert.ok(!list.some((s) => s.part === 'security'));
|
|
271
|
+
assert.ok(!('security' in r.parts), 'the gate is not one of the parts a buyer compares');
|
|
272
|
+
assert.deepEqual(r.parts.install, { outcome: 'no_data', score: null, sample_size: null });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('readParts: strongest first, weakest first, and the detail says what the number rests on', () => {
|
|
276
|
+
const r = readParts([sig('tests', 85, { sample_size: 40 }), sig('docs', 95), sig('install', 30, { sample_size: 1 }), sig('reliability', 45, { sample_size: 12 })]);
|
|
277
|
+
assert.deepEqual(r.strengths.map((s) => [s.part, s.score]), [['docs', 95], ['tests', 85]]);
|
|
278
|
+
assert.deepEqual(r.weaknesses.map((s) => [s.part, s.score]), [['install', 30], ['reliability', 45]]);
|
|
279
|
+
assert.equal(r.strengths[1].detail, 'Tests scored 85 out of 100, from 40 test cases.');
|
|
280
|
+
assert.equal(r.weaknesses[0].detail, 'Clean installs scored 30 out of 100, from 1 install.');
|
|
281
|
+
assert.equal(r.strengths[0].detail, 'How-to scored 95 out of 100.');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('the criterion: two modules with the same overall score read differently', async () => {
|
|
285
|
+
state.modules = { steady: { status: 'listed', title: 'Steady' }, tested: { status: 'listed', title: 'Tested' } };
|
|
286
|
+
state.versions = [
|
|
287
|
+
{ id: 11, module_key: 'steady', version: '1.0.0', published_at: 't' },
|
|
288
|
+
{ id: 12, module_key: 'tested', version: '1.0.0', published_at: 't' },
|
|
289
|
+
];
|
|
290
|
+
state.scores = [
|
|
291
|
+
{ id: 11, version_id: 11, computed_at: 1, ...score({ overall: 74 }) },
|
|
292
|
+
{ id: 12, version_id: 12, computed_at: 1, ...score({ overall: 74 }) },
|
|
293
|
+
];
|
|
294
|
+
state.signals = [
|
|
295
|
+
{ id: 1, version_id: 11, measured_at: 1, ...sig('tests', 58) },
|
|
296
|
+
{ id: 2, version_id: 11, measured_at: 1, ...sig('reliability', 40) }, // superseded below
|
|
297
|
+
{ id: 3, version_id: 11, measured_at: 2, ...sig('reliability', 90) },
|
|
298
|
+
{ id: 4, version_id: 12, measured_at: 1, ...sig('tests', 96) },
|
|
299
|
+
{ id: 5, version_id: 12, measured_at: 1, ...sig('reliability', 52) },
|
|
300
|
+
];
|
|
301
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
302
|
+
const res = await invoke(handle, {});
|
|
303
|
+
const by = Object.fromEntries(res.body.listed.map((m) => [m.key, m]));
|
|
304
|
+
assert.equal(by.steady.overall, by.tested.overall);
|
|
305
|
+
assert.deepEqual(by.steady.strengths.map((s) => s.part), ['reliability'], 'the newest reliability row wins');
|
|
306
|
+
assert.deepEqual(by.tested.strengths.map((s) => s.part), ['tests']);
|
|
307
|
+
assert.equal(by.steady.parts.reliability.score, 90);
|
|
308
|
+
state.signals = [];
|
|
156
309
|
});
|