(page: Page<CliTestStep>)
| 7999 | } |
| 8000 | |
| 8001 | function renderStepsText(page: Page<CliTestStep>): string { |
| 8002 | if (page.items.length === 0) { |
| 8003 | return page.nextToken ? `No steps on this page.\nnextToken: ${page.nextToken}` : 'No steps.'; |
| 8004 | } |
| 8005 | |
| 8006 | // M2.1 piece 4: prefix every row with a marker column. `*` flags |
| 8007 | // steps the facade marked as contributing to the test failure |
| 8008 | // (synthetic terminal "assertion" rows always get the marker; |
| 8009 | // pre-M2.1 callers see no markers because the field is absent or |
| 8010 | // null). Two-character column ("* " or " ") so alignment stays |
| 8011 | // stable across pages. |
| 8012 | // Collapse newlines / runs of whitespace to single spaces and cap the |
| 8013 | // column width. Without this, one long or multi-line description — e.g. |
| 8014 | // a synthetic "TEST BLOCKED\n\n<paragraphs>…" assertion blob — set the |
| 8015 | // column to its full length, padding every short row with hundreds of |
| 8016 | // trailing spaces and shoving UPDATED far off-screen; embedded newlines |
| 8017 | // broke alignment outright (dogfood 2026-06-04). Full untruncated text |
| 8018 | // is still available via `--output json`. |
| 8019 | const descOf = (s: CliTestStep): string => { |
| 8020 | const isSynthetic = |
| 8021 | s.action === 'assertion' && s.htmlSnapshotUrl === null && s.screenshotUrl === null; |
| 8022 | const base = s.description.length > 0 ? s.description : '—'; |
| 8023 | // Truncate the base FIRST, then append the synthetic hint, so the |
| 8024 | // "(synthetic assertion failure)" marker always survives truncation |
| 8025 | // (it explains why an `assertion` row exists when the code had none). |
| 8026 | const oneLine = base.replace(/\s+/g, ' ').trim(); |
| 8027 | const clamped = |
| 8028 | oneLine.length > DESC_COL_MAX ? `${oneLine.slice(0, DESC_COL_MAX - 1)}…` : oneLine; |
| 8029 | return isSynthetic ? `${clamped} (synthetic assertion failure)` : clamped; |
| 8030 | }; |
| 8031 | |
| 8032 | const indexWidth = Math.max(5, ...page.items.map(s => String(s.stepIndex).length)); |
| 8033 | const actionWidth = Math.max(6, ...page.items.map(s => s.action.length)); |
| 8034 | const statusWidth = 6; // "passed" / "failed" / "—" |
| 8035 | const descWidth = Math.max(11, ...page.items.map(s => descOf(s).length)); |
| 8036 | |
| 8037 | const header = |
| 8038 | pad(' ', 2) + |
| 8039 | pad('INDEX', indexWidth) + |
| 8040 | ' ' + |
| 8041 | pad('ACTION', actionWidth) + |
| 8042 | ' ' + |
| 8043 | pad('STATUS', statusWidth) + |
| 8044 | ' ' + |
| 8045 | pad('DESCRIPTION', descWidth) + |
| 8046 | ' ' + |
| 8047 | 'UPDATED'; |
| 8048 | |
| 8049 | const rows = page.items.map(s => { |
| 8050 | const marker = s.outcomeContributesToFailure === true ? '* ' : ' '; |
| 8051 | return [ |
| 8052 | marker, |
| 8053 | pad(String(s.stepIndex), indexWidth), |
| 8054 | pad(s.action, actionWidth), |
| 8055 | pad(s.status ?? '—', statusWidth), |
| 8056 | pad(descOf(s), descWidth), |
| 8057 | s.updatedAt, |
| 8058 | ].join(' '); |
no test coverage detected