@bongos/core 1.25.3 → 1.25.5
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 +72 -47
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +185 -3
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +17 -18
- package/docs/copy-registry.json +44 -43
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +43 -43
- package/modules/hall-ui/public/builders.js +3 -27
- package/modules/hall-ui/public/hall-render.js +5 -119
- package/modules/hall-ui/public/hall-widgets.js +3 -3
- package/modules/hall-ui/public/index.html +0 -7
- package/modules/hall-ui/public/shell-game.js +22 -4
- package/modules/hall-ui/public/style.css +3 -73
- package/modules/hall-ui/public/visor.css +31 -1
- package/modules/hall-ui/public/visor.js +53 -3
- package/modules/hall-ui/records/home-reshuffle.md +1 -1
- package/modules/hall-ui/records/visor.md +10 -0
- package/modules/provisioning/migrations/provisioning_037_update_subscribed.sql +21 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/routes/provisioning.js +4 -0
- package/modules/provisioning/routes/update-subscription.js +51 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/bongos/update-channel.js +27 -4
- package/scripts/bongos/update-sweep.js +17 -3
- package/src/bongos/api-errors.js +2 -0
- package/src/bongos/db.js +43 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/bongos/routes/builders.js +37 -0
- package/src/module-api.js +1 -1
- package/tests/builder_card_requirement.mjs +75 -0
- package/tests/hall_board_world.mjs +7 -35
- package/tests/hall_home_ships_bounded.mjs +11 -27
- package/tests/hall_landing_boot.mjs +7 -8
- package/tests/hall_nav.mjs +2 -2
- package/tests/hall_visor.mjs +9 -2
- package/tests/hall_visor_profile.mjs +150 -0
- package/tests/leaderboard_roles.mjs +3 -17
- package/tests/provisioning_update_subscription_route.mjs +141 -0
- package/tests/update_channel_db.mjs +24 -0
package/src/bongos/db.js
CHANGED
|
@@ -724,6 +724,48 @@ async function setBuilderTester({ builderId, isTester }) {
|
|
|
724
724
|
});
|
|
725
725
|
}
|
|
726
726
|
|
|
727
|
+
// setRequireCard — an Archon switches the hardware-card requirement on or off for one
|
|
728
|
+
// builder (task 1004748, ADR 0382 D3/D4). Switching ON needs at least one registered
|
|
729
|
+
// card, so nobody is locked out by the switch. The audit row is written in the SAME
|
|
730
|
+
// transaction as the flag, so a change cannot happen without its entry; unlike the
|
|
731
|
+
// generic request log it names the builder it was done TO.
|
|
732
|
+
async function setRequireCard({ builderId, required, actorBuilderId, ip = null, userAgent = null }) {
|
|
733
|
+
return withTx(async (client) => {
|
|
734
|
+
const { rows: found } = await client.query(
|
|
735
|
+
`SELECT id, system_role, require_card FROM builders WHERE id = $1 FOR UPDATE`,
|
|
736
|
+
[builderId]
|
|
737
|
+
);
|
|
738
|
+
const before = found[0];
|
|
739
|
+
if (!before) return null;
|
|
740
|
+
if (before.system_role) throw { code: 'TARGET_IS_SYSTEM_PRINCIPAL' };
|
|
741
|
+
if (required) {
|
|
742
|
+
const { rows: cards } = await client.query(
|
|
743
|
+
`SELECT 1 FROM passkey_credentials WHERE builder_id = $1 LIMIT 1 FOR SHARE`,
|
|
744
|
+
[builderId]
|
|
745
|
+
);
|
|
746
|
+
if (!cards[0]) throw { code: 'NO_CARD_REGISTERED' };
|
|
747
|
+
}
|
|
748
|
+
const { rows } = await client.query(
|
|
749
|
+
`UPDATE builders SET require_card = $2 WHERE id = $1
|
|
750
|
+
RETURNING id, github_login, display_name, rank, status, require_card`,
|
|
751
|
+
[builderId, !!required]
|
|
752
|
+
);
|
|
753
|
+
await client.query(
|
|
754
|
+
`INSERT INTO audit_log
|
|
755
|
+
(builder_id, route, method, request_body_redacted, response_status, ip, user_agent, surface)
|
|
756
|
+
VALUES ($1, $2, 'PATCH', $3, 200, $4, $5, NULL)`,
|
|
757
|
+
[
|
|
758
|
+
actorBuilderId,
|
|
759
|
+
`${require('./api-prefix').API_PREFIX}/builders/:id/card-requirement`,
|
|
760
|
+
{ target_builder_id: String(builderId), require_card: !!required, was: before.require_card === true },
|
|
761
|
+
ip,
|
|
762
|
+
userAgent,
|
|
763
|
+
]
|
|
764
|
+
);
|
|
765
|
+
return rows[0];
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
727
769
|
async function reactivateBuilder({ builderId, actorBuilderId }) {
|
|
728
770
|
return withTx(async (client) => {
|
|
729
771
|
const { rows } = await client.query(
|
|
@@ -1183,6 +1225,7 @@ module.exports = {
|
|
|
1183
1225
|
RANKS,
|
|
1184
1226
|
setBuilderRank,
|
|
1185
1227
|
setBuilderTester,
|
|
1228
|
+
setRequireCard,
|
|
1186
1229
|
getLatestRankChangeForBuilder,
|
|
1187
1230
|
deactivateBuilder,
|
|
1188
1231
|
reactivateBuilder,
|
|
@@ -194,6 +194,7 @@ const EXPECTED_RANKS = {
|
|
|
194
194
|
// builder authority — rank + lifecycle status mutation
|
|
195
195
|
'PATCH /builders/:id/rank': 'perm:builder.rank.set',
|
|
196
196
|
'PATCH /builders/:id/status': 'perm:builder.status.set',
|
|
197
|
+
'PATCH /builders/:id/card-requirement': 'perm:builder.rank.set',
|
|
197
198
|
// task 1004561: the Treasury desk sets a builder's monthly limit through it
|
|
198
199
|
'PATCH /builders/:id/budget': 'perm:builder.budget.set',
|
|
199
200
|
// session revocation (kill another builder's / all sessions)
|
|
@@ -421,6 +421,43 @@ module.exports = function buildBuildersRouter() {
|
|
|
421
421
|
}
|
|
422
422
|
});
|
|
423
423
|
|
|
424
|
+
// PATCH /builders/:id/card-requirement — an Archon switches the hardware-card
|
|
425
|
+
// requirement on or off for one builder (task 1004748, ADR 0382 D4). Body:
|
|
426
|
+
// { require_card: boolean }. Default is off for everyone. Switching ON refuses 409
|
|
427
|
+
// no_card_registered unless the builder has a registered card, so the switch cannot
|
|
428
|
+
// lock anyone out. You cannot switch OFF your own requirement (403
|
|
429
|
+
// cannot_disable_own_card_requirement) — a stolen session must not be able to drop
|
|
430
|
+
// it; another Archon does it. Every change writes an audit row naming the target.
|
|
431
|
+
// rank: archon. Gate: requirePermission('builder.rank.set') (the non-delegable atom that
|
|
432
|
+
// guards the step-up's own target route) then requireCardStepUp; read fresh per request.
|
|
433
|
+
router.patch('/builders/:id/card-requirement', auth.requireBuilder, auth.requirePermission('builder.rank.set'), requireCardStepUp, async (req, res) => {
|
|
434
|
+
if (validateOrRespond(req, res, { require_card: { required: true, type: 'boolean' } })) return;
|
|
435
|
+
const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
|
|
436
|
+
if (targetId === null) return;
|
|
437
|
+
const required = req.body.require_card;
|
|
438
|
+
if (!required && String(targetId) === String(req.builder.id)) {
|
|
439
|
+
return res.fail('cannot_disable_own_card_requirement', 403);
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const builder = await db.setRequireCard({
|
|
443
|
+
builderId: targetId,
|
|
444
|
+
required,
|
|
445
|
+
actorBuilderId: req.builder.id,
|
|
446
|
+
ip: req.ip || null,
|
|
447
|
+
userAgent: req.headers['user-agent'] || null,
|
|
448
|
+
});
|
|
449
|
+
if (!builder) return res.fail('builder_not_found', 404);
|
|
450
|
+
res.json({ builder });
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (err && err.code === 'TARGET_IS_SYSTEM_PRINCIPAL') return res.fail('target_is_system_principal', 403);
|
|
453
|
+
if (err && err.code === 'NO_CARD_REGISTERED') {
|
|
454
|
+
return res.fail('no_card_registered', 409, { message: 'That builder has no registered card yet; they must register one before the requirement can be switched on.' });
|
|
455
|
+
}
|
|
456
|
+
log.error('[gds] PATCH /builders/:id/card-requirement', err);
|
|
457
|
+
res.fail('card_requirement_change_failed', { status: 500, message: 'internal error' });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
424
461
|
// PATCH /builders/:id/capabilities — the owner gives or takes a builder's server
|
|
425
462
|
// access (task 1004950, ADR 0395). Body: { server: boolean }. A task that needs
|
|
426
463
|
// the server is shown to and claimable by a builder only while this is on.
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.25.
|
|
84
|
+
const CORE_VERSION = '1.25.5'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// tests/builder_card_requirement.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1004748 (ADR 0382 D3/D4) — an Archon switches the hardware-card requirement on
|
|
4
|
+
// per builder. DB-free: the shared pool's client is a recorder.
|
|
5
|
+
//
|
|
6
|
+
// Run: node tests/builder_card_requirement.mjs
|
|
7
|
+
|
|
8
|
+
import { strict as assert } from 'node:assert';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { makeRunner } from './helpers.mjs';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const { pool } = require('../src/bongos/pool');
|
|
14
|
+
const { test, summary } = makeRunner();
|
|
15
|
+
|
|
16
|
+
let builderRow; let hasCard; const sent = [];
|
|
17
|
+
pool.connect = async () => ({
|
|
18
|
+
query: async (sql, params = []) => {
|
|
19
|
+
const q = String(sql);
|
|
20
|
+
sent.push({ sql: q, params });
|
|
21
|
+
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(q)) return { rows: [] };
|
|
22
|
+
if (/FROM builders WHERE id/.test(q)) return { rows: builderRow ? [builderRow] : [] };
|
|
23
|
+
if (/FROM passkey_credentials/.test(q)) return { rows: hasCard ? [{}] : [] };
|
|
24
|
+
if (/UPDATE builders SET require_card/.test(q)) return { rows: [{ id: params[0], require_card: params[1] }] };
|
|
25
|
+
return { rows: [] };
|
|
26
|
+
},
|
|
27
|
+
release() {},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const { setRequireCard } = require('../src/bongos/db');
|
|
31
|
+
const reset = () => { sent.length = 0; };
|
|
32
|
+
|
|
33
|
+
await test('switching on refuses a builder with no registered card, writes nothing', async () => {
|
|
34
|
+
reset(); builderRow = { id: '7', system_role: null, require_card: false }; hasCard = false;
|
|
35
|
+
await assert.rejects(setRequireCard({ builderId: '7', required: true, actorBuilderId: '1' }), { code: 'NO_CARD_REGISTERED' });
|
|
36
|
+
assert.equal(sent.some((s) => /UPDATE builders|INSERT INTO audit_log/.test(s.sql)), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await test('switching on with a card sets the flag and audits the target in the same transaction', async () => {
|
|
40
|
+
reset(); hasCard = true;
|
|
41
|
+
const r = await setRequireCard({ builderId: '7', required: true, actorBuilderId: '1' });
|
|
42
|
+
assert.equal(r.require_card, true);
|
|
43
|
+
const audit = sent.find((s) => /INSERT INTO audit_log/.test(s.sql));
|
|
44
|
+
assert.ok(audit);
|
|
45
|
+
assert.equal(audit.params[0], '1');
|
|
46
|
+
assert.deepEqual(audit.params[2], { target_builder_id: '7', require_card: true, was: false });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await test('switching off needs no card and is audited', async () => {
|
|
50
|
+
reset(); hasCard = false; builderRow = { id: '7', system_role: null, require_card: true };
|
|
51
|
+
const r = await setRequireCard({ builderId: '7', required: false, actorBuilderId: '1' });
|
|
52
|
+
assert.equal(r.require_card, false);
|
|
53
|
+
assert.deepEqual(sent.find((s) => /INSERT INTO audit_log/.test(s.sql)).params[2].was, true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('unknown builder is null; a system principal is refused', async () => {
|
|
57
|
+
reset(); builderRow = null;
|
|
58
|
+
assert.equal(await setRequireCard({ builderId: '9', required: false, actorBuilderId: '1' }), null);
|
|
59
|
+
builderRow = { id: '9', system_role: 'runner', require_card: false };
|
|
60
|
+
await assert.rejects(setRequireCard({ builderId: '9', required: false, actorBuilderId: '1' }), { code: 'TARGET_IS_SYSTEM_PRINCIPAL' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await test('route: rank atom + step-up gate, self-disable refused, 409 mapped, errors classified', async () => {
|
|
64
|
+
const fs = await import('node:fs');
|
|
65
|
+
const src = fs.readFileSync(new URL('../src/bongos/routes/builders.js', import.meta.url), 'utf8');
|
|
66
|
+
const line = src.split(/\r?\n/).find((l) => l.includes("router.patch('/builders/:id/card-requirement'"));
|
|
67
|
+
assert.match(line, /requirePermission\('builder\.rank\.set'\), requireCardStepUp/);
|
|
68
|
+
assert.match(src, /cannot_disable_own_card_requirement', 403/);
|
|
69
|
+
assert.match(src, /NO_CARD_REGISTERED[\s\S]{0,80}no_card_registered', 409/);
|
|
70
|
+
const { classify } = require('../src/bongos/api-errors');
|
|
71
|
+
assert.equal(classify('no_card_registered'), 'conflict');
|
|
72
|
+
assert.equal(classify('cannot_disable_own_card_requirement'), 'forbidden');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
summary();
|
|
@@ -270,23 +270,6 @@ test('the band ends level: no align-items on the grid, so the two cards stretch'
|
|
|
270
270
|
'and the single-column fallback still exists below 768px (the task text said 860; the sheet says 768)');
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
-
test('the Standing card is the mock\'s: an identity line and a three-row fact ledger, no banner', () => {
|
|
274
|
-
const block = hallRenderJs.match(/const homeStanding = document\.getElementById\('home-standing-body'\);[\s\S]*?renderSystemNotices\(/);
|
|
275
|
-
assert.ok(block, 'the Home card render is where it was');
|
|
276
|
-
assert.match(block[0], /class="home-who"/, 'the identity line');
|
|
277
|
-
assert.match(block[0], /<dt>\$\{escapeHtml\(curLabel\(true\)\)\}<\/dt>/,
|
|
278
|
-
'the currency row takes its label from the branding contract at render time — a data-currency-label attribute is rebranded once at load, before this row exists');
|
|
279
|
-
for (const label of ['Leaderboard place', 'Preferred work']) {
|
|
280
|
-
assert.match(block[0], new RegExp(`<dt>${label}</dt>`), `the ledger row: ${label}`);
|
|
281
|
-
}
|
|
282
|
-
assert.match(block[0], /: PREFS_EMPTY\}/, 'the empty-preference copy is the one string both Standing surfaces share');
|
|
283
|
-
assert.doesNotMatch(block[0], /Banner\}/, 'no banner is stacked into the card any more');
|
|
284
|
-
for (const sel of ['.home-who', '.home-facts', '.home-facts dt', '.home-facts dd']) {
|
|
285
|
-
assert.ok(rule(styleCss, sel), `style.css styles ${sel}`);
|
|
286
|
-
}
|
|
287
|
-
assert.match(rule(styleCss, '.home-who__avatar') || '', /var\(--skeleton\)/, 'the avatar placeholder is the skeleton tone, not --bg-deep (pure black in dark)');
|
|
288
|
-
});
|
|
289
|
-
|
|
290
273
|
test('the system notices are inbox LINES, first in the card, and counted by its refresh()', () => {
|
|
291
274
|
// Anchored on the statement AFTER the markup, so a ';' inside the markup or its comments cannot cut it short.
|
|
292
275
|
const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);\n host\.addEventListener/);
|
|
@@ -322,7 +305,7 @@ test('the greeting reads /me only; the version tab and its progress read left Ho
|
|
|
322
305
|
|
|
323
306
|
// Boot hall-render.js against a stub DOM and a ctx of getters (the way
|
|
324
307
|
// builders.js builds it) and read what the greeting and the card actually say.
|
|
325
|
-
function bootHallRender({ me, progress = null,
|
|
308
|
+
function bootHallRender({ me, progress = null, ctxExtra = {} }) {
|
|
326
309
|
const els = new Map();
|
|
327
310
|
const makeEl = () => ({
|
|
328
311
|
dataset: {}, style: {}, textContent: '', hidden: false, _html: '', innerHTML: '',
|
|
@@ -364,7 +347,6 @@ function bootHallRender({ me, progress = null, leaderboard = [], ctxExtra = {} }
|
|
|
364
347
|
...ctxExtra,
|
|
365
348
|
};
|
|
366
349
|
const api = windowObj.OTBHallRender(ctx);
|
|
367
|
-
api.lbState.rows = leaderboard;
|
|
368
350
|
return { api, el: (id) => documentObj.getElementById(id) };
|
|
369
351
|
}
|
|
370
352
|
|
|
@@ -374,7 +356,6 @@ const ME = {
|
|
|
374
356
|
cost: { level: 'warn', spend_mtd_usd: 80, budget_mtd_usd: 100, pct_used: 80 },
|
|
375
357
|
needs_rebase_tasks: [{ id: 1003460 }],
|
|
376
358
|
};
|
|
377
|
-
const LEADERBOARD = [{ github_login: 'a', total_credits: 20000 }, { github_login: 'percy', total_credits: 14441 }, { github_login: 'b', total_credits: 3 }, { github_login: 'c', total_credits: 1 }];
|
|
378
359
|
const PROGRESS = { progress: [{ version_id: 'BONGOS-V1', status: 'building', total_count: 605, percent_complete: 75 }, { version_id: 'V2', status: 'planning', total_count: 9000, percent_complete: 0 }] };
|
|
379
360
|
|
|
380
361
|
test('RUN: the greeting names the builder, and writes nothing else', () => {
|
|
@@ -386,22 +367,13 @@ test('RUN: the greeting names the builder, and writes nothing else', () => {
|
|
|
386
367
|
assert.equal(el('home-version').textContent || '', '', 'no version tab is drawn');
|
|
387
368
|
});
|
|
388
369
|
|
|
389
|
-
test('RUN:
|
|
390
|
-
const { api, el } = bootHallRender({ me: ME
|
|
370
|
+
test('RUN: Home draws no Standing chip, your details are on the visor now (task 1005249)', () => {
|
|
371
|
+
const { api, el } = bootHallRender({ me: ME });
|
|
372
|
+
assert.equal(api.lbState, undefined, 'no leaderboard rows are kept for a place nobody shows');
|
|
373
|
+
assert.equal(api.setRecentShips, undefined, 'no shipped count is kept for a chip that is gone');
|
|
391
374
|
api.renderProfile(ME.builder);
|
|
392
|
-
|
|
393
|
-
assert.
|
|
394
|
-
assert.match(html, /<p class="home-who__handle">@percy<\/p>/);
|
|
395
|
-
// written from curLabel(true), not a data-currency-label attribute: brand-apply
|
|
396
|
-
// rebrands those once at load, and this row is stamped after /me answers
|
|
397
|
-
assert.match(html, /<dt>Credits<\/dt><dd>14,441<\/dd>/);
|
|
398
|
-
assert.match(html, /<dt>Leaderboard place<\/dt><dd>2nd of 4<\/dd>/);
|
|
399
|
-
assert.match(html, /<dt>Preferred work<\/dt><dd>Not set yet<\/dd>/, 'the mock\'s copy for an empty preference');
|
|
400
|
-
assert.doesNotMatch(html, /profile__(needs|budget|rebase)/, 'the banners are not in the card');
|
|
401
|
-
const withPrefs = bootHallRender({ me: ME, leaderboard: [] });
|
|
402
|
-
withPrefs.api.renderProfile({ ...ME.builder, preferred_disciplines: ['artisan', 'ui'] });
|
|
403
|
-
assert.match(withPrefs.el('home-standing-body').innerHTML, /<dd>artisan, ui<\/dd>/);
|
|
404
|
-
assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>Leaderboard place<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
|
|
375
|
+
// the stub document hands back a blank element for any id, so "drew nothing" is an empty one
|
|
376
|
+
assert.equal(el('home-standing-body').innerHTML || '', '', 'no Standing card is written');
|
|
405
377
|
});
|
|
406
378
|
|
|
407
379
|
// task 1005046: "Where you stand" left the retired Ranks page for Accomplishments,
|
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
// table and a count. This pins the bounded shape:
|
|
8
8
|
//
|
|
9
9
|
// 1. the route answers a small page plus `total`, counting only when it must;
|
|
10
|
-
// 2.
|
|
11
|
-
//
|
|
12
|
-
// newest ships or the count moved.
|
|
10
|
+
// 2. Home asks for no ships at all (task 1005249 removed the last count it
|
|
11
|
+
// showed); the Profile page reads its own ledger.
|
|
13
12
|
//
|
|
14
13
|
// (builders.js's own request is pinned in tests/hall_landing_boot.mjs, which
|
|
15
14
|
// already boots the real page.) DB-free: vm + stubs.
|
|
@@ -120,31 +119,16 @@ function loadRender({ view = 'home' } = {}) {
|
|
|
120
119
|
return { r, el, fetched, showView };
|
|
121
120
|
}
|
|
122
121
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
assert.equal(r.HOME_SHIPS_LIMIT,
|
|
130
|
-
r.
|
|
131
|
-
await new Promise((done) => setTimeout(done, 0));
|
|
132
|
-
assert.deepEqual(fetched, [], 'Home never asks for the full history');
|
|
133
|
-
assert.equal(el('standing-shipped').textContent, '3000', 'the chip shows the server total, not rows held');
|
|
134
|
-
assert.equal(el('recent-ships tbody').innerHTML, '', 'the ledger is untouched until its view opens');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
// task 1005050: the full ledger was the Accomplishments view's, and that view
|
|
138
|
-
// folded into the Profile page (whose Works ledger reads its own record). Home
|
|
139
|
-
// now NEVER asks for the whole history — not on a view change, not on a refresh.
|
|
140
|
-
test('Home never downloads the whole history, whatever view event or refresh arrives', async () => {
|
|
141
|
-
const { r, el, fetched, showView } = loadRender({ view: 'standing' });
|
|
142
|
-
r.setRecentShips(homePage(3000));
|
|
122
|
+
// task 1005249: the Standing chip, the last thing on Home that showed a shipped
|
|
123
|
+
// count, is gone, so Home keeps no ships at all — not a small page, not a count —
|
|
124
|
+
// and asks for none, whatever view event arrives.
|
|
125
|
+
test('Home keeps and asks for no ships at all, whatever view event arrives', async () => {
|
|
126
|
+
const { r, fetched, showView } = loadRender({ view: 'home' });
|
|
127
|
+
assert.equal(r.setRecentShips, undefined, 'no ships setter');
|
|
128
|
+
assert.equal(r.HOME_SHIPS_LIMIT, undefined, 'no small page size');
|
|
129
|
+
assert.equal(r.loadShipLedger, undefined, 'no ledger loader');
|
|
143
130
|
showView('standing');
|
|
144
|
-
r.setRecentShips(homePage(3001, 3001)); // this builder shipped
|
|
145
131
|
await new Promise((done) => setTimeout(done, 0));
|
|
146
|
-
assert.deepEqual(fetched, [], 'no
|
|
147
|
-
assert.equal(el('standing-shipped').textContent, '3001', 'the chip follows the newest total');
|
|
148
|
-
assert.equal(r.loadShipLedger, undefined, 'the ledger loader went with the view');
|
|
132
|
+
assert.deepEqual(fetched, [], 'no ships read from Home');
|
|
149
133
|
});
|
|
150
134
|
|
|
@@ -91,10 +91,9 @@ async function boot({ meStatus, settle = false }) {
|
|
|
91
91
|
wirePager: () => {},
|
|
92
92
|
},
|
|
93
93
|
OTBHallRender: () => ({
|
|
94
|
-
analyticsState: {},
|
|
94
|
+
analyticsState: {},
|
|
95
95
|
shipsPager: { page: 1 },
|
|
96
|
-
|
|
97
|
-
HOME_SHIPS_LIMIT: 5, setRecentShips: spy('setRecentShips'), populateShipVersionFilter: spy('populateShipVersionFilter'),
|
|
96
|
+
populateShipVersionFilter: spy('populateShipVersionFilter'),
|
|
98
97
|
mountHallWidgets: spy('mountHallWidgets'),
|
|
99
98
|
renderLanding: spy('renderLanding'),
|
|
100
99
|
renderCurveBody: spy('renderCurveBody'), renderGreeting: spy('renderGreeting'),
|
|
@@ -154,10 +153,10 @@ test('a signed-in builder still gets the hall', async () => {
|
|
|
154
153
|
|
|
155
154
|
// task 1004854: Home used to download the builder's WHOLE ship history here, on
|
|
156
155
|
// every load and every live refresh — ~3.4 s of database time for 3,000 ships, to
|
|
157
|
-
// fill a five-row table and a count.
|
|
158
|
-
|
|
156
|
+
// fill a five-row table and a count. Since task 1005249 it shows no ship count at
|
|
157
|
+
// all (the Standing chip went), so it asks for no ships and no leaderboard.
|
|
158
|
+
test('Home asks for no ships and no leaderboard', async () => {
|
|
159
159
|
const calls = await boot({ meStatus: 200, settle: true });
|
|
160
|
-
const
|
|
161
|
-
assert.deepEqual(
|
|
162
|
-
assert.ok(calls.rendered.includes('setRecentShips'), 'the small page is handed to the renderer');
|
|
160
|
+
const reads = calls.requests.filter((r) => r.includes('/me/recent-ships') || r.includes('/leaderboard'));
|
|
161
|
+
assert.deepEqual(reads, [], `Home still read: ${reads.join(', ')}`);
|
|
163
162
|
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -905,9 +905,9 @@ test('Accomplishments is gone from the sidebar, and every old link lands on your
|
|
|
905
905
|
}
|
|
906
906
|
// No view is left for them to open on the index.
|
|
907
907
|
assert.ok(!/id="view-standing"/.test(fs.readFileSync(path.join(HALL, 'index.html'), 'utf8')), 'the view is gone');
|
|
908
|
-
// Home's Standing card
|
|
908
|
+
// Home's Standing card went in task 1005249: the visor's you-block opens the profile now
|
|
909
|
+
// (tests/hall_visor_profile.mjs runs it).
|
|
909
910
|
const render = fs.readFileSync(path.join(HALL, 'hall-render.js'), 'utf8');
|
|
910
|
-
assert.match(render, /View full profile →/);
|
|
911
911
|
assert.ok(!/\$\('#profile-body'\)/.test(render), 'the Accomplishments band is not drawn any more');
|
|
912
912
|
const fn = render.slice(render.indexOf(' function renderProfile(builder) {'), render.indexOf(' function renderSystemNotices('));
|
|
913
913
|
assert.match(fn, /renderSystemNotices\(\{ rebaseTasks, need, actionNeeds, cost \}\)/, 'Home still gets every notice');
|
package/tests/hall_visor.mjs
CHANGED
|
@@ -503,9 +503,16 @@ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the
|
|
|
503
503
|
assert.equal(g.placeShadow, 'none', 'type on a plate needs no halo');
|
|
504
504
|
assert.equal(g.radius, '0px', 'no round thing on the glass');
|
|
505
505
|
assert.match(g.spriteClip, /^polygon\(/, 'the sprite sits in a cut box');
|
|
506
|
+
// task 1005249: you (left of the foot row) are a link to your profile now, so the
|
|
507
|
+
// stops run in reading order: you, then the accent button. Both take the inset ring.
|
|
508
|
+
const focused = () => page.evaluate(() => { const a = document.activeElement; const s = getComputedStyle(a); return { cls: a.className, outline: s.outlineStyle, ring: s.boxShadow }; });
|
|
506
509
|
await page.keyboard.press('Tab');
|
|
507
|
-
const
|
|
508
|
-
assert.equal(
|
|
510
|
+
const me = await focused();
|
|
511
|
+
assert.equal(me.cls, 'visor__me', 'you are the first stop, reading left to right');
|
|
512
|
+
assert.match(me.ring, /inset/, 'you take the inset ring too');
|
|
513
|
+
await page.keyboard.press('Tab');
|
|
514
|
+
const f = await focused();
|
|
515
|
+
assert.equal(f.cls, 'visor__main', 'the accent button is the next stop');
|
|
509
516
|
assert.equal(f.outline, 'none');
|
|
510
517
|
assert.match(f.ring, /inset/, 'focus is the inset two-tone ring (--press-focus)');
|
|
511
518
|
await ctx.close();
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// tests/hall_visor_profile.mjs — you on the visor (task 1005249).
|
|
2
|
+
//
|
|
3
|
+
// The owner's ask (2026-11-12): the Standing chip that sat top right of Home is gone;
|
|
4
|
+
// the visor's bottom-left you-block shows your details on hover and opens your whole
|
|
5
|
+
// profile, in the game's window over the place, on click. This runs the real visor,
|
|
6
|
+
// the real shell-game window and the real dialog in a browser, plus the pure facts in
|
|
7
|
+
// a vm, and checks Home's page no longer carries the chip.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/hall_visor_profile.mjs
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test, after } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import vm from 'node:vm';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
21
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
22
|
+
|
|
23
|
+
// ---------- the facts, in a vm ----------
|
|
24
|
+
function loadVisor(me) {
|
|
25
|
+
const doc = {
|
|
26
|
+
readyState: 'complete', body: null, documentElement: { dataset: {}, classList: { contains: () => false, toggle() {}, remove() {} } },
|
|
27
|
+
addEventListener() {}, querySelector: () => null, createElement: () => ({}),
|
|
28
|
+
};
|
|
29
|
+
const win = { document: doc, location: { pathname: '/', assign() {} }, addEventListener() {}, OTBCraftMode: { me: () => me } };
|
|
30
|
+
win.window = win;
|
|
31
|
+
vm.runInNewContext(read('visor.js'), win);
|
|
32
|
+
return win.OTBVisor;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('the card\'s facts come from /me: name, handle, rank, credits, karma, preferred work', () => {
|
|
36
|
+
const me = { builder: { display_name: 'Percy Jackson', github_login: 'percy', rank: 'metic', total_credits: 14441, karma: 96.4, preferred_disciplines: ['artisan', 'ui'] } };
|
|
37
|
+
const f = JSON.parse(JSON.stringify(loadVisor(me).meFacts(me)));
|
|
38
|
+
assert.deepEqual(f, { name: 'Percy Jackson', handle: '@percy', rank: 'Metic', credits: 14441, karma: 96, prefs: 'artisan, ui' });
|
|
39
|
+
const bare = JSON.parse(JSON.stringify(loadVisor(null).meFacts({ builder: { github_login: 'x' } })));
|
|
40
|
+
assert.equal(bare.name, 'x', 'no display name: the login');
|
|
41
|
+
assert.equal(bare.karma, 0);
|
|
42
|
+
assert.equal(bare.prefs, '', 'no preference: empty, and the card says "Not set yet"');
|
|
43
|
+
assert.equal(loadVisor(null).meFacts(null), null, 'before /me answers there is nothing to show');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('Home carries no Standing chip any more, and no sheet styles one', () => {
|
|
47
|
+
assert.doesNotMatch(read('index.html'), /hall-standing|home-standing-body/);
|
|
48
|
+
assert.doesNotMatch(read('style.css'), /\.hall-standing|\.home-who|\.home-facts/);
|
|
49
|
+
assert.doesNotMatch(read('hall-render.js'), /home-standing-body|hall-standing|leaderboardPlace/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------- in a browser ----------
|
|
53
|
+
const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
|
|
54
|
+
let browser = null;
|
|
55
|
+
try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
|
|
56
|
+
after(() => browser && browser.close());
|
|
57
|
+
const skip = !browser && 'no system Edge or Chrome';
|
|
58
|
+
|
|
59
|
+
const ORIGIN = 'http://hall.test';
|
|
60
|
+
const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' };
|
|
61
|
+
const SCENE = `<script>
|
|
62
|
+
window.OTBCraftMode = { character() { return 'artisan'; }, current() { return null; },
|
|
63
|
+
me() { return { builder: { display_name: 'Percy Jackson', github_login: 'percy', rank: 'metic', total_credits: 14441, karma: 96, preferred_disciplines: [] } }; },
|
|
64
|
+
spriteSrc(id) { return '/builders/characters/' + id + '.png'; } };
|
|
65
|
+
window.__visorScene = { name: 'test', place: 'Test city', actions: [], keys: [] };
|
|
66
|
+
</script>`;
|
|
67
|
+
const PAGE = (withWindow) => `<!doctype html><html data-theme="dark" data-faces="helm"><head><meta charset="utf-8">
|
|
68
|
+
<link rel="stylesheet" href="/builders/style.css"><link rel="stylesheet" href="/builders/visor.css"></head>
|
|
69
|
+
<body>${SCENE}<script src="/builders/hall-dialog.js"></script>${withWindow ? '<script src="/builders/shell-game.js"></script>' : ''}<script src="/builders/visor.js"></script></body></html>`;
|
|
70
|
+
|
|
71
|
+
async function open({ withWindow = true } = {}) {
|
|
72
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
73
|
+
await ctx.route(ORIGIN + '/**', (route) => {
|
|
74
|
+
const p = new URL(route.request().url()).pathname;
|
|
75
|
+
if (p === '/') return route.fulfill({ contentType: 'text/html', body: PAGE(withWindow) });
|
|
76
|
+
if (p === '/profile') return route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>profile</title><p id="pp">your profile</p><script src="/builders/shell-game.js"></script>' }); // the real page carries the game shell, which hands an Escape up
|
|
77
|
+
const f = path.join(HALL, p.replace(/^\/builders\//, ''));
|
|
78
|
+
if (p.startsWith('/builders/') && TYPES[path.extname(p)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(p)], body: fs.readFileSync(f) });
|
|
79
|
+
return route.fulfill({ status: 404, body: '' });
|
|
80
|
+
});
|
|
81
|
+
const page = await ctx.newPage();
|
|
82
|
+
const errors = [];
|
|
83
|
+
page.on('pageerror', (e) => errors.push(String(e.message)));
|
|
84
|
+
await page.addInitScript(() => { try { localStorage.setItem('hall-game', 'on'); document.documentElement.dataset.game = 'on'; window.OTB = { escapeHtml: (s) => String(s) }; } catch (_) { /* none */ } });
|
|
85
|
+
await page.goto(ORIGIN + '/');
|
|
86
|
+
await page.waitForFunction(() => !!document.querySelector('.visor__me'));
|
|
87
|
+
return { ctx, page, errors };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('hovering you on the visor shows your details; at rest they are hidden', { skip }, async () => {
|
|
91
|
+
const { ctx, page, errors } = await open();
|
|
92
|
+
const shown = () => page.evaluate(() => getComputedStyle(document.querySelector('.visor__card')).visibility);
|
|
93
|
+
assert.equal(await shown(), 'hidden', 'nothing over the scene at rest');
|
|
94
|
+
await page.hover('.visor__me');
|
|
95
|
+
await page.waitForFunction(() => getComputedStyle(document.querySelector('.visor__card')).opacity === '1');
|
|
96
|
+
assert.equal(await shown(), 'visible');
|
|
97
|
+
const text = await page.evaluate(() => [...document.querySelectorAll('.visor__card > span')].map((s) => s.innerText.replace(/\s+/g, ' ').trim()));
|
|
98
|
+
assert.match(text[0], /Percy Jackson @percy/i);
|
|
99
|
+
assert.match(text[1], /Rank Metic/i);
|
|
100
|
+
assert.match(text[1], /Credits 14,441/i);
|
|
101
|
+
assert.match(text[1], /Karma 96/i);
|
|
102
|
+
assert.match(text[1], /Preferred work Not set yet/i);
|
|
103
|
+
assert.match(text[2], /Open your profile/i);
|
|
104
|
+
const box = await page.evaluate(() => { const r = document.querySelector('.visor__card').getBoundingClientRect(); return { top: r.top, left: r.left }; });
|
|
105
|
+
assert.ok(box.top >= 0 && box.left >= 0, 'the card stays on screen');
|
|
106
|
+
assert.deepEqual(errors, []);
|
|
107
|
+
await ctx.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('clicking you opens your whole profile in the window over the place; Escape closes it', { skip }, async () => {
|
|
111
|
+
const { ctx, page, errors } = await open();
|
|
112
|
+
await page.click('.visor__me');
|
|
113
|
+
await page.waitForFunction(() => window.OTBOptionsOverlay.isOpen());
|
|
114
|
+
const w = await page.evaluate(() => ({
|
|
115
|
+
url: location.pathname,
|
|
116
|
+
src: document.querySelector('.opt-window__frame').getAttribute('src'),
|
|
117
|
+
label: document.querySelector('.opt-window__box').getAttribute('aria-label'),
|
|
118
|
+
close: document.querySelector('.opt-window__x').getAttribute('aria-label'),
|
|
119
|
+
}));
|
|
120
|
+
assert.equal(w.url, '/', 'the place stays: no navigation');
|
|
121
|
+
assert.equal(w.src, '/profile?embed=1');
|
|
122
|
+
assert.equal(w.label, 'Your profile');
|
|
123
|
+
assert.equal(w.close, 'Close your profile');
|
|
124
|
+
// the frame's own shell must be up before an Escape inside it can be handed up
|
|
125
|
+
await page.waitForFunction(() => { const f = document.querySelector('.opt-window__frame'); try { return !!(f && f.contentDocument.readyState === 'complete' && f.contentDocument.documentElement.dataset.embed === 'on'); } catch (_) { return false; } });
|
|
126
|
+
await page.keyboard.press('Escape');
|
|
127
|
+
await page.waitForFunction(() => !window.OTBOptionsOverlay.isOpen());
|
|
128
|
+
// the same window still opens Options under its own name
|
|
129
|
+
await page.evaluate(() => window.OTBOptionsOverlay.open(''));
|
|
130
|
+
assert.equal(await page.evaluate(() => document.querySelector('.opt-window__box').getAttribute('aria-label')), 'Options');
|
|
131
|
+
assert.equal(await page.evaluate(() => document.querySelector('.opt-window__frame').getAttribute('src')), '/settings?embed=1');
|
|
132
|
+
assert.deepEqual(errors, []);
|
|
133
|
+
await ctx.close();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('with no window to draw, you are still a link to your profile', { skip }, async () => {
|
|
137
|
+
const { ctx, page, errors } = await open({ withWindow: false });
|
|
138
|
+
const r = await page.evaluate(() => {
|
|
139
|
+
const me = document.querySelector('.visor__me');
|
|
140
|
+
const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
141
|
+
// stop the browser before it leaves, once the visor has had its say
|
|
142
|
+
window.addEventListener('click', (e) => { window.__left = !e.defaultPrevented; e.preventDefault(); });
|
|
143
|
+
me.querySelector('.visor__name').dispatchEvent(ev);
|
|
144
|
+
return { href: me.getAttribute('href'), followed: window.__left };
|
|
145
|
+
});
|
|
146
|
+
assert.equal(r.href, '/profile');
|
|
147
|
+
assert.equal(r.followed, true, 'the visor lets the link take you to the profile page');
|
|
148
|
+
assert.deepEqual(errors, []);
|
|
149
|
+
await ctx.close();
|
|
150
|
+
});
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// that craft earned, and a dash, not a 0, for a builder with no AI sessions.
|
|
17
17
|
// (Two crafts since task 1005141: the engineer is the artisan, and the
|
|
18
18
|
// ideator folded into the artist, so idea credits rank as artist credits.)
|
|
19
|
-
//
|
|
19
|
+
// 5. THE STATUS PAGE: the real function, run. (4, the Standing chip, went in task 1005249.)
|
|
20
20
|
|
|
21
21
|
import { strict as assert } from 'node:assert';
|
|
22
22
|
import { createRequire } from 'node:module';
|
|
@@ -234,7 +234,7 @@ await test('the Artists toggle shows only artists, ranked by the credits that cr
|
|
|
234
234
|
assert.equal(r.rows.length, FIX.leaderboard.length, 'Everyone brings every builder back');
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
// ---- 4. the standing card
|
|
237
|
+
// ---- 4. (the standing card, gone; extract() serves section 5) ---------------------
|
|
238
238
|
|
|
239
239
|
function extract(src, start, end) {
|
|
240
240
|
const a = src.indexOf(start);
|
|
@@ -243,21 +243,7 @@ function extract(src, start, end) {
|
|
|
243
243
|
return src.slice(a, b);
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
|
|
247
|
-
const src = read('modules/hall-ui/public/hall-render.js');
|
|
248
|
-
const code = extract(src, 'const lbState = { rows: [] };', ' return { lbState');
|
|
249
|
-
const sandbox = { rows: FIX.leaderboard };
|
|
250
|
-
vm.createContext(sandbox);
|
|
251
|
-
vm.runInContext(`${code}; lbState.rows = rows; this.place = leaderboardPlace; this.text = rolePlaceText;`, sandbox);
|
|
252
|
-
const art = sandbox.place('builder-7');
|
|
253
|
-
assert.equal(art.role.craft, 'artist');
|
|
254
|
-
assert.equal(sandbox.text(art), `#2 as an artist`, 'fourth overall, second among artists: something new, so it is said');
|
|
255
|
-
const top = sandbox.place(FIX.leaderboard[0].github_login);
|
|
256
|
-
assert.equal(sandbox.text(top), '', 'the top artisan is #1 either way, so nothing is added');
|
|
257
|
-
// task 1005054: the stats strip (and its rank tile) left Home; the Standing chip's card carries the role place
|
|
258
|
-
assert.match(src, /\$\{rolePlaceText\(rollPlace\) \? `<span title="Your place among the/);
|
|
259
|
-
assert.match(read('modules/hall-ui/public/builders.js'), /getJSON\(`\$\{API\}\/public\/leaderboard`\)/, 'the hall reads the list with the roles');
|
|
260
|
-
});
|
|
246
|
+
// (Its test of the Standing chip's role place went with the chip in task 1005249.)
|
|
261
247
|
|
|
262
248
|
// ---- 5. the status page -----------------------------------------------------------
|
|
263
249
|
|