@bongos/core 1.20.45 → 1.20.47
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 +64 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +85 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +170 -161
- package/docs/copy-registry.json +333 -212
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +96 -96
- package/modules/hall-ui/public/bulk-toolbar.js +291 -0
- package/modules/hall-ui/public/goals-page.js +29 -44
- package/modules/hall-ui/public/goals-render.js +33 -21
- package/modules/hall-ui/public/goals.html +12 -16
- package/modules/hall-ui/public/modules.css +45 -0
- package/modules/hall-ui/public/modules.html +23 -1
- package/modules/hall-ui/public/modules.js +94 -2
- package/modules/hall-ui/public/style.css +74 -3
- package/modules/hall-ui/public/work.css +4 -66
- package/modules/hall-ui/public/work.html +2 -1
- package/modules/hall-ui/public/work.js +17 -236
- package/modules/hall-ui/records/catalog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/module-artifact.js +21 -1
- package/scripts/gds/module.js +15 -1
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/module-store.js +30 -1
- package/src/bongos/routes/modules.js +91 -1
- package/src/module-api.js +1 -1
- package/tests/bulk_toolbar.mjs +202 -0
- package/tests/claim_action.mjs +13 -4
- package/tests/goal_category_ui.mjs +1 -0
- package/tests/goal_criterion_confirm_ui.mjs +2 -0
- package/tests/goal_forest_page.mjs +1 -0
- package/tests/goal_invite_consent.mjs +1 -1
- package/tests/goal_mine_lens.mjs +1 -0
- package/tests/hall_board_world.mjs +1 -1
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/hall_mine_lens.mjs +1 -0
- package/tests/module_store_howto.mjs +244 -0
|
@@ -266,4 +266,24 @@ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, in
|
|
|
266
266
|
return out;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
// The how-to a published version shipped with (task 1004366): read from the version's
|
|
270
|
+
// own tarball, after the same hash check install runs, so the page always shows the
|
|
271
|
+
// exact file that version was published with. A version from before the gate may have
|
|
272
|
+
// none (text null). Text only — nothing in the module is run or rendered here.
|
|
273
|
+
// Returns { ok, text, artifactUrl, title, version, tarballSha256 } or verifyModuleArtifact's failure.
|
|
274
|
+
async function readHowto(tgz, { key }) {
|
|
275
|
+
const v = await verifyModuleArtifact(tgz, { key, includeFiles: true });
|
|
276
|
+
if (!v.ok) return v;
|
|
277
|
+
const file = v.files.find((f) => f.path === HOWTO_FILE);
|
|
278
|
+
const extra = v.moduleJson.howto || {};
|
|
279
|
+
return {
|
|
280
|
+
ok: true,
|
|
281
|
+
text: file ? file.buf.toString('utf8') : null,
|
|
282
|
+
artifactUrl: extra.artifactUrl || null,
|
|
283
|
+
title: v.moduleJson.title || key,
|
|
284
|
+
version: v.version,
|
|
285
|
+
tarballSha256: v.tarballSha256,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = { MAX_TARBALL_BYTES, HOWTO_FILE, HOWTO_SECTIONS, packModule, verifyModuleArtifact, deniedFiles, checkHowto, readHowto };
|
package/scripts/gds/module.js
CHANGED
|
@@ -930,7 +930,7 @@ function storeRequest() {
|
|
|
930
930
|
const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
|
|
931
931
|
const session = readSessionToken();
|
|
932
932
|
if (!session) throw new Error('no Bongos session — run `bongos setup` first');
|
|
933
|
-
|
|
933
|
+
const call = async (method, urlPath, { json, binary = false } = {}) => {
|
|
934
934
|
const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
|
|
935
935
|
method,
|
|
936
936
|
headers: {
|
|
@@ -946,6 +946,18 @@ function storeRequest() {
|
|
|
946
946
|
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
|
947
947
|
return { ok: res.ok, status: res.status, data };
|
|
948
948
|
};
|
|
949
|
+
// Where the store lives, so install can print a full link to the how-to page.
|
|
950
|
+
call.apiBase = session.apiBase;
|
|
951
|
+
return call;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// The hall page that shows a version's HOWTO.md (task 1004366) — the Modules tab,
|
|
955
|
+
// opened on one version. null when the version shipped without one (published
|
|
956
|
+
// before ADR 0347's gate), so install never prints a link that would 404.
|
|
957
|
+
function howtoLink(key, version, files, apiBase) {
|
|
958
|
+
if (!files.some((f) => f.path === 'HOWTO.md')) return null;
|
|
959
|
+
const p = `/builders/modules?howto=${encodeURIComponent(key)}&version=${encodeURIComponent(version)}`;
|
|
960
|
+
return apiBase ? `${String(apiBase).replace(/\/+$/, '')}${p}` : p;
|
|
949
961
|
}
|
|
950
962
|
|
|
951
963
|
function storeError(res) {
|
|
@@ -1093,6 +1105,8 @@ async function cmdInstall(args, {
|
|
|
1093
1105
|
|
|
1094
1106
|
if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'install' }))) return 1;
|
|
1095
1107
|
log(`\n✓ Installed "${key}" ${got.described.version}. It is not switched on: enable it from the hall's Modules tab when you want it.`);
|
|
1108
|
+
const howto = howtoLink(key, got.described.version, got.verified.files, call.apiBase);
|
|
1109
|
+
if (howto) log(` How to use it: ${howto}`);
|
|
1096
1110
|
return 0;
|
|
1097
1111
|
}
|
|
1098
1112
|
|
|
@@ -144,6 +144,9 @@ const FIXTURE_FALLBACKS = [
|
|
|
144
144
|
// the Approval queue's renders (task 1004323): a round's named visuals, one
|
|
145
145
|
// fixture for any task id, as the editor read above serves any page.
|
|
146
146
|
[/^tasks\/\d+\/visuals$/, 'tasks__visuals'],
|
|
147
|
+
// a store version's how-to page (task 1004366): one reading serves any key and
|
|
148
|
+
// version, since the Modules tab's how-to view shows one document.
|
|
149
|
+
[/^store\/modules\/[a-z0-9-]+\/versions\/[^/]+\/howto$/, 'store__modules__howto'],
|
|
147
150
|
];
|
|
148
151
|
|
|
149
152
|
// CANNED WRITES (task 1004321). The tweak editor's whole flow is writes (the
|
|
@@ -179,6 +179,35 @@ async function resolveAcquirable(key, { version = null, db } = {}) {
|
|
|
179
179
|
return { ok: true, module: mod, version: row, price: price || null, free: isFreePrice(price) };
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// Resolve ONE exact version for reading its how-to (task 1004366). Unlike
|
|
183
|
+
// resolveAcquirable this does not refuse a delisted module: ADR 0338 D2 keeps a
|
|
184
|
+
// delisted module readable for the people who already hold it, so it hands the
|
|
185
|
+
// caller the module's status and the route decides who may read on.
|
|
186
|
+
// Returns { ok: true, module, version } or { ok: false, status, code, message }.
|
|
187
|
+
async function resolveReadable(key, version, { db } = {}) {
|
|
188
|
+
const pool = db || require('./pool').pool;
|
|
189
|
+
const refuse = (status, code, message) => ({ ok: false, status, code, message });
|
|
190
|
+
const { rows: [mod] } = await pool.query(
|
|
191
|
+
'SELECT module_key, title, author_id, status FROM store_modules WHERE module_key = $1', [key]);
|
|
192
|
+
if (!mod) return refuse(404, 'module_not_found', `The store has no module "${key}".`);
|
|
193
|
+
const { rows: [row] } = await pool.query(
|
|
194
|
+
`SELECT id, module_key, version, tarball_sha256, artifact_path, published_at
|
|
195
|
+
FROM store_module_versions WHERE module_key = $1 AND version = $2`, [key, version]);
|
|
196
|
+
if (!row) return refuse(404, 'version_not_found', `"${key}" has no published version ${version}.`);
|
|
197
|
+
return { ok: true, module: mod, version: row };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Which of these (key, version) pairs are published store versions, as a Set of
|
|
201
|
+
// "key@version" — so the hall's Modules tab can link an installed module's how-to
|
|
202
|
+
// (task 1004366). One query for the whole list.
|
|
203
|
+
async function publishedVersionSet(keys, { db } = {}) {
|
|
204
|
+
if (!keys.length) return new Set();
|
|
205
|
+
const pool = db || require('./pool').pool;
|
|
206
|
+
const { rows } = await pool.query(
|
|
207
|
+
'SELECT module_key, version FROM store_module_versions WHERE module_key = ANY($1)', [keys]);
|
|
208
|
+
return new Set(rows.map((r) => `${r.module_key}@${r.version}`));
|
|
209
|
+
}
|
|
210
|
+
|
|
182
211
|
// The tarball on disk for a version row. artifact_path is relative to the store dir
|
|
183
212
|
// (relativeArtifactPath), and artifactPath() re-validates key + version, so a row
|
|
184
213
|
// can never point a read outside the store.
|
|
@@ -192,5 +221,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
192
221
|
|
|
193
222
|
module.exports = {
|
|
194
223
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
195
|
-
isFreePrice, resolveAcquirable, versionArtifactFile,
|
|
224
|
+
isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
|
|
196
225
|
};
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
// the server end of `bongos module install` (task
|
|
33
33
|
// 1003785): take the entitlement, download the
|
|
34
34
|
// checked tarball, record the version placed.
|
|
35
|
+
// GET /store/modules/:key/versions/:version/howto
|
|
36
|
+
// the HOWTO.md that version shipped with, as text for
|
|
37
|
+
// the hall's how-to page (task 1004366, ADR 0347).
|
|
35
38
|
//
|
|
36
39
|
// Read is open to any signed-in builder (same gate as the atlas/primer pages);
|
|
37
40
|
// every write (enable/disable, submit) is metic+archon, same rank as
|
|
@@ -78,7 +81,7 @@ const moduleArtifact = require('../../../scripts/gds/module-artifact');
|
|
|
78
81
|
const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
|
|
79
82
|
const {
|
|
80
83
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
81
|
-
resolveAcquirable, versionArtifactFile,
|
|
84
|
+
resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
|
|
82
85
|
} = require('../module-store');
|
|
83
86
|
const { KEY_RE } = require('../../module-loader/manifest-schema');
|
|
84
87
|
const { parseVersion } = require('../../module-loader/semver');
|
|
@@ -146,8 +149,20 @@ function listModules() {
|
|
|
146
149
|
});
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// How many checked how-tos one router keeps in memory (task 1004366). Each is one
|
|
153
|
+
// module's HOWTO.md text, so a few hundred is well under a megabyte; past the cap
|
|
154
|
+
// the oldest entry is dropped.
|
|
155
|
+
const HOWTO_CACHE_MAX = 200;
|
|
156
|
+
|
|
149
157
|
module.exports = function buildModulesRouter() {
|
|
150
158
|
const router = express.Router();
|
|
159
|
+
// Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
|
|
160
|
+
// router starts empty.
|
|
161
|
+
const howtoCache = new Map();
|
|
162
|
+
const rememberHowto = (k, v) => {
|
|
163
|
+
howtoCache.set(k, v);
|
|
164
|
+
if (howtoCache.size > HOWTO_CACHE_MAX) howtoCache.delete(howtoCache.keys().next().value);
|
|
165
|
+
};
|
|
151
166
|
|
|
152
167
|
// rank: any-builder — list every on-disk module with attribution + provenance (same read gate as the atlas/primer pages).
|
|
153
168
|
router.get('/modules', auth.requireBuilder, asyncHandler('GET /modules', async (_req, res) => {
|
|
@@ -160,11 +175,22 @@ module.exports = function buildModulesRouter() {
|
|
|
160
175
|
const sources = (resolvedSet && resolvedSet.sources) || {};
|
|
161
176
|
const overrideRows = await listOverrides();
|
|
162
177
|
const byKey = new Map(overrideRows.map((r) => [r.module_key, r]));
|
|
178
|
+
// Which on-disk modules are a published store version, so the row can link that
|
|
179
|
+
// version's how-to page (task 1004366). Failure-tolerant like the override read:
|
|
180
|
+
// a store that cannot be read costs the link, never the tab.
|
|
181
|
+
let published = new Set();
|
|
182
|
+
try { published = await publishedVersionSet(all.map((m) => m.key)); }
|
|
183
|
+
catch (e) { log.warn({ err: e.message }, 'store versions unreadable for the modules list'); }
|
|
163
184
|
for (const m of all) {
|
|
164
185
|
m.enabled_source = sources[m.key] || 'default';
|
|
165
186
|
m.override = byKey.has(m.key)
|
|
166
187
|
? { enabled: byKey.get(m.key).enabled, set_by: byKey.get(m.key).set_by_login || null, set_at: byKey.get(m.key).set_at }
|
|
167
188
|
: null;
|
|
189
|
+
// Only a module in THIS instance's tree: install places store modules there, so
|
|
190
|
+
// a core-shipped module that happens to share a store key@version never links.
|
|
191
|
+
m.store_howto = m.source === 'instance' && m.version && published.has(`${m.key}@${m.version}`)
|
|
192
|
+
? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
|
|
193
|
+
: null;
|
|
168
194
|
}
|
|
169
195
|
res.json({
|
|
170
196
|
modules: all,
|
|
@@ -475,6 +501,70 @@ module.exports = function buildModulesRouter() {
|
|
|
475
501
|
res.send(buf);
|
|
476
502
|
}));
|
|
477
503
|
|
|
504
|
+
// GET /api/bongos/store/modules/:key/versions/:version/howto — the how-to a version
|
|
505
|
+
// shipped with (task 1004366, ADR 0347 D1/D3), read from that version's own stored
|
|
506
|
+
// tarball after the install hash check, so it always matches the version and cannot
|
|
507
|
+
// change after publishing. Returns the markdown as TEXT, never HTML: the hall's
|
|
508
|
+
// Modules tab renders it through its one escaping markdown reader (md-reader.js),
|
|
509
|
+
// because the file is author-written and must be treated as untrusted input.
|
|
510
|
+
//
|
|
511
|
+
// rank: any-builder — no entitlement needed, since a buyer reads the how-to before
|
|
512
|
+
// deciding. The one exception is a delisted module: ADR 0338 D2 keeps it readable
|
|
513
|
+
// only for the people who already hold it, so there the caller's own entitlement
|
|
514
|
+
// is checked (own-scoped, like the reads above).
|
|
515
|
+
router.get('/store/modules/:key/versions/:version/howto', auth.requireBuilder,
|
|
516
|
+
asyncHandler('GET /store/modules/:key/versions/:version/howto', async (req, res) => {
|
|
517
|
+
const { key, version } = req.params;
|
|
518
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
519
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
520
|
+
}
|
|
521
|
+
const r = await resolveReadable(key, version);
|
|
522
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
523
|
+
const delisted = r.module.status === 'delisted';
|
|
524
|
+
if (delisted) {
|
|
525
|
+
const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
526
|
+
if (!held.entitled) {
|
|
527
|
+
return res.fail('module_delisted', { status: 409, message: `"${key}" is delisted: its how-to stays readable only for people who already hold it (ADR 0338 D2).` });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// A published version never changes, so its checked how-to is cached by the
|
|
531
|
+
// tarball hash the registry holds: the inflate + hash check runs once per
|
|
532
|
+
// version per process, not on every page view.
|
|
533
|
+
const cacheKey = `${key}@${version}:${r.version.tarball_sha256}`;
|
|
534
|
+
let h = howtoCache.get(cacheKey);
|
|
535
|
+
if (!h) {
|
|
536
|
+
let buf;
|
|
537
|
+
try { buf = await fs.promises.readFile(versionArtifactFile(r.version)); }
|
|
538
|
+
catch (e) {
|
|
539
|
+
log.error({ key, version, err: e.message }, 'store tarball missing');
|
|
540
|
+
return res.fail('artifact_missing', { status: 500, message: `The store has no file for ${key} ${version}.` });
|
|
541
|
+
}
|
|
542
|
+
h = await moduleArtifact.readHowto(buf, { key });
|
|
543
|
+
if (!h.ok) {
|
|
544
|
+
log.error({ key, version, code: h.code }, 'stored tarball failed verification');
|
|
545
|
+
return res.fail('artifact_invalid', { status: 500, message: `The stored file for ${key} ${version} did not pass its hash check.` });
|
|
546
|
+
}
|
|
547
|
+
if (h.tarballSha256 !== r.version.tarball_sha256) {
|
|
548
|
+
log.error({ key, version }, 'stored tarball is not the one the registry recorded');
|
|
549
|
+
return res.fail('artifact_invalid', { status: 500, message: `The stored file for ${key} ${version} is not the one that was published.` });
|
|
550
|
+
}
|
|
551
|
+
rememberHowto(cacheKey, h);
|
|
552
|
+
}
|
|
553
|
+
if (h.text == null) {
|
|
554
|
+
return res.fail('howto_missing', { status: 404, message: `${key} ${version} was published before every module shipped a how-to, so it has none.` });
|
|
555
|
+
}
|
|
556
|
+
res.set('Cache-Control', 'no-store');
|
|
557
|
+
res.json({
|
|
558
|
+
module_key: key,
|
|
559
|
+
title: h.title,
|
|
560
|
+
version: h.version,
|
|
561
|
+
published_at: r.version.published_at,
|
|
562
|
+
delisted,
|
|
563
|
+
markdown: h.text,
|
|
564
|
+
artifact_url: h.artifactUrl,
|
|
565
|
+
});
|
|
566
|
+
}));
|
|
567
|
+
|
|
478
568
|
router.post('/store/modules/:key/acquired', auth.requireBuilder,
|
|
479
569
|
asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
|
|
480
570
|
const { key } = req.params;
|
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.20.
|
|
78
|
+
const CORE_VERSION = '1.20.47'; // 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,202 @@
|
|
|
1
|
+
// tests/bulk_toolbar.mjs — the hall's shared bulk toolbar (task 1003141).
|
|
2
|
+
//
|
|
3
|
+
// One toolbar, two boards: /work and the goal board build the SAME one from
|
|
4
|
+
// modules/hall-ui/public/bulk-toolbar.js. This runs the real module (it is
|
|
5
|
+
// node-require-safe) against a stub page, so what it proves is behaviour — a
|
|
6
|
+
// release selection really enables Bulk release, a cycle really disables the
|
|
7
|
+
// sequential prompt — rather than that two files agree on a spelling.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ROOT = path.resolve(import.meta.dirname, '..');
|
|
16
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
17
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
18
|
+
const { topoOrder, buildBulkParallelPrompt, buildBulkSequentialPrompt, makeBulkToolbar } = require(path.join(PUB, 'bulk-toolbar.js'));
|
|
19
|
+
|
|
20
|
+
const loc = { hostname: 'builders.example.com', origin: 'https://builders.example.com' };
|
|
21
|
+
|
|
22
|
+
test('topoOrder puts a dependency first and ignores edges outside the selection', () => {
|
|
23
|
+
const edges = new Map([[2, [1]], [1, [99]], [3, []]]);
|
|
24
|
+
const order = topoOrder([2, 1, 3], edges);
|
|
25
|
+
assert.ok(order.indexOf(1) < order.indexOf(2), 'a dependency precedes its dependent');
|
|
26
|
+
assert.equal(order.length, 3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('topoOrder answers null for a cycle', () => {
|
|
30
|
+
assert.equal(topoOrder([1, 2], new Map([[1, [2]], [2, [1]]])), null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the prompts name every task and the hall link', () => {
|
|
34
|
+
const tasks = [{ id: 5, title: 'Five' }, { id: 6, title: 'Six' }];
|
|
35
|
+
const par = buildBulkParallelPrompt(tasks, loc);
|
|
36
|
+
assert.match(par, /2 Bongos tasks IN PARALLEL/);
|
|
37
|
+
assert.match(par, /Session 2 — task 6 \(Six\) — https:\/\/builders\.example\.com#\/task\/6/);
|
|
38
|
+
const seq = buildBulkSequentialPrompt(tasks, { hostname: 'example.com', origin: 'https://example.com' });
|
|
39
|
+
assert.match(seq, /IN ORDER/);
|
|
40
|
+
assert.match(seq, /1\. Task 5 \(Five\) — https:\/\/example\.com\/builders#\/task\/5/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// --- the factory, against a stub page --------------------------------------
|
|
44
|
+
|
|
45
|
+
function stubPage(present) {
|
|
46
|
+
const els = {};
|
|
47
|
+
for (const id of present) els[id] = { hidden: true, disabled: false, textContent: '', title: '', listeners: {}, addEventListener(t, fn) { this.listeners[t] = fn; } };
|
|
48
|
+
globalThis.document = { getElementById: (id) => els[id] || null };
|
|
49
|
+
globalThis.window = { confirm: () => true };
|
|
50
|
+
globalThis.location = loc;
|
|
51
|
+
return els;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function build(els, over = {}) {
|
|
55
|
+
const calls = { claimMany: [], releaseMany: [], copied: [], toasts: [] };
|
|
56
|
+
const selection = { claim: new Set(), release: new Map() };
|
|
57
|
+
const tasks = new Map([[1, { id: 1, title: 'One' }], [2, { id: 2, title: 'Two' }]]);
|
|
58
|
+
const bar = makeBulkToolbar({
|
|
59
|
+
ids: { bar: 'bar', count: 'count', claim: 'claim', release: 'release', parallel: 'parallel', sequential: 'sequential', clear: 'clear' },
|
|
60
|
+
selection,
|
|
61
|
+
findTask: (id) => tasks.get(id),
|
|
62
|
+
claimAction: {
|
|
63
|
+
validateMany: async () => ({ ok: true, parallelSafe: true, byId: new Map() }),
|
|
64
|
+
claimMany: async (ids) => { calls.claimMany.push(ids); return { ok: true }; },
|
|
65
|
+
releaseMany: async (e) => { calls.releaseMany.push(e); return { ok: true }; },
|
|
66
|
+
},
|
|
67
|
+
getJSON: async () => ({ task: { dependencies: [] } }),
|
|
68
|
+
API: '/api/bongos',
|
|
69
|
+
copyText: async (t) => { calls.copied.push(t); return true; },
|
|
70
|
+
toast: (m) => calls.toasts.push(m),
|
|
71
|
+
curLabel: () => 'credits',
|
|
72
|
+
describeReason: (r) => r,
|
|
73
|
+
onClear: () => { calls.cleared = true; },
|
|
74
|
+
...over,
|
|
75
|
+
});
|
|
76
|
+
return { bar, selection, calls };
|
|
77
|
+
}
|
|
78
|
+
const settle = () => new Promise((r) => setTimeout(r, 1000));
|
|
79
|
+
|
|
80
|
+
test('nothing selected hides the bar', () => {
|
|
81
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
82
|
+
const { bar } = build(els);
|
|
83
|
+
els.bar.hidden = false;
|
|
84
|
+
bar.render();
|
|
85
|
+
assert.equal(els.bar.hidden, true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a release selection alone enables Bulk release and leaves claim off', () => {
|
|
89
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
90
|
+
const { bar, selection } = build(els);
|
|
91
|
+
selection.release.set('77', '9');
|
|
92
|
+
bar.render();
|
|
93
|
+
assert.equal(els.bar.hidden, false);
|
|
94
|
+
assert.equal(els.count.textContent, '1 to release');
|
|
95
|
+
assert.equal(els.release.disabled, false);
|
|
96
|
+
assert.equal(els.claim.disabled, true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('two ready tasks enable both prompts once the checks come back, and the prompts copy', async () => {
|
|
100
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
101
|
+
const { bar, selection, calls } = build(els);
|
|
102
|
+
selection.claim.add('1'); selection.claim.add('2');
|
|
103
|
+
bar.render();
|
|
104
|
+
assert.equal(els.parallel.disabled, true, 'disabled while the dry run is pending');
|
|
105
|
+
await settle();
|
|
106
|
+
assert.equal(els.parallel.disabled, false);
|
|
107
|
+
assert.equal(els.sequential.disabled, false);
|
|
108
|
+
assert.equal(els.claim.textContent, 'Bulk claim (2)');
|
|
109
|
+
bar.wire();
|
|
110
|
+
await els.parallel.listeners.click();
|
|
111
|
+
await els.sequential.listeners.click();
|
|
112
|
+
assert.equal(calls.copied.length, 2);
|
|
113
|
+
assert.match(calls.copied[0], /IN PARALLEL/);
|
|
114
|
+
assert.match(calls.copied[1], /IN ORDER/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a dependency cycle disables the sequential prompt with the reason', async () => {
|
|
118
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
119
|
+
const edges = { 1: [2], 2: [1] };
|
|
120
|
+
const { bar, selection } = build(els, { getJSON: async (p) => ({ task: { dependencies: edges[Number(p.split('/').pop())].map((id) => ({ id })) } }) });
|
|
121
|
+
selection.claim.add('1'); selection.claim.add('2');
|
|
122
|
+
bar.render();
|
|
123
|
+
await settle();
|
|
124
|
+
assert.equal(els.sequential.disabled, true);
|
|
125
|
+
assert.match(els.sequential.title, /cycle/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a page without the optional buttons simply has no such feature', async () => {
|
|
129
|
+
const els = stubPage(['bar', 'count', 'claim', 'clear']);
|
|
130
|
+
const { bar, selection } = build(els);
|
|
131
|
+
selection.claim.add('1'); selection.claim.add('2');
|
|
132
|
+
assert.doesNotThrow(() => bar.render());
|
|
133
|
+
assert.doesNotThrow(() => bar.wire());
|
|
134
|
+
assert.equal(els.claim.textContent, 'Bulk claim (2)');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('Bulk claim and Bulk release go through the shared action and empty their selection', async () => {
|
|
138
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
139
|
+
const { bar, selection, calls } = build(els);
|
|
140
|
+
selection.claim.add('1'); selection.release.set('77', '9');
|
|
141
|
+
bar.wire();
|
|
142
|
+
await els.claim.listeners.click();
|
|
143
|
+
assert.deepEqual(calls.claimMany, [[1]]);
|
|
144
|
+
assert.equal(selection.claim.size, 0);
|
|
145
|
+
await els.release.listeners.click();
|
|
146
|
+
assert.deepEqual(calls.releaseMany, [[['77', '9']]]);
|
|
147
|
+
assert.equal(selection.release.size, 0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('Clear empties both selections and lets the page repaint its cards', () => {
|
|
151
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
152
|
+
const { bar, selection, calls } = build(els);
|
|
153
|
+
selection.claim.add('1'); selection.release.set('77', '9');
|
|
154
|
+
bar.clearSelection();
|
|
155
|
+
assert.equal(selection.claim.size + selection.release.size, 0);
|
|
156
|
+
assert.equal(calls.cleared, true);
|
|
157
|
+
assert.equal(els.bar.hidden, true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// --- the two boards use it, and neither keeps a private copy -----------------
|
|
161
|
+
|
|
162
|
+
test('both boards build the shared toolbar and carry all five buttons', () => {
|
|
163
|
+
const work = read('work.html');
|
|
164
|
+
const goals = read('goals.html');
|
|
165
|
+
for (const k of ['claim', 'release', 'parallel', 'sequential', 'clear']) {
|
|
166
|
+
assert.match(work, new RegExp(`id="bulk-${k}-btn"`), `work.html has bulk-${k}-btn`);
|
|
167
|
+
assert.match(goals, new RegExp(`id="goal-bulk-${k}-btn"`), `goals.html has goal-bulk-${k}-btn`);
|
|
168
|
+
}
|
|
169
|
+
assert.match(read('work.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
|
|
170
|
+
assert.match(read('goals-render.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
|
|
171
|
+
for (const [page, scripts] of [['work.html', ['bulk-toolbar.js', 'work.js']], ['goals.html', ['bulk-toolbar.js', 'goals-render.js']]]) {
|
|
172
|
+
const html = read(page);
|
|
173
|
+
const at = (n) => html.indexOf(`src="/builders/${n}`);
|
|
174
|
+
assert.ok(at(scripts[0]) > -1 && at(scripts[0]) < at(scripts[1]), `${page}: bulk-toolbar.js loads before ${scripts[1]}`);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('no board keeps a private copy of the toolbar logic', () => {
|
|
179
|
+
for (const f of ['work.js', 'goals-page.js', 'goals-render.js']) {
|
|
180
|
+
const src = read(f);
|
|
181
|
+
assert.doesNotMatch(src, /function topoOrder|function buildBulk(Parallel|Sequential)Prompt|function scheduleValidate|function doBulk(Claim|Release)|function doGoalBulkClaim/, `${f} re-grew toolbar logic`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('the goal board offers a release checkbox on the viewer\'s own cards, and it styles', () => {
|
|
186
|
+
const render = read('goals-render.js');
|
|
187
|
+
assert.match(render, /class="wip__select" data-claim-id=/, 'a mine-active card carries the release checkbox');
|
|
188
|
+
assert.match(read('goals-page.js'), /closest\('\.task__select, \.wip__select'\)/, 'the card list listens for it');
|
|
189
|
+
assert.match(read('style.css'), /\n\.wip__select \{/, '.wip__select is in the shared sheet the goal board loads');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('goals.html no longer borrows work.css, and work.css no longer holds what it lent', () => {
|
|
193
|
+
assert.doesNotMatch(read('goals.html'), /work\.css/, 'the goal board is off the board-only sheet');
|
|
194
|
+
const work = read('work.css');
|
|
195
|
+
for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
|
|
196
|
+
assert.ok(!new RegExp(`^${sel.replace('.', '\\.')}(\\s|:|,|\\{)`, 'm').test(work), `work.css must not restate ${sel}`);
|
|
197
|
+
}
|
|
198
|
+
const style = read('style.css');
|
|
199
|
+
for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
|
|
200
|
+
assert.ok(new RegExp(`^${sel.replace('.', '\\.')} \\{`, 'm').test(style), `style.css carries ${sel}`);
|
|
201
|
+
}
|
|
202
|
+
});
|
package/tests/claim_action.mjs
CHANGED
|
@@ -264,8 +264,8 @@ await test('both boards CONSTRUCT the shared action, not just its error text', (
|
|
|
264
264
|
|
|
265
265
|
await test('every claim/release entry point routes through it', () => {
|
|
266
266
|
const sites = {
|
|
267
|
-
'work.js': ['doClaim', 'doRelease'
|
|
268
|
-
'goals-page.js': ['doGoalClaim', 'doGoalRelease'
|
|
267
|
+
'work.js': ['doClaim', 'doRelease'],
|
|
268
|
+
'goals-page.js': ['doGoalClaim', 'doGoalRelease'],
|
|
269
269
|
};
|
|
270
270
|
for (const [f, fns] of Object.entries(sites)) {
|
|
271
271
|
const src = read('modules', 'hall-ui', 'public', f);
|
|
@@ -278,6 +278,15 @@ await test('every claim/release entry point routes through it', () => {
|
|
|
278
278
|
`${f}: ${fn} still maps its own refusal`);
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
|
+
// Both boards' BULK claim/release live in the shared toolbar now (task 1003141),
|
|
282
|
+
// built over the same action — so the same rule is checked there, once.
|
|
283
|
+
const bulk = read('modules', 'hall-ui', 'public', 'bulk-toolbar.js');
|
|
284
|
+
for (const fn of ['doBulkClaim', 'doBulkRelease']) {
|
|
285
|
+
const m = bulk.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
|
|
286
|
+
assert.ok(m, `bulk-toolbar.js: ${fn} not found`);
|
|
287
|
+
assert.match(m[0], /claimAction\.(claimMany|releaseMany)\(/, `bulk-toolbar.js: ${fn} does not go through the shared action`);
|
|
288
|
+
assert.doesNotMatch(m[0], /friendlyClaimError\(/, `bulk-toolbar.js: ${fn} maps its own refusal`);
|
|
289
|
+
}
|
|
281
290
|
});
|
|
282
291
|
|
|
283
292
|
await test('no call site reloads on top of the action\'s own refresh', async () => {
|
|
@@ -287,7 +296,7 @@ await test('no call site reloads on top of the action\'s own refresh', async ()
|
|
|
287
296
|
// twice. work.js's doBulkClaim is the correct shape: redraw the toolbar,
|
|
288
297
|
// never re-fetch. A finally that awaits a loader is the tell.
|
|
289
298
|
const src = read('modules', 'hall-ui', 'public', 'goals-page.js');
|
|
290
|
-
for (const fn of ['doGoalClaim', 'doGoalRelease'
|
|
299
|
+
for (const fn of ['doGoalClaim', 'doGoalRelease']) {
|
|
291
300
|
const m = src.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
|
|
292
301
|
assert.ok(m, `${fn} not found`);
|
|
293
302
|
assert.doesNotMatch(m[0], /finally \{[\s\S]*?await loadGoalTasks\(/,
|
|
@@ -393,7 +402,7 @@ await test('the cache-bust moved on every page whose script changed', () => {
|
|
|
393
402
|
]) {
|
|
394
403
|
const html = read('modules', 'hall-ui', 'public', page);
|
|
395
404
|
for (const s of scripts) {
|
|
396
|
-
assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-
|
|
405
|
+
assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-30-bulktoolbar'), `${page}: ${s} not busted`);
|
|
397
406
|
}
|
|
398
407
|
}
|
|
399
408
|
});
|
|
@@ -126,6 +126,7 @@ async function renderList() {
|
|
|
126
126
|
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
127
127
|
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
128
128
|
vm.runInContext(read('modules/hall-ui/public/claim-action.js'), sandbox, { filename: 'claim-action.js' });
|
|
129
|
+
vm.runInContext(read('modules/hall-ui/public/bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
|
|
129
130
|
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
130
131
|
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
131
132
|
vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
|
|
@@ -157,6 +157,8 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
|
|
|
157
157
|
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
158
158
|
vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
|
|
159
159
|
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
160
|
+
// task 1003141: goals-render.js builds the shared bulk toolbar at boot.
|
|
161
|
+
vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/bulk-toolbar.js'), 'utf8'), sandbox, { filename: 'bulk-toolbar.js' });
|
|
160
162
|
vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
|
|
161
163
|
vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
|
|
162
164
|
vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
|
|
@@ -80,6 +80,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
|
|
|
80
80
|
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
81
81
|
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
82
82
|
vm.runInContext(read('claim-action.js'), sandbox, { filename: 'claim-action.js' });
|
|
83
|
+
vm.runInContext(read('bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
|
|
83
84
|
vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
|
|
84
85
|
|
|
85
86
|
const state = { current: 7, forest: null, forestError: false };
|
|
@@ -171,7 +171,7 @@ function harness({ pendingRequests = [] } = {}) {
|
|
|
171
171
|
vm.createContext(sandbox);
|
|
172
172
|
// task 1003458: claim-action.js sits between task-lib and goals-render on the
|
|
173
173
|
// real page (goals.html); the stub mirrors that order.
|
|
174
|
-
for (const f of ['hall-kit.js', 'task-lib.js', 'claim-action.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
|
|
174
|
+
for (const f of ['hall-kit.js', 'task-lib.js', 'claim-action.js', 'bulk-toolbar.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
|
|
175
175
|
vm.runInContext(read(P + f), sandbox, { filename: f });
|
|
176
176
|
}
|
|
177
177
|
|
package/tests/goal_mine_lens.mjs
CHANGED
|
@@ -150,6 +150,7 @@ async function boot({
|
|
|
150
150
|
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
151
151
|
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
152
152
|
vm.runInContext(hall('claim-action.js'), sandbox, { filename: 'claim-action.js' });
|
|
153
|
+
vm.runInContext(hall('bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
|
|
153
154
|
vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
|
|
154
155
|
vm.runInContext(GOALS_PAGE_SRC, sandbox, { filename: 'goals-page.js' });
|
|
155
156
|
for (let i = 0; i < 30; i += 1) await new Promise((r) => setTimeout(r, 0));
|
|
@@ -80,7 +80,7 @@ test('.task IS the panel — --bg-card at the full radius, with no drawn edge at
|
|
|
80
80
|
// gets missed: "Git" came back 17x24 after a height-only fix.
|
|
81
81
|
for (const sel of ['.task__select', '.wip__select']) {
|
|
82
82
|
test(`${sel} is a 24px target with a 14px drawn box`, () => {
|
|
83
|
-
const css =
|
|
83
|
+
const css = styleCss; // .wip__select joined .task__select here when the goal board borrowed it (task 1003141)
|
|
84
84
|
const box = rule(css, sel);
|
|
85
85
|
assert.ok(box, `${sel} has a rule`);
|
|
86
86
|
assert.match(box, /width:\s*24px/, 'the HIT AREA is 24px — this is what the floor measures');
|
|
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
|
|
|
58
58
|
assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
|
|
59
59
|
assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
|
|
60
60
|
assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
|
|
61
|
-
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}<\/div>/, 'provenance, credit and
|
|
61
|
+
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}<\/div>/, 'provenance, credit, upkeep and (for a store version, task 1004366) the how-to link are the facts line');
|
|
62
62
|
assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
|
|
63
63
|
const css = read('modules.css');
|
|
64
64
|
assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');
|
package/tests/hall_mine_lens.mjs
CHANGED
|
@@ -204,6 +204,7 @@ function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
|
|
|
204
204
|
// task 1003458: the page loads claim-action.js before this script; the stub
|
|
205
205
|
// must mirror that, or the destructure of window.OTBClaimAction throws.
|
|
206
206
|
vm.runInContext(fs.readFileSync(path.join(HALL, 'claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
|
|
207
|
+
vm.runInContext(fs.readFileSync(path.join(HALL, 'bulk-toolbar.js'), 'utf8'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
|
|
207
208
|
vm.runInContext(WORK_JS, sandbox, { filename: 'work.js' });
|
|
208
209
|
(documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
|
|
209
210
|
|