bmad-plus 0.17.1 → 0.18.0
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/CHANGELOG.md +18 -1
- package/README.md +16 -16
- package/package.json +3 -1
- package/readme-international/README.de.md +16 -17
- package/readme-international/README.es.md +16 -17
- package/readme-international/README.fr.md +16 -17
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +61 -1
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +11 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +455 -137
- package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +230 -20
- package/tools/cli/commands/uat.js +50 -5
- package/tools/cli/commands/update-check.js +5 -1
- package/tools/cli/commands/update.js +9 -3
- package/tools/cli/lib/install-manifest.js +19 -1
- package/tools/cli/lib/packs.js +1 -1
- package/tools/cli/lib/uat.js +139 -1
package/tools/cli/lib/uat.js
CHANGED
|
@@ -472,8 +472,144 @@ function languageCode(value, strings = STRINGS) {
|
|
|
472
472
|
return Object.keys(strings).find((code) => code.toLowerCase().split('-')[0] === base) || null;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
/**
|
|
476
|
+
* What every built page guarantees a tester, by construction. Each entry names a
|
|
477
|
+
* behaviour the DOM suite exercises (tests/unit/uat-page.test.js) and the literal
|
|
478
|
+
* the template must carry for it. A template that lost one does not build: the
|
|
479
|
+
* FormaPro incident of 2026-09-25 (answers typed before any run existed, then lost
|
|
480
|
+
* on reload, with a "saved" status nobody had verified) came from exactly such a loss.
|
|
481
|
+
*/
|
|
482
|
+
const PAGE_GUARANTEES = [
|
|
483
|
+
{
|
|
484
|
+
id: 'hidden-before-start',
|
|
485
|
+
what: 'no answer field is shown before a run exists, whatever a class says about display',
|
|
486
|
+
// The rule must also be the LAST rule of the sheet: a later !important display rule would beat it.
|
|
487
|
+
markers: [
|
|
488
|
+
'[hidden] { display: none !important; }\n</style>',
|
|
489
|
+
'<main class="steps" id="steps" hidden>',
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
id: 'no-answer-before-run',
|
|
494
|
+
what: 'a tick or a note before the run starts is refused and explained, never discarded in silence',
|
|
495
|
+
markers: [
|
|
496
|
+
'function requireRun(',
|
|
497
|
+
'T.startFirst',
|
|
498
|
+
'!requireRun(event)',
|
|
499
|
+
'if (!requireRun()) return;',
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
id: 'verified-local-write',
|
|
504
|
+
what: 'a write to this browser counts only once it reads back; a refused write returns false',
|
|
505
|
+
markers: ['return localStorage.getItem(k) === v;', 'function saveLocal()'],
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
id: 'honest-save-status',
|
|
509
|
+
what: 'the status line reports the verified outcome, and names a failed save',
|
|
510
|
+
markers: ['function showSaveState(', 'T.saveFailed', 'T.remoteFailed', 'T.saving'],
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: 'save-on-every-change',
|
|
514
|
+
what: 'every tick and every keystroke in a note is saved at once, without waiting for blur, and a save never moves the cursor',
|
|
515
|
+
markers: [
|
|
516
|
+
'addEventListener("change"',
|
|
517
|
+
'addEventListener("input"',
|
|
518
|
+
'document.activeElement !== field',
|
|
519
|
+
],
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
id: 'restore-before-capabilities',
|
|
523
|
+
what: 'the browser copy is restored synchronously, before any optional capability answers',
|
|
524
|
+
markers: ['function restoreLocal()'],
|
|
525
|
+
order: ['restoreLocal();', 'probe()'],
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
id: 'newer-copy-wins',
|
|
529
|
+
what: 'an older remote copy never overwrites a newer local one; a copy without a date is the oldest of all',
|
|
530
|
+
markers: ['function connectDb()', 'remoteNewer', 'function at(', 'function adopt('],
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
id: 'revision-guard',
|
|
534
|
+
what: 'a run that answered another revision is offered, explained and carried by an explicit rule, never poured in blindly',
|
|
535
|
+
markers: ['function sameRevision(', 'function carry(', 'T.otherRevision', 'T.continueHere'],
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: 'other-tab-notice',
|
|
539
|
+
what: 'two tabs on one run converge on the latest change, and the page says so',
|
|
540
|
+
markers: ['addEventListener("storage"', 'T.otherTab'],
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: 'unreadable-draft-kept',
|
|
544
|
+
what: 'a saved run that cannot be read is reported and exportable, never deleted',
|
|
545
|
+
markers: ['id="draft-problem"', 'T.draftUnreadable', 'id="btn-save-raw"'],
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
id: 'progress-accessible',
|
|
549
|
+
what: 'the share of lines answered is exposed with a progressbar role and value; answered is not passed',
|
|
550
|
+
markers: ['role="progressbar"', 'aria-valuenow', 'T.percentAnswered'],
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
id: 'storage-explained',
|
|
554
|
+
what: 'the page says where answers live and what can make them disappear',
|
|
555
|
+
markers: ['id="storage-note"', 'id="mode-note"'],
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
id: 'finished-is-not-accepted',
|
|
559
|
+
what: 'finishing keeps the date and says it is not an acceptance',
|
|
560
|
+
markers: ['T.finishedNote', 'run.finishedAt = new Date().toISOString();'],
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: 'unload-guard',
|
|
564
|
+
what: 'leaving with an unsaved run is questioned; pending remote writes are flushed',
|
|
565
|
+
markers: ['addEventListener("beforeunload"', 'addEventListener("pagehide"'],
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
id: 'utf8-and-escaped-diacritics',
|
|
569
|
+
what: 'the page declares UTF-8 and writes the combining-mark range as escapes',
|
|
570
|
+
markers: ['<meta charset="utf-8">', '\\u0300-\\u036f'],
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
id: 'export-is-the-run',
|
|
574
|
+
what: 'the exported JSON is the run as answered, and nothing is exported before a run exists',
|
|
575
|
+
markers: ['download(runId + ".json", JSON.stringify(run, null, 2))'],
|
|
576
|
+
},
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Which guarantees a template carries: every marker present, in the required order where one
|
|
581
|
+
* is set. Checked on the TEMPLATE, never on the built page: a recipe's own text must not be
|
|
582
|
+
* able to satisfy or defeat a marker.
|
|
583
|
+
*/
|
|
584
|
+
function pageGuarantees(template) {
|
|
585
|
+
const missing = [];
|
|
586
|
+
for (const guarantee of PAGE_GUARANTEES) {
|
|
587
|
+
const lost = guarantee.markers.filter((marker) => !template.includes(marker));
|
|
588
|
+
if (!lost.length && guarantee.order) {
|
|
589
|
+
const positions = guarantee.order.map((marker) => template.indexOf(marker));
|
|
590
|
+
if (positions.some((at) => at < 0) || positions.some((at, i) => i && at < positions[i - 1]))
|
|
591
|
+
lost.push(`order ${guarantee.order.join(' → ')}`);
|
|
592
|
+
}
|
|
593
|
+
if (lost.length) missing.push({ id: guarantee.id, what: guarantee.what, lost });
|
|
594
|
+
}
|
|
595
|
+
const carried = PAGE_GUARANTEES.map((g) => g.id).filter(
|
|
596
|
+
(id) => !missing.some((entry) => entry.id === id)
|
|
597
|
+
);
|
|
598
|
+
return { ok: missing.length === 0, carried, missing };
|
|
599
|
+
}
|
|
600
|
+
|
|
475
601
|
function buildPage(spec, options = {}) {
|
|
476
602
|
const template = options.template || fs.readFileSync(TEMPLATE, 'utf8');
|
|
603
|
+
// A page missing one guarantee is not a page: refusing here is what keeps a tester's
|
|
604
|
+
// answers safe in an installation that has no test suite of its own.
|
|
605
|
+
const guarantees = pageGuarantees(template);
|
|
606
|
+
if (!guarantees.ok) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`the page template (${options.template ? 'the template given' : TEMPLATE}) lost a guarantee — not built: ${guarantees.missing
|
|
609
|
+
.map((entry) => `${entry.id}: ${entry.what} — missing ${entry.lost.join(', ')}`)
|
|
610
|
+
.join('; ')}`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
477
613
|
const hash = specHash(spec);
|
|
478
614
|
const json = canonical(spec).replace(/<\//g, '<\\/');
|
|
479
615
|
const strings = options.strings || STRINGS;
|
|
@@ -502,7 +638,7 @@ function buildPage(spec, options = {}) {
|
|
|
502
638
|
cause: error,
|
|
503
639
|
});
|
|
504
640
|
}
|
|
505
|
-
return { html, sha256: hash, language };
|
|
641
|
+
return { html, sha256: hash, language, guarantees: guarantees.carried };
|
|
506
642
|
}
|
|
507
643
|
|
|
508
644
|
// ── Runs: legacy adapter, normalisation, reading ──────────────────────────────
|
|
@@ -873,6 +1009,8 @@ module.exports = {
|
|
|
873
1009
|
lintSpec,
|
|
874
1010
|
screenLabels,
|
|
875
1011
|
buildPage,
|
|
1012
|
+
PAGE_GUARANTEES,
|
|
1013
|
+
pageGuarantees,
|
|
876
1014
|
languageCode,
|
|
877
1015
|
normalizeRun,
|
|
878
1016
|
readRuns,
|