@sdods/core 0.2.2 → 0.3.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.
Files changed (52) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/index.d.ts +0 -1
  4. package/dist/analyze/index.js +0 -1
  5. package/dist/analyze/propose.js +80 -38
  6. package/dist/analyze/scan.js +19 -1
  7. package/dist/api/client.js +7 -1
  8. package/dist/auth/capture.js +19 -6
  9. package/dist/auth/index.js +23 -2
  10. package/dist/config/resolve.d.ts +13 -0
  11. package/dist/config/resolve.js +1 -0
  12. package/dist/config/tags.d.ts +29 -1
  13. package/dist/config/tags.js +46 -0
  14. package/dist/data/provider.js +5 -1
  15. package/dist/data/user-pool.js +35 -3
  16. package/dist/fixtures/api-context.d.ts +14 -1
  17. package/dist/fixtures/api-context.js +13 -0
  18. package/dist/fixtures/scenario.js +1 -4
  19. package/dist/fixtures/test.js +42 -1
  20. package/dist/fixtures/types.d.ts +2 -0
  21. package/dist/reporters/dashboard.d.ts +86 -0
  22. package/dist/reporters/dashboard.js +319 -61
  23. package/dist/shots/hooks.js +0 -10
  24. package/dist/steps/a11y.steps.d.ts +180 -0
  25. package/dist/steps/a11y.steps.js +598 -0
  26. package/dist/steps/api.steps.js +5 -1
  27. package/dist/steps/browser.steps.d.ts +27 -0
  28. package/dist/steps/browser.steps.js +653 -0
  29. package/dist/steps/clock.steps.d.ts +4 -0
  30. package/dist/steps/clock.steps.js +73 -0
  31. package/dist/steps/data.steps.js +50 -2
  32. package/dist/steps/db.steps.d.ts +5 -0
  33. package/dist/steps/db.steps.js +105 -0
  34. package/dist/steps/dom.steps.d.ts +2 -0
  35. package/dist/steps/dom.steps.js +583 -0
  36. package/dist/steps/iframe.steps.d.ts +2 -0
  37. package/dist/steps/iframe.steps.js +93 -0
  38. package/dist/steps/index.d.ts +10 -0
  39. package/dist/steps/index.js +10 -0
  40. package/dist/steps/net.steps.d.ts +63 -0
  41. package/dist/steps/net.steps.js +728 -0
  42. package/dist/steps/perf.steps.d.ts +248 -0
  43. package/dist/steps/perf.steps.js +514 -0
  44. package/dist/steps/tabs.steps.d.ts +5 -0
  45. package/dist/steps/tabs.steps.js +109 -0
  46. package/dist/steps/webhook.steps.d.ts +46 -0
  47. package/dist/steps/webhook.steps.js +129 -0
  48. package/package.json +3 -4
  49. package/dist/analyze/modules.d.ts +0 -74
  50. package/dist/analyze/modules.js +0 -353
  51. package/dist/config/playwright.d.ts +0 -37
  52. package/dist/config/playwright.js +0 -262
@@ -1,5 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { createRequire } from 'node:module';
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
3
  import { parseRunnerProjectName } from '@sdods/contracts';
