diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d036a8..403a762b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,14 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased - fix(gates): **a decision taken during a stage no longer reads as that stage's approval** (#477). The #207 flag prefixed a FAIL with `APPROVED OVER A FAILING GATE` whenever any CONFIRMED row carried the stage's number, so a Stage-5 build with `| 5 | Sub-agents run on Sonnet | CONFIRMED |` was flagged, and `status.sh` told the lead to re-open an approval nobody gave while the real gap (PENDING obligations) sat underneath. The register has no dedicated exit row, so `gate-check.sh` now counts a stage as approved on either of two pieces of evidence in the Decisions table: an explicit exit row ("Stage N approved", "Stage 4 — approved with conditions", "Approved: Stage N …", "Gate approved", or " approved" for a fixed artifact list such as "Build plan approved"), or the stage having CONFIRMED rows of its own plus a CONFIRMED decision for a later stage up to 6 (the #207 field case); an early Stage-7 "no cutover" row or an N/A/skip row is not evidence. The Stage 3/4 gate checks and every verdict and exit code are unchanged. `test-bug03-gates.sh` T17 (16 cases, checked under gawk, mawk and original-awk): 7 FAIL on master, 47/47 with the fix. — MendixMau +- fix(progress-report): **the top bar is one group of section links (Stages, Build, Pages, Tips) instead of stage chips plus links; the page carries one `id=stamp`, and the header drops the build-plan.html note.** The sticky bar loses the `Stage P 0 … 6` chips, which repeated the stage list right below it, and gains a `Stages` link to that list; the `#stage-N` card anchors stay for links from other pages, and the header now states the current stage. Five elements shared `id=stamp` (invalid HTML); the "Last updated" span keeps it and the others get their own ids plus a `data-stamp` marker, which `build-plan-status.sh --refresh` now reads (with `id=stamp`, so pages written before this still compare) to leave an unchanged page alone. The filter now depends on both scripts: a project whose `bin/build-plan-status.sh` is locally edited keeps the old `id=stamp` filter after sync, so the page is rewritten on every refresh; such a project needs `sync-project.sh --only-bin build-plan-status.sh --upgrade-bin build-plan-status.sh` alongside this. "replaces the former build-plan.html" is gone from the header sub-line. Fixes #479 items 1–3 — MendixMau (review 2026-10-20) +- fix(conformance): **a definition that merely contains "(0 " or "not found" is no longer scored ABSENT** (#470). `conformance-check.sh` matched both markers anywhere in mxcli's answer, so a built microflow whose documentation read "HttpStatus (0 = no response or time-out)" went STALE (five false STALEs in one module), and so does every `describe navigation` with a not-found page, which mxcli prints as " not found page ". It now reads an error only on the first non-blank line (`Error:…`, the old start-of-output meaning; enumeration, scheduled-event and image-collection docs print raw, so a doc line may start `Error:`), and an empty set only when the last non-blank line is exactly the `(0 …)` summary (also `(0 workflow group(s))`); CRLF output reads the same. New fixture `tests/wave2/test-conformance-absent-marker.sh` replays two verbatim captures already in the repo (the v0.25.0 navigation describe of mxcli's public test app, and a v0.23.0 microflow describe with the issue's doc sentence spliced in), plus an enumeration with a raw `Error:` doc line and CRLF-only cases, both layouts; 6 FAIL on master, 24/24 with the fix. — jopterhorst +- fix(page-fidelity): **a popup wireframe is scored against its dialog, not the page drawn behind it** (#471). A popup wireframe draws the caller's page behind a scrim (`.popup-stage > .shell-behind + .dialog-scrim > .dialog`); `page-fidelity.js` looked for `div.main` first and landed in the shell, so a popup whose fields and bindings all matched scored 29% in the field, every miss being the caller's page head, h1 and card. With popup evidence (a `.popup-stage` or `.dialog-scrim` in the wireframe, or a page whose MDL names a popup layout) the scorer now scores the `.dialog` only (class word, so `dialog-head` does not count), or the stage minus `.shell-behind` when there is no `.dialog`. Without that evidence nothing changes, so a full-page wireframe that draws an inline `.dialog` confirm (the design system's own component) scores exactly as before, and a bare-`.dialog` popup wireframe still reads ``. `test-page-fidelity-mocks.sh` gains the popup cases on a verbatim v0.23.0 describe of a real popup page and a wireframe drawn in the issue's shape, plus layout-only evidence and the full-page-plus-inline-confirm regression case: 18% and 8 FAIL on master, 100% and 78/78 with the fix. — jopterhorst +- fix(monkey): **the monkey no longer signs itself out of a non-English app** (#469). `monkey.js` recognised only English sign-out captions, so in a Dutch app it clicked "Afmelden", landed on `login.html`, scored a false CRASH and fuzzed nothing more (seed 204262061: 3 observations, 1 crash-class; 24 and 0 with the label excluded). The default now also covers Dutch, German, French, Spanish, Portuguese and Italian sign-out captions, matched as the whole caption so ordinary verbs stay fuzzable ("Ziek afmelden", "Vom Kurs abmelden", and sign-in captions such as "Aanmelden"); a project adds its own in `project.config.js` `signOutPattern` or `SIGN_OUT_PATTERN`, added to the default, never replacing it, with its own regex flags kept. `rapidTab` no longer presses Enter while focus is on a sign-out control (top bar, nav item, aria-label or title, each tested on its own), the route by which a layout control outside `.mx-page` was reached. `test-monkey-left-app.sh` gains the issue's captions, sign-in near-misses, the over-match captions, the project pattern and its flags, and a rapidTab guard scoped to that branch (it fails on a blind-Enter mutation): 9 FAIL on master, 22/22 with the fix. — jopterhorst - fix(progress-report): **the build plan is shown once: the phase cards are the plan.** Expanding a card now shows its full rows (#, Kind, Step, Produces/Proves, Depends on, State, Scripts) and Claims, which only the duplicate "Full plan tables" block carried; that block is gone and views A (script folders) and B (module proof) are their own section. The `now` phase renders open with a selected style, opening another phase closes the previous one (accordion; `#phase-N` links still open their card), and a viewer's saved open/closed state survives the 30 s reload and wins over the default (#474) — field feedback from a requirements-driven project at Stage 5 — MendixMau (field feedback 2026-10-20) +- fix(status): **`status.sh --brief` no longer aborts with "LAST_FAIL: unbound variable" before printing NEXT (#467).** Two NEXT lines wrote a bare `$LAST_FAIL`/`$NOT_DONE_PASS` straight before an em-dash; in a single-byte locale (and on macOS bash 3.2 even under `en_US.UTF-8`) bash reads the dash's first byte as part of the name and `set -u` kills the run. Braced there and at the two other sites with the same idiom (`project-bin/constants-audit.sh`'s `…`, a case pattern in `test-runbook-slice.sh`). `check-portability.sh` now flags any bare `$NAME` followed by a non-ASCII byte, in every file including the meta-exempt ones (matched with `LC_ALL=C`, so the check does not depend on the locale it guards against; `# portability-ok:` opts out). New `tests/wave2/test-status-bare-var.sh`: the lint cases on any machine, plus both NEXT rules run under an ISO-8859-1 locale where one exists (reproduced on bash 5.2 with a compiled `en_US.ISO-8859-1`: both rules aborted on master, both print after). — jopterhorst +- fix(portability): **`[ \t]` in a sed or grep bracket no longer trims a trailing "t" (#468).** BSD/macOS sed reads `[ \t]` as "space, backslash or t" (only GNU sed expands `\t` there, and grep never does), so `context-pack.sh` printed `Mod.ENUM_Product` as `Mod.ENUM_Produc` and called it "not in the folder plan"; `closeout.sh`'s cell trim cut "bulk import" to "bulk impor" and "test" to "est"; `entry-mode.sh` read an answer written `answered:migration` as no entry mode at all; and `gate-check.sh`'s intake section count matched `##t…` but never `##`. All five sites now use `[[:blank:]]`, and a test fixture's `[^\t]` uses a real tab (`$'…'`). `check-portability.sh` flags `\t` inside a bracket that follows `sed`/`grep` on the line (awk expands `\t` everywhere and is not flagged; a `$'…'` string is skipped). Reproduced on GNU sed through a `sed --posix` shim, which reads the bracket the BSD way: the captured context-pack golden brief (`Sales.Product`, `Sales.ACT_ApprovalStep_Reject`) failed 4 of 4 new BSD-sed checks on master and passes after; new `tests/wave2/test-sed-tab-bracket.sh` covers `closeout.sh`, `entry-mode.sh` and the lint (6 of 9 fail on master). — jopterhorst ## v2026.10.19 - new(progress-report): **the progress report gains a Project pages section: a compact folder tree of the HTML pages found in the project's output folders (gate dashboard, analysis reports, blueprint, design system, process map, wireframes, UI reviews, test reports), each a relative link from `architecture/` with its modified date.** Pages are found by scanning the project's files on every render, never from a stored list; only the top-level folders in `PAGE_DIRS` are scanned (each named with its producer; a symlinked one is skipped), `node_modules`, `.mpr-snapshots`, `deployment`, `mprcontents`, `theme`, `.git`, vendor and dot folders are never entered, a folder with more than 8 pages folds behind a count, every folder keeps its row and lists at most 100 links (`+K more`), and the report never lists itself. Dates are shown as days, so the section is unchanged on the same day unless a listed page changed and a `--refresh` leaves the page alone. Test: `tests/wave2/test-progress-report-pages.sh` (single and two-tree layouts, Windows hrefs) — MendixMau (request 2026-10-19) diff --git a/bin/check-portability.sh b/bin/check-portability.sh index 444dee0f..6daf33e9 100755 --- a/bin/check-portability.sh +++ b/bin/check-portability.sh @@ -67,6 +67,21 @@ while IFS= read -r f; do report "$f" 1 "CRLF line endings" "bash fails on line 1. Ensure .gitattributes has *.sh text eol=lf." fi + # 2. A bare $NAME followed directly by a non-ASCII byte (an em-dash, an ellipsis). In a + # single-byte locale bash reads that byte as part of the variable NAME (macOS bash 3.2 did + # it under en_US.UTF-8 too), so set -u aborts with "NAME: unbound variable" (#467: + # status.sh --brief died before printing NEXT). Checked before is_meta: no file is exempt + # from this one. Matched byte-wise (LC_ALL=C) so the result does not depend on the locale + # it is meant to guard against. Fix: brace it, ${NAME}—. + while IFS= read -r hit; do + [ -n "$hit" ] || continue + case "$hit" in *portability-ok:*) continue ;; esac + report "$f" "${hit%%:*}" "bare \$NAME followed by a non-ASCII character" \ + 'Brace the name: ${NAME}. A single-byte locale (and bash 3.2) reads the first byte as part of the name; set -u then aborts.' + done <*_-]*(answered|answer|a|decision|assumed)[ \t]*(\([^)]*\))?[ \t]*:[ \t]*//')" + v="$(printf '%s' "$v" | sed -E 's/^[[:blank:]>*_-]*(answered|answer|a|decision|assumed)[[:blank:]]*(\([^)]*\))?[[:blank:]]*:[[:blank:]]*//')" v="${v%%(*}" # drop a trailing parenthetical: "migration (not greenfield)" v="${v%%,*}" # drop anything after the first comma v="${v%%;*}" # ...or semicolon # Trim leading/trailing noise, INCLUDING markdown emphasis markers on both ends # ("**migration**", "_greenfield_") — the old trailing trim only stripped spaces, # tabs and a period, so a bolded or italicised value never matched anything. - v="$(printf '%s' "$v" | sed 's/^[ \t>*_-]*//; s/[ \t.*_-]*$//')" + v="$(printf '%s' "$v" | sed 's/^[[:blank:]>*_-]*//; s/[[:blank:].*_-]*$//')" full="$v" # Documented multi-word phrases resolve on the WHOLE value, before the first-word # truncation below: the runbook's own label for the fourth mode is "Change an existing diff --git a/bin/status.sh b/bin/status.sh index 1b26ceea..e3004717 100755 --- a/bin/status.sh +++ b/bin/status.sh @@ -240,9 +240,9 @@ next_action() { esac if [ "$UNSYNCED" -gt 0 ]; then echo "ba-agent flushes $UNSYNCED UNSYNCED marker(s) in PROJECT.md — every gate is blocked until then (conversion-runbook.md §3b)"; return; fi case "$COH" in *"DUE"*) echo "run process-coherence-pass.md — the cross-module seam check is due"; return ;; esac # process-coherence-pass.md - if [ -n "$LAST_FAIL" ]; then echo "last gate FAILED for: $LAST_FAIL— fix and re-exec (docs/BUILD-LOG.md, .mpr-snapshots/last-mxbuild-errors.json)"; return; fi # iterative-build-loop.md Gate: BUILD + if [ -n "$LAST_FAIL" ]; then echo "last gate FAILED for: ${LAST_FAIL}— fix and re-exec (docs/BUILD-LOG.md, .mpr-snapshots/last-mxbuild-errors.json)"; return; fi # iterative-build-loop.md Gate: BUILD if [ -n "$DONE_STALL" ]; then echo "$DONE_STALL — done- needs the coverage checklist; run bin/coverage-preflight.sh to see what blocks it (iterative-build-loop.md)"; return; fi - if [ -n "$NOT_DONE_PASS" ]; then echo "gate-passed but not done-: $NOT_DONE_PASS— walk the happy path + coverage checklist, then git mv to done- (iterative-build-loop.md step 13-15)"; return; fi + if [ -n "$NOT_DONE_PASS" ]; then echo "gate-passed but not done-: ${NOT_DONE_PASS}— walk the happy path + coverage checklist, then git mv to done- (iterative-build-loop.md step 13-15)"; return; fi if [ -n "$OB_NEXT" ]; then echo "discharge: $OB_NEXT (bin/lib/obligations.tsv names the artifact each owes)"; return; fi [ -n "$GC_NEXT" ] && { echo "$GC_NEXT"; return; } echo "nothing overdue — take the next script in architecture/build-plan.md" diff --git a/project-bin/build-plan-status.sh b/project-bin/build-plan-status.sh index c7f1ac32..b450ca84 100755 --- a/project-bin/build-plan-status.sh +++ b/project-bin/build-plan-status.sh @@ -739,8 +739,10 @@ if [ "$WRITE_HTML" -eq 1 ]; then if "$PY" "$GEN" "$ROOT" "${GEN_ARGS[@]}" >/dev/null 2>"$OUT.err.$$"; then # Two renders of the same state differ only in the stamp lines. A refresh that changed # nothing leaves the file alone, or every exec.sh run would dirty the working tree. + # Stamp lines carry a data-stamp attribute (one of them also id=stamp); 'id=stamp' is kept in + # the pattern so a page written before #479, with id=stamp on every such line, still compares. if [ "$REFRESH" -eq 1 ] && [ -f "$OUT" ] \ - && [ "$(grep -v 'id=stamp' "$OUT")" = "$(grep -v 'id=stamp' "$OUT_TMP")" ]; then + && [ "$(grep -vE 'id=stamp|data-stamp' "$OUT")" = "$(grep -vE 'id=stamp|data-stamp' "$OUT_TMP")" ]; then rm -f "${OUT_TMP:?}" else mv -f "$OUT_TMP" "$OUT" diff --git a/project-bin/conformance-check.sh b/project-bin/conformance-check.sh index 531bbb94..f5520cc4 100755 --- a/project-bin/conformance-check.sh +++ b/project-bin/conformance-check.sh @@ -360,10 +360,31 @@ probe() { esac if [ $rc -ne 0 ]; then echo "ABSENT"; return; fi if [ -z "$(printf '%s' "$out" | tr -d '[:space:]')" ]; then echo "ABSENT"; return; fi - case "$out" in - Error:*|*"not found"*) echo "ABSENT"; return ;; - *"(0 "*) echo "ABSENT"; return ;; # empty result set, e.g. "(0 entities)" - esac + # Read the two absence markers only where mxcli prints them, never in the definition body. + # A substring match read a DESCRIBE whose documentation said "HttpStatus (0 = no response or + # time-out)" as an empty set, and the verbatim v0.25.0 `describe navigation` of mxcli's public + # test app carries " not found page Pages.Vehicle_Overview" (a navigation setting): both + # scored ABSENT, so built rows went STALE (#470). + # Error — mxcli puts its error FIRST (stderr is merged above, and `-c` pairs it with exit 1, + # which the rc test already scored); so only the first non-blank line is read, the + # old `Error:*` start-of-output meaning. Not any line: a body line CAN start at + # column 0 — enumerations, scheduled events and image collections print their + # documentation raw inside `/** … */` (one ` * ` on the first line only), and a + # multi-line string prints raw too, so a doc line "Error: …" is still PRESENT. + # (0 …) — a table's summary is printed as its own LAST line, `fmt.Fprintf("\n%s\n", Summary)` + # with Summary = "(%d entities)", "(%d workflow group(s))", …; a definition ends in + # `/`, `;` or a grant, never in a parenthesised count. + # CR stripped for a Windows-built mxcli (printed "\n%s\n", so nothing else trails the summary). + # Here-strings, not `printf | grep -q`: under pipefail + # that pipe can lose to SIGPIPE on a long definition (test-pipefail-grep-q.sh). + local first last + out="$(tr -d '\r' <<<"$out")" + first="$(sed -n '/[^[:space:]]/{p;q;}' <<<"$out")" + case "$first" in Error:*) echo "ABSENT"; return ;; esac + last="$(sed '/^[[:space:]]*$/d' <<<"$out" | tail -n 1)" + if grep -Eq '^\(0 .*\)$' <<<"$last"; then # no trailing class: the CR strip above is what lets CRLF output match + echo "ABSENT"; return # empty result set, e.g. "(0 entities)" + fi echo "PRESENT" } diff --git a/project-bin/constants-audit.sh b/project-bin/constants-audit.sh index bf0637bd..7616bb4c 100755 --- a/project-bin/constants-audit.sh +++ b/project-bin/constants-audit.sh @@ -167,7 +167,7 @@ while IFS= read -r line; do # four sentences — and printing it whole turns a 30-row table into a wall. The register is # the place to read it; this column only has to prove a reason exists. short=$(printf '%s' "$reason" | cut -c1-58) - [ "${#reason}" -gt 58 ] && short="$short…" + [ "${#reason}" -gt 58 ] && short="${short}…" ROWS="$ROWS$(printf '%-13s %-44s %-10s %-8s %s' "$verdict" "$qname" "$type" "$state" "$short") " diff --git a/project-bin/context-pack.sh b/project-bin/context-pack.sh index a07a1eed..29f34c0a 100755 --- a/project-bin/context-pack.sh +++ b/project-bin/context-pack.sh @@ -114,7 +114,7 @@ SLICE="$(printf '%s' "$ROW" | cut -d'|' -f4)" case "$SLICE" in -|—|none) SLICE="" ;; esac # a cell is a comma-separated list of qualified names; "—" or "-" means none -names() { printf '%s\n' "$1" | tr ',' '\n' | sed 's/^[ \t]*//; s/[ \t]*$//' | grep -vE '^(-|—|none)?$'; } +names() { printf '%s\n' "$1" | tr ',' '\n' | sed 's/^[[:blank:]]*//; s/[[:blank:]]*$//' | grep -vE '^(-|—|none)?$'; } # folder for a document: its row in the "Document folder plan" table, by full or short name folder_of() { diff --git a/project-bin/page-fidelity.js b/project-bin/page-fidelity.js index 804d202f..acda5f49 100755 --- a/project-bin/page-fidelity.js +++ b/project-bin/page-fidelity.js @@ -143,7 +143,7 @@ function innerBalanced(html, openRe) { return html.slice(open, end); } -function contentOf(html) { +function contentOf(html, popupLayout) { // Boundary preference, most-specific first: //
ToeicBuddy shape — content-only wireframes // div.main the topbar portal full-page shape — the wireframe mocks the WHOLE app @@ -161,10 +161,34 @@ function contentOf(html) { // 06/07 .mockup-frame. Measured, not assumed — every one of them // fell through to before this list existed. // div.content same shape when the wireframe has no .main wrapper - // popup/dialog wireframes with annotation siblings - // then strip annotation chrome either way. - const m = html.match(/]*>([\s\S]*?)<\/main>/i); - let s = m ? m[1] + // popup/dialog wireframes with annotation siblings — a bare `.dialog` + // popup with no stage and no scrim still lands here, as it always did + // then strip annotation chrome either way. Ahead of the whole list, only with popup + // evidence: the dialog itself (see POPUP below). + // + // POPUP, ONLY WITH POPUP EVIDENCE (#471). A popup wireframe draws the CALLER's page behind a + // scrim for context: `.popup-stage > .shell-behind + .dialog-scrim > .dialog`. The shell + // behind carries its own div.main, so the list above landed on the caller's page and scored + // the popup against it: 29% in the field for a popup whose fields and bindings all matched, + // every miss in the shell. So when the wireframe is a popup, the dialog is the page. + // + // But `.dialog` alone is not evidence: it is the toolkit's own design-system component, and + // a full-page wireframe may draw an inline confirm with it. Switching on any `.dialog` scored + // a grouped overview plus a confirm as the confirm alone (39% on master, 5% on the first cut + // of this fix; review of #482). Evidence is a `.popup-stage` or `.dialog-scrim` in the + // wireframe, or a page whose MDL names a popup layout (`Layout: …Popup…`, the same test the + // chrome set uses). Matched on the class WORD, so `dialog-head`/`dialog-foot` never count. + const cls = name => new RegExp(`<(div|section|dialog|aside)\\b[^>]*class="(?:[^"]*\\s)?${name}(?:\\s[^"]*)?"`, 'i'); + const popupEvidence = !!popupLayout || cls('popup-stage').test(html) || cls('dialog-scrim').test(html); + const popup = !popupEvidence ? null + : innerBalanced(html, cls('dialog')) + || (() => { // a stage with no .dialog: everything but the shell + const stage = innerBalanced(html, cls('popup-stage')); + return stage === null ? null : dropBalanced(stage, cls('shell-behind')); + })(); + const m = popup === null ? html.match(/]*>([\s\S]*?)<\/main>/i) : null; + let s = popup !== null ? popup + : m ? m[1] : innerBalanced(html, /<(div)[^>]*class="[^"]*\bwf-screen\b[^"]*"/i) || innerBalanced(html, /<(div)[^>]*class="[^"]*\bwf-shell\b[^"]*"/i) || innerBalanced(html, /<(div)[^>]*class="[^"]*\bmockup-frame\b[^"]*"/i) @@ -318,7 +342,7 @@ function bindRows(html) { return rows; } -function wfFacts(html) { +function wfFacts(html, popupLayout) { // HTML COMMENTS ARE NOT PAGE. contentOf() picks its boundary with a regex, and a template // whose header comment says "put the screen inside
… the bind table goes AFTER //
" handed it the comment text as the page. Field case, 2026-10-02, two builds @@ -327,7 +351,7 @@ function wfFacts(html) { // neither describing the page. Strip comments before anything reads the document. html = html.replace(//g, ' '); const mock = localMockClasses(html); - let main = contentOf(html); + let main = contentOf(html, popupLayout); const mockUsed = []; const structural = []; for (const c of mock) { @@ -739,9 +763,10 @@ function score(wf, mdl, chromeExtra) { return { pct: den ? Math.round(100 * num / den) : null, h, b, k, c, bd }; } -const wf = wfFacts(readWireframe()); const ownMdl = pageMdl(); if (!ownMdl.trim()) { console.error('page-fidelity: no declaration of page "' + PAGE + '" found in input'); process.exit(2); } +// Read first: a popup layout is evidence that a `.dialog` in the wireframe is the page (#471). +const wf = wfFacts(readWireframe(), /Layout:\s*[^,)\n]*Popup/i.test(ownMdl)); const snips = expandSnippets(ownMdl); const mdl = ownMdl + snips.extra; const lay = layoutChrome(SRCS, ownMdl); diff --git a/project-bin/progress-report.py b/project-bin/progress-report.py index 951675d7..5e27a503 100755 --- a/project-bin/progress-report.py +++ b/project-bin/progress-report.py @@ -1392,7 +1392,7 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, health = run_health(cs) return fired[:ADV["max_items"]], quiet + fired[ADV["max_items"]:], health, cs, hidden[0] -JS = "" +JS = "" # ----------------------------------------------------------------- render CSS = """ :root{--bg:#f6f7f9;--card:#fff;--ink:#1c2430;--mut:#667085;--line:#e3e7ee;--accent:#2f6fed;--ok:#1f9d63;--warn:#d98a0b;--bad:#c93c3c;--idle:#d9dde5;--main:#2f6fed;--sub:#8b5cf6;--soft:#eef2fb;--shadow:0 1px 2px rgba(20,30,50,.06)} @@ -1433,10 +1433,10 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, .stale{color:var(--warn)}.r-partial td:last-child{color:var(--warn)}.r-built td:last-child{color:var(--ok)} .stack{display:flex;height:16px;border-radius:5px;overflow:hidden;background:var(--idle)}.stack>i{display:block;height:100%} .note{font-size:12px;color:var(--mut);margin:4px 0} -html{scroll-behavior:smooth}[id^=stage-],[id^=agents-],[id^=phase-],#tips,#pages{scroll-margin-top:56px} +html{scroll-behavior:smooth}[id^=stage-],[id^=agents-],[id^=phase-],#stages,#build-detail,#tips,#pages{scroll-margin-top:56px} .snav{position:sticky;top:0;z-index:20;background:var(--bg);border-bottom:1px solid var(--line);padding:8px 16px;display:flex;gap:6px;align-items:center;flex-wrap:wrap} -.snav b{font-size:12px;color:var(--mut);margin-right:4px}.snav a{min-width:34px;text-align:center;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid var(--line);font-weight:650;font-size:13px;color:var(--ink)} -.snav a.passed{border-color:var(--ok)}.snav a.cur{background:var(--accent);color:#fff;border-color:var(--accent)}.snav a:hover{text-decoration:none;border-color:var(--accent)} +.snav a{min-width:34px;text-align:center;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid var(--line);font-weight:650;font-size:13px;color:var(--ink)} +.snav a:hover{text-decoration:none;border-color:var(--accent)} .hd h3 a{color:inherit}.updated{display:inline-block;margin-top:6px;padding:3px 10px;border-radius:8px;background:var(--soft);font-size:13px;font-weight:600} .layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;align-items:start}.layout.solo{display:block}.content{min-width:0}.side{position:sticky;top:52px;max-height:calc(100vh - 64px);overflow:auto;padding-right:2px}.side h2{margin-top:0}.health{margin-top:8px}.health .hi{font-size:13px;margin-top:3px}.grp{margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--card)}.grp>summary{cursor:pointer;padding:9px 12px;font-size:14px}.grp .speed,.grp>.split,.grp>div,.grp .tip{padding:0 12px 10px}.tip{border-top:1px solid var(--line);padding-top:10px}.tip h3{font-size:13px;margin:0 0 4px}.tip p{margin:0 0 4px;font-size:12.5px}.p-info{background:#8793a6;color:#fff}@media(max-width:1000px){.layout{grid-template-columns:minmax(0,1fr)}.side{position:static;max-height:none}}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px}.p-green{background:#2e9e5b;color:#fff}.p-amber{background:#e0a100;color:#fff}.p-red{background:#d1383d;color:#fff}.pill{padding:1px 9px;margin-left:6px;border-radius:99px;font-size:12px}.speed{margin-top:8px;font-size:13px}.split{display:flex;gap:2px;margin:6px 0}.seg{background:var(--accent);color:#fff;font-size:11px;padding:3px 6px;border-radius:4px;white-space:nowrap;overflow:hidden}.seg:nth-child(2){opacity:.75}.seg:nth-child(3){background:var(--idle);color:var(--ink)}.seg:nth-child(4){opacity:.55}.muted{color:var(--mut);font-size:12px}\n.advl{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:10px;margin-top:8px} .advi{border-left:4px solid var(--warn)}.advi.hi{border-left-color:var(--bad)}.advi .rk{font-size:12px;color:var(--mut)}.advi h3{margin:2px 0 6px}.advi p{margin:4px 0;font-size:13px}.advi .why{color:var(--mut);font-size:12px} @@ -1452,7 +1452,7 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, .pages .tree{margin-top:6px;font-size:13px}.pg{display:grid;grid-template-columns:minmax(120px,220px) minmax(0,1fr);gap:2px 12px;padding:5px 0;border-bottom:1px solid var(--line)}.pg:last-child{border-bottom:0} .pg .dir{font-weight:600;overflow-wrap:anywhere}.pg ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:3px 14px}.pg li{overflow-wrap:anywhere}.pg details{margin:0}.pg details ul{margin-top:4px} footer{margin-top:36px;font-size:13px;color:var(--mut)}footer li{margin:3px 0} -@media(max-width:520px){code,pre,.stage dd,.stage dt{overflow-wrap:anywhere;min-width:0}.stage dl{grid-template-columns:minmax(0,1fr)}.stage dd{text-align:left}.hd .sub{white-space:normal;flex:1 1 100%}.snav{padding:6px 12px;flex-wrap:nowrap;overflow-x:auto;max-width:100%}.snav a{flex:0 0 auto}.snav b{flex:0 0 auto}.updated{max-width:100%;overflow-wrap:anywhere}.advl{grid-template-columns:minmax(0,1fr)}.pg{grid-template-columns:minmax(0,1fr)}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.rail{grid-template-columns:minmax(0,1fr)}main{padding:12px}h1{font-size:20px}.kpi .v{font-size:19px}} +@media(max-width:520px){code,pre,.stage dd,.stage dt{overflow-wrap:anywhere;min-width:0}.stage dl{grid-template-columns:minmax(0,1fr)}.stage dd{text-align:left}.hd .sub{white-space:normal;flex:1 1 100%}.snav{padding:6px 12px;flex-wrap:nowrap;overflow-x:auto;max-width:100%}.snav a{flex:0 0 auto}.updated{max-width:100%;overflow-wrap:anywhere}.advl{grid-template-columns:minmax(0,1fr)}.pg{grid-template-columns:minmax(0,1fr)}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.rail{grid-template-columns:minmax(0,1fr)}main{padding:12px}h1{font-size:20px}.kpi .v{font-size:19px}} """ KC = {"BRIEF": "#8b5cf6", "BUILD": "#2f6fed", "PROVE": "#1f9d63", "HARNESS": "#d98a0b", "RUN": "#c93c3c"} @@ -1516,13 +1516,16 @@ def money(c): return "n/a" if unp else "$%.0f" % c w("") w("") w("Progress report: %s" % (esc(info["name"]), CSS)) - w("
" % ("" if L else " solo")) + # One group of section links (issue #479). The stage list is one link away; the current stage is in the + # header line below, and the #stage-N card anchors stay for links from other pages. + w("
" % ("" if L else " solo")) last_ev = datetime.datetime.fromtimestamp(an["t1"]).strftime("%Y-%m-%d %H:%M") if L else "no session log" w("

Progress report: %s

" % esc(info["name"])) - w("Last updated %s %s session log last event %s%s
" % (gen, datetime.datetime.now().astimezone().strftime("%Z"), last_ev, + w("Last updated %s %s session log last event %s%s
" % (gen, datetime.datetime.now().astimezone().strftime("%Z"), last_ev, " · Live · refreshes every 30 s · Pause" if args.reload else "")) - w("
Stage P to 6 · entry mode %s · PROJECT.md · gate-check page · replaces the former build-plan.html
" % - (esc(info["entry_mode"]), href(os.path.join(proj, "PROJECT.md")), href(os.path.join(proj, "index.html")))) + nowstage = ("Stage %s: %s" % (info["current"], info["current"], esc(STAGE_NAME[info["current"]]))) if info["current"] in STAGE_NAME else "unknown" + w("
Current stage: %s · Stage P to 6 · entry mode %s · PROJECT.md · gate-check page
" % + (nowstage, esc(info["entry_mode"]), href(os.path.join(proj, "PROJECT.md")), href(os.path.join(proj, "index.html")))) how_parts = [] if L and DISCOVERY.get("mention") and not args.session_log: rows = "".join("%s%s%d%%" % (esc(u["id"]), datetime.datetime.fromtimestamp(u["t"]).strftime("%Y-%m-%d"), round(u["share"] * 100)) for u in sorted(DISCOVERY.get("used", []), key=lambda u: u["t"])) @@ -1585,7 +1588,7 @@ def money(c): return "n/a" if unp else "$%.0f" % c parts.append("
%d check(s) ran without needing action; %d more are inherent to how the toolkit and mxcli work (build time, required gates and reviews, waiting) and are never shown.
" % (len(quiet), n_inherent)) aside = "".join(parts) # stage rail - w("

Stage rail: Kickoff (P) to Verify and report (6)

Each card shows gate state, artifact progress%s, decisions and links.%s
" % (", wall time, tokens, sub-agents" if L else "", " Time and token splits per stage are approximate (see footer)." if L else "")) + w("

Stage rail: Kickoff (P) to Verify and report (6)

Each card shows gate state, artifact progress%s, decisions and links.%s
" % (", wall time, tokens, sub-agents" if L else "", " Time and token splits per stage are approximate (see footer)." if L else "")) wins = {s: (a, b) for s, a, b in an["wins"]} for s in ["pre"] + STAGES: sd = S[s] @@ -1746,10 +1749,10 @@ def chip(f, cls=""): w("
" + "".join("" % (k, v, pct(v, bp["total"]), KC.get(k, "#888")) for k, v in kinds.items()) + "
") w("
" + "".join("%s %d" % (KC.get(k, "#888"), k, v) for k, v in kinds.items()) + "
") lb = (facts["last_run"]["t"], facts["last_run"]["script"], facts["last_run"]["result"]) if facts else None - if lb: w("
%s. Last build step: %s (%s), %s at %s.
" % (esc(facts_line(facts)), esc(lb[1][:90]), esc(re.sub(r"[^\w .,/()+-]", "", lb[2])[:40].strip() or "no result"), esc(ago(lb[0])), esc(datetime.datetime.fromtimestamp(lb[0]).strftime("%Y-%m-%d %H:%M")))) - if facts and facts["score"]: w("
Latest model score %.1f (%s).
" % (facts["score"]["score"], esc(facts["score"]["script"][:40]))) + if lb: w("
%s. Last build step: %s (%s), %s at %s.
" % (esc(facts_line(facts)), esc(lb[1][:90]), esc(re.sub(r"[^\w .,/()+-]", "", lb[2])[:40].strip() or "no result"), esc(ago(lb[0])), esc(datetime.datetime.fromtimestamp(lb[0]).strftime("%Y-%m-%d %H:%M")))) + if facts and facts["score"]: w("
Latest model score %.1f (%s).
" % (facts["score"]["score"], esc(facts["score"]["script"][:40]))) if bp.get("note"): w("
%s.
" % esc(bp["note"])) - w("
Kinds are the plan's own Kind column; row state is the plan's State column read by prefix (built or done = built, partial or in progress = partial, anything else = not built). Plan read %s from %s.
" % (esc(bp.get("generated") or "unknown"), "architecture/build-plan.md" if bp.get("source") == "md" else "architecture/build-plan.json")) + w("
Kinds are the plan's own Kind column; row state is the plan's State column read by prefix (built or done = built, partial or in progress = partial, anything else = not built). Plan read %s from %s.
" % (esc(bp.get("generated") or "unknown"), "architecture/build-plan.md" if bp.get("source") == "md" else "architecture/build-plan.json")) w("
") for p in bp["phases"]: now = p is cur @@ -1787,7 +1790,7 @@ def chip(f, cls=""): direct = "progress-report.py <project> " + (" ".join("--session-log %s" % esc(x) for x in args.session_log) if args.session_log else "--logs-dir <dir>") w("
  • Inputs. Project folder: %s. Session log(s) (%s): %s. Rates file: %s. To regenerate with the same inputs: %s, or %s.
  • " % (esc(proj), how, logs_txt, rates_txt, regen, direct)) w("") - w("
    Rebuild: project-bin/build-plan-status.sh --html. Setting in PROJECT.md: Progress report: off, ask or auto (now %s). Generated %s.
    %s
    " % (esc(info["setting"]), gen, aside)) + w("
    Rebuild: project-bin/build-plan-status.sh --html. Setting in PROJECT.md: Progress report: off, ask or auto (now %s). Generated %s.
    %s
    " % (esc(info["setting"]), gen, aside)) w(JS) if args.reload: w("" % ("(function(){\nvar VK='pr-view:'+location.pathname,PK='pr-paused',IDLE=20000,EVERY=30000,last=%d,active=%d,lastInput=0,de=document.documentElement;\nfunction get(k){try{return sessionStorage.getItem(k)}catch(e){return null}}\nfunction put(k,v){try{if(v===null)sessionStorage.removeItem(k);else sessionStorage.setItem(k,v)}catch(e){}}\ntry{history.scrollRestoration='manual'}catch(e){}\nfunction restore(){try{var st=JSON.parse(get(VK)||'null');if(!st)return;\n var d=st.d||{};for(var id in d){var e=document.getElementById(id);if(e&&e.tagName==='DETAILS')e.open=!!d[id]}\n var sb=de.style.scrollBehavior;de.style.scrollBehavior='auto';window.scrollTo(0,st.y||0);de.style.scrollBehavior=sb;\n }catch(e){}}\nrestore();window.addEventListener('load',function(){restore();put(VK,null)});\nfunction save(){var d={};var all=document.querySelectorAll('details[id]');for(var i=0;i { + const e = document.activeElement; + if (!e || e === document.body) return []; + return [e.innerText || e.textContent || '', e.getAttribute('aria-label') || '', e.getAttribute('title') || '']; + }).catch(() => []); + return isSignOutLabel(labels); +} +// Each label on its own: the non-English captions are anchored to the whole caption. +function isSignOutLabel(labels) { return labels.some(t => t && SIGN_OUT.test(t)); } function leftApp(url, baseUrl) { const base = String(baseUrl).replace(/\/+$/, ''); if (!url || (url !== base && !url.startsWith(base + '/'))) return true; @@ -208,7 +253,10 @@ async function round(page, target, bucket) { } } else if (abuse === 'rapidTab') { for (let i = 0; i < 15; i++) await page.keyboard.press('Tab').catch(() => {}); - await page.keyboard.press('Enter').catch(() => {}); + // Never press Enter on a sign-out control (#469): step past it, a few Tabs at most. + for (let k = 0; k < 5 && await focusIsSignOut(page); k++) await page.keyboard.press('Tab').catch(() => {}); + if (await focusIsSignOut(page)) what += ' (focus stayed on a sign-out control: Enter skipped)'; + else await page.keyboard.press('Enter').catch(() => {}); } else if (abuse === 'reloadMidFlow') { if (btn) btn.click({ force: true, timeout: 4000 }).catch(() => {}); await page.waitForTimeout(300); @@ -224,7 +272,7 @@ async function round(page, target, bucket) { } // leftApp is exported for tests/wave2/test-monkey-left-app.sh; requiring this file runs nothing. -module.exports = { leftApp, SIGN_OUT }; +module.exports = { leftApp, SIGN_OUT, SIGN_OUT_DEFAULT, signOutPattern, isSignOutLabel }; // ── main ──────────────────────────────────────────────────────────────────── if (require.main === module) (async () => { diff --git a/project-tests/e2e/project.config.template.js b/project-tests/e2e/project.config.template.js index 790e23c2..0bb35334 100644 --- a/project-tests/e2e/project.config.template.js +++ b/project-tests/e2e/project.config.template.js @@ -586,6 +586,11 @@ module.exports = { mxcli: process.env.MXCLI || './mxcli', credentials, readySelectors: READY_SELECTORS, + // Sign-out captions monkey.js must never click, ADDED to its built-in list (English, Dutch, + // German, French, Spanish, Portuguese, Italian). Set it when the app's sign-out control + // reads otherwise, e.g. /\bkirjaudu ulos\b/i — a monkey that signs itself out scores the + // login page as a crash and fuzzes nothing more (#469). + signOutPattern: null, dsCss: DS_CSS, NAV, TARGETS, diff --git a/tests/wave2/fixtures/page-fidelity-popup/CAPTURE.md b/tests/wave2/fixtures/page-fidelity-popup/CAPTURE.md new file mode 100644 index 00000000..2cae4adc --- /dev/null +++ b/tests/wave2/fixtures/page-fidelity-popup/CAPTURE.md @@ -0,0 +1,19 @@ +# page-fidelity-popup — a popup wireframe and the popup page it draws (#471) + +`product-newedit-describe-v023.mdl` is **verbatim** `mxcli describe page Sales.Product_NewEdit` +output (mxcli v0.23.0, Mendix 11.14.0, captured 2026-10-05), copied unchanged from +`tests/wave2/fixtures/context-pack/capture/describe/PAGE__Sales.Product_NewEdit.out`. It is a +real popup page: `Layout: Atlas_Core.PopupLayout`, a form grid, Cancel and Save in a footer. + +`product-newedit-popup.html` is **hand-drawn**, because issue #471 quotes the shape of the field +wireframe, not the file: `.popup-stage > .shell-behind + .dialog-scrim > .dialog`. The shell +behind holds the caller's page as such wireframes draw it (a sidebar, a `div.main` with a page +head, its `

    ` and a card), and the dialog holds the popup page's own title, fields, help +texts and buttons, taken from the describe above. The `wf-bar` and `wf-note` annotation +siblings are the toolkit's own convention. + +On master the scorer chose `div.main` inside `.shell-behind` and printed 18%, every miss from +the shell (the issue measured 29% on the field popup and 13% on a throwaway one, the same way). +With the fix it scores the dialog: 100%. + +Re-capture the wireframe from a real popup wireframe when one can be shared. diff --git a/tests/wave2/fixtures/page-fidelity-popup/product-newedit-describe-v023.mdl b/tests/wave2/fixtures/page-fidelity-popup/product-newedit-describe-v023.mdl new file mode 100644 index 00000000..34fcd84d --- /dev/null +++ b/tests/wave2/fixtures/page-fidelity-popup/product-newedit-describe-v023.mdl @@ -0,0 +1,63 @@ +create or modify page Sales.Product_NewEdit ( + Title: 'Catalog item', + Layout: Atlas_Core.PopupLayout, + Folder: 'Catalog/Pages', + Params: { $Product: Sales.Product } +) { + layoutgrid g { + row row1 { + column col1 (DesktopWidth: AutoFill) { + dataview dv (DataSource: $Product, ReadOnlyStyle: Control) { + -- Context: $currentObject (Product) + container formGrid (Class: 'form-grid') { + container fgSku (Class: 'form-group') { + textbox tbSku (Label: 'SKU *', Attribute: Sku) + dynamictext helpSku ( + Content: 'Set when the item is created. It cannot change afterwards.', + Class: 'form-help' + ) + } + container fgName (Class: 'form-group') { + textbox tbName (Label: 'Name *', Attribute: Name) + } + container fgDescription (Class: 'form-group span-2') { + textarea taDescription (Label: 'Description', Attribute: Description) + } + container fgCategory (Class: 'form-group') { + textbox tbCategory (Label: 'Category', Attribute: Category) + } + container fgPrice (Class: 'form-group') { + textbox tbPrice (Label: 'Unit price *', Attribute: UnitPrice) + } + container fgBrand (Class: 'form-group') { + combobox cbBrand ( + Label: 'Brand *', + Attribute: Product_Brand, + DataSource: database from Sales.Brand, + CaptionAttribute: Name + ) + } + container fgActive (Class: 'form-group') { + checkbox cbActive (Label: 'Active', Attribute: Active) + dynamictext helpActive ( + Content: 'Inactive items are not offered on new sales orders.', + Class: 'form-help' + ) + } + } + footer footer1 { + actionbutton btnCancel (Caption: 'Cancel', Action: cancel_changes close_page, Class: 'btn') + actionbutton btnSave ( + Caption: 'Save', + Action: microflow Sales.ACT_Product_Save(Product: $Product), + ButtonStyle: Primary, + Class: 'btn btn-primary' + ) + } + } + } + } + } +} + +grant view on page Sales.Product_NewEdit to Sales.Admin, Sales.SalesOfficer; diff --git a/tests/wave2/fixtures/page-fidelity-popup/product-newedit-popup.html b/tests/wave2/fixtures/page-fidelity-popup/product-newedit-popup.html new file mode 100644 index 00000000..d9675926 --- /dev/null +++ b/tests/wave2/fixtures/page-fidelity-popup/product-newedit-popup.html @@ -0,0 +1,48 @@ + + + + +Wireframe: Sales.Product_NewEdit (popup) + + + +
    Wireframe · Sales.Product_NewEdit · popup over Product_Overview
    + +
    The overview behind the scrim is the caller's page, drawn for context only.
    + + diff --git a/tests/wave2/test-conformance-absent-marker.sh b/tests/wave2/test-conformance-absent-marker.sh new file mode 100644 index 00000000..e0dd5b71 --- /dev/null +++ b/tests/wave2/test-conformance-absent-marker.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Usage: bash test-conformance-absent-marker.sh [path-to-conformance-check.sh] +# +# Fixture for project-bin/conformance-check.sh's probe(): where it looks for the two "absent" +# markers in mxcli's answer (issue #470). +# +# The defect: any "(0 " or "not found" ANYWHERE in the output scored the element ABSENT, so a +# built row went STALE when the definition merely contained those characters. The issue's field +# case: a microflow whose documentation reads "HttpStatus (0 = no response or time-out)" — five +# false STALEs in one module. The same substring match hits every `describe navigation` with a +# not-found page set, because mxcli prints that setting as " not found page ". +# +# Golden input. No mxcli binary was available when this fixture was written, so it replays +# captures already in this repo, verbatim: +# - fixtures/design-audit-nav/describe-navigation-responsive-v025.txt — `describe navigation +# Responsive`, mxcli v0.25.0 on mxcli's public test app testapp-views/TestApp.mpr (carries +# " not found page Pages.Vehicle_Overview"); replayed without its four "-- GOLDEN" header lines; +# - fixtures/context-pack/capture/describe/MICROFLOW__Sales.ACT_ApprovalStep_Approve.out — +# `DESCRIBE MICROFLOW`, mxcli v0.23.0. The issue's doc text is quoted verbatim from #470 and +# spliced into that capture's documentation line (the only edit), since #470 quotes the +# sentence, not the whole definition. +# The empty-set reply follows mxcli's own printer (mdl/executor/format.go: the summary is +# printed as "\n%s\n" after the table, Summary = "(%d entities)", "(%d workflow group(s))", …), +# not a capture — re-capture it from a real `SHOW … IN ` when a binary is at hand. +# +# Cases, each on BOTH layouts (single-tree, and two-tree with the .mpr under app/): +# 1. a built microflow whose documentation says "(0 = …)" is PRESENT (was ABSENT -> STALE) +# 2. a built navigation profile with a not-found page is PRESENT (was ABSENT -> STALE) +# 3. the unedited microflow capture stays PRESENT +# 4. an empty result set, "(0 entities)" as the last line, stays ABSENT +# 5. an empty set whose noun has parentheses, "(0 workflow group(s))", stays ABSENT +# 6. a missing element ("Error: … not found", rc 1) stays ABSENT +# 7. an "Error:" line with rc 0 is still ABSENT +# 8. CRLF output (a Windows-built mxcli): the doc'd microflow PRESENT, and the empty set +# ABSENT — the summary regex has no trailing-space class, so this passes only because of +# the CR strip (delete the `tr -d '\r'` and it fails); an rc-0 "Error:" first line ABSENT +# 9. an enumeration whose documentation has a raw line starting "Error:" stays PRESENT: mxcli +# prints enumeration docs as "/**\n * %s\n */" with the multi-line text raw +# (mdl/executor/cmd_enumerations.go), so a doc line can start at column 0. Built from that +# printer, not captured. + +set -uo pipefail + +SUT="${1:-}" +[ -z "$SUT" ] && SUT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/project-bin/conformance-check.sh" +if [ ! -f "$SUT" ]; then + echo "SKIP: subject not found at $SUT" + echo "SCORE: 0/0 — nothing to test" + exit 0 +fi +SUT="$(cd "$(dirname "$SUT")" && pwd)/$(basename "$SUT")" +[ -f "$(dirname "$SUT")/_common.sh" ] || { echo "FAIL — _common.sh not beside $SUT"; exit 1; } +FIX="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fixtures" +NAV="$FIX/design-audit-nav/describe-navigation-responsive-v025.txt" +MF="$FIX/context-pack/capture/describe/MICROFLOW__Sales.ACT_ApprovalStep_Approve.out" +for f in "$NAV" "$MF"; do [ -f "$f" ] || { echo "FAIL — golden capture missing: $f"; exit 1; }; done + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok — $1"; } +bad() { FAIL=$((FAIL+1)); echo " FAIL — $1"; [ -n "${2:-}" ] && echo " got: $2"; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/conf-absent.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +setup() { # setup -> sets P + P="$WORK/$1-$2" + rm -rf "$P"; mkdir -p "$P/architecture/modules/Sales" "$P/cap" + if [ "$1" = two ]; then mkdir -p "$P/app"; : > "$P/app/App.mpr"; else : > "$P/App.mpr"; fi + grep -v '^-- GOLDEN\|^-- built from\|^-- testdata/\|^-- the same text' "$NAV" > "$P/cap/nav.out" + cp "$MF" "$P/cap/mf.out" + # The one edit: the issue's verbatim documentation text in place of the capture's. + sed 's#^ \* Page button: approve the current step with the comment typed on it\.$# * Page button: approve. HttpStatus (0 = no response or time-out).#' \ + "$MF" > "$P/cap/mfdoc.out" + printf '| Name |\n|------|\n\n(0 entities)\n' > "$P/cap/empty.out" + printf '| Name |\n|------|\n\n(0 workflow group(s))\n' > "$P/cap/emptyparen.out" + printf '/**\n * Payment states.\nError: shown when the provider rejects the payment.\n */\ncreate or modify enumeration Sales.PayStatus (\n Paid '"'"'Paid'"'"',\n Error '"'"'Error'"'"'\n);\n/\n' > "$P/cap/enum.out" + printf 'Error: microflow not found: Sales.ACT_Rc0\n' > "$P/cap/rc0.out" + if [ "$2" = crlf ]; then + for f in "$P"/cap/*.out; do sed 's/$/\r/' "$f" > "$f.tmp" && mv "$f.tmp" "$f"; done + fi + cat > "$P/mxcli" <<'EOF' +#!/usr/bin/env bash +cmd=""; while [ $# -gt 0 ]; do [ "$1" = -c ] && cmd="$2"; shift; done +c="$(dirname "$0")/cap" +case "$cmd" in + "DESCRIBE NAVIGATION Responsive") cat "$c/nav.out" ;; + "DESCRIBE MICROFLOW Sales.ACT_Plain") cat "$c/mf.out" ;; + "DESCRIBE MICROFLOW Sales.ACT_Doc") cat "$c/mfdoc.out" ;; + "SHOW ENTITIES IN Empty") cat "$c/empty.out" ;; + "SHOW WORKFLOWS IN Empty") cat "$c/emptyparen.out" ;; + "DESCRIBE MICROFLOW Sales.ACT_Rc0") cat "$c/rc0.out" ;; + "DESCRIBE ENUMERATION Sales.PayStatus") cat "$c/enum.out" ;; + *) echo "Error: microflow not found: ${cmd##* }" >&2; exit 1 ;; +esac +EOF + chmod +x "$P/mxcli" + cat > "$P/architecture/modules/Sales/coverage-ledger.md" <<'EOF' +| Pointer | Kind | Name | Row | Proof | Acceptance | Status | +|---|---|---|---|---|---|---| +| `/microflows/1` | microflow | ACT_Doc | 1.1 | exec.sh | `DESCRIBE MICROFLOW Sales.ACT_Doc` | built | +| `/navigation/0` | navigation | Responsive | 1.2 | exec.sh | `DESCRIBE NAVIGATION Responsive` | built | +| `/microflows/0` | microflow | ACT_Plain | 1.3 | exec.sh | `DESCRIBE MICROFLOW Sales.ACT_Plain` | built | +| `/entities/0` | entity | Nothing | 1.4 | exec.sh | `SHOW ENTITIES IN Empty` | not-built | +| `/workflows/0` | workflow | Nothing | 1.5 | exec.sh | `SHOW WORKFLOWS IN Empty` | not-built | +| `/microflows/2` | microflow | ACT_Gone | 1.6 | exec.sh | `DESCRIBE MICROFLOW Sales.ACT_Gone` | not-built | +| `/microflows/3` | microflow | ACT_Rc0 | 1.7 | exec.sh | `DESCRIBE MICROFLOW Sales.ACT_Rc0` | not-built | +| `/enumerations/0` | enumeration | PayStatus | 1.8 | exec.sh | `DESCRIBE ENUMERATION Sales.PayStatus` | built | +EOF +} + +observed() { # observed -> the observed column of today's report + P_PTR="\`$1\`" awk -F'\t' '$2==ENVIRON["P_PTR"] {print $4}' "$P"/docs/conformance/report-*.tsv 2>/dev/null | head -1 +} + +for layout in single two; do + for eol in lf crlf; do + setup "$layout" "$eol" + ( cd "$P" && PROJECT_ROOT="$P" bash "$SUT" --quiet ) > "$WORK/out" 2>&1; rc=$? + tag="[$layout/$eol]" + if [ "$eol" = lf ]; then + [ "$rc" = 0 ] && ok "$tag run exits 0" || bad "$tag run exit $rc" "$(tr '\n' ' ' < "$WORK/out" | cut -c1-300)" + [ "$(observed /microflows/1)" = PRESENT ] && ok "$tag 1. doc with '(0 = …)' is PRESENT" \ + || bad "$tag 1. doc with '(0 = …)' read as $(observed /microflows/1) (the #470 false STALE)" + [ "$(observed /navigation/0)" = PRESENT ] && ok "$tag 2. navigation with a not-found page is PRESENT" \ + || bad "$tag 2. navigation read as $(observed /navigation/0)" + [ "$(observed /microflows/0)" = PRESENT ] && ok "$tag 3. unedited capture PRESENT" \ + || bad "$tag 3. unedited capture read as $(observed /microflows/0)" + [ "$(observed /entities/0)" = ABSENT ] && ok "$tag 4. '(0 entities)' is ABSENT" \ + || bad "$tag 4. '(0 entities)' read as $(observed /entities/0)" + [ "$(observed /workflows/0)" = ABSENT ] && ok "$tag 5. '(0 workflow group(s))' is ABSENT" \ + || bad "$tag 5. '(0 workflow group(s))' read as $(observed /workflows/0)" + [ "$(observed /microflows/2)" = ABSENT ] && ok "$tag 6. Error + rc 1 is ABSENT" \ + || bad "$tag 6. missing element read as $(observed /microflows/2)" + [ "$(observed /microflows/3)" = ABSENT ] && ok "$tag 7. Error line with rc 0 is ABSENT" \ + || bad "$tag 7. rc-0 Error read as $(observed /microflows/3)" + [ "$(observed /enumerations/0)" = PRESENT ] && ok "$tag 9. enum doc with a raw 'Error:' line stays PRESENT" \ + || bad "$tag 9. enum doc line 'Error: …' read as $(observed /enumerations/0)" + else + [ "$(observed /microflows/1)" = PRESENT ] && ok "$tag 8a. CRLF: doc'd microflow PRESENT" \ + || bad "$tag 8a. CRLF doc'd microflow read as $(observed /microflows/1)" + [ "$(observed /entities/0)" = ABSENT ] && [ "$(observed /workflows/0)" = ABSENT ] \ + && ok "$tag 8b. CRLF: '(0 …)\\r' summaries are ABSENT (the CR strip, not a trailing-space class)" \ + || bad "$tag 8b. CRLF empty set misread" "entities=$(observed /entities/0) workflows=$(observed /workflows/0)" + [ "$(observed /microflows/3)" = ABSENT ] && [ "$(observed /enumerations/0)" = PRESENT ] \ + && ok "$tag 8c. CRLF: rc-0 'Error:' first line ABSENT, enum doc 'Error:' line PRESENT" \ + || bad "$tag 8c. CRLF Error handling" "rc0=$(observed /microflows/3) enum=$(observed /enumerations/0)" + fi + done +done + +echo "" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/wave2/test-context-pack.sh b/tests/wave2/test-context-pack.sh index cb0ba375..bf6c5632 100644 --- a/tests/wave2/test-context-pack.sh +++ b/tests/wave2/test-context-pack.sh @@ -16,6 +16,10 @@ # an unrecognised value warns and runs as on. # 7. OLD BRIEF: a brief with no "### Build steps" table (written before packs) exits 3, like off. # 6. BOTH LAYOUTS: the .mpr at the root, and under app/ (two-tree). +# 8. BSD SED (#468): `[ \t]` in a sed bracket is "space, backslash or t" on macOS, so a trim +# ate the trailing t of Sales.ACT_ApprovalStep_Reject and Sales.Product and both steps +# exited 1. On GNU sed the case re-runs through a `sed --posix` shim, which reads the +# bracket the same way BSD does; on macOS the plain runs above already are that case. # # Usage: bash test-context-pack.sh [path-to-context-pack.sh] @@ -73,6 +77,24 @@ for layout in root app; do check "unknown step exits 2" "$(run "$P" 9.9)" "2" done +echo "== BSD sed bracket semantics (#468) ==" +if sed --posix -n p /dev/null 2>&1; then + REAL_SED="$(command -v sed)"; mkdir -p "$TMP/bsdsed" + printf '#!/usr/bin/env bash\nexec "%s" --posix "$@"\n' "$REAL_SED" > "$TMP/bsdsed/sed"; chmod +x "$TMP/bsdsed/sed" + if [ "$(printf 'Product\n' | "$TMP/bsdsed/sed" 's/[ \t]*$//')" = "Produc" ]; then # portability-ok: the #468 defect, run on purpose to prove the shim reproduces it + P="$TMP/bsd"; mkproj "$P" root + runbsd() { ( cd "$1" && PATH="$TMP/bsdsed:$PATH" PROJECT_ROOT="$1" bash "$CP" Sales "$2" --out "$1/pack.md" ) 2>"$1/err"; echo $?; } + check "bsd sed: 5.1 exits 0 (example name ends in t)" "$(runbsd "$P" 5.1)" "0" + has "bsd sed: 5.1 example kept its trailing t" "### Sales.ACT_ApprovalStep_Reject (microflow)" "$P/pack.md" + check "bsd sed: 5.2 exits 0 (Reads name ends in t)" "$(runbsd "$P" 5.2)" "0" + has "bsd sed: 5.2 Sales.Product described" "Sales.Product: Active" "$P/pack.md" + else + echo " SKIP bsd sed: this sed --posix expands \\t in a bracket; the case is not reproducible here" + fi +else + echo " (sed is not GNU sed: the runs above already used BSD bracket semantics)" +fi + echo "== switch ==" P="$TMP/root"; mkdir -p "$P/docs/brain" diff --git a/tests/wave2/test-monkey-left-app.sh b/tests/wave2/test-monkey-left-app.sh index 654260e8..2c31aa4c 100755 --- a/tests/wave2/test-monkey-left-app.sh +++ b/tests/wave2/test-monkey-left-app.sh @@ -64,6 +64,21 @@ L('OTHER', 'http://localhost:80801/p/cases'); L('SLASHBASE', 'http://localhost:8080/p/cases', base + '/'); const S = t => console.log(`S:${t} ${M.SIGN_OUT.test(t)}`); ['Sign out', 'Log off', 'Logout', 'Switch theme', 'Signature', 'Log in'].forEach(S); +// #469: the captions the issue names, verbatim, and near-misses that must stay fuzzable. +['Afmelden', 'Uitloggen', 'Abmelden', 'Se déconnecter', 'Aanmelden', 'Inloggen', 'Anmelden', 'Se connecter', 'Melden'].forEach(S); +const X = (k, extra, t) => console.log(`X:${k} ${typeof M.signOutPattern === 'function' ? M.signOutPattern(extra).test(t) : 'NOFN'}`); +X('cfgre', /\bkirjaudu ulos\b/i, 'Kirjaudu ulos'); +X('cfgstr', '\\bkirjaudu ulos\\b', 'Kirjaudu ulos'); +X('cfgkeeps', /\bkirjaudu ulos\b/i, 'Afmelden'); +X('cfgbad', '(', 'Afmelden'); +X('cfgnone', null, 'Kirjaudu ulos'); +// Review of #481: ordinary verbs inside a longer caption are not sign-out controls. +['Ziek afmelden', 'Afmelden nieuwsbrief', 'Vom Kurs abmelden', 'Disconnetti dispositivo', ' Afmelden '].forEach(S); +X('cfgflags', /^kirjaudu ulos$/m, 'Valikko\nKirjaudu ulos'); +const I = (k, labels) => console.log(`I:${k} ${typeof M.isSignOutLabel === 'function' ? M.isSignOutLabel(labels) : 'NOFN'}`); +I('aria', ['', 'Afmelden', '']); +I('title', ['\ue000', '', 'Uitloggen']); +I('none', ['Ziek afmelden', 'Menu', '']); process.exit(0); EOF OUT="$(cd "$WORK/tests/e2e" && printf '%s' "$JS" | APP_PORT=1 timeout 30 node - 2>&1)" @@ -85,12 +100,41 @@ fi [ "$(line OTHER)" = true ] && ok "a lookalike origin (port prefix match) is outside the app" || bad "prefix match leaks another origin in" "$(line OTHER)" [ "$(line SLASHBASE)" = false ] && ok "a baseUrl with a trailing slash still matches its routes" || bad "trailing-slash baseUrl breaks the match" "$(line SLASHBASE)" -s() { printf '%s\n' "$OUT" | sed -n "s/^S:$1 //p"; } +s() { printf '%s\n' "$OUT" | K="S:$1" awk '{b=$NF; sub(/ [^ ]*$/, ""); if ($0 == ENVIRON["K"]) print b}'; } # exact label: "Afmelden" must not read "Afmelden nieuwsbrief" [ "$(s 'Sign out')" = true ] && [ "$(s 'Log off')" = true ] && [ "$(s 'Logout')" = true ] \ && ok "SIGN_OUT matches Sign out (captured), Log off, Logout" || bad "a sign-out label is not excluded" "$(s 'Sign out')/$(s 'Log off')/$(s 'Logout')" [ "$(s 'Switch theme')" = false ] && [ "$(s 'Signature')" = false ] && [ "$(s 'Log in')" = false ] \ && ok "SIGN_OUT leaves Switch theme (captured), Signature, Log in fuzzable" || bad "SIGN_OUT over-matches" "$(s 'Switch theme')/$(s 'Signature')/$(s 'Log in')" +# #469 — non-English sign-out captions (verbatim from the issue's field run and its list). +[ "$(s 'Afmelden')" = true ] && [ "$(s 'Uitloggen')" = true ] && [ "$(s 'Abmelden')" = true ] && [ "$(s 'Se déconnecter')" = true ] \ + && ok "SIGN_OUT matches Afmelden (captured), Uitloggen, Abmelden, Se déconnecter" \ + || bad "a non-English sign-out label is clicked — the #469 false CRASH" "$(s 'Afmelden')/$(s 'Uitloggen')/$(s 'Abmelden')/$(s 'Se déconnecter')" +[ "$(s 'Aanmelden')" = false ] && [ "$(s 'Inloggen')" = false ] && [ "$(s 'Anmelden')" = false ] && [ "$(s 'Se connecter')" = false ] && [ "$(s 'Melden')" = false ] \ + && ok "SIGN_OUT leaves sign-IN and look-alike captions fuzzable (Aanmelden, Inloggen, Anmelden, Se connecter, Melden)" \ + || bad "SIGN_OUT over-matches a sign-in caption" "$(s 'Aanmelden')/$(s 'Inloggen')/$(s 'Anmelden')/$(s 'Se connecter')/$(s 'Melden')" +x() { printf '%s\n' "$OUT" | sed -n "s/^X:$1 //p"; } +[ "$(x cfgre)" = true ] && [ "$(x cfgstr)" = true ] && ok "a project signOutPattern (RegExp or string) is honoured" \ + || bad "project signOutPattern ignored" "$(x cfgre)/$(x cfgstr)" +[ "$(x cfgkeeps)" = true ] && ok "a project pattern adds to the default, it does not replace it" || bad "project pattern dropped the default" "$(x cfgkeeps)" +[ "$(x cfgbad)" = true ] && [ "$(x cfgnone)" = false ] && ok "an invalid project pattern falls back to the default; none means default only" \ + || bad "invalid/absent project pattern mishandled" "$(x cfgbad)/$(x cfgnone)" +[ "$(s 'Ziek afmelden')" = false ] && [ "$(s 'Afmelden nieuwsbrief')" = false ] && [ "$(s 'Vom Kurs abmelden')" = false ] && [ "$(s 'Disconnetti dispositivo')" = false ] \ + && ok "a non-English verb inside a longer caption stays fuzzable (Ziek afmelden, Afmelden nieuwsbrief, Vom Kurs abmelden, Disconnetti dispositivo)" \ + || bad "SIGN_OUT over-matches an ordinary caption" "$(s 'Ziek afmelden')/$(s 'Afmelden nieuwsbrief')/$(s 'Vom Kurs abmelden')/$(s 'Disconnetti dispositivo')" +[ "$(s ' Afmelden ')" = true ] && ok "a whole caption with surrounding whitespace still matches" || bad "padded caption missed" "$(s ' Afmelden ')" +[ "$(x cfgflags)" = true ] && ok "a project RegExp keeps its own flags (m)" || bad "project RegExp flags dropped" "$(x cfgflags)" +i() { printf '%s\n' "$OUT" | sed -n "s/^I:$1 //p"; } +[ "$(i aria)" = true ] && [ "$(i title)" = true ] && [ "$(i none)" = false ] \ + && ok "focus labels (text, aria-label, title) are tested one by one" || bad "focus label test wrong" "$(i aria)/$(i title)/$(i none)" +grep -q 'signOutPattern: *process.env.SIGN_OUT_PATTERN' "$E2E/config.js" && grep -q '^ *signOutPattern:' "$CFG" \ + && ok "config.js reads signOutPattern (env, then project.config) and the template declares it" \ + || bad "signOutPattern is not plumbed through config.js / the template (a consumer with no producer)" +# Scoped to the rapidTab branch itself: the ABUSES list and focusIsSignOut's definition also +# mention these names, and an unscoped match passed a blind Enter (review of #481). +awk '/abuse === .rapidTab./{r=1;next} r&&/else if \(abuse ===/{r=0} r&&/focusIsSignOut\(page\)/{f=1} r&&/press\(.Enter.\)/{if(!f)bad=1; e=1} END{exit !(e&&!bad)}' "$SUT" \ + && ok "rapidTab checks focus for a sign-out control before Enter" || bad "rapidTab presses Enter without checking what has focus (#469)" + # The tested functions must be the ones the rounds use. awk '/hasNotText: *SIGN_OUT/ {f=1} END{exit !f}' "$SUT" && ok "round()'s button locator filters SIGN_OUT" \ || bad "the button locator does not filter SIGN_OUT — the tested regex is not the one that runs" diff --git a/tests/wave2/test-page-fidelity-mocks.sh b/tests/wave2/test-page-fidelity-mocks.sh index abf768e7..a4676d87 100755 --- a/tests/wave2/test-page-fidelity-mocks.sh +++ b/tests/wave2/test-page-fidelity-mocks.sh @@ -247,6 +247,40 @@ hasnt "no layout hint when no missed class looks like chrome" "$OUT19" "is not i hasnt "no layout hint for page classes (seg-tabs, dg-*)" "$OUT6" "is not in the input" has "the hint names the layout when region-* is missed" "$OUT16" "layout LayoutTest.App_Default is not in the input" +echo " -- a popup wireframe scores the dialog, not the caller's page behind it (#471)" +# fixtures/page-fidelity-popup/CAPTURE.md: the MDL is a verbatim mxcli v0.23.0 describe of a +# popup page; the wireframe draws it in the issue's shape, .popup-stage > .shell-behind + +# .dialog-scrim > .dialog, with the caller's overview (div.main, h1, card) behind the scrim. +PF="$TOOLKIT/tests/wave2/fixtures/page-fidelity-popup" +OUT20=$(cd "$TMP" && node "$SUT" --no-log "$PF/product-newedit-popup.html" Product_NewEdit "$PF/product-newedit-describe-v023.mdl" 2>&1) +echo "$OUT20" | sed 's/^/ | /' +hasnt "the caller's h1 behind the scrim is not scored" "$OUT20" "heading: Product catalog" +hasnt "the caller's card behind the scrim is not scored" "$OUT20" "heading: Active items" +hasnt "the caller's button is not an owed action" "$OUT20" "action: New item" +has "the dialog's fields and buttons all match" "$OUT20" "text-match 100%" +sed 's/class="dialog" /class="sheet" /' "$PF/product-newedit-popup.html" > "$TMP/stage-only.html" +OUT21=$(cd "$TMP" && node "$SUT" --no-log "$TMP/stage-only.html" Product_NewEdit "$PF/product-newedit-describe-v023.mdl" 2>&1) +hasnt "a .popup-stage with no .dialog still drops the shell" "$OUT21" "heading: Product catalog" +has "...and still scores what is in front of it" "$OUT21" "headings 1/1" +sed 's/class="dialog" /class="panel" /; s/class="popup-stage"/class="stage"/' "$PF/product-newedit-popup.html" > "$TMP/no-popup.html" +OUT22=$(cd "$TMP" && node "$SUT" --no-log "$TMP/no-popup.html" Product_NewEdit "$PF/product-newedit-describe-v023.mdl" 2>&1) +has "popup evidence but no .dialog and no stage: the old div.main boundary, as before" "$OUT22" "heading: Product catalog" +# Popup evidence from the page alone: no stage, no scrim, but the MDL names Atlas_Core.PopupLayout. +sed 's/class="popup-stage"/class="stage"/; s/class="dialog-scrim"/class="scrim"/' "$PF/product-newedit-popup.html" > "$TMP/layout-only.html" +OUT23=$(cd "$TMP" && node "$SUT" --no-log "$TMP/layout-only.html" Product_NewEdit "$PF/product-newedit-describe-v023.mdl" 2>&1) +hasnt "a popup layout in the MDL is evidence enough: the shell is not scored" "$OUT23" "heading: Product catalog" +has "...and the dialog is" "$OUT23" "text-match 100%" +echo " -- a full-page wireframe with an inline .dialog scores as before (review of #482)" +# .dialog is the design system's component; a full page may draw an open confirm with it. On +# master this wireframe scored 39% (the confirm counted as page content); the first cut of #471 +# scored the confirm alone, 5%. No stage, no scrim, a full-page layout: no popup evidence. +sed 's#
    #&#' \ + "$FIX/grouped-overview.html" > "$TMP/inline-dialog.html" +grep -q 'Delete project' "$TMP/inline-dialog.html" || bad "inline-dialog setup: the sed did not insert the confirm" "" +OUT24=$(cd "$TMP" && node "$SUT" --no-log "$TMP/inline-dialog.html" Demo_Overview "$FIX/demo-overview.mdl" 2>&1) +echo "$OUT24" | sed 's/^/ | /' +has "an inline confirm does not become the boundary: the master score, unchanged" "$OUT24" "text-match 39% headings 1/2 actions 1/3 content 1/2 classes 1/5" + echo printf 'test-page-fidelity-mocks: %d passed, %d failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] diff --git a/tests/wave2/test-progress-report-field-fixes.sh b/tests/wave2/test-progress-report-field-fixes.sh index bf387456..39345250 100644 --- a/tests/wave2/test-progress-report-field-fixes.sh +++ b/tests/wave2/test-progress-report-field-fixes.sh @@ -373,7 +373,7 @@ if [ -n "$ADD" ] && git -C "$ROOT" show "$ADD^:project-bin/build-plan-status.sh" [ "$RC" -eq 0 ] && ok "build-plan-status.sh --html runs without error" || { bad "build-plan-status.sh --html exit $RC"; tail -5 "$WORK/bps11.out"; } hasnt "$WORK/bps11.out" "Traceback\|not written\|WARN" "no error or warning in its output" [ -s "$P11/architecture/progress-report.html" ] && ok "architecture/progress-report.html written" || bad "architecture/progress-report.html written" - has "$P11/architecture/progress-report.html" "replaces the former build-plan.html" "the page says it replaces build-plan.html" + hasnt "$P11/architecture/progress-report.html" "replaces the former build-plan.html" "the header no longer carries the 1.0 build-plan.html note (#479)" [ "$(cksum < "$P11/architecture/build-plan.html")" = "$OLDSUM" ] && ok "the old build-plan.html is left exactly as it was (documented)" || bad "old build-plan.html untouched" hasnt "$P11/architecture/progress-report.html" "href='[^']*progress-report" "the report never links itself" else @@ -459,7 +459,7 @@ grep -q "discovery cold" "$WORK/out15.b" && ok "a copied project with its cache hasnt "$WORK/out15/b.html" "Sessions from other folders (1)" "the copy does not show the original project's sessions" CLAUDE_CONFIG_DIR="$CFG15" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P15" --out "$WORK/out15/c.html" >"$WORK/out15.c" 2>&1 grep -q "discovery warm" "$WORK/out15.c" && ok "the original's own cache is still warm" || bad "the original's cache is warm" -sed -i.bak '1s/rules=[^\t]*/rules=share=0.10;calls=5/' "$P15/.claude/progress-report-sessions.txt"; rm -f "$P15/.claude/progress-report-sessions.txt.bak" +sed -i.bak $'1s/rules=[^\t]*/rules=share=0.10;calls=5/' "$P15/.claude/progress-report-sessions.txt"; rm -f "$P15/.claude/progress-report-sessions.txt.bak" CLAUDE_CONFIG_DIR="$CFG15" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P15" --out "$WORK/out15/d.html" >"$WORK/out15.d" 2>&1 grep -q "discovery cold" "$WORK/out15.d" && ok "a cache written under other thresholds is rebuilt" || bad "other thresholds rebuild the cache" @@ -502,6 +502,9 @@ has "$WORK/out14/r.html" "history.scrollRestoration='manual'" "scrollRestoration has "$WORK/out14/r.html" "sessionStorage" "scroll position and open details go to sessionStorage" has "$WORK/out14/r.html" "id=livebtn" "Pause control present" has "$WORK/out14/r.html" "Live · refreshes every 30 s" "live indicator present" +[ "$(grep -c 'id=stamp' "$WORK/out14/r.html")" -eq 1 ] && ok "exactly one id=stamp on the live page (#479)" || bad "id=stamp count $(grep -c 'id=stamp' "$WORK/out14/r.html")" +grep 'id=stamp' "$WORK/out14/r.html" | grep -q "Last updated.*id=livetxt.*id=livebtn" && ok "the autoreload finds its stamp: Last updated and the live controls share the one id=stamp line" || bad "autoreload stamp line" +for _id in livetxt livebtn; do [ "$(grep -o "id=$_id\b" "$WORK/out14/r.html" | wc -l | tr -d ' ')" -eq 1 ] && ok "id=$_id is unique" || bad "id=$_id not unique"; done if command -v node >/dev/null 2>&1; then node - "$WORK/out14/r.html" <<'JSEOF' && ok "node: state saved and restored, no reload within 20 s of input, Pause stops it" || bad "autoreload behaviour (node)" const fs = require("fs"); diff --git a/tests/wave2/test-progress-report-polish.sh b/tests/wave2/test-progress-report-polish.sh index 59fe2091..4773e87c 100644 --- a/tests/wave2/test-progress-report-polish.sh +++ b/tests/wave2/test-progress-report-polish.sh @@ -16,6 +16,10 @@ # P6b an older progress-report.py without --phase-detail (a partial sync) still renders, with the short row tables. # P6c the page's own scripts, run in node against a DOM shim: the now phase is open on load, opening phase 1 # closes phase 2, a #phase-3 link on load wins over the default, and restored state wins over the default. +# P7 top bar and header (#479): one group of section links (Stages, Build, Pages), no stage chips; the bar stays +# sticky and phone-safe; every #stage-N card anchor stays; the header states the current stage; exactly one +# id=stamp, every other volatile line marked data-stamp, every id on the page unique; the autoreload's +# live controls sit on the id=stamp line; no "replaces the former build-plan.html" in the header. # P3 no session log: one calm line (not the orange box), the regenerate hint inside "How this was measured"; # with --no-log the page never claims it looked in folders or logs. # @@ -147,6 +151,48 @@ assert.deepStrictEqual(load("", saved).open(), ["phase-1"], "restored state wins JSEOF else ok "SKIP: node not available for the accordion behaviour test"; fi +echo "P7 top bar, unique stamp id, header wording (#479)" +P7="$WORK/p7"; mkproj "$P7" "**Stage 5 - Build**, in progress - gates passed: P, 0, 1, 2, 3, 4" +cp "$P1/architecture/build-plan.md" "$P7/architecture/" +mkdir -p "$P7/.claude/loop" # recent activity, so the page carries the autoreload script and its live controls +printf 'ts\tscript\tgate\tresult\telapsed_s\tmxbuild_errors\tlint_rc\tlint_total\trestored\tscore\n%s\t02-billing.mdl\tpass\tapplied\t40\t0\t0\t\t0\t90.0\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$P7/.claude/loop/metrics-ledger.tsv" +CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P7" --html >/dev/null 2>&1 +R7="$P7/architecture/progress-report.html" +[ "$(grep -c 'id=stamp' "$R7")" -le 1 ] && ok "grep -c 'id=stamp' is at most 1 ($(grep -c 'id=stamp' "$R7"))" || bad "id=stamp appears $(grep -c 'id=stamp' "$R7") times" +"$PY" - "$R7" "$ROOT/project-bin/progress-report.py" <<'PYEOF7' && ok "one nav group with Stages, sticky and phone-safe; stage anchors kept; current stage in the header; ids unique; volatile lines marked; autoreload controls on the stamp line" || bad "top bar / stamp / header (P7)" +import re, sys, collections +h = open(sys.argv[1], encoding="utf-8").read(); gen = open(sys.argv[2], encoding="utf-8").read() +nav = re.search(r"", h).group(1) +links = re.findall(r"]*>([^<]+)", nav) +assert [t for _, t in links] == ["Stages", "Build", "Pages"], links # no Tips without a session log +assert not re.search(r"href='#stage-", nav) and "" not in nav, nav # the chip group is gone +assert "id=stages" in h and h.index("id=stages") < h.index("id='stage-P'"), "Stages target sits above the stage cards" +for s_ in ["P", "0", "1", "2", "3", "4", "5", "6"]: assert "id='stage-%s'" % s_ in h, s_ +assert ".snav{position:sticky;top:0" in gen and ".snav{padding:6px 12px;flex-wrap:nowrap;overflow-x:auto" in gen +sm = re.search(r"([^{}]*)\{scroll-margin-top:56px\}", gen).group(1).split(",") +assert "#stages" in sm and "#build-detail" in sm, sm # nav targets clear the sticky bar +assert ".snav b{" not in gen, "no rule for the removed Stage label" +head = h[h.index("
    "):h.index("
    ")] +assert re.search(r"Current stage: Stage 5: [^<]+", head), "current stage in the header" +assert "replaces the former build-plan.html" not in head +ids = re.findall(r"""\bid=['"]?([\w:.-]+)""", h) +dup = [i for i, c in collections.Counter(ids).items() if c > 1]; assert not dup, dup +lines = h.split("\n") +st = [l for l in lines if "id=stamp" in l]; assert len(st) == 1 and "Last updated" in st[0], st +for marker in ("Last updated", "session log last event", "Last build step", "Latest model score", "Plan read", "Rebuild: project-bin"): + for l in lines: + if marker in l and "/dev/null 2>&1 +has "$P7u/architecture/progress-report.html" "Current stage: unknown" "an unreadable current stage reads plain 'unknown', no link to #stage-P" +# --refresh still leaves an unchanged page alone: the volatile lines are found by id=stamp or data-stamp +SUM7="$(cksum < "$R7")"; sleep 1 +CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P7" --refresh >/dev/null 2>&1 +[ "$(cksum < "$R7")" = "$SUM7" ] && ok "--refresh leaves the page byte-identical when only stamp lines would change" || bad "--refresh rewrote an unchanged page" + echo "P2 placeholder failure on a later stage is pending" P2="$WORK/p2"; mkproj "$P2" "**Stage P - Kickoff**, in progress - gates passed: none" cat > "$P2/index.html" <<'HTML' diff --git a/tests/wave2/test-runbook-slice.sh b/tests/wave2/test-runbook-slice.sh index 75fbd653..103c3dc5 100644 --- a/tests/wave2/test-runbook-slice.sh +++ b/tests/wave2/test-runbook-slice.sh @@ -43,7 +43,7 @@ SP="$(bash "$SUT" --spans 2)" S2="$(grep -n '^### Stage 2 — ' "$RB" | head -1 | cut -d: -f1)" S1B="$(grep -n '^## 1b\.' "$RB" | head -1 | cut -d: -f1)" case "$SP" in - "skills/conversion-runbook.md §1b lines $S1B–"*" + Stage 2 lines $S2–"*) ok "--spans starts each span at its real heading ($S1B, $S2)" ;; + "skills/conversion-runbook.md §1b lines ${S1B}–"*" + Stage 2 lines ${S2}–"*) ok "--spans starts each span at its real heading ($S1B, $S2)" ;; *) bad "--spans wrong: $SP" ;; esac E2="${SP##*–}" diff --git a/tests/wave2/test-sed-tab-bracket.sh b/tests/wave2/test-sed-tab-bracket.sh new file mode 100755 index 00000000..e75b1612 --- /dev/null +++ b/tests/wave2/test-sed-tab-bracket.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# test-sed-tab-bracket.sh — `[ \t]` in a sed/grep bracket is not a tab off GNU sed (#468). +# +# Usage: bash tests/wave2/test-sed-tab-bracket.sh bin/lib/entry-mode.sh +# +# BSD/macOS sed reads `[ \t]` as "space, backslash or t" (only GNU sed expands \t there), so +# every trim built on it also ate a leading or trailing letter t and left real tabs alone. +# context-pack.sh's own case is pinned in test-context-pack.sh (its captured golden brief has +# Sales.Product and Sales.ACT_ApprovalStep_Reject); this fixture covers the two libraries +# that carried the same trim, and the lint that keeps it out. +# +# On GNU sed the BSD reading is reproduced through a `sed --posix` shim on PATH (verified to +# turn "Product" into "Produc" with the old pattern before anything is asserted); on a machine +# whose sed is already BSD the shim is not needed and the functions run as they are. +# +# T1 closeout.sh _co_trim keeps a trailing/leading t ("bulk import", "**test harness**") +# and still trims a real tab; _co_stage_of reads "Stage3" as 3 +# T2 entry-mode.sh entry_mode_token reads a tab-separated answer ("answered:migration", +# "greenfield"), which the old trim left untouched and the first-word cut then emptied +# T3 bin/check-portability.sh (same toolkit) flags `sed 's/[ \t]*$//'` and +# `grep -c '^##[ \t]'`, passes [[:blank:]], an awk regex and a $'...' real tab + +set -uo pipefail +SUT="${1:?usage: test-sed-tab-bracket.sh /path/to/bin/lib/entry-mode.sh}" +case "$SUT" in /*) ;; *) SUT="$PWD/$SUT" ;; esac +LIB="$(cd "$(dirname "$SUT")" && pwd)" +TK="$(cd "$LIB/../.." && pwd)" +W="$(mktemp -d "${TMPDIR:-/tmp}/sedtab.XXXXXX")" || exit 2 +trap 'rm -rf "$W"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ FAIL-detail: /'; } +check() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3], got [$2]"; fi; } +TAB="$(printf '\t')" + +SHIMPATH="$PATH" +if sed --posix -n p /dev/null 2>&1; then + mkdir -p "$W/bin" + printf '#!/usr/bin/env bash\nexec "%s" --posix "$@"\n' "$(command -v sed)" > "$W/bin/sed"; chmod +x "$W/bin/sed" + SHIMPATH="$W/bin:$PATH" + echo "== GNU sed: running under a sed --posix shim (BSD bracket reading) ==" +else + echo "== sed is not GNU sed: running as-is ==" +fi +probe="$(printf 'Product\n' | PATH="$SHIMPATH" sed 's/[ \t]*$//')" # portability-ok: the #468 defect, run on purpose +if [ "$probe" != "Produc" ]; then + echo " SKIP T1-T2: this sed expands \\t in a bracket, the old defect cannot show here" +else + run() { PATH="$SHIMPATH" bash -c ". '$LIB/closeout.sh'; . '$SUT'; $1"; } + echo "== T1 closeout.sh ==" + check "T1a trailing t kept" "$(run "_co_trim ' Decision about bulk import '")" "Decision about bulk import" + check "T1b leading t kept" "$(run "_co_trim '**test harness**'")" "test harness" + check "T1c real tab trimmed" "$(run "_co_trim '${TAB}CONFIRMED${TAB}'")" "CONFIRMED" + check "T1d Stage3 is stage 3" "$(run "_co_stage_of 'Stage${TAB}3'")" "3" + echo "== T2 entry-mode.sh ==" + check "T2a answered:migration" "$(run "entry_mode_token 'answered:${TAB}migration'" 2>/dev/null)" "migration" + check "T2b greenfield" "$(run "entry_mode_token '${TAB}greenfield'" 2>/dev/null)" "greenfield" + check "T2c bold value unchanged" "$(run "entry_mode_token '**requirements-driven**'" 2>/dev/null)" "requirements-driven" +fi + +echo "== T3 check-portability.sh rule ==" +mkdir -p "$W/lint/bad" "$W/lint/good" +# Written with @ for the backslash, so this file does not carry the pattern it plants. +tr '@' '\\' > "$W/lint/bad/a.sh" <<'X' +#!/usr/bin/env bash +x="$(printf '%s' "$1" | sed 's/^[ @t]*//; s/[ @t]*$//')" +total=$(grep -c '^##[ @t]' "$2") +X +cat > "$W/lint/good/b.sh" <<'X' +#!/usr/bin/env bash +x="$(printf '%s' "$1" | sed 's/^[[:blank:]]*//')" +y="$(awk '{gsub(/^[ \t]+/, ""); print}' "$2")" +sed -i.bak $'1s/rules=[^\t]*/rules=x/' "$2" +X +out="$(bash "$TK/bin/check-portability.sh" "$W/lint/bad" 2>&1)"; rc=$? +n="$(grep -c 'inside a sed/grep bracket' <<<"$out")" +if [ "$rc" -eq 1 ] && [ "$n" -eq 2 ]; then ok "T3a sed trim and grep -c: 2 of 2 flagged"; else bad "T3a want rc 1 and 2 hits, got rc $rc and $n" "$out"; fi +out="$(bash "$TK/bin/check-portability.sh" "$W/lint/good" 2>&1)"; rc=$? +check "T3b [[:blank:]], awk regex, \$'...' tab: clean" "$rc" "0" + +echo "-- $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/tests/wave2/test-status-bare-var.sh b/tests/wave2/test-status-bare-var.sh new file mode 100755 index 00000000..92a26092 --- /dev/null +++ b/tests/wave2/test-status-bare-var.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# test-status-bare-var.sh — bin/status.sh --brief must print NEXT, not abort, when its NEXT line +# carries a variable followed by an em-dash (#467). +# +# Usage: bash tests/wave2/test-status-bare-var.sh bin/status.sh +# +# The defect: "$LAST_FAIL— fix…" — in a single-byte locale bash reads the em-dash's first byte as +# part of the variable NAME, and set -u aborts with "LAST_FAIL: unbound variable" (macOS +# bash 3.2 did it under en_US.UTF-8 too). bash 5 in a UTF-8 locale never shows it, which is why +# it shipped. So the runs below use an ISO-8859-1 locale: the first one `locale -a` lists, or a +# compiled one via LOCPATH (localedef -i en_US -f ISO-8859-1 "$LOCPATH/en_US.ISO-8859-1"). +# No such locale: T1/T2 SKIP (stated, not silent) and T3 still runs. +# +# T1 latest BUILD-LOG row a fail -> NEXT "last gate FAILED for: 010-a.mdl — …", no abort +# T2 latest row a pass, not done- -> NEXT "gate-passed but not done-: 010-a.mdl — …", no abort +# T3 static guard, any machine: bin/check-portability.sh (same toolkit) flags a bare name +# before an em-dash and an ellipsis, passes the braced form, a comment line, a positional +# $1 and a `# portability-ok:` line, and the shipped status.sh/constants-audit.sh are clean +# +# GOLDEN INPUT: PROJECT.md is the real bin/init-project.sh scaffold; the BUILD-LOG row shape is +# project-bin/exec.sh log_build()'s. gate-check.sh is stubbed silent in the toolkit copy so the +# NEXT ladder reaches the two rules under test instead of stopping at a scaffold finding. +export MXTK_LEAKGUARD_DENYFILE="${TMPDIR:-/tmp}/mxtk-fixture-denylist.$$" +export MXTK_NO_GUIDE=1 MXTK_SYNC_SKIP_CLONE_CHECK=1 +set -uo pipefail +SUT="${1:?usage: test-status-bare-var.sh /path/to/bin/status.sh}" +case "$SUT" in /*) ;; *) SUT="$PWD/$SUT" ;; esac +SRC="$(cd "$(dirname "$SUT")/.." && pwd)" + +W="$(mktemp -d "${TMPDIR:-/tmp}/statusbv.XXXXXX")" || exit 2 +trap 'rm -rf "$W"; rm -f "$MXTK_LEAKGUARD_DENYFILE"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ FAIL-detail: /'; } + +echo "== T3 check-portability.sh bare-\$NAME rule (#467) ==" +PORT="$SRC/bin/check-portability.sh" +mkdir -p "$W/lint/bad" "$W/lint/good" +# Non-ASCII written as octal escapes so this fixture file itself stays clean under the rule. +EM="$(printf '\342\200\224')"; EL="$(printf '\342\200\246')" +printf '#!/usr/bin/env bash\necho "failed: $X%s fix"\n' "$EM" > "$W/lint/bad/a.sh" +printf '#!/usr/bin/env bash\ns="$s%s"\n' "$EL" > "$W/lint/bad/b.sh" +printf '#!/usr/bin/env bash\necho "${X}%s ok"\n# $X%s in a comment\necho "$1%s"\necho "$X%s" # portability-ok: test\n' \ + "$EM" "$EM" "$EM" "$EM" > "$W/lint/good/c.sh" +out="$(bash "$PORT" "$W/lint/bad" 2>&1)"; rc=$? +n="$(printf '%s\n' "$out" | grep -c 'bare \$NAME followed by a non-ASCII')" +if [ "$rc" -eq 1 ] && [ "$n" -eq 2 ]; then ok "T3a em-dash and ellipsis after a bare name: 2 of 2 flagged"; else bad "T3a want rc 1, 2 hits; got rc $rc, $n" "$out"; fi +out="$(bash "$PORT" "$W/lint/good" 2>&1)"; rc=$? +if [ "$rc" -eq 0 ]; then ok "T3b braced, comment, \$1 and portability-ok: not flagged"; else bad "T3b want rc 0, got $rc" "$out"; fi +mkdir -p "$W/lint/shipped"; cp "$SUT" "$W/lint/shipped/status.sh" +cp "$SRC/project-bin/constants-audit.sh" "$W/lint/shipped/" +out="$(bash "$PORT" "$W/lint/shipped" 2>&1)" +if grep -q 'bare \$NAME' <<<"$out"; then bad "T3c shipped scripts carry a bare name before non-ASCII" "$out"; else ok "T3c status.sh and constants-audit.sh: 0 bare names before non-ASCII"; fi + +LOC="$(locale -a 2>/dev/null | grep -iE '^en_US\.ISO-?8859-?1$' | head -1)" +[ -z "$LOC" ] && [ -n "${LOCPATH:-}" ] && [ -d "$LOCPATH/en_US.ISO-8859-1" ] && LOC=en_US.ISO-8859-1 +done_() { echo "-- $PASS passed, $FAIL failed"; [ "$FAIL" -eq 0 ]; exit $?; } +if [ -z "$LOC" ]; then echo " SKIP T1-T2: no ISO-8859-1 locale (see header to compile one)"; done_; fi +# The locale must actually reproduce the trap, or a pass below proves nothing. +if LC_ALL="$LOC" bash -uc "X=a; : \"\$X$EM\"" 2>/dev/null; then + echo " SKIP T1-T2: bash under $LOC does not read the byte as a name character here"; done_ +fi + +mkdir -p "$W/tk" +(cd "$SRC" && tar --exclude=.git --exclude=node_modules -cf - .) | (cd "$W/tk" && tar -xf -) +cp "$SUT" "$W/tk/bin/status.sh" +mkproj() { # mkproj + local d="$W/proj-$1"; mkdir -p "$d/mdlsource" "$d/docs"; : > "$d/Fixture.mpr" + "$W/tk/bin/init-project.sh" "$d" >/dev/null 2>&1 + { echo "| when (ISO-8601 local) | script | gate | result | detail |"; echo "|---|---|---|---|---|"; } > "$d/docs/BUILD-LOG.md" + echo "-- a" > "$d/mdlsource/010-a.mdl" + echo "| 2026-10-13T10:00:00+0200 | \`010-a.mdl\` | $1 | x | 010-a.mdl |" >> "$d/docs/BUILD-LOG.md" + echo "$d" +} +PF="$(mkproj fail)"; PP="$(mkproj pass)" +printf '#!/usr/bin/env bash\nexit 0\n' > "$W/tk/bin/gate-check.sh" + +echo "== bin/status.sh --brief under $LOC (#467) ==" +check() { # check + local out; out="$(LC_ALL="$LOC" bash "$W/tk/bin/status.sh" "$2" --brief 2>&1)" + case "$out" in *"unbound variable"*) bad "$1: aborted" "$out"; return ;; esac + if grep -q "^NEXT $3" <<<"$out"; then ok "$1"; else bad "$1: NEXT not printed" "$out"; fi +} +check "T1 failed row: NEXT names the script, no abort" "$PF" 'last gate FAILED for: 010-a.mdl ' +check "T2 gate-pass, not done-: NEXT names it, no abort" "$PP" 'gate-passed but not done-: 010-a.mdl ' + +done_