@bongos/core 1.21.163 → 1.21.164
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 +54 -39
- package/.gitattributes +6 -0
- package/docs/copy-inventory.md +99 -99
- package/docs/copy-registry.json +149 -139
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +141 -306
- package/modules/copy-desk/page-status.js +3 -0
- package/modules/copy-desk/pages.js +5 -0
- package/modules/hall-ui/public/approval-queue-lib.js +4 -0
- package/modules/hall-ui/public/approval-queue.css +2 -0
- package/modules/hall-ui/public/approval-queue.js +1 -0
- package/modules/hall-ui/public/builders.js +6 -4
- package/modules/hall-ui/public/goal-inbox.js +100 -7
- package/modules/hall-ui/public/goals.js +2 -4
- package/modules/hall-ui/public/hall-render.js +39 -9
- package/modules/hall-ui/public/hall-widgets.js +20 -10
- package/modules/hall-ui/public/index.html +7 -0
- package/modules/hall-ui/public/style.css +95 -0
- package/modules/hall-ui/public/task-detail.js +8 -0
- package/modules/hall-ui/records/home-reshuffle.md +31 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/scripts/gds/copy-apply.js +65 -2
- package/scripts/gds/git-merge-regen.js +14 -0
- package/scripts/gds/page-reader.js +46 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +6 -0
- package/tests/hall_approval_queue.mjs +16 -0
- package/tests/hall_board_world.mjs +7 -6
- package/tests/home_attention_cards.mjs +119 -0
- package/tests/home_bounded_reads.mjs +1 -1
- package/tests/promote_goal_id.mjs +8 -0
- package/tests/tweak_batch_apply.mjs +40 -0
- package/tests/tweak_land_heal.mjs +139 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.164",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -10020,5 +10020,19 @@
|
|
|
10020
10020
|
"id": "1004773",
|
|
10021
10021
|
"text": "The Private option on the account page now opens with the artist line \"you are a ninja.\", and still tells people Private hides their page from everyone, them included."
|
|
10022
10022
|
}
|
|
10023
|
+
],
|
|
10024
|
+
"1.21.164": [
|
|
10025
|
+
{
|
|
10026
|
+
"id": "1004772",
|
|
10027
|
+
"text": "Promoting an idea from the home page's Triage button no longer fails with \"discipline was not supplied\". The Promote form now has a Craft choice (Engineer by default), so every promoted idea becomes a task."
|
|
10028
|
+
},
|
|
10029
|
+
{
|
|
10030
|
+
"id": "1004775",
|
|
10031
|
+
"text": "Approved page tweaks now land on their own. When two rounds were applied from the same main, the second used to hit a conflict in the page readings file and wait for a person; that file now merges page by page, so the second r"
|
|
10032
|
+
},
|
|
10033
|
+
{
|
|
10034
|
+
"id": "1004774",
|
|
10035
|
+
"text": "Home got the owner's reshuffle: a slimmer banner with the version's progress bar under it; your picture and rank at the top right, opening on hover into your standing card with a View full profile link; every Needs-attention s"
|
|
10036
|
+
}
|
|
10023
10037
|
]
|
|
10024
10038
|
}
|
|
@@ -458,17 +458,73 @@ function applyBatchToSources({ batch, registry, readSource }) {
|
|
|
458
458
|
return { applied: applied.sort(byOrder).map(({ order, ...rest }) => rest), refused, sources };
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
// findPinningTests({ applied, listTests, readFile }) -> [{ key, tests: [rel…] }]
|
|
462
|
+
//
|
|
463
|
+
// Task 1004775. A test that asserts a line's OLD words verbatim goes red the
|
|
464
|
+
// moment the round lands, which used to surface only in CI, after the artist had
|
|
465
|
+
// approved, and needed a person. So at APPLY time every applied line's old text is
|
|
466
|
+
// searched for in the test files (tests/ and every module's own tests folder), and each test that
|
|
467
|
+
// still carries it is named in the applied block and shown in the Approval queue.
|
|
468
|
+
// Nothing is edited: a test may be guarding what the words MEAN, and only a person
|
|
469
|
+
// can tell that apart from a test that pins the wording alone.
|
|
470
|
+
//
|
|
471
|
+
// The needle is the line's old text; where it has {…} holes, its longest literal
|
|
472
|
+
// run between them, and a needle under MIN_PIN_NEEDLE characters is skipped (a
|
|
473
|
+
// two-word label matches half the suite and would name nothing useful).
|
|
474
|
+
const MIN_PIN_NEEDLE = 12;
|
|
475
|
+
const PIN_TEST_RE = /\.(mjs|cjs|js)$/;
|
|
476
|
+
|
|
477
|
+
function pinNeedle(text) {
|
|
478
|
+
const runs = String(text == null ? '' : text).split('{…}').map((s) => s.trim());
|
|
479
|
+
const longest = runs.reduce((a, b) => (b.length > a.length ? b : a), '');
|
|
480
|
+
return longest.length >= MIN_PIN_NEEDLE ? longest : null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function listTestFiles({ root = REPO_ROOT, fsImpl = fs } = {}) {
|
|
484
|
+
const out = [];
|
|
485
|
+
const walk = (rel) => {
|
|
486
|
+
let ents;
|
|
487
|
+
try { ents = fsImpl.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { return; }
|
|
488
|
+
for (const e of ents) {
|
|
489
|
+
const r = `${rel}/${e.name}`;
|
|
490
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'fixtures') walk(r); } else if (PIN_TEST_RE.test(e.name)) out.push(r);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
walk('tests');
|
|
494
|
+
let mods = [];
|
|
495
|
+
try { mods = fsImpl.readdirSync(path.join(root, 'modules'), { withFileTypes: true }); } catch { mods = []; }
|
|
496
|
+
for (const m of mods) if (m.isDirectory()) walk(`modules/${m.name}/tests`);
|
|
497
|
+
return out.sort();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function findPinningTests({ applied, listTests = listTestFiles, readFile = (rel) => fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8') }) {
|
|
501
|
+
const wants = (applied || []).map((a) => ({ key: a.key, needle: pinNeedle(a.before) })).filter((w) => w.needle);
|
|
502
|
+
if (!wants.length) return [];
|
|
503
|
+
const hits = new Map();
|
|
504
|
+
for (const rel of listTests()) {
|
|
505
|
+
let src;
|
|
506
|
+
try { src = readFile(rel); } catch { continue; }
|
|
507
|
+
for (const w of wants) {
|
|
508
|
+
if (!src.includes(w.needle)) continue;
|
|
509
|
+
if (!hits.has(w.key)) hits.set(w.key, []);
|
|
510
|
+
hits.get(w.key).push(rel);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return wants.filter((w) => hits.has(w.key)).map((w) => ({ key: w.key, tests: hits.get(w.key) }));
|
|
514
|
+
}
|
|
515
|
+
|
|
461
516
|
// The ```page-tweak-applied``` block's content, from the page's NEW reading.
|
|
462
517
|
// lines_after is the non-shared lines' texts: shared shell lines are left out of
|
|
463
518
|
// drift on both sides (D8, builder pick 7), so they must be left out here too or
|
|
464
519
|
// every one of them would read as "removed" on the next drift check.
|
|
465
|
-
function appliedBlockFor({ pageId, readingPage, refused, appliedAt }) {
|
|
520
|
+
function appliedBlockFor({ pageId, readingPage, refused, appliedAt, pinnedTests = [] }) {
|
|
466
521
|
return {
|
|
467
522
|
page_id: pageId,
|
|
468
523
|
applied_at: appliedAt,
|
|
469
524
|
reading_hash_after: readingPage ? readingPage.reading_hash : null,
|
|
470
525
|
lines_after: readingPage ? (readingPage.lines || []).filter((l) => l && l.placement !== 'shared').map((l) => String(l.text)) : [],
|
|
471
526
|
refused: (refused || []).map((x) => ({ key: x.key, code: x.code })),
|
|
527
|
+
...(pinnedTests && pinnedTests.length ? { pinned_tests: pinnedTests.map((p) => ({ key: p.key, tests: p.tests.slice() })) } : {}),
|
|
472
528
|
};
|
|
473
529
|
}
|
|
474
530
|
|
|
@@ -564,7 +620,10 @@ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () =>
|
|
|
564
620
|
return 4;
|
|
565
621
|
}
|
|
566
622
|
const readingPage = (deps.readingFor || ((id) => readingFor(id, { readFile })))(batch.page_id);
|
|
567
|
-
const
|
|
623
|
+
const oldText = new Map(batch.lines.map((l) => [l.key, l.before]));
|
|
624
|
+
const pinnedTests = (deps.findPinningTests || findPinningTests)({ applied: result.applied.map((a) => ({ key: a.key, before: oldText.get(a.key) })) });
|
|
625
|
+
for (const p of pinnedTests) log(` ${p.key} the old words are still asserted by: ${p.tests.join(', ')} (update the test or it fails once this lands)`);
|
|
626
|
+
const block = appliedBlockFor({ pageId: batch.page_id, readingPage, refused: result.refused, appliedAt: now(), pinnedTests });
|
|
568
627
|
if (batchFile) {
|
|
569
628
|
log('copy-apply --batch: the page-tweak-applied block (a --batch-file run writes no task):');
|
|
570
629
|
log(pages.composeAppliedBlock(block));
|
|
@@ -684,6 +743,10 @@ module.exports = {
|
|
|
684
743
|
applyBatchToSources,
|
|
685
744
|
appliedBlockFor,
|
|
686
745
|
batchMain,
|
|
746
|
+
// task 1004775: tests that still assert a rewritten line's old words.
|
|
747
|
+
findPinningTests,
|
|
748
|
+
listTestFiles,
|
|
749
|
+
pinNeedle,
|
|
687
750
|
surfaceRootFor,
|
|
688
751
|
checkPatchPath,
|
|
689
752
|
locateOccurrence,
|
|
@@ -115,6 +115,8 @@ const REGENERABLE = new Set([
|
|
|
115
115
|
'docs/session-log-index.md',
|
|
116
116
|
'docs/copy-inventory.md',
|
|
117
117
|
'docs/copy-registry.json',
|
|
118
|
+
// task 1004775: merged per page (page-reader.js mergeReadingDocs), see the rule below.
|
|
119
|
+
'docs/page-readings.json',
|
|
118
120
|
...G.NESTED_DIRS.map((d) => `${d}/CLAUDE.md`),
|
|
119
121
|
...API_DOC_FILES,
|
|
120
122
|
...API_CLIENT_FILES,
|
|
@@ -170,6 +172,18 @@ function regenerateResolved(relpath, { base = '', ours = '', theirs = '' } = {})
|
|
|
170
172
|
if (relpath === 'docs/copy-registry.json') {
|
|
171
173
|
return { content: `${JSON.stringify(CI.buildRegistry(), null, 2)}\n`, ok: true };
|
|
172
174
|
}
|
|
175
|
+
// docs/page-readings.json (task 1004775): one entry per page, and a re-read needs a
|
|
176
|
+
// browser no merge has. Two approved page-tweak rounds re-read DIFFERENT pages and
|
|
177
|
+
// both rewrite the top counts line, so every pair conflicted and the second round
|
|
178
|
+
// stranded for a person. Merge it per page from the three sides and recompute the
|
|
179
|
+
// counts (page-reader.js mergeReadingDocs). Unlike the scans above it reads the
|
|
180
|
+
// merge sides, not the tree, so it is not provisional; a page BOTH sides re-read
|
|
181
|
+
// differently is a real conflict and stays one.
|
|
182
|
+
if (relpath === 'docs/page-readings.json') {
|
|
183
|
+
const PR = require('./page-reader');
|
|
184
|
+
const m = PR.mergeReadingDocs({ base, ours, theirs });
|
|
185
|
+
return m.ok ? { content: m.content, ok: true } : { content: null, ok: false };
|
|
186
|
+
}
|
|
173
187
|
if (relpath === 'docs/copy-inventory.md') {
|
|
174
188
|
return { content: CI.renderReport(CI.buildRegistry()), ok: true };
|
|
175
189
|
}
|
|
@@ -600,6 +600,50 @@ function mergeReadings(inventory, previous, fresh) {
|
|
|
600
600
|
return inventory.pages.map((p) => byId.get(p.id)).filter(Boolean);
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
// mergeReadingDocs({ base, ours, theirs }) -> { ok, content, conflicted }
|
|
604
|
+
//
|
|
605
|
+
// The merge rule for docs/page-readings.json (task 1004775). Two approved page
|
|
606
|
+
// tweak rounds branched from the same main each re-read THEIR page, and both
|
|
607
|
+
// rewrite the top `counts` line — so git conflicts on every pair, and the second
|
|
608
|
+
// round stranded as unresolvable_conflict until a person merged it by hand. A
|
|
609
|
+
// re-read needs a browser, which a merge never has, but it does not need one: the
|
|
610
|
+
// file is one entry per page, so the merge is per page. A page only one side
|
|
611
|
+
// changed takes that side's reading; a page both changed the same way takes it; a
|
|
612
|
+
// page both changed DIFFERENTLY is a real conflict (two rounds re-read one page —
|
|
613
|
+
// their source files conflict too) and is named, never guessed. The summary counts
|
|
614
|
+
// are recomputed from the merged pages and the whole file re-serialized, so the
|
|
615
|
+
// result is byte-for-byte what the reader itself would write for those pages.
|
|
616
|
+
// Pages keep ours' order; a page only theirs has goes where theirs had it.
|
|
617
|
+
function mergeReadingDocs({ base, ours, theirs }) {
|
|
618
|
+
const parse = (s) => { try { return s && String(s).trim() ? JSON.parse(s) : { pages: [] }; } catch { return null; } };
|
|
619
|
+
const b = parse(base), o = parse(ours), t = parse(theirs);
|
|
620
|
+
if (!b || !o || !t) return { ok: false, content: null, conflicted: ['(unreadable json)'] };
|
|
621
|
+
const map = (d) => new Map((d.pages || []).map((p) => [p.id, p]));
|
|
622
|
+
const B = map(b), O = map(o), T = map(t);
|
|
623
|
+
const same = (x, y) => JSON.stringify(x === undefined ? null : x) === JSON.stringify(y === undefined ? null : y);
|
|
624
|
+
const order = [...O.keys()];
|
|
625
|
+
for (const id of T.keys()) {
|
|
626
|
+
if (order.includes(id)) continue;
|
|
627
|
+
const at = (t.pages || []).findIndex((p) => p.id === id);
|
|
628
|
+
const prev = at > 0 ? t.pages[at - 1].id : null;
|
|
629
|
+
const i = prev ? order.indexOf(prev) : -1;
|
|
630
|
+
order.splice(i + 1, 0, id);
|
|
631
|
+
}
|
|
632
|
+
const pages = [];
|
|
633
|
+
const conflicted = [];
|
|
634
|
+
for (const id of order) {
|
|
635
|
+
const bv = B.get(id), ov = O.get(id), tv = T.get(id);
|
|
636
|
+
let pick;
|
|
637
|
+
if (same(ov, tv)) pick = ov;
|
|
638
|
+
else if (same(ov, bv)) pick = tv;
|
|
639
|
+
else if (same(tv, bv)) pick = ov;
|
|
640
|
+
else { conflicted.push(id); continue; }
|
|
641
|
+
if (pick) pages.push(pick);
|
|
642
|
+
}
|
|
643
|
+
if (conflicted.length) return { ok: false, content: null, conflicted };
|
|
644
|
+
return { ok: true, content: serialize(buildDoc(pages)), conflicted: [] };
|
|
645
|
+
}
|
|
646
|
+
|
|
603
647
|
function loadPrevious() {
|
|
604
648
|
try { return readJsonFile(READINGS_REL); } catch { return null; }
|
|
605
649
|
}
|
|
@@ -671,6 +715,8 @@ module.exports = {
|
|
|
671
715
|
filesHash, readingHash, buildMatcher, placeText, collectStrings, holeData,
|
|
672
716
|
mergeStates, assembleReading, serialize, buildDoc, checkReadings, planRenders,
|
|
673
717
|
captureInPage, mergeReadings,
|
|
718
|
+
// task 1004775: the merge driver's per-page rule for docs/page-readings.json.
|
|
719
|
+
mergeReadingDocs,
|
|
674
720
|
// task 1004322: /tweak renders the page the reader reads, through the same harness.
|
|
675
721
|
startHarness, SURFACE_MODULE, PINNED_CLOCK,
|
|
676
722
|
};
|
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.164'; // 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');
|
|
@@ -591,7 +591,13 @@ test('1003546 every REGENERABLE artifact has a producer, and every producer is i
|
|
|
591
591
|
.map((p) => [p, 'scripts/gds/gen-api-client.js']),
|
|
592
592
|
]);
|
|
593
593
|
|
|
594
|
+
// task 1004775 — docs/page-readings.json is merged per page FROM THE THREE MERGE
|
|
595
|
+
// SIDES (page-reader.js mergeReadingDocs), never by scanning the tree, so the
|
|
596
|
+
// driver's result is already final; its only producer opens a browser, which the
|
|
597
|
+
// server never has. It is the one REGENERABLE entry with no post-merge generator.
|
|
598
|
+
const MERGED_FROM_SIDES = new Set(['docs/page-readings.json']);
|
|
594
599
|
for (const p of regen.REGENERABLE) {
|
|
600
|
+
if (MERGED_FROM_SIDES.has(p)) continue;
|
|
595
601
|
const producer = PRODUCED_BY.get(p);
|
|
596
602
|
assert.ok(producer, `REGENERABLE knows ${p} but this test has no producer for it — name the generator that writes it`);
|
|
597
603
|
assert.ok(
|
|
@@ -380,4 +380,20 @@ await test('the dialog fits the viewport at every width: capped by the screen, t
|
|
|
380
380
|
assert.match(rule('.aq-line__text'), /overflow-wrap: anywhere/, 'a long word breaks inside its column');
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
// task 1004775: a test the applier found still asserting a line's old words is
|
|
384
|
+
// shown on that line, so the artist knows before approving, not CI after.
|
|
385
|
+
await test('a line whose old words a test still checks says so, through the real composer', () => {
|
|
386
|
+
const description = [
|
|
387
|
+
pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: [
|
|
388
|
+
{ key: 'L0003', section: 'hero', before: 'AI agents claim tasks and ship the code.', after: 'Agents write the code.', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' },
|
|
389
|
+
] }),
|
|
390
|
+
pages.composeAppliedBlock({ page_id: 'landing:index', applied_at: '2026-09-21T00:00:00Z', reading_hash_after: 'h2', lines_after: ['Agents write the code.'], refused: [], pinned_tests: [{ key: 'L0003', tests: ['tests/landing_page.mjs'] }] }),
|
|
391
|
+
].join('\n\n');
|
|
392
|
+
const tasks = [{ id: 51, status: 'completed', grade_passed: true, description, source_ref: pages.roundSourceRef('landing:index', 1), holder_id: null, artist_id: ME, artist_login: 'me' }];
|
|
393
|
+
const q = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'] });
|
|
394
|
+
const line = q.queue[0].lines[0];
|
|
395
|
+
assert.deepEqual(line.pinned_by, ['tests/landing_page.mjs']);
|
|
396
|
+
assert.equal(lib.lineRow(line, 'after').pinned, 'A test still checks the old words: tests/landing_page.mjs. It needs updating when this ships.');
|
|
397
|
+
});
|
|
398
|
+
|
|
383
399
|
summary();
|
|
@@ -266,7 +266,7 @@ test('the Standing card is the mock\'s: an identity line and a three-row fact le
|
|
|
266
266
|
assert.match(block[0], /class="home-who"/, 'the identity line');
|
|
267
267
|
assert.match(block[0], /<dt>\$\{escapeHtml\(curLabel\(true\)\)\}<\/dt>/,
|
|
268
268
|
'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');
|
|
269
|
-
for (const label of ['
|
|
269
|
+
for (const label of ['Leaderboard place', 'Preferred work']) {
|
|
270
270
|
assert.match(block[0], new RegExp(`<dt>${label}</dt>`), `the ledger row: ${label}`);
|
|
271
271
|
}
|
|
272
272
|
assert.match(block[0], /: PREFS_EMPTY\}/, 'the empty-preference copy is the one string both Standing surfaces share');
|
|
@@ -278,7 +278,8 @@ test('the Standing card is the mock\'s: an identity line and a three-row fact le
|
|
|
278
278
|
});
|
|
279
279
|
|
|
280
280
|
test('the system notices are inbox LINES, first in the card, and counted by its refresh()', () => {
|
|
281
|
-
|
|
281
|
+
// Anchored on the statement AFTER the markup, so a ';' inside the markup or its comments cannot cut it short.
|
|
282
|
+
const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);\n host\.addEventListener/);
|
|
282
283
|
assert.ok(mount, 'the inbox mounts its slices');
|
|
283
284
|
const sys = mount[1].indexOf('id="attention-system"');
|
|
284
285
|
const goals = mount[1].indexOf('id="attention-goals"');
|
|
@@ -378,7 +379,7 @@ test('RUN: the greeting names the builder, and the sub-line says how far the ver
|
|
|
378
379
|
const { api, el } = bootHallRender({ me: ME, progress: PROGRESS, claimable: 5 });
|
|
379
380
|
api.renderGreeting();
|
|
380
381
|
assert.match(el('hall-h1').textContent, /^Good (morning|afternoon|evening), Percy Jackson$/);
|
|
381
|
-
assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent
|
|
382
|
+
assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete. Five tasks are claimable right now.');
|
|
382
383
|
assert.equal(el('hall-sub').hidden, false);
|
|
383
384
|
});
|
|
384
385
|
|
|
@@ -388,7 +389,7 @@ test('RUN: each half of the sentence renders as its data lands, and the line hid
|
|
|
388
389
|
assert.equal(none.el('hall-sub').hidden, true, 'nothing to say yet');
|
|
389
390
|
const onlyProgress = bootHallRender({ me: ME, progress: PROGRESS });
|
|
390
391
|
onlyProgress.api.renderGreeting();
|
|
391
|
-
assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent
|
|
392
|
+
assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete.');
|
|
392
393
|
const zero = bootHallRender({ me: ME, progress: PROGRESS, claimable: 0 });
|
|
393
394
|
zero.api.renderGreeting();
|
|
394
395
|
assert.match(zero.el('hall-sub').textContent, /Nothing is claimable right now\.$/);
|
|
@@ -409,13 +410,13 @@ test('RUN: the Standing card carries the three facts, the place as "2nd of 4", a
|
|
|
409
410
|
// written from curLabel(true), not a data-currency-label attribute: brand-apply
|
|
410
411
|
// rebrands those once at load, and this row is stamped after /me answers
|
|
411
412
|
assert.match(html, /<dt>Credits<\/dt><dd>14,441<\/dd>/);
|
|
412
|
-
assert.match(html, /<dt>
|
|
413
|
+
assert.match(html, /<dt>Leaderboard place<\/dt><dd>2nd of 4<\/dd>/);
|
|
413
414
|
assert.match(html, /<dt>Preferred work<\/dt><dd>Not set yet<\/dd>/, 'the mock\'s copy for an empty preference');
|
|
414
415
|
assert.doesNotMatch(html, /profile__(needs|budget|rebase)/, 'the banners are not in the card');
|
|
415
416
|
const withPrefs = bootHallRender({ me: ME, leaderboard: [] });
|
|
416
417
|
withPrefs.api.renderProfile({ ...ME.builder, preferred_disciplines: ['engineer', 'ui'] });
|
|
417
418
|
assert.match(withPrefs.el('home-standing-body').innerHTML, /<dd>engineer, ui<\/dd>/);
|
|
418
|
-
assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>
|
|
419
|
+
assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>Leaderboard place<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
|
|
419
420
|
});
|
|
420
421
|
|
|
421
422
|
test('RUN: the three notices are inbox lines with a kind, a line and — where one exists — a door', () => {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// tests/home_attention_cards.mjs — the Needs-attention CARDS and their window (task 1004774).
|
|
2
|
+
//
|
|
3
|
+
// WHY. Home's attention inbox became one card per slice, and a click moves the slice itself into a
|
|
4
|
+
// <dialog> so its buttons keep working through the one delegated handler. That is DOM behaviour —
|
|
5
|
+
// moving nodes, the native dialog's open/close, a card derived from a heading — so it runs the REAL
|
|
6
|
+
// goal-inbox.js in a real browser (the ui-design kit's launcher) over stubbed feeds, rather than
|
|
7
|
+
// grepping the source. Skips when the machine has no system Edge or Chrome, as ui_design_kit_fit does.
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test, after } from 'node:test';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
|
|
18
|
+
const GOAL_INBOX = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'goal-inbox.js'), 'utf8');
|
|
19
|
+
|
|
20
|
+
let browser = null;
|
|
21
|
+
try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
|
|
22
|
+
after(() => browser && browser.close());
|
|
23
|
+
|
|
24
|
+
// Two feeds answer: the blockers queue (2 rows shown of a server total of 7) and the rot feed
|
|
25
|
+
// (1 row, with its timer). Every other feed 404s, which the inbox treats as "nothing here".
|
|
26
|
+
const STUBS = `
|
|
27
|
+
window.__FEEDS = {
|
|
28
|
+
'/api/bongos/blockers?limit=5': { blockers: [{ id: 1, title: 'Key needed' }, { id: 2, title: 'Pick a host' }], page: { total: 7 } },
|
|
29
|
+
'/api/bongos/inbox/rotting': { timer: { rot_days: 30 }, rows: [{ tier: 'task', id: 9, title: 'Old task', status: 'ready', goal_id: 3,
|
|
30
|
+
verbs: [{ verb: 'water', label: 'Water', path: '/api/bongos/tasks/:id/water' }] }] },
|
|
31
|
+
};
|
|
32
|
+
window.BongosClient = { createClient: () => ({ request: async (m, p) => {
|
|
33
|
+
const d = window.__FEEDS[p];
|
|
34
|
+
return d ? { ok: true, status: 200, data: d } : { ok: false, status: 404, data: null };
|
|
35
|
+
} }) };
|
|
36
|
+
window.OTBKit = { esc: (s) => String(s), fmtAbsDate: () => 'Oct 1' };
|
|
37
|
+
window.OTBBlockersLib = { blockerFacts: (b) => ({ titleHtml: '<a class="ginbox__goal">' + b.title + '</a>', factsHtml: '', noteHtml: '' }) };
|
|
38
|
+
window.OTB = {
|
|
39
|
+
escapeHtml: (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
40
|
+
toast() {}, rankLabel: (r) => r,
|
|
41
|
+
contributeHallWidget(w) { window.__widget = w; },
|
|
42
|
+
};`;
|
|
43
|
+
|
|
44
|
+
const PAGE = '<!doctype html><html><body><section id="attention-scroll"></section></body></html>';
|
|
45
|
+
|
|
46
|
+
async function bootInbox() {
|
|
47
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
48
|
+
const page = await ctx.newPage();
|
|
49
|
+
await page.setContent(PAGE);
|
|
50
|
+
await page.addScriptTag({ content: STUBS });
|
|
51
|
+
await page.addScriptTag({ content: GOAL_INBOX });
|
|
52
|
+
await page.evaluate(() => window.__widget.mount(document.getElementById('attention-scroll')));
|
|
53
|
+
await page.waitForFunction(() => document.querySelectorAll('.attn-card').length === 2);
|
|
54
|
+
return { ctx, page };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cards = (page) => page.evaluate(() => [...document.querySelectorAll('.attn-card')].map((c) => ({
|
|
58
|
+
open: c.getAttribute('data-attn-open'),
|
|
59
|
+
n: c.querySelector('.attn-card__n').textContent,
|
|
60
|
+
t: c.querySelector('.attn-card__t').textContent,
|
|
61
|
+
s: c.querySelector('.attn-card__s') ? c.querySelector('.attn-card__s').textContent : null,
|
|
62
|
+
})));
|
|
63
|
+
|
|
64
|
+
test('one card per non-empty slice, titled from its heading, carrying the SERVER total', { skip: !browser && 'no system Edge or Chrome' }, async () => {
|
|
65
|
+
const { ctx, page } = await bootInbox();
|
|
66
|
+
const got = await cards(page);
|
|
67
|
+
await ctx.close();
|
|
68
|
+
// Slice order is the card order: blockers sit before rot in the mount.
|
|
69
|
+
assert.deepEqual(got, [
|
|
70
|
+
{ open: 'attention-blockers', n: '7', t: 'Requires project owner fix', s: null },
|
|
71
|
+
{ open: 'attention-rot', n: '1', t: 'Things not moving', s: 'untouched 30+ days' },
|
|
72
|
+
], 'the blockers card says 7 (page.total), not the 2 rows the slice drew; empty slices get no card');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a card opens its slice in the window, and the X puts it back', { skip: !browser && 'no system Edge or Chrome' }, async () => {
|
|
76
|
+
const { ctx, page } = await bootInbox();
|
|
77
|
+
await page.click('[data-attn-open="attention-rot"]');
|
|
78
|
+
const opened = await page.evaluate(() => ({
|
|
79
|
+
open: document.getElementById('attention-dialog').open,
|
|
80
|
+
title: document.getElementById('attention-dialog-h').textContent,
|
|
81
|
+
inDialog: !!document.querySelector('#attention-dialog-body > #attention-rot'),
|
|
82
|
+
verb: !!document.querySelector('#attention-dialog [data-rot-verb="water"]'),
|
|
83
|
+
}));
|
|
84
|
+
assert.deepEqual(opened, { open: true, title: 'Things not moving', inDialog: true, verb: true },
|
|
85
|
+
'the SAME slice node moved into the dialog, with its action buttons');
|
|
86
|
+
await page.click('#attention-dialog-x');
|
|
87
|
+
const closed = await page.evaluate(() => ({
|
|
88
|
+
open: document.getElementById('attention-dialog').open,
|
|
89
|
+
back: !!document.querySelector('#attention-slices > #attention-rot'),
|
|
90
|
+
cards: document.querySelectorAll('.attn-card').length,
|
|
91
|
+
}));
|
|
92
|
+
await ctx.close();
|
|
93
|
+
assert.deepEqual(closed, { open: false, back: true, cards: 2 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('closing natively (Escape) also returns the slice, so a reopen still finds it', { skip: !browser && 'no system Edge or Chrome' }, async () => {
|
|
97
|
+
const { ctx, page } = await bootInbox();
|
|
98
|
+
await page.click('[data-attn-open="attention-blockers"]');
|
|
99
|
+
await page.keyboard.press('Escape');
|
|
100
|
+
await page.waitForFunction(() => !document.getElementById('attention-dialog').open);
|
|
101
|
+
const back = await page.evaluate(() => !!document.querySelector('#attention-slices > #attention-blockers'));
|
|
102
|
+
assert.equal(back, true, 'the dialog close event moves the slice home');
|
|
103
|
+
await page.click('[data-attn-open="attention-blockers"]');
|
|
104
|
+
const rows = await page.evaluate(() => document.querySelectorAll('#attention-dialog-body .ginbox__item').length);
|
|
105
|
+
await ctx.close();
|
|
106
|
+
assert.equal(rows, 2, 'the reopened window shows the slice rows again');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a window whose slice empties while open closes itself', { skip: !browser && 'no system Edge or Chrome' }, async () => {
|
|
110
|
+
const { ctx, page } = await bootInbox();
|
|
111
|
+
await page.click('[data-attn-open="attention-rot"]');
|
|
112
|
+
await page.evaluate(() => { document.getElementById('attention-rot').innerHTML = ''; window.OTBAttention.refresh(); });
|
|
113
|
+
const state = await page.evaluate(() => ({
|
|
114
|
+
open: document.getElementById('attention-dialog').open,
|
|
115
|
+
cards: [...document.querySelectorAll('.attn-card')].map((c) => c.getAttribute('data-attn-open')),
|
|
116
|
+
}));
|
|
117
|
+
await ctx.close();
|
|
118
|
+
assert.deepEqual(state, { open: false, cards: ['attention-blockers'] });
|
|
119
|
+
});
|
|
@@ -60,7 +60,7 @@ await test('no Home read downloads a feed and slices it in the browser', () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
await test('the two cards ask for exactly the rows they draw', () => {
|
|
63
|
-
assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=
|
|
63
|
+
assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=5/, 'Next up draws five rows, level with Recently shipped');
|
|
64
64
|
assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card draws five');
|
|
65
65
|
// …and no longer re-slices what came back.
|
|
66
66
|
const src = noComments(BUILDERS_JS);
|
|
@@ -227,6 +227,14 @@ await test('BEHAVIOUR: a failed create throws with the status, it does not retur
|
|
|
227
227
|
// STATIC — the surfaces a unit test cannot invoke (browser DOM strings, docs).
|
|
228
228
|
// ==========================================================================
|
|
229
229
|
|
|
230
|
+
// The hall promote once sent no discipline, and an idea whose wording names no
|
|
231
|
+
// craft was refused "discipline was not supplied and could not be inferred".
|
|
232
|
+
await test('BOTH PATHS: the hall promote payload always sends a discipline', () => {
|
|
233
|
+
assert.match(HALL, /discipline: \(form\.discipline && form\.discipline\.value\) \|\| 'engineer'/,
|
|
234
|
+
'hall: discipline must reach the create payload, defaulting to engineer');
|
|
235
|
+
assert.match(HALL, /<select name="discipline">/, 'hall: the promote form offers a Craft picker');
|
|
236
|
+
});
|
|
237
|
+
|
|
230
238
|
// ---- 4. both paths send goal_id -------------------------------------------
|
|
231
239
|
|
|
232
240
|
await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
|
|
@@ -198,6 +198,8 @@ function harness({ batch, source = 'page-tweak', registryOverride = null, reread
|
|
|
198
198
|
registry: REG,
|
|
199
199
|
regenerateRegistry: () => ({ reg: registryOverride || registryOver(Object.fromEntries(Object.keys(tree).map((rel) => [rel, files.get(abs(rel))]))) }),
|
|
200
200
|
reread: () => (rereadOk ? { ok: true } : { ok: false, code: 'no_browser' }),
|
|
201
|
+
// Hermetic by default: the real finder scans the repo's own tests (task 1004775's case below injects a tree).
|
|
202
|
+
findPinningTests: () => [],
|
|
201
203
|
readingFor: () => ({ reading_hash: 'r-after', lines: [{ key: 'L0001', text: 'Skip to content', placement: 'shared' }, { key: 'L0002', text: 'Welcome to your studio', placement: 'placed' }] }),
|
|
202
204
|
api: async (method, url, body) => {
|
|
203
205
|
calls.push({ method, url, body });
|
|
@@ -299,4 +301,42 @@ await test("--page: the round's own page applies as usual", async () => {
|
|
|
299
301
|
assert.ok(h.writes.length > 0);
|
|
300
302
|
});
|
|
301
303
|
|
|
304
|
+
// task 1004775: a test that asserts a rewritten line's OLD words verbatim is named
|
|
305
|
+
// in the applied block at apply time, not discovered by CI after the artist approved.
|
|
306
|
+
await test('copy-apply names a test that pins a line\'s old text in the applied block', async () => {
|
|
307
|
+
const testTree = {
|
|
308
|
+
'tests/landing_fixture.mjs': "assert.match(html, /x/);\nassert.ok(html.includes('Take a page and rewrite every word of it.'));\n",
|
|
309
|
+
'modules/copy-desk/tests/other.mjs': "assert.ok(s.includes('nothing relevant here'));\n",
|
|
310
|
+
'tests/short_label.mjs': "assert.ok(s.includes('Take next'));\n",
|
|
311
|
+
};
|
|
312
|
+
const h = harness({ batch: BATCH() });
|
|
313
|
+
h.deps.findPinningTests = (args) => apply.findPinningTests({ ...args, listTests: () => Object.keys(testTree), readFile: (rel) => testTree[rel] });
|
|
314
|
+
const logs = [];
|
|
315
|
+
h.deps.log = (s) => logs.push(s);
|
|
316
|
+
const code = await apply.batchMain({ taskId: '5001', now: () => 't' }, h.deps);
|
|
317
|
+
assert.equal(code, 0);
|
|
318
|
+
const applied = pages.parseAppliedBlock(h.calls.find((c) => c.method === 'PATCH').body.description);
|
|
319
|
+
assert.deepEqual(applied.applied.pinned_tests, [{ key: 'L0002', tests: ['tests/landing_fixture.mjs'] }],
|
|
320
|
+
'the pinning test is named against its line; a short label (Take next) is too common to search for');
|
|
321
|
+
assert.ok(logs.some((l) => /L0002 the old words are still asserted by: tests\/landing_fixture\.mjs/.test(l)), 'and said on the terminal');
|
|
322
|
+
assert.equal(testTree['tests/landing_fixture.mjs'].includes('rewrite every word'), true, 'the test itself is never edited');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await test('a hole in the old text searches its longest literal run', () => {
|
|
326
|
+
assert.equal(apply.pinNeedle('You have {…} pages waiting for review'), 'pages waiting for review');
|
|
327
|
+
assert.equal(apply.pinNeedle('Take next'), null);
|
|
328
|
+
const hits = apply.findPinningTests({
|
|
329
|
+
applied: [{ key: 'L0003', before: 'You have {…} pages waiting for review' }],
|
|
330
|
+
listTests: () => ['tests/a.mjs'], readFile: () => '`You have ${n} pages waiting for review`',
|
|
331
|
+
});
|
|
332
|
+
assert.deepEqual(hits, [{ key: 'L0003', tests: ['tests/a.mjs'] }]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await test('the real test lister walks tests/ and modules/*/tests/, skipping fixtures', () => {
|
|
336
|
+
const list = apply.listTestFiles();
|
|
337
|
+
assert.ok(list.includes('tests/tweak_batch_apply.mjs'));
|
|
338
|
+
assert.ok(list.includes('modules/copy-desk/tests/copy_page_status.mjs'));
|
|
339
|
+
assert.ok(!list.some((f) => f.includes('/fixtures/')));
|
|
340
|
+
});
|
|
341
|
+
|
|
302
342
|
summary();
|