@bongos/core 1.19.481 → 1.19.483
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 +26 -26
- package/docs/copy-inventory.md +137 -131
- package/docs/copy-registry.json +227 -173
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/builders.js +20 -2
- package/modules/hall-ui/public/goal-inbox.js +7 -0
- package/modules/hall-ui/public/hall-render.js +108 -17
- package/modules/hall-ui/public/idea.css +2 -1
- package/modules/hall-ui/public/idea.html +1 -0
- package/modules/hall-ui/public/idea.js +9 -3
- package/modules/hall-ui/public/index.html +11 -1
- package/modules/hall-ui/public/md-reader.js +4 -1
- package/modules/hall-ui/public/record.css +61 -1
- package/modules/hall-ui/public/style.css +70 -1
- package/modules/hall-ui/public/task.html +1 -0
- package/modules/hall-ui/public/task.js +28 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_board_world.mjs +199 -0
- package/tests/hall_reading_world.mjs +12 -0
- package/tests/hall_record_world.mjs +81 -1
|
@@ -220,3 +220,202 @@ test('the link a list writes carries WHERE it was opened from — path, query an
|
|
|
220
220
|
sandbox.location.hash = '#backlog';
|
|
221
221
|
assert.equal(api.href('7'), '/task/7?from=%2Fwork%23backlog', 'on the builders host the hall is mounted at /');
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
// ---- 8. Home matches its board mock (task 1003464) ---------------------------
|
|
225
|
+
//
|
|
226
|
+
// The owner compared live Home against docs/design/mocks/hall/board/v3.html and
|
|
227
|
+
// found four gaps the floors cannot see: the greeting was never built (the page
|
|
228
|
+
// opened cold on a row of numbers, its h1 sr-only), the band ended ragged
|
|
229
|
+
// (`align-items: start` let Standing shrink under Goals), the Standing card had
|
|
230
|
+
// no fact ledger, and the system banners stacked inside it against the
|
|
231
|
+
// placement rule. A floor measures overflow and contrast; none compares a page
|
|
232
|
+
// to the mock it was built from — so these pins do, statically and then by
|
|
233
|
+
// RUNNING hall-render.js against a stub DOM.
|
|
234
|
+
|
|
235
|
+
const indexHtml = read('index.html');
|
|
236
|
+
const hallRenderJs = read('hall-render.js');
|
|
237
|
+
const buildersJs = read('builders.js');
|
|
238
|
+
const goalInboxJs = read('goal-inbox.js');
|
|
239
|
+
|
|
240
|
+
test('the greeting IS the page\'s one h1, in the board\'s label voice, with a sub-line beside it', () => {
|
|
241
|
+
const h1s = indexHtml.match(/<h1\b/g) || [];
|
|
242
|
+
assert.equal(h1s.length, 1, 'one-header-main-h1: the greeting must BECOME the h1, never add a second');
|
|
243
|
+
assert.match(indexHtml, /<div class="page-head page-head--board page-head--hall">\s*<h1 id="hall-h1" class="page-head__h">Builders' Hall<\/h1>\s*<p id="hall-sub" class="page-head__sub" hidden><\/p>/,
|
|
244
|
+
'the h1 is visible (no sr-only), keeps the id brand-apply.js writes into, and the sub-line ships hidden');
|
|
245
|
+
assert.doesNotMatch(indexHtml, /id="hall-h1" class="sr-only"/, 'the sr-only h1 is gone');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('the band ends level: no align-items on the grid, so the two cards stretch', () => {
|
|
249
|
+
const band = rule(styleCss, '.view-zone--band');
|
|
250
|
+
assert.ok(band, '.view-zone--band has a rule');
|
|
251
|
+
assert.doesNotMatch(band, /align-items/, 'the mock\'s .band declares none — stretch is what makes Standing end where Goals ends');
|
|
252
|
+
assert.match(styleCss.replace(/\/\*[\s\S]*?\*\//g, ''), /@media \(max-width:\s*768px\)[\s\S]*?\.view-zone--band \{ grid-template-columns: 1fr; \}/,
|
|
253
|
+
'and the single-column fallback still exists below 768px (the task text said 860; the sheet says 768)');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('the Standing card is the mock\'s: an identity line and a three-row fact ledger, no banner', () => {
|
|
257
|
+
const block = hallRenderJs.match(/const homeStanding = document\.getElementById\('home-standing-body'\);[\s\S]*?renderSystemNotices\(/);
|
|
258
|
+
assert.ok(block, 'the Home card render is where it was');
|
|
259
|
+
assert.match(block[0], /class="home-who"/, 'the identity line');
|
|
260
|
+
assert.match(block[0], /<dt>\$\{escapeHtml\(curLabel\(true\)\)\}<\/dt>/,
|
|
261
|
+
'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');
|
|
262
|
+
for (const label of ['On the board', 'Preferred work']) {
|
|
263
|
+
assert.match(block[0], new RegExp(`<dt>${label}</dt>`), `the ledger row: ${label}`);
|
|
264
|
+
}
|
|
265
|
+
assert.match(block[0], /: PREFS_EMPTY\}/, 'the empty-preference copy is the one string both Standing surfaces share');
|
|
266
|
+
assert.doesNotMatch(block[0], /Banner\}/, 'no banner is stacked into the card any more');
|
|
267
|
+
for (const sel of ['.home-who', '.home-facts', '.home-facts dt', '.home-facts dd']) {
|
|
268
|
+
assert.ok(rule(styleCss, sel), `style.css styles ${sel}`);
|
|
269
|
+
}
|
|
270
|
+
assert.match(rule(styleCss, '.home-who__avatar') || '', /var\(--skeleton\)/, 'the avatar placeholder is the skeleton tone, not --bg-deep (pure black in dark)');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('the system notices are inbox LINES, first in the card, and counted by its refresh()', () => {
|
|
274
|
+
const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);/);
|
|
275
|
+
assert.ok(mount, 'the inbox mounts its slices');
|
|
276
|
+
const sys = mount[1].indexOf('id="attention-system"');
|
|
277
|
+
const goals = mount[1].indexOf('id="attention-goals"');
|
|
278
|
+
assert.ok(sys > -1 && sys < goals, '#attention-system is a slice, ahead of the goals slice');
|
|
279
|
+
assert.match(hallRenderJs, /function renderSystemNotices\(/, 'hall-render.js renders them from the /me state it already holds');
|
|
280
|
+
const fn = hallRenderJs.match(/function renderSystemNotices\([\s\S]*?\n \}\n/);
|
|
281
|
+
assert.ok(fn, 'renderSystemNotices is a whole function');
|
|
282
|
+
assert.match(fn[0], /class="ginbox__item ginbox__item--/, 'rows carry .ginbox__item, so OTBAttention.refresh() counts them');
|
|
283
|
+
assert.match(fn[0], /OTBAttention[\s\S]*refresh\(\)/, 'and the renderer asks the card to re-decide its visibility');
|
|
284
|
+
assert.doesNotMatch(fn[0], /covered/, 'a "we cover it" note is information, not an action, and does not land in the inbox');
|
|
285
|
+
// the rebase-owed line carries task refs as links in prose; a 13.5px link
|
|
286
|
+
// with no compensation sits under the 24px floor, and the kit stub renders
|
|
287
|
+
// no notices, so this is pinned in the sheet rather than left to a render
|
|
288
|
+
for (const sel of ['.ginbox__line a', '.ginbox__goal, .triage-inbox__link, .next-up__title']) {
|
|
289
|
+
const r = rule(styleCss, sel) || '';
|
|
290
|
+
assert.match(r, /min-height:\s*24px/, `${sel} clears the floor`);
|
|
291
|
+
assert.match(r, /padding:\s*3px 0/, `${sel} buys it with the row recipe`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('the greeting reads /me, the public progress and the claimable feed, and builders.js feeds all three', () => {
|
|
296
|
+
assert.match(buildersJs, /getJSON\(`\$\{API\}\/public\/progress`\)/, 'Home fetches the public progress once');
|
|
297
|
+
assert.match(buildersJs, /lastClaimableCount = resp && Array\.isArray\(resp\.claimable\) \? resp\.claimable\.length : null;/,
|
|
298
|
+
'the claimable feed Home already fetches for Next-up answers the count');
|
|
299
|
+
assert.match(buildersJs, /get lastProgressState\(\)/, 'the renderer reads progress through a getter, so it cannot freeze at boot');
|
|
300
|
+
assert.match(buildersJs, /get lastClaimableCount\(\)/, 'and the count the same way');
|
|
301
|
+
assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 3, 'rendered after /me, after progress and after the claimable feed — each half as it lands');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Boot hall-render.js against a stub DOM and a ctx of getters (the way
|
|
305
|
+
// builders.js builds it) and read what the greeting and the card actually say.
|
|
306
|
+
function bootHallRender({ me, progress = null, claimable = null, leaderboard = [] }) {
|
|
307
|
+
const els = new Map();
|
|
308
|
+
const makeEl = () => ({
|
|
309
|
+
dataset: {}, style: {}, textContent: '', hidden: false, _html: '', innerHTML: '',
|
|
310
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
311
|
+
setAttribute() {}, getAttribute() { return null; }, addEventListener() {},
|
|
312
|
+
querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {}, remove() {},
|
|
313
|
+
});
|
|
314
|
+
const documentObj = {
|
|
315
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
316
|
+
querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
|
|
317
|
+
querySelectorAll() { return []; },
|
|
318
|
+
createElement() { return makeEl(); },
|
|
319
|
+
addEventListener() {},
|
|
320
|
+
body: { dataset: {} }, documentElement: { dataset: {} },
|
|
321
|
+
};
|
|
322
|
+
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
323
|
+
const windowObj = {
|
|
324
|
+
OTB: { escapeHtml, $: (sel) => documentObj.querySelector(sel), fmtNum: (n) => Number(n || 0).toLocaleString('en-US'), moduleOn: () => false },
|
|
325
|
+
// the factory builds the Standing ships pager + rail at creation
|
|
326
|
+
OTBKit: { makePager: () => ({ page: 1, perPage: 10 }), makeSectionRail: () => ({ render() {}, refresh() {} }), paginate: (rows) => rows, shipVisualThumbHtml: () => '' },
|
|
327
|
+
OTBTaskDetail: { href: (id) => `/task/${id}?from=%2F` },
|
|
328
|
+
location: { hostname: 'builders.example.com', pathname: '/', hash: '', href: 'https://builders.example.com/' },
|
|
329
|
+
localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
|
|
330
|
+
addEventListener() {},
|
|
331
|
+
};
|
|
332
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, localStorage: windowObj.localStorage, console, setTimeout, clearTimeout, Date, URL };
|
|
333
|
+
sandbox.globalThis = sandbox;
|
|
334
|
+
vm.createContext(sandbox);
|
|
335
|
+
vm.runInContext(read('hall-render.js'), sandbox, { filename: 'hall-render.js' });
|
|
336
|
+
const ctx = {
|
|
337
|
+
API: '/api/gds', RANK_ORDER: ['xenos', 'thetes', 'metic', 'archon'], api: {}, brandWorld: () => 'Cloud Bongos',
|
|
338
|
+
curLabel: (caps) => (caps ? 'Credits' : 'credits'), emptyRow: () => '', fmtDate: (d) => String(d).slice(0, 10),
|
|
339
|
+
mins: (m) => `${m}m`, normalizeRank: (r) => String(r || '').toLowerCase(), pct: (n) => `${n}%`, postJSON: async () => ({}),
|
|
340
|
+
rankLabel: (r) => r, rankWeight: () => 0, signClasses: () => '', toast() {}, usd: (n) => `$${n}`,
|
|
341
|
+
instanceRepo: null, newcomerGated: false,
|
|
342
|
+
get lastMeState() { return me; }, get lastCostState() { return me.cost || null; }, get lastKarmaState() { return me.karma || null; },
|
|
343
|
+
get lastNeedsState() { return me.needs || null; }, get lastRebaseState() { return me.needs_rebase_tasks || null; },
|
|
344
|
+
get lastProgressState() { return progress; }, get lastClaimableCount() { return claimable; },
|
|
345
|
+
};
|
|
346
|
+
const api = windowObj.OTBHallRender(ctx);
|
|
347
|
+
api.lbState.rows = leaderboard;
|
|
348
|
+
return { api, el: (id) => documentObj.getElementById(id) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const ME = {
|
|
352
|
+
builder: { id: 7, display_name: 'Percy Jackson', github_login: 'percy', rank: 'metic', total_credits: 14441, karma: 96, preferred_disciplines: [], created_at: '2026-01-01T00:00:00Z' },
|
|
353
|
+
needs: { items: [{ state: 'action_needed', title: 'Image-generation key', message: 'Your free art coverage has ended.', action: { href: '/settings#art', label: 'Add your key' } }] },
|
|
354
|
+
cost: { level: 'warn', spend_mtd_usd: 80, budget_mtd_usd: 100, pct_used: 80 },
|
|
355
|
+
needs_rebase_tasks: [{ id: 1003460 }],
|
|
356
|
+
};
|
|
357
|
+
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 }];
|
|
358
|
+
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 }] };
|
|
359
|
+
|
|
360
|
+
test('RUN: the greeting names the builder, and the sub-line says how far the version is and what is claimable', () => {
|
|
361
|
+
const { api, el } = bootHallRender({ me: ME, progress: PROGRESS, claimable: 5 });
|
|
362
|
+
api.renderGreeting();
|
|
363
|
+
assert.match(el('hall-h1').textContent, /^Good (morning|afternoon|evening), Percy Jackson$/);
|
|
364
|
+
assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent shipped. Five tasks are claimable right now.');
|
|
365
|
+
assert.equal(el('hall-sub').hidden, false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('RUN: each half of the sentence renders as its data lands, and the line hides with neither', () => {
|
|
369
|
+
const none = bootHallRender({ me: ME });
|
|
370
|
+
none.api.renderGreeting();
|
|
371
|
+
assert.equal(none.el('hall-sub').hidden, true, 'nothing to say yet');
|
|
372
|
+
const onlyProgress = bootHallRender({ me: ME, progress: PROGRESS });
|
|
373
|
+
onlyProgress.api.renderGreeting();
|
|
374
|
+
assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent shipped.');
|
|
375
|
+
const zero = bootHallRender({ me: ME, progress: PROGRESS, claimable: 0 });
|
|
376
|
+
zero.api.renderGreeting();
|
|
377
|
+
assert.match(zero.el('hall-sub').textContent, /Nothing is claimable right now\.$/);
|
|
378
|
+
const one = bootHallRender({ me: ME, claimable: 1 });
|
|
379
|
+
one.api.renderGreeting();
|
|
380
|
+
assert.equal(one.el('hall-sub').textContent, 'One task is claimable right now.');
|
|
381
|
+
const many = bootHallRender({ me: ME, claimable: 63 });
|
|
382
|
+
many.api.renderGreeting();
|
|
383
|
+
assert.equal(many.el('hall-sub').textContent, '63 tasks are claimable right now.');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('RUN: the Standing card carries the three facts, the place as "2nd of 4", and no banner', () => {
|
|
387
|
+
const { api, el } = bootHallRender({ me: ME, leaderboard: LEADERBOARD });
|
|
388
|
+
api.renderProfile(ME.builder);
|
|
389
|
+
const html = el('home-standing-body').innerHTML;
|
|
390
|
+
assert.match(html, /<p class="home-who__name">Percy Jackson<\/p>/);
|
|
391
|
+
assert.match(html, /<p class="home-who__handle">@percy<\/p>/);
|
|
392
|
+
// written from curLabel(true), not a data-currency-label attribute: brand-apply
|
|
393
|
+
// rebrands those once at load, and this row is stamped after /me answers
|
|
394
|
+
assert.match(html, /<dt>Credits<\/dt><dd>14,441<\/dd>/);
|
|
395
|
+
assert.match(html, /<dt>On the board<\/dt><dd>2nd of 4<\/dd>/);
|
|
396
|
+
assert.match(html, /<dt>Preferred work<\/dt><dd>Not set yet<\/dd>/, 'the mock\'s copy for an empty preference');
|
|
397
|
+
assert.doesNotMatch(html, /profile__(needs|budget|rebase)/, 'the banners are not in the card');
|
|
398
|
+
const withPrefs = bootHallRender({ me: ME, leaderboard: [] });
|
|
399
|
+
withPrefs.api.renderProfile({ ...ME.builder, preferred_disciplines: ['engineer', 'ui'] });
|
|
400
|
+
assert.match(withPrefs.el('home-standing-body').innerHTML, /<dd>engineer, ui<\/dd>/);
|
|
401
|
+
assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>On the board<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('RUN: the three notices are inbox lines with a kind, a line and — where one exists — a door', () => {
|
|
405
|
+
const { api, el } = bootHallRender({ me: ME });
|
|
406
|
+
api.renderProfile(ME.builder);
|
|
407
|
+
const html = el('attention-system').innerHTML;
|
|
408
|
+
assert.match(html, /<h3 class="ginbox__sec-h">From the system<\/h3>/);
|
|
409
|
+
assert.equal((html.match(/class="ginbox__item /g) || []).length, 3, 'rebase, the needed input, the budget');
|
|
410
|
+
assert.match(html, /ginbox__item--danger"><div class="ginbox__body"><span class="ginbox__kind">Rebase owed<\/span>/);
|
|
411
|
+
assert.match(html, /<a href="\/task\/1003460\?from=%2F">task 1003460<\/a>/, 'a task ref is a door through OTBTaskDetail.href, never a bare #N');
|
|
412
|
+
assert.match(html, /ginbox__item--warn"><div class="ginbox__body"><span class="ginbox__kind">Image-generation key<\/span>/);
|
|
413
|
+
assert.match(html, /<a class="btn-ghost" href="\/settings#art">Add your key<\/a>/);
|
|
414
|
+
assert.match(html, /<span class="ginbox__kind">Approaching monthly budget<\/span>/);
|
|
415
|
+
const quiet = bootHallRender({ me: { builder: ME.builder } });
|
|
416
|
+
quiet.api.renderProfile(ME.builder);
|
|
417
|
+
assert.equal(quiet.el('attention-system').innerHTML, '', 'nothing owed, nothing rendered');
|
|
418
|
+
const covered = bootHallRender({ me: { builder: ME.builder, needs: { items: [{ state: 'covered', title: 'Art', message: 'We cover it.' }] } } });
|
|
419
|
+
covered.api.renderProfile(ME.builder);
|
|
420
|
+
assert.equal(covered.el('attention-system').innerHTML, '', 'a covered need is not an action');
|
|
421
|
+
});
|
|
@@ -408,6 +408,18 @@ test('it slugs h2s the way the table of contents and the in-doc anchors expect',
|
|
|
408
408
|
'the anchor in the markup and the id the TOC links to come from one call, so they cannot drift');
|
|
409
409
|
});
|
|
410
410
|
|
|
411
|
+
test('a code span round-trips & < > " \' to exactly ONE entity each', () => {
|
|
412
|
+
// renderInline receives text the block pass has already escaped and lifted
|
|
413
|
+
// each span into a placeholder; restoring it through escapeHtml again showed
|
|
414
|
+
// `a && b` as "a && b" (idea 1000889, folded into task 1003465).
|
|
415
|
+
const MD = loadReader();
|
|
416
|
+
const { html } = MD.parseMarkdown('use `a && b < c > "d" \'e\'` here\n', (h) => h);
|
|
417
|
+
assert.match(html, /<code>a && b < c > "d" 'e'<\/code>/);
|
|
418
|
+
assert.doesNotMatch(html, /&(amp|lt|gt|quot|#39);/, 'no entity is encoded twice');
|
|
419
|
+
const li = MD.parseMarkdown('- run `x & y`\n', (h) => h).html;
|
|
420
|
+
assert.match(li, /<li>run <code>x & y<\/code><\/li>/, 'and the same inside a list item');
|
|
421
|
+
});
|
|
422
|
+
|
|
411
423
|
test('the primer\'s link resolver sends a repo-relative doc to GitHub and leaves the rest alone', () => {
|
|
412
424
|
const MD = loadReader();
|
|
413
425
|
const resolve = MD.makeResolveLinkHref({
|
|
@@ -258,8 +258,11 @@ test('the preview harness can reach both record pages', () => {
|
|
|
258
258
|
// string-building, so this exercises the real render path rather than a
|
|
259
259
|
// re-implementation of it.
|
|
260
260
|
const TASK_SRC = read('task.js');
|
|
261
|
+
// task.html loads md-reader.js ahead of task.js (task 1003465), so the sandbox
|
|
262
|
+
// does the same: the description renders through it.
|
|
263
|
+
const MD_SRC = read('md-reader.js');
|
|
261
264
|
|
|
262
|
-
function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '' } = {}) {
|
|
265
|
+
function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '', withReader = true } = {}) {
|
|
263
266
|
const els = new Map();
|
|
264
267
|
const makeEl = () => ({
|
|
265
268
|
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
@@ -311,6 +314,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
311
314
|
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams };
|
|
312
315
|
sandbox.globalThis = sandbox;
|
|
313
316
|
vm.createContext(sandbox);
|
|
317
|
+
if (withReader) vm.runInContext(MD_SRC, sandbox, { filename: 'md-reader.js' });
|
|
314
318
|
vm.runInContext(TASK_SRC, sandbox, { filename: 'task.js' });
|
|
315
319
|
assert.ok(onReady, 'task.js registers its DOMContentLoaded boot');
|
|
316
320
|
return {
|
|
@@ -489,3 +493,79 @@ test('the crumb is an element task.js can point, with its label apart from the i
|
|
|
489
493
|
assert.match(taskHtml, /<a class="rec-back" id="task-back" href="\/work">/, 'the static href is the fallback the page ships with');
|
|
490
494
|
assert.match(taskHtml, /<span id="task-back-label">Back to tasks<\/span>/, 'the label is its own node, so the icon survives a relabel');
|
|
491
495
|
});
|
|
496
|
+
|
|
497
|
+
// ---- 11. the description is markdown, and renders as it (task 1003465) --------
|
|
498
|
+
//
|
|
499
|
+
// The owner's screenshot: ## headings, **bold**, `code` and - bullets as literal
|
|
500
|
+
// characters, in a column where every line wrapped twice. The description was
|
|
501
|
+
// escaped into a <p> under white-space: pre-wrap; the DB hard-wraps at ~78
|
|
502
|
+
// columns and the re-skin's --measure (~60) broke each line again. It renders
|
|
503
|
+
// through md-reader.js now, on both record pages, and pre-wrap is gone.
|
|
504
|
+
|
|
505
|
+
test('both record pages load the shared reader BEFORE their page script', () => {
|
|
506
|
+
for (const [name, html, page] of [['task.html', taskHtml, 'task.js'], ['idea.html', ideaHtml, 'idea.js']]) {
|
|
507
|
+
const reader = html.indexOf('<script src="/builders/md-reader.js" defer>');
|
|
508
|
+
const script = html.indexOf(`<script src="/builders/${page}`);
|
|
509
|
+
assert.ok(reader > -1, `${name} loads md-reader.js`);
|
|
510
|
+
assert.ok(script > reader, `${name}: both deferred, so document order is execution order — the reader comes first`);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('neither prose container is pre-wrap any more — rendered paragraphs wrap themselves', () => {
|
|
515
|
+
assert.doesNotMatch(rule(recordCss, '.rec-desc') || '', /white-space:\s*pre-wrap/, '.rec-desc: pre-wrap is exactly what fought the measure');
|
|
516
|
+
assert.doesNotMatch(rule(ideaCss, '.ig-body') || '', /white-space:\s*pre-wrap/, '.ig-body: the idea body renders the same way');
|
|
517
|
+
assert.match(rule(recordCss, '.rec-desc') || '', /max-width:\s*var\(--measure\)/, 'the measure stays — it is correct once the source wrap is gone');
|
|
518
|
+
for (const sel of ['.rec-md p', '.rec-md code', '.rec-md ul, .rec-md ol']) {
|
|
519
|
+
assert.ok(rule(recordCss, sel), `record.css styles what the parser emits: ${sel}`);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const MD_DESC = [
|
|
524
|
+
'## What is wrong',
|
|
525
|
+
'',
|
|
526
|
+
'The **description** shows `a && b < c` as text, and folds ideas #1000891',
|
|
527
|
+
'and #1000889 — a bare hash that would autolink to a GitHub issue.',
|
|
528
|
+
'',
|
|
529
|
+
'- one bullet',
|
|
530
|
+
'- [a doc](docs/adr/0229-the-way-out.md) and [a trap](javascript:alert(1))',
|
|
531
|
+
].join('\n');
|
|
532
|
+
|
|
533
|
+
test('the description renders as markup: headings, bold, code, bullets — not their characters', async () => {
|
|
534
|
+
const r = renderTask({ ...BASE_TASK, description: MD_DESC });
|
|
535
|
+
await r.boot(); await settle();
|
|
536
|
+
const html = r.html();
|
|
537
|
+
const m = html.match(/<div class="rec-desc rec-md">([\s\S]*?)<\/div><\/section>/);
|
|
538
|
+
assert.ok(m, 'the description is a block container, not a <p> — a paragraph cannot hold a heading or a list');
|
|
539
|
+
const desc = m[1];
|
|
540
|
+
assert.match(desc, /<h2 id="what-is-wrong">What is wrong<\/h2>/, 'the heading is a heading');
|
|
541
|
+
assert.match(desc, /<strong>description<\/strong>/, 'bold is bold');
|
|
542
|
+
assert.match(desc, /<code>a && b < c<\/code>/, 'a code span with & and < round-trips to ONE entity each');
|
|
543
|
+
assert.match(desc, /<li>one bullet<\/li>/, 'the bullet is a list item');
|
|
544
|
+
assert.doesNotMatch(desc, /##|\*\*|`/, 'no markdown character survives as text');
|
|
545
|
+
assert.doesNotMatch(desc, /white-space/, 'nothing is inlined to fake the old wrap');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('the rendered path drops the hash from a bare #NNN, as the copied prompt already did', async () => {
|
|
549
|
+
const r = renderTask({ ...BASE_TASK, description: MD_DESC });
|
|
550
|
+
await r.boot(); await settle();
|
|
551
|
+
const desc = r.html().match(/<div class="rec-desc rec-md">([\s\S]*?)<\/div><\/section>/)[1];
|
|
552
|
+
assert.match(desc, /folds ideas 1000891\s+and 1000889/, 'the ids read as ids');
|
|
553
|
+
assert.doesNotMatch(desc, /#10008/, 'no bare #NNN reaches the page');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('a description\'s links resolve repo-root-relative, and a hostile target is text', async () => {
|
|
557
|
+
const r = renderTask({ ...BASE_TASK, description: MD_DESC });
|
|
558
|
+
await r.boot(); await settle();
|
|
559
|
+
const desc = r.html().match(/<div class="rec-desc rec-md">([\s\S]*?)<\/div><\/section>/)[1];
|
|
560
|
+
assert.match(desc, /<a href="docs\/adr\/0229-the-way-out\.md">a doc<\/a>/,
|
|
561
|
+
'no docs/onboarding/ base: a task path is repo-root-relative, and without the repo bases it passes through');
|
|
562
|
+
assert.doesNotMatch(desc, /javascript:/, 'the reader\'s allowlist (task 1003482) holds on the record page too');
|
|
563
|
+
assert.match(desc, /a trap/, 'the label survives as text');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('the description still reads when the reader is absent', async () => {
|
|
567
|
+
const r = renderTask({ ...BASE_TASK, description: 'plain <b>text</b>' }, { withReader: false });
|
|
568
|
+
await r.boot(); await settle();
|
|
569
|
+
assert.match(r.html(), /<div class="rec-desc rec-md"><p>plain <b>text<\/b><\/p><\/div>/,
|
|
570
|
+
'escaped into one paragraph — a missing script must never blank the record');
|
|
571
|
+
});
|