5
4
  /**
@@ -85,10 +84,42 @@ export default class DashboardReporter {
85
84
  g.failed++;
86
85
  }
87
86
  }
87
+ // Group failures by error SIGNATURE. Twelve scenarios failing on one broken
88
+ // selector is ONE problem, and a list of twelve reads like twelve. The
89
+ // signature is the first line of the error with volatile parts stripped, so
90
+ // "expected 204, got 403" and "expected 201, got 403" stay distinct while
91
+ // two runs of the same defect collapse.
92
+ const failedRows = rows.filter((r) => r.outcome === 'unexpected');
93
+ const clusters = {};
94
+ for (const r of failedRows) {
95
+ const sig = errorSignature(r.error);
96
+ const c = (clusters[sig] ??= { signature: sig, count: 0, titles: [] });
97
+ c.count++;
98
+ if (c.titles.length < 25)
99
+ c.titles.push(r.title);
100
+ }
101
+ // Outcome per role tag. A suite where @user:viewer fails and @user:admin
102
+ // passes has a permissions regression, and that is invisible in a total.
103
+ const byRole = {};
104
+ for (const r of rows) {
105
+ for (const t of r.tags) {
106
+ if (!t.startsWith('@user:'))
107
+ continue;
108
+ const g = (byRole[t.slice(6)] ??= { total: 0, passed: 0, failed: 0 });
109
+ g.total++;
110
+ if (r.outcome === 'unexpected')
111
+ g.failed++;
112
+ else if (r.outcome !== 'skipped')
113
+ g.passed++;
114
+ }
115
+ }
88
116
  const metrics = {
89
117
  title: this.title,
90
118
  generatedAt: new Date().toISOString(),
91
119
  summary,
120
+ clusters: Object.values(clusters).sort((a, b) => b.count - a.count),
121
+ byRole,
122
+ slowest: [...rows].sort((a, b) => b.duration - a.duration).slice(0, 15),
92
123
  byProject: group('projectName'),
93
124
  byLayer: group('layer'),
94
125
  byBrowser: group('browser'),
@@ -118,19 +149,32 @@ export default class DashboardReporter {
118
149
  return false;
119
150
  }
120
151
  }
152
+ /**
153
+ * Collapse an error to a stable signature so one root cause reads as one problem.
154
+ * Numbers, ids, timings, quoted literals and paths are the parts that differ
155
+ * between two instances of the SAME defect, so they go; the assertion shape stays.
156
+ */
157
+ /** ANSI SGR sequences, built by code point so the source carries no control character. */
158
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
159
+ export function errorSignature(error) {
160
+ if (!error)
161
+ return 'no error message';
162
+ const firstLine = error
163
+ .replace(ANSI, '')
164
+ .split('\n')
165
+ .find((l) => l.trim()) ?? error;
166
+ return firstLine
167
+ .replace(/\b[0-9a-f]{8,}\b/gi, '<id>')
168
+ .replace(/\b\d+(?:\.\d+)?\s?ms\b/g, '<time>')
169
+ .replace(/\b\d+\b/g, '<n>')
170
+ .replace(/(["'`])(?:[^"'`\\]|\\.)*\1/g, '<str>')
171
+ .replace(/\/[^\s:]+/g, '<path>')
172
+ .trim()
173
+ .slice(0, 200);
174
+ }
121
175
  function esc(s) {
122
176
  return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
123
177
  }
124
- function chartJs() {
125
- try {
126
- const require = createRequire(import.meta.url);
127
- const file = require.resolve('chart.js/dist/chart.umd.js');
128
- return existsSync(file) ? readFileSync(file, 'utf8') : '';
129
- }
130
- catch {
131
- return '';
132
- }
133
- }
134
178
  function fmt(ms) {
135
179
  return ms < 1000
136
180
  ? `${Math.round(ms)}ms`
@@ -138,62 +182,276 @@ function fmt(ms) {
138
182
  ? `${(ms / 1000).toFixed(1)}s`
139
183
  : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
140
184
  }
141
- function renderHtml(m) {
185
+ function pct(n, d) {
186
+ return d ? Math.round((n / d) * 100) : 0;
187
+ }
188
+ /**
189
+ * The dashboard is a DECISION surface, not a report.
190
+ *
191
+ * Every run ends with someone asking one of four questions, and the layout answers
192
+ * them in the order they get asked:
193
+ *
194
+ * 1. Can I ship? -> the verdict line, in words, before any number
195
+ * 2. What do I fix first? -> failures CLUSTERED by error signature, because
196
+ * twelve scenarios failing on one broken selector
197
+ * is one problem and a list of twelve reads as twelve
198
+ * 3. Is it real or flaky? -> flake, retry and heal counts sit beside the verdict,
199
+ * not in a footer
200
+ * 4. Do I believe this run? -> skipped and healed are shown as WARNINGS, never
201
+ * folded into a pass rate. A suite that skipped a
202
+ * third of itself is not 100% green, and a healed
203
+ * locator means the application's DOM moved under us
204
+ *
205
+ * The role matrix is the one view a general-purpose reporter never has: when
206
+ * `@user:viewer` fails and `@user:admin` passes, that is a permissions regression,
207
+ * and it is invisible in any total.
208
+ */
209
+ export function renderHtml(m) {
142
210
  const s = m.summary;
143
- const passRate = s.total ? Math.round(((s.passed + s.flaky) / s.total) * 100) : 0;
144
- const card = (label, value, cls = '') => `<div class="card ${cls}"><div class="v">${esc(value)}</div><div class="l">${esc(label)}</div></div>`;
145
- const projectRows = Object.entries(m.byProject)
146
- .map(([name, g]) => `<tr><td>${esc(name)}</td><td>${g.total}</td><td class="ok">${g.passed}</td><td class="bad">${g.failed}</td><td>${g.skipped}</td><td class="warn">${g.flaky}</td></tr>`)
211
+ const executed = s.total - s.skipped;
212
+ const passRate = pct(s.passed + s.flaky, executed);
213
+ const skipRate = pct(s.skipped, s.total);
214
+ const blocking = s.failed + s.timedOut;
215
+ const verdict = blocking
216
+ ? {
217
+ word: 'Failing',
218
+ cls: 'bad',
219
+ line: `${blocking} scenario${blocking === 1 ? '' : 's'} failed across ${m.clusters.length} distinct cause${m.clusters.length === 1 ? '' : 's'}.`,
220
+ }
221
+ : s.total === 0
222
+ ? {
223
+ word: 'Nothing ran',
224
+ cls: 'bad',
225
+ line: 'No scenario was executed. A run that registers nothing exits 0 and looks identical to a green run — it is not.',
226
+ }
227
+ : executed === 0
228
+ ? {
229
+ word: 'Nothing ran',
230
+ cls: 'bad',
231
+ line: `All ${s.total} scenarios were skipped. Skipped is not passed.`,
232
+ }
233
+ : s.flaky
234
+ ? {
235
+ word: 'Passing, with flake',
236
+ cls: 'warn',
237
+ line: `${s.flaky} scenario${s.flaky === 1 ? '' : 's'} only passed on retry. Treat as unproven until the cause is known.`,
238
+ }
239
+ : {
240
+ word: 'Passing',
241
+ cls: 'ok',
242
+ line: `${executed} scenario${executed === 1 ? '' : 's'} executed, all green.`,
243
+ };
244
+ // Warnings are things that make a green run untrustworthy. They are deliberately
245
+ // NOT folded into the pass rate, because averaging them away is how a suite stops
246
+ // measuring anything without anyone noticing.
247
+ const warnings = [];
248
+ if (skipRate > 5)
249
+ warnings.push(`${s.skipped} of ${s.total} scenarios (${skipRate}%) were skipped. A skip is an untested path, not a pass.`);
250
+ if (s.healed)
251
+ warnings.push(`${s.healed} scenario${s.healed === 1 ? '' : 's'} needed a healed locator. The application's DOM moved — the test passed, but the selector it was written against no longer matches.`);
252
+ if (s.flaky)
253
+ warnings.push(`${s.flaky} scenario${s.flaky === 1 ? '' : 's'} passed only on retry.`);
254
+ const stat = (label, value, sub = '', cls = '') => `<div class="stat ${cls}"><div class="v">${esc(value)}</div><div class="l">${esc(label)}</div>${sub ? `<div class="s">${esc(sub)}</div>` : ''}</div>`;
255
+ const clusterCards = m.clusters
256
+ .map((c, i) => `<details class="cluster"${i === 0 ? ' open' : ''}>
257
+ <summary><span class="count">${c.count}&times;</span><code>${esc(c.signature)}</code></summary>
258
+ <ul>${c.titles.map((t) => `<li>${esc(t)}</li>`).join('')}</ul>
259
+ </details>`)
147
260
  .join('');
148
- const testRows = m.tests
149
- .map((t) => `<tr class="${esc(t.outcome)}"><td>${esc(t.fullTitle)}</td><td>${esc(t.projectName)}</td><td><span class="pill ${esc(t.outcome)}">${esc(t.outcome === 'expected' ? 'passed' : t.outcome === 'unexpected' ? 'failed' : t.outcome)}</span></td><td>${fmt(t.duration)}</td><td>${t.retries}</td><td>${t.heals}</td><td class="tags">${t.tags.map((x) => `<code>${esc(x)}</code>`).join(' ')}</td></tr>`)
261
+ const roleRows = Object.entries(m.byRole)
262
+ .sort((a, b) => b[1].failed - a[1].failed || a[0].localeCompare(b[0]))
263
+ .map(([role, g]) => `<tr class="${g.failed ? 'row-bad' : ''}">
264
+ <td><span class="tag">@user:${esc(role)}</span></td>
265
+ <td class="num">${g.total}</td>
266
+ <td class="num ok">${g.passed}</td>
267
+ <td class="num ${g.failed ? 'bad' : 'muted'}">${g.failed}</td>
268
+ <td class="barcell">${bar(g.passed, g.failed, g.total)}</td>
269
+ </tr>`)
150
270
  .join('');
151
- const failedCards = m.failed
152
- .map((f) => `<div class="fail"><div class="t">${esc(f.title)} <small>${esc(f.runnerProject)}</small></div><pre>${esc(f.error ?? '')}</pre></div>`)
271
+ const groupTable = (title, data, label) => {
272
+ const rows = Object.entries(data)
273
+ .sort((a, b) => b[1].failed - a[1].failed || b[1].total - a[1].total)
274
+ .slice(0, 40);
275
+ if (!rows.length)
276
+ return '';
277
+ return `<section><h2>${esc(title)}</h2><table>
278
+ <thead><tr><th>${esc(label)}</th><th class="num">Total</th><th class="num">Passed</th><th class="num">Failed</th><th></th></tr></thead>
279
+ <tbody>${rows
280
+ .map(([k, g]) => `<tr class="${g.failed ? 'row-bad' : ''}">
281
+ <td>${esc(k)}</td><td class="num">${g.total}</td>
282
+ <td class="num ok">${g.passed}</td>
283
+ <td class="num ${g.failed ? 'bad' : 'muted'}">${g.failed}</td>
284
+ <td class="barcell">${bar(g.passed, g.failed, g.total)}</td>
285
+ </tr>`)
286
+ .join('')}</tbody></table></section>`;
287
+ };
288
+ const slowRows = m.slowest
289
+ .filter((t) => t.duration > 0)
290
+ .map((t) => `<tr><td>${esc(t.title)}</td><td class="num">${esc(fmt(t.duration))}</td><td>${t.tags
291
+ .map((x) => `<span class="tag">${esc(x)}</span>`)
292
+ .join(' ')}</td></tr>`)
153
293
  .join('');
154
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
155
- <title>${esc(m.title)} · SDODS dashboard</title>
294
+ const testRows = m.tests
295
+ .map((t) => `<tr data-status="${esc(t.outcome)}" data-text="${esc(`${t.title} ${t.tags.join(' ')} ${t.projectName}`.toLowerCase())}">
296
+ <td><span class="pill ${esc(t.outcome)}">${esc(t.outcome)}</span></td>
297
+ <td>${esc(t.title)}</td>
298
+ <td>${t.tags.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')}</td>
299
+ <td class="num">${esc(fmt(t.duration))}</td>
300
+ <td class="num">${t.retries || ''}</td>
301
+ <td class="num">${t.heals || ''}</td>
302
+ </tr>`)
303
+ .join('');
304
+ return `<!doctype html>
305
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
306
+ <title>${esc(m.title)}</title>
156
307
  <style>
157
- :root{--bg:#0f172a;--panel:#1e293b;--text:#e2e8f0;--dim:#94a3b8;--ok:#22c55e;--bad:#ef4444;--warn:#f59e0b;--skip:#64748b;--accent:#6366f1}
158
- *{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Inter,Roboto,sans-serif;background:var(--bg);color:var(--text)}
159
- header{padding:20px 28px;border-bottom:1px solid #334155;display:flex;justify-content:space-between;align-items:center}
160
- h1{margin:0;font-size:20px}h1 span{color:var(--accent)}header small{color:var(--dim)}
161
- main{padding:24px 28px;display:grid;gap:20px}
162
- .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}
163
- .card{background:var(--panel);border-radius:10px;padding:14px 16px}.card .v{font-size:26px;font-weight:700}.card .l{color:var(--dim);font-size:12px;text-transform:uppercase;letter-spacing:.06em}
164
- .card.ok .v{color:var(--ok)}.card.bad .v{color:var(--bad)}.card.warn .v{color:var(--warn)}
165
- .bar{height:10px;background:#334155;border-radius:6px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--ok),#10b981)}
166
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}.panel{background:var(--panel);border-radius:10px;padding:16px}.panel h2{margin:0 0 10px;font-size:14px;color:var(--dim);text-transform:uppercase;letter-spacing:.06em}
167
- table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid #334155;vertical-align:top}th{color:var(--dim);font-weight:600}
168
- .ok{color:var(--ok)}.bad{color:var(--bad)}.warn{color:var(--warn)}
169
- .pill{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:#334155}.pill.expected{background:#14532d;color:#86efac}.pill.unexpected{background:#7f1d1d;color:#fecaca}.pill.flaky{background:#78350f;color:#fde68a}.pill.skipped{background:#334155;color:#cbd5e1}
170
- code{background:#0f172a;padding:1px 6px;border-radius:4px;font-size:11px;color:#c7d2fe}.tags{max-width:340px}
171
- .fail{background:#1f1523;border:1px solid #7f1d1d;border-radius:8px;padding:12px;margin-bottom:10px}.fail .t{font-weight:600}.fail small{color:var(--dim);margin-left:6px}.fail pre{white-space:pre-wrap;color:#fecaca;font-size:12px;margin:8px 0 0}
172
- canvas{max-height:260px}
173
- </style></head><body>
174
- <header><h1><span>SDODS</span> · ${esc(m.title)}</h1><small>${esc(m.generatedAt)} · ${s.workers} workers · ${fmt(s.durationMs)}</small></header>
175
- <main>
176
- <div class="cards">${card('Total', s.total)}${card('Passed', s.passed, 'ok')}${card('Failed', s.failed, 'bad')}${card('Flaky', s.flaky, 'warn')}${card('Skipped', s.skipped)}${card('Healed', s.healed, 'warn')}${card('Pass rate', passRate + '%', passRate === 100 ? 'ok' : passRate < 80 ? 'bad' : 'warn')}</div>
177
- <div class="bar"><i style="width:${passRate}%"></i></div>
178
- <div class="grid">
179
- <div class="panel"><h2>Status</h2><canvas id="status"></canvas></div>
180
- <div class="panel"><h2>By project</h2><canvas id="project"></canvas></div>
181
- <div class="panel"><h2>By tag</h2><canvas id="tag"></canvas></div>
182
- <div class="panel"><h2>Duration (top 20)</h2><canvas id="duration"></canvas></div>
308
+ :root{
309
+ --bg:#fbfbfd; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e6e8ec;
310
+ --ok:#0f9d58; --bad:#d93025; --warn:#e37400; --accent:#3b5bdb;
311
+ --ok-bg:#e8f5ec; --bad-bg:#fdecea; --warn-bg:#fff4e5;
312
+ }
313
+ @media (prefers-color-scheme:dark){:root{
314
+ --bg:#0e1014; --panel:#171a21; --ink:#e8eaed; --muted:#9aa0a6; --line:#2a2f39;
315
+ --ok:#4ade80; --bad:#f87171; --warn:#fbbf24; --accent:#8ea2ff;
316
+ --ok-bg:#12291c; --bad-bg:#2c1618; --warn-bg:#2b2110;
317
+ }}
318
+ *{box-sizing:border-box}
319
+ body{margin:0;background:var(--bg);color:var(--ink);
320
+ font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
321
+ .wrap{max-width:1180px;margin:0 auto;padding:32px 20px 80px}
322
+ h1{font-size:20px;margin:0 0 2px;font-weight:650}
323
+ h2{font-size:14px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
324
+ margin:34px 0 10px;font-weight:650}
325
+ .sub{color:var(--muted);font-size:13px;margin-bottom:22px}
326
+ .verdict{border-radius:14px;padding:20px 22px;margin-bottom:22px;border:1px solid var(--line);background:var(--panel)}
327
+ .verdict.ok{background:var(--ok-bg);border-color:var(--ok)}
328
+ .verdict.bad{background:var(--bad-bg);border-color:var(--bad)}
329
+ .verdict.warn{background:var(--warn-bg);border-color:var(--warn)}
330
+ .verdict .word{font-size:26px;font-weight:680;letter-spacing:-.02em}
331
+ .verdict.ok .word{color:var(--ok)} .verdict.bad .word{color:var(--bad)} .verdict.warn .word{color:var(--warn)}
332
+ .verdict .line{margin-top:4px}
333
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:10px;margin-bottom:8px}
334
+ .stat{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
335
+ .stat .v{font-size:24px;font-weight:650;letter-spacing:-.02em}
336
+ .stat .l{color:var(--muted);font-size:12px;margin-top:2px}
337
+ .stat .s{color:var(--muted);font-size:11px;margin-top:3px}
338
+ .stat.bad .v{color:var(--bad)} .stat.ok .v{color:var(--ok)} .stat.warn .v{color:var(--warn)}
339
+ .warnings{margin:18px 0 0;padding:0;list-style:none}
340
+ .warnings li{background:var(--warn-bg);border:1px solid var(--warn);border-radius:10px;
341
+ padding:10px 14px;margin-bottom:8px;font-size:13.5px}
342
+ table{width:100%;border-collapse:collapse;background:var(--panel);
343
+ border:1px solid var(--line);border-radius:12px;overflow:hidden;font-size:13.5px}
344
+ th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
345
+ th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:650}
346
+ tbody tr:last-child td{border-bottom:0}
347
+ td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
348
+ td.ok{color:var(--ok)} td.bad{color:var(--bad);font-weight:650} td.muted{color:var(--muted)}
349
+ tr.row-bad td:first-child{box-shadow:inset 3px 0 0 var(--bad)}
350
+ .barcell{width:150px}
351
+ .bar{display:flex;height:7px;border-radius:4px;overflow:hidden;background:var(--line);min-width:110px}
352
+ .bar i{display:block;height:100%}
353
+ .bar .p{background:var(--ok)} .bar .f{background:var(--bad)}
354
+ .tag{display:inline-block;background:var(--line);color:var(--muted);border-radius:5px;
355
+ padding:1px 6px;font-size:11px;margin:1px 2px 1px 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
356
+ .pill{display:inline-block;border-radius:5px;padding:1px 8px;font-size:11px;font-weight:650;text-transform:uppercase}
357
+ .pill.expected{background:var(--ok-bg);color:var(--ok)}
358
+ .pill.unexpected{background:var(--bad-bg);color:var(--bad)}
359
+ .pill.flaky{background:var(--warn-bg);color:var(--warn)}
360
+ .pill.skipped{background:var(--line);color:var(--muted)}
361
+ .cluster{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--bad);
362
+ border-radius:10px;margin-bottom:10px;padding:12px 16px}
363
+ .cluster summary{cursor:pointer;display:flex;gap:10px;align-items:baseline}
364
+ .cluster summary::marker{color:var(--muted)}
365
+ .cluster .count{color:var(--bad);font-weight:680;white-space:nowrap}
366
+ .cluster code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;word-break:break-word}
367
+ .cluster ul{margin:10px 0 2px 4px;padding-left:16px;color:var(--muted);font-size:13px}
368
+ .cluster li{margin:3px 0}
369
+ .controls{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}
370
+ .controls input,.controls select{background:var(--panel);border:1px solid var(--line);color:var(--ink);
371
+ border-radius:8px;padding:7px 11px;font:inherit;font-size:13px}
372
+ .controls input{flex:1;min-width:220px}
373
+ .controls input:focus-visible,.controls select:focus-visible,.cluster summary:focus-visible{
374
+ outline:2px solid var(--accent);outline-offset:2px}
375
+ .empty{color:var(--muted);font-style:italic;padding:14px 0}
376
+ footer{margin-top:44px;color:var(--muted);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
377
+ </style></head><body><div class="wrap">
378
+
379
+ <h1>${esc(m.title)}</h1>
380
+ <div class="sub">${esc(new Date(m.generatedAt).toUTCString())} &middot; ${esc(fmt(s.durationMs))} wall clock &middot; ${s.workers} worker${s.workers === 1 ? '' : 's'}</div>
381
+
382
+ <div class="verdict ${verdict.cls}">
383
+ <div class="word">${esc(verdict.word)}</div>
384
+ <div class="line">${esc(verdict.line)}</div>
385
+ </div>
386
+
387
+ <div class="stats">
388
+ ${stat('Pass rate', `${passRate}%`, `${s.passed + s.flaky} of ${executed} executed`, blocking ? 'bad' : 'ok')}
389
+ ${stat('Failed', blocking, blocking ? `${m.clusters.length} distinct cause${m.clusters.length === 1 ? '' : 's'}` : 'none', blocking ? 'bad' : '')}
390
+ ${stat('Skipped', s.skipped, `${skipRate}% of the suite`, skipRate > 5 ? 'warn' : '')}
391
+ ${stat('Flaky', s.flaky, s.flaky ? 'passed only on retry' : 'none', s.flaky ? 'warn' : '')}
392
+ ${stat('Healed', s.healed, s.healed ? 'the DOM moved' : 'none', s.healed ? 'warn' : '')}
393
+ ${stat('Executed', executed, `of ${s.total} registered`)}
394
+ </div>
395
+
396
+ ${warnings.length ? `<ul class="warnings">${warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul>` : ''}
397
+
398
+ <h2>What to fix first</h2>
399
+ ${m.clusters.length ? clusterCards : '<div class="empty">Nothing failed.</div>'}
400
+
401
+ ${roleRows
402
+ ? `<section><h2>By role &mdash; a role that fails alone is a permissions regression</h2>
403
+ <table><thead><tr><th>Role</th><th class="num">Total</th><th class="num">Passed</th><th class="num">Failed</th><th></th></tr></thead>
404
+ <tbody>${roleRows}</tbody></table></section>`
405
+ : ''}
406
+
407
+ ${groupTable('By module tag', m.byTag, 'Tag')}
408
+ ${groupTable('By layer', m.byLayer, 'Layer')}
409
+ ${groupTable('By browser', m.byBrowser, 'Browser')}
410
+ ${groupTable('By runner project', m.byProject, 'Project')}
411
+
412
+ ${slowRows
413
+ ? `<section><h2>Slowest scenarios</h2><table>
414
+ <thead><tr><th>Scenario</th><th class="num">Duration</th><th>Tags</th></tr></thead>
415
+ <tbody>${slowRows}</tbody></table></section>`
416
+ : ''}
417
+
418
+ <h2>All scenarios</h2>
419
+ <div class="controls">
420
+ <input id="q" type="search" placeholder="Filter by title, tag or project&hellip;" aria-label="Filter scenarios">
421
+ <select id="st" aria-label="Filter by status">
422
+ <option value="">All statuses</option>
423
+ <option value="unexpected">Failed</option>
424
+ <option value="flaky">Flaky</option>
425
+ <option value="expected">Passed</option>
426
+ <option value="skipped">Skipped</option>
427
+ </select>
428
+ </div>
429
+ <table id="all"><thead><tr><th>Status</th><th>Scenario</th><th>Tags</th><th class="num">Time</th><th class="num">Retries</th><th class="num">Heals</th></tr></thead>
430
+ <tbody>${testRows}</tbody></table>
431
+ <div class="empty" id="none" hidden>No scenario matches that filter.</div>
432
+
433
+ <footer>Generated by SDODS &middot; metrics.json sits beside this file for scripting.</footer>
183
434
  </div>
184
- <div class="panel"><h2>Projects</h2><table><thead><tr><th>Project</th><th>Total</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Flaky</th></tr></thead><tbody>${projectRows}</tbody></table></div>
185
- ${m.failed.length ? `<div class="panel"><h2>Failures</h2>${failedCards}</div>` : ''}
186
- <div class="panel"><h2>All tests</h2><table><thead><tr><th>Test</th><th>Project</th><th>Status</th><th>Duration</th><th>Retries</th><th>Heals</th><th>Tags</th></tr></thead><tbody>${testRows}</tbody></table></div>
187
- </main>
188
- <script>${chartJs()}</script>
189
435
  <script>
190
- (function(){if(typeof Chart==='undefined')return;const M=${JSON.stringify({ summary: s, byProject: m.byProject, byTag: m.byTag, durations: m.tests.slice(0, 20).map((t) => ({ t: t.title.slice(0, 40), d: t.duration })) })};
191
- Chart.defaults.color='#94a3b8';
192
- new Chart(document.getElementById('status'),{type:'doughnut',data:{labels:['Passed','Failed','Flaky','Skipped'],datasets:[{data:[M.summary.passed,M.summary.failed,M.summary.flaky,M.summary.skipped],backgroundColor:['#22c55e','#ef4444','#f59e0b','#64748b']}]},options:{plugins:{legend:{position:'bottom'}}}});
193
- const P=Object.keys(M.byProject);new Chart(document.getElementById('project'),{type:'bar',data:{labels:P,datasets:[{label:'Passed',data:P.map(k=>M.byProject[k].passed),backgroundColor:'#22c55e'},{label:'Failed',data:P.map(k=>M.byProject[k].failed),backgroundColor:'#ef4444'},{label:'Flaky',data:P.map(k=>M.byProject[k].flaky),backgroundColor:'#f59e0b'}]},options:{scales:{x:{stacked:true},y:{stacked:true}}}});
194
- const T=Object.keys(M.byTag);new Chart(document.getElementById('tag'),{type:'bar',data:{labels:T,datasets:[{label:'Passed',data:T.map(k=>M.byTag[k].passed),backgroundColor:'#22c55e'},{label:'Failed',data:T.map(k=>M.byTag[k].failed),backgroundColor:'#ef4444'}]},options:{indexAxis:'y',scales:{x:{stacked:true},y:{stacked:true}}}});
195
- new Chart(document.getElementById('duration'),{type:'bar',data:{labels:M.durations.map(x=>x.t),datasets:[{label:'ms',data:M.durations.map(x=>x.d),backgroundColor:'#6366f1'}]},options:{plugins:{legend:{display:false}}}});
436
+ (function(){
437
+ var q=document.getElementById('q'),st=document.getElementById('st'),
438
+ rows=[].slice.call(document.querySelectorAll('#all tbody tr')),none=document.getElementById('none');
439
+ function apply(){
440
+ var t=q.value.trim().toLowerCase(), s=st.value, shown=0;
441
+ rows.forEach(function(r){
442
+ var ok=(!t||r.dataset.text.indexOf(t)>-1)&&(!s||r.dataset.status===s);
443
+ r.hidden=!ok; if(ok)shown++;
444
+ });
445
+ none.hidden=shown>0;
446
+ }
447
+ q.addEventListener('input',apply); st.addEventListener('change',apply);
196
448
  })();
197
- </script></body></html>`;
449
+ </script>
450
+ </body></html>`;
451
+ }
452
+ function bar(passed, failed, total) {
453
+ if (!total)
454
+ return '';
455
+ return `<div class="bar" role="img" aria-label="${passed} passed, ${failed} failed of ${total}"><i class="p" style="width:${pct(passed, total)}%"></i><i class="f" style="width:${pct(failed, total)}%"></i></div>`;
198
456
  }
199
457
  //# sourceMappingURL=dashboard.js.map
@@ -1,4 +1,3 @@
1
- import { attachmentNames } from '@sdods/contracts';
2
1
  import { AfterScenario, AfterStep, BeforeScenario, BeforeStep } from '../fixtures/test.js';
3
2
  /**
4
3
  * Screenshot narrative hooks. Tag-filtered so API scenarios never instantiate a page.
@@ -33,14 +32,5 @@ AfterScenario({ name: 'sdods:finalize' }, async ({ scenario, apiContext, heal, $
33
32
  apiCalls: apiContext.history.length,
34
33
  heals: heal.events.length,
35
34
  });
36
- // Publish the scenario identity to the report. Ingest already understands this attachment
37
- // (parseAttachmentName -> kind 'meta') and prefers its `module` over the directory guess in
38
- // moduleFromUri(), which is wrong whenever a module's `path` differs from its `name` -
39
- // demo-shop's `posts-api` lives in features/api/, so the guess yields "api" and never joins
40
- // to modules.name. Without this attach the meta branch only ever fired in tests.
41
- await $testInfo.attach(attachmentNames.meta, {
42
- body: JSON.stringify(scenario.data),
43
- contentType: 'application/json',
44
- });
45
35
  });
46
36
  //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,180 @@
1
+ import './params.js';
2
+ /**
3
+ * Structural subset of axe-core's result types. Declared locally rather than imported from
4
+ * `axe-core`: that package is a transitive dependency of `@axe-core/playwright`, not a direct
5
+ * dependency of this one, so importing its types would couple the whole step library's
6
+ * compilation to a package a consumer may not have installed.
7
+ */
8
+ export interface A11yViolationNode {
9
+ target: unknown[];
10
+ html?: string;
11
+ failureSummary?: string;
12
+ }
13
+ export interface A11yViolation {
14
+ id: string;
15
+ impact?: string | null;
16
+ help?: string;
17
+ helpUrl?: string;
18
+ nodes: A11yViolationNode[];
19
+ }
20
+ export interface A11yResults {
21
+ violations: A11yViolation[];
22
+ passes: {
23
+ id: string;
24
+ }[];
25
+ incomplete: {
26
+ id: string;
27
+ }[];
28
+ inapplicable: {
29
+ id: string;
30
+ }[];
31
+ url?: string;
32
+ }
33
+ /** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
34
+ export declare const IMPACT_ORDER: readonly ["minor", "moderate", "serious", "critical"];
35
+ /**
36
+ * WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
37
+ * changes between axe releases, so including it would let the same unchanged page pass one week
38
+ * and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
39
+ */
40
+ export declare const WCAG_AA_TAGS: string[];
41
+ /** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
42
+ export declare function impactRank(impact: string | null | undefined): number;
43
+ /** Rejects an impact level the feature file invented, instead of silently matching nothing. */
44
+ export declare function parseImpactFloor(level: string): number;
45
+ export declare function violationsAtOrAbove(violations: A11yViolation[], floor: number): A11yViolation[];
46
+ /** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
47
+ export declare function describeViolations(violations: A11yViolation[]): string;
48
+ /**
49
+ * PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
50
+ * axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
51
+ * an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
52
+ * in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
53
+ */
54
+ export declare function assertAxeChecked(results: A11yResults, where: string): void;
55
+ export type RuleBucket = 'violations' | 'passes' | 'incomplete' | 'inapplicable' | 'none';
56
+ /** Which of axe's four result buckets a rule landed in. `none` means the rule never ran. */
57
+ export declare function ruleBucket(results: A11yResults, ruleId: string): RuleBucket;
58
+ /**
59
+ * PROVES a rule-scoped audit was capable of failing. `none` means the rule id does not exist, so
60
+ * the run examined nothing; `inapplicable` means the rule found no matching element, so a green
61
+ * result is silence rather than evidence. Both are reported as defects in the step.
62
+ */
63
+ export declare function assertRuleRan(results: A11yResults, ruleId: string, where: string): RuleBucket;
64
+ /** One record per element the browser gathered, plus whether the scoped region existed at all. */
65
+ export interface Gathered<T> {
66
+ regionFound: boolean;
67
+ items: T[];
68
+ }
69
+ /** PROVES the scoped region exists. A selector matching nothing must fail, never scan the void. */
70
+ export declare function assertRegion<T>(gathered: Gathered<T>, selector: string): T[];
71
+ /**
72
+ * PROVES the step had something to check. This is the guard the whole library turns on: without
73
+ * it "every image carries an alt" is green on a page with no images, and "no focus indicator is
74
+ * missing" is green on a region with no focusable elements.
75
+ */
76
+ export declare function assertGathered<T>(items: T[], what: string, where: string): T[];
77
+ export interface HeadingRecord {
78
+ level: number;
79
+ text: string;
80
+ }
81
+ /** Adjacent heading pairs that jump more than one level, described in document order. */
82
+ export declare function headingSkips(headings: HeadingRecord[]): string[];
83
+ export interface ImageRecord {
84
+ src: string;
85
+ hasAlt: boolean;
86
+ }
87
+ /**
88
+ * A MISSING alt and `alt=""` are different bugs: the empty one is a decision that the image is
89
+ * decorative, the absent one is an omission. axe's `image-alt` reports only the second, so this
90
+ * judge keeps a distinction the rule blurs.
91
+ */
92
+ export declare function imagesWithoutAlt(images: ImageRecord[]): string[];
93
+ export interface ControlRecord {
94
+ tag: string;
95
+ text: string;
96
+ name: string;
97
+ html: string;
98
+ }
99
+ /**
100
+ * An accessible name that still looks like an i18n catalogue key ("nav.workspace.settings") is a
101
+ * `t()` lookup that failed at render time. axe reports that only as a generic missing-name
102
+ * violation, with no hint that the translation catalogue is the cause.
103
+ */
104
+ export declare const RAW_I18N_KEY: RegExp;
105
+ /** Controls that render no text — the ones whose only name is an aria-label or a title. */
106
+ export declare function iconOnlyControls(controls: ControlRecord[]): ControlRecord[];
107
+ export declare function unnamedControls(controls: ControlRecord[]): string[];
108
+ export interface FocusRecord {
109
+ label: string;
110
+ rest: string;
111
+ focused: string;
112
+ }
113
+ /** Focusable elements whose computed style is identical focused and unfocused. */
114
+ export declare function focusRingMissing(records: FocusRecord[]): string[];
115
+ /**
116
+ * Visible headings in document order, `h1`–`h6` and `role="heading"` alike, with `aria-level`
117
+ * winning over the tag. `checkVisibility()` is called WITHOUT the opacity check on purpose: a
118
+ * visually-hidden ("sr-only") h1 is a correct, common pattern and still forms the outline.
119
+ */
120
+ export declare const gatherHeadings: (sel: string | null) => {
121
+ regionFound: boolean;
122
+ items: {
123
+ level: number;
124
+ text: string;
125
+ }[];
126
+ };
127
+ /** Every `<img>` in scope with whether it carries an alt ATTRIBUTE (empty counts as present). */
128
+ export declare const gatherImages: (sel: string | null) => {
129
+ regionFound: boolean;
130
+ items: {
131
+ src: string;
132
+ hasAlt: boolean;
133
+ }[];
134
+ };
135
+ /**
136
+ * Visible controls in scope with their rendered text and their accessible name. The name is
137
+ * resolved the way a screen reader does — aria-label, then aria-labelledby, then title, then a
138
+ * contained `img[alt]`, then an `<svg><title>` — because reading only aria-label (as a naive
139
+ * probe does) flags every correctly-labelled icon button that uses one of the other four.
140
+ *
141
+ * TRAP: text is read with `innerText`, not `textContent`. An `<svg><title>` is part of
142
+ * `textContent` but is never painted, so a genuinely icon-only button named through its SVG title
143
+ * would look like a button that renders a label and drop out of the audit entirely.
144
+ */
145
+ export declare const gatherControls: (sel: string) => {
146
+ regionFound: boolean;
147
+ items: {
148
+ tag: string;
149
+ text: string;
150
+ name: string;
151
+ html: string;
152
+ }[];
153
+ };
154
+ /**
155
+ * Computed style of every visible focusable in scope, at rest and while focused.
156
+ *
157
+ * TRAP: modern apps style `:focus-visible` only, and the browser matches that pseudo-class from
158
+ * the user's last INTERACTION modality, not from the `focus()` call. Measured across Chromium,
159
+ * Firefox and WebKit: after any real mouse click, EVERY `:focus-visible`-styled control reports
160
+ * an unchanged style — a total false failure on a perfectly accessible page. The step therefore
161
+ * presses Tab first to restore keyboard modality; Tab is the only key that does so in all three
162
+ * engines (Shift restores it in Chromium only, ArrowRight in Chromium and WebKit only).
163
+ *
164
+ * That Tab press lands focus on an element, which is the second half of the trap: sampling that
165
+ * element's resting style while it is focused reports no change and accuses it wrongly. Hence the
166
+ * guard below. Programmatic focus keeps the keyboard modality alive, so moving focus to a sibling
167
+ * is safe; `blur()` is the fallback for a region holding a single control.
168
+ *
169
+ * `checkVisibility` replaces the `offsetParent !== null` idiom, which reports `null` for any
170
+ * `position: fixed` element and would silently drop every floating control from the audit.
171
+ */
172
+ export declare const gatherFocusIndicators: (sel: string) => {
173
+ regionFound: boolean;
174
+ items: {
175
+ label: string;
176
+ rest: string;
177
+ focused: string;
178
+ }[];
179
+ };
180
+ //# sourceMappingURL=a11y.steps.d.ts.map