@post-print/agent-test 1.1.0 → 1.1.2

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 (76) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +34 -6
  4. package/dist/cli.js.map +1 -1
  5. package/dist/compare-scenario.d.ts +10 -0
  6. package/dist/compare-scenario.d.ts.map +1 -1
  7. package/dist/compare-scenario.js +48 -3
  8. package/dist/compare-scenario.js.map +1 -1
  9. package/dist/expect.d.ts +1 -0
  10. package/dist/expect.d.ts.map +1 -1
  11. package/dist/expect.js +24 -0
  12. package/dist/expect.js.map +1 -1
  13. package/dist/html-report.d.ts +4 -1
  14. package/dist/html-report.d.ts.map +1 -1
  15. package/dist/html-report.js +224 -143
  16. package/dist/html-report.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/live-isolation.d.ts +2 -0
  22. package/dist/live-isolation.d.ts.map +1 -1
  23. package/dist/live-isolation.js +8 -2
  24. package/dist/live-isolation.js.map +1 -1
  25. package/dist/report-preview.d.ts +24 -0
  26. package/dist/report-preview.d.ts.map +1 -1
  27. package/dist/report-preview.js +46 -2
  28. package/dist/report-preview.js.map +1 -1
  29. package/dist/run-suite.d.ts +10 -0
  30. package/dist/run-suite.d.ts.map +1 -1
  31. package/dist/run-suite.js +146 -126
  32. package/dist/run-suite.js.map +1 -1
  33. package/dist/scenario-finalizer.d.ts +27 -0
  34. package/dist/scenario-finalizer.d.ts.map +1 -0
  35. package/dist/scenario-finalizer.js +39 -0
  36. package/dist/scenario-finalizer.js.map +1 -0
  37. package/dist/scenario-story.d.ts.map +1 -1
  38. package/dist/scenario-story.js +14 -8
  39. package/dist/scenario-story.js.map +1 -1
  40. package/dist/types.d.ts +7 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/validate-suite.d.ts.map +1 -1
  43. package/dist/validate-suite.js +1 -0
  44. package/dist/validate-suite.js.map +1 -1
  45. package/dist/viewer/catalog.d.ts +14 -3
  46. package/dist/viewer/catalog.d.ts.map +1 -1
  47. package/dist/viewer/catalog.js +33 -6
  48. package/dist/viewer/catalog.js.map +1 -1
  49. package/dist/viewer/compare-metrics.d.ts.map +1 -1
  50. package/dist/viewer/compare-metrics.js +8 -9
  51. package/dist/viewer/compare-metrics.js.map +1 -1
  52. package/dist/viewer/events.d.ts +22 -1
  53. package/dist/viewer/events.d.ts.map +1 -1
  54. package/dist/viewer/events.js +1 -0
  55. package/dist/viewer/events.js.map +1 -1
  56. package/dist/viewer/live-runner.d.ts +7 -0
  57. package/dist/viewer/live-runner.d.ts.map +1 -1
  58. package/dist/viewer/live-runner.js +36 -23
  59. package/dist/viewer/live-runner.js.map +1 -1
  60. package/dist/viewer/page.d.ts +14 -1
  61. package/dist/viewer/page.d.ts.map +1 -1
  62. package/dist/viewer/page.js +758 -141
  63. package/dist/viewer/page.js.map +1 -1
  64. package/dist/viewer/run-controller.d.ts +13 -6
  65. package/dist/viewer/run-controller.d.ts.map +1 -1
  66. package/dist/viewer/run-controller.js +246 -74
  67. package/dist/viewer/run-controller.js.map +1 -1
  68. package/dist/viewer/server.d.ts +5 -0
  69. package/dist/viewer/server.d.ts.map +1 -1
  70. package/dist/viewer/server.js +72 -17
  71. package/dist/viewer/server.js.map +1 -1
  72. package/dist/viewer/session-server.d.ts +2 -0
  73. package/dist/viewer/session-server.d.ts.map +1 -0
  74. package/dist/viewer/session-server.js +71 -0
  75. package/dist/viewer/session-server.js.map +1 -0
  76. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { reportCss } from "../html-report.js";
1
+ import { describeCompareGate } from "../compare-scenario.js";
2
2
  function escapeHtml(value) {
3
3
  return value
4
4
  .replaceAll("&", "&")
@@ -25,6 +25,7 @@ function viewerCss() {
25
25
  margin-bottom: 1.25rem;
26
26
  }
27
27
  .toolbar-block { display: grid; gap: 0.35rem; }
28
+ .run-history { min-width: min(26rem, 80vw); }
28
29
  .toolbar-label { color: var(--muted); font-size: 0.75rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; }
29
30
  .host-toggles, .toolbar-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
30
31
  .host-toggles label { display: inline-flex; gap: 0.35rem; align-items: center; font-size: 0.85rem; }
@@ -72,6 +73,8 @@ function viewerCss() {
72
73
  .host-tab[data-status="passed"] { color: var(--pass); }
73
74
  .host-tab[data-status="failed"] { color: var(--fail); }
74
75
  .host-tab[data-status="skipped"], .host-tab[data-status="skip"] { color: var(--muted); }
76
+ .host-tab[hidden], .host-empty-selection[hidden] { display: none; }
77
+ .host-empty-selection { color: var(--muted); font-size: .78rem; }
75
78
  .host-status { display: inline-flex; align-items: center; }
76
79
  .cell-status { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
77
80
  .scenario-live { display: grid; gap: 0.75rem; }
@@ -268,52 +271,318 @@ function viewerCss() {
268
271
  @media (prefers-reduced-motion: reduce) {
269
272
  .chat-progress span, .bubble.is-streaming .bubble-text::after { animation: none; }
270
273
  }
274
+ .scenario-card {
275
+ background: linear-gradient(135deg, color-mix(in oklch, var(--panel) 94%, white), var(--panel));
276
+ padding: 1rem 1.05rem;
277
+ }
278
+ .scenario-detail { gap: 0.4rem; }
279
+ .scenario-detail strong { font-size: 1.05rem; letter-spacing: -0.01em; }
280
+ .scenario-toolbar { gap: 0.45rem 0.85rem; padding-top: 0.6rem; border-top: 1px solid var(--border); }
281
+ .host-runs { display: flex; gap: 0.4rem; align-items: center; }
282
+ .host-runs-label { color: var(--muted); font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
283
+ .host-tablist { gap: 0.25rem; }
284
+ .host-tab { gap: 0.28rem; padding: 0.25rem 0.45rem; font-size: 0.82rem; }
285
+ body { background: oklch(0.155 0.015 250); }
286
+ .viewer-shell { max-width: none; min-height: 100dvh; padding: 0; }
287
+ .viewer-topbar {
288
+ position: sticky; inset-block-start: 0; z-index: 10;
289
+ display: grid; grid-template-columns: minmax(12rem, .7fr) minmax(18rem, 1.2fr) minmax(14rem, 1fr);
290
+ align-items: center; gap: 1rem; min-height: 4.5rem;
291
+ padding: .75rem clamp(1rem, 2.5vw, 2rem);
292
+ background: color-mix(in oklch, var(--bg) 92%, transparent);
293
+ border-block-end: 1px solid var(--border); backdrop-filter: blur(18px);
294
+ }
295
+ .viewer-identity { display: flex; align-items: baseline; gap: .65rem; min-width: 0; }
296
+ .viewer-identity .brand { color: var(--pass); }
297
+ .viewer-identity h1 { font-size: 1.05rem; white-space: nowrap; }
298
+ .run-context { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: .5rem; min-width: 0; }
299
+ .run-history { width: 100%; min-width: 0; border-radius: 7px; background: var(--panel-2); color: var(--text); border: 1px solid var(--border); padding: .42rem .6rem; }
300
+ .run-banner { margin: 0; overflow: hidden; color: var(--muted); font-size: .78rem; text-align: end; text-overflow: ellipsis; white-space: nowrap; }
301
+ .viewer-command-row { display: flex; flex-wrap: wrap; align-items: center; gap: .55rem 1rem; padding: .55rem clamp(1rem, 2.5vw, 2rem); background: var(--panel-2); border-block-end: 1px solid var(--border); }
302
+ .viewer-command-row .toolbar-block { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .55rem; }
303
+ .viewer-command-row .host-toggles { gap: .25rem .55rem; }
304
+ .viewer-command-row .parallel-control { display: inline-flex; align-items: center; gap: .35rem; color: var(--muted); font-size: .78rem; white-space: nowrap; }
305
+ .viewer-command-row .run-toggle { min-width: 5.75rem; margin-inline-start: auto; padding-block: .3rem; font-size: .78rem; }
306
+ .run-progress { margin: 0; padding: .7rem clamp(1rem, 2.5vw, 2rem); border: 0; border-block-end: 1px solid var(--border); border-radius: 0; background: var(--bg); }
307
+ .run-progress-track { height: .35rem; }
308
+ .viewer-workspace { display: grid; grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr); min-height: calc(100dvh - 7.5rem); }
309
+ .test-navigator { position: sticky; inset-block-start: 7.5rem; align-self: start; height: calc(100dvh - 7.5rem); overflow: auto; padding: 1.15rem .75rem 2rem; background: color-mix(in oklch, var(--panel-2) 72%, var(--bg)); border-inline-end: 1px solid var(--border); }
310
+ .test-navigator-heading { display: flex; justify-content: space-between; align-items: end; padding-inline: .5rem; margin-block-end: 1rem; }
311
+ .section-label, .scenario-path { margin: 0; color: var(--muted); font-size: .68rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
312
+ .test-navigator-heading h2 { margin: .1rem 0 0; font-size: 1rem; }
313
+ .test-picker, .test-picker-label { display: none; }
314
+ .test-tree { display: grid; gap: 1.1rem; }
315
+ .test-nav-suite { display: grid; gap: .35rem; }
316
+ .test-nav-suite > header { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding-inline: .45rem; }
317
+ .test-nav-suite > header h2 { margin: 0; color: var(--muted); font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; }
318
+ .test-nav-suite > header p { display: none; }
319
+ .test-nav-suite > header .run-suite { padding: .12rem .35rem; color: var(--muted); border: 0; background: transparent; font-size: .7rem; }
320
+ .test-nav-items { display: grid; gap: .18rem; }
321
+ .test-nav-item { display: grid; grid-template-columns: .6rem minmax(0, 1fr); align-items: start; gap: .55rem; width: 100%; padding: .5rem .55rem; border-color: transparent; background: transparent; text-align: start; }
322
+ .test-nav-item:hover { background: color-mix(in oklch, var(--text) 5%, transparent); }
323
+ .test-nav-item[aria-current="true"] { background: color-mix(in oklch, var(--pass) 9%, var(--panel)); border-color: color-mix(in oklch, var(--pass) 28%, var(--border)); }
324
+ .test-nav-status { width: .48rem; height: .48rem; margin-block-start: .28rem; border: 1px solid var(--muted); border-radius: 50%; }
325
+ .test-nav-item[data-status="running"] .test-nav-status { border-color: var(--skip); background: var(--skip); box-shadow: 0 0 0 3px color-mix(in oklch, var(--skip) 14%, transparent); }
326
+ .test-nav-item[data-status="passed"] .test-nav-status { border-color: var(--pass); background: var(--pass); }
327
+ .test-nav-item[data-status="failed"] .test-nav-status { border-color: var(--fail); background: var(--fail); }
328
+ .test-nav-item[data-status="skipped"] .test-nav-status { border-color: var(--skip); }
329
+ .test-nav-copy { display: grid; min-width: 0; }
330
+ .test-nav-copy strong { overflow: hidden; font-size: .82rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
331
+ .test-nav-copy small { overflow: hidden; color: var(--muted); font-size: .7rem; text-overflow: ellipsis; white-space: nowrap; }
332
+ .test-stage { min-width: 0; padding: clamp(1rem, 3vw, 2.25rem); }
333
+ .test-stage > .suite { display: block; margin: 0; }
334
+ .test-stage > .suite > .suite-header { display: none; }
335
+ .test-stage .scenario-list { display: contents; }
336
+ .scenario-card, .scenario-card[data-selected="true"] { max-width: 76rem; margin-inline: auto; padding: 0; gap: 0; overflow: hidden; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 12px 36px oklch(0 0 0 / .16); }
337
+ .scenario-card[hidden] { display: none; }
338
+ .scenario-focus-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: clamp(1rem, 2vw, 1.5rem); border-block-end: 1px solid var(--border); }
339
+ .scenario-heading-copy { display: grid; gap: .3rem; min-width: 0; }
340
+ .scenario-heading-copy h2 { margin: 0; font-size: clamp(1.25rem, 2.5vw, 1.75rem); letter-spacing: -.025em; }
341
+ .scenario-heading-copy .suite-description { max-width: 62ch; color: var(--muted); font-size: .9rem; }
342
+ .scenario-heading-copy .scenario-lede { max-width: 62ch; font-size: .9rem; }
343
+ .focus-verdict { display: inline-flex; align-items: center; gap: .4rem; flex: none; padding: .28rem .55rem; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; font-size: .72rem; font-weight: 700; text-transform: uppercase; }
344
+ .focus-verdict > span { width: .45rem; height: .45rem; border-radius: 50%; background: currentColor; }
345
+ .focus-verdict[data-status="running"] { color: var(--skip); }
346
+ .focus-verdict[data-status="passed"] { color: var(--pass); }
347
+ .focus-verdict[data-status="failed"] { color: var(--fail); }
348
+ .scenario-definition { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(15rem, .7fr); gap: .8rem; padding: clamp(1rem, 2vw, 1.5rem); background: color-mix(in oklch, var(--panel) 88%, var(--bg)); }
349
+ .test-intent, .criteria-panel, .compare-definition, .comparison-criteria { min-width: 0; padding: .9rem; background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; }
350
+ .prompt-preview { margin: .45rem 0 0; color: var(--text); font-size: .94rem; line-height: 1.55; }
351
+ .task-metadata { display: flex; flex-wrap: wrap; align-items: baseline; gap: .3rem .55rem; margin-block-start: .75rem; padding-block-start: .65rem; border-block-start: 1px solid var(--border); }
352
+ .task-metadata + .task-metadata { margin-block-start: .55rem; padding-block-start: 0; border-block-start: 0; }
353
+ .task-metadata .task-meta-label { color: var(--muted); font-size: .66rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }
354
+ .task-metadata p { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.6; }
355
+ .task-metadata code { padding: .08rem .28rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; overflow-wrap: anywhere; }
356
+ .criteria-panel > summary { display: flex; justify-content: space-between; gap: .5rem; cursor: pointer; font-size: .82rem; font-weight: 650; }
357
+ .criteria-summary { color: var(--muted); font-size: .72rem; font-weight: 400; }
358
+ .criterion-groups { display: grid; gap: .65rem; margin-block-start: .75rem; }
359
+ .criterion-group { display: grid; gap: .35rem; padding-block-start: .6rem; border-block-start: 1px solid var(--border); }
360
+ .criterion-group:first-child { padding-block-start: 0; border-block-start: 0; }
361
+ .criterion-group h4 { margin: 0; color: var(--muted); font-size: .66rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }
362
+ .criterion-group p { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.4; }
363
+ .criterion-list { display: grid; gap: .28rem; margin: 0; padding: 0; list-style: none; }
364
+ .criterion-list li { color: var(--text); font-size: .78rem; line-height: 1.4; }
365
+ .criterion-list code { padding: .08rem .28rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; overflow-wrap: anywhere; }
366
+ .criteria-empty { margin: .7rem 0 0; color: var(--muted); font-size: .78rem; line-height: 1.45; }
367
+ .compare-definition { grid-column: 1 / -1; }
368
+ .comparison-intro { margin: .35rem 0 0; color: var(--muted); font-size: .78rem; }
369
+ .compare-definition { padding: 0; background: transparent; border: 0; }
370
+ .compare-definition-tabs { display: flex; flex-wrap: wrap; gap: .35rem; margin-block-start: .55rem; }
371
+ .compare-definition-tab-input { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; pointer-events: none; }
372
+ .compare-definition-tab { padding: .35rem .65rem; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; cursor: pointer; font-size: .78rem; }
373
+ .compare-definition-tab-input:checked + .compare-definition-tab { color: var(--text); background: color-mix(in oklch, var(--pass) 12%, var(--panel-2)); border-color: color-mix(in oklch, var(--pass) 36%, var(--border)); }
374
+ .compare-definition-tab-input:focus-visible + .compare-definition-tab { outline: 2px solid var(--pass); outline-offset: 2px; }
375
+ .compare-definition-grid { display: grid; grid-template-columns: minmax(0, 1fr); margin-block-start: .55rem; }
376
+ .compare-arm-definition { min-width: 0; overflow: hidden; background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; }
377
+ .compare-definition-tabbed .compare-arm-definition { display: none; }
378
+ .compare-arm-header { display: grid; gap: .2rem; padding: .8rem .9rem; border-block-end: 1px solid var(--border); }
379
+ .compare-arm-header h3 { margin: 0; font-size: .95rem; }
380
+ .compare-arm-description { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.45; }
381
+ .compare-arm-definition .test-intent, .compare-arm-definition .criteria-panel { border: 0; border-radius: 0; }
382
+ .compare-arm-definition .test-intent { background: transparent; }
383
+ .compare-arm-definition .criteria-panel { border-block-start: 1px solid var(--border); background: color-mix(in oklch, var(--panel-2) 82%, var(--bg)); }
384
+ .scenario-toolbar { justify-content: space-between; gap: .75rem 1rem; padding: .8rem clamp(1rem, 2vw, 1.5rem); background: var(--panel); border-block: 1px solid var(--border); }
385
+ .host-runs-label { font-size: .65rem; }
386
+ .host-tab { padding: .3rem .5rem; background: transparent; }
387
+ .host-tab[aria-selected="true"] { color: var(--text); background: color-mix(in oklch, var(--pass) 11%, var(--panel-2)); }
388
+ .scenario-actions .run-toggle { min-width: 5.6rem; padding: .28rem .55rem; font-size: .78rem; }
389
+ .scenario-live { padding: clamp(1rem, 2vw, 1.5rem); background: var(--bg); }
390
+ .scenario-live .host-empty { padding: 2rem; text-align: center; border: 1px dashed var(--border); border-radius: 8px; }
391
+ @container (max-width: 48rem) { .scenario-definition { grid-template-columns: minmax(0, 1fr); } .compare-definition { grid-column: auto; } }
392
+ @media (max-width: 800px) {
393
+ .viewer-topbar { position: static; grid-template-columns: minmax(0, 1fr); gap: .6rem; padding-block: .85rem; }
394
+ .viewer-identity { justify-content: space-between; }
395
+ .run-context { grid-template-columns: minmax(0, 1fr); gap: .25rem; }
396
+ .run-banner { text-align: start; }
397
+ .viewer-command-row { position: sticky; inset-block-start: 0; z-index: 9; }
398
+ .viewer-workspace { display: block; min-height: 0; }
399
+ .test-navigator { position: static; height: auto; padding: .75rem 1rem; border-inline-end: 0; border-block-end: 1px solid var(--border); }
400
+ .test-navigator-heading, .test-tree { display: none; }
401
+ .test-picker-label { display: block; margin-block-end: .25rem; color: var(--muted); font-size: .68rem; font-weight: 700; text-transform: uppercase; }
402
+ .test-picker { display: block; width: 100%; padding: .5rem .6rem; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); }
403
+ .test-stage { padding: .8rem; }
404
+ .scenario-focus-header { align-items: center; }
405
+ .scenario-definition { grid-template-columns: minmax(0, 1fr); }
406
+ .compare-definition { grid-column: auto; }
407
+ .scenario-toolbar { align-items: flex-start; }
408
+ .host-runs { align-items: flex-start; flex-direction: column; }
409
+ }
410
+ @media (max-width: 480px) { .scenario-focus-header { display: grid; } .focus-verdict { justify-self: start; } .scenario-toolbar { display: grid; } .scenario-actions { width: 100%; } .scenario-actions .primary { flex: 1; } .viewer-command-row .run-toggle { width: 100%; margin-inline-start: 0; } }
271
411
  `;
272
412
  }
273
- function rubricSummary(scenario) {
274
- const parts = [
275
- scenario.contextMode === "host-native" ? "host-native context" : "harness preamble",
276
- ];
277
- const rubric = scenario.rubric;
278
- if (rubric.must?.length) {
279
- parts.push(`must ${rubric.must.join(", ")}`);
280
- }
281
- if (rubric.mustNot?.length) {
282
- parts.push(`must not ${rubric.mustNot.join(", ")}`);
283
- }
284
- if (rubric.mustRun?.length) {
285
- parts.push(`run ${rubric.mustRun.join(", ")}`);
286
- }
287
- if (rubric.allowedCommands) {
288
- parts.push(rubric.allowedCommands.length > 0
289
- ? `allow ${rubric.allowedCommands.join(", ")}`
290
- : "allow no shell");
291
- }
292
- if (rubric.mustCallTool?.length) {
293
- parts.push(`call ${rubric.mustCallTool.join(", ")}`);
294
- }
295
- if (rubric.mustReadPath?.length) {
296
- parts.push(`read ${rubric.mustReadPath.join(", ")}`);
297
- }
298
- if (rubric.mustInvokeSkill?.length) {
299
- parts.push(`skill ${rubric.mustInvokeSkill.join(", ")}`);
413
+ function criterionCode(value) {
414
+ return `<code>${escapeHtml(value)}</code>`;
415
+ }
416
+ function renderAllowedCommands(target) {
417
+ const commands = target.rubric.allowedCommands;
418
+ if (commands === undefined)
419
+ return "";
420
+ const list = commands.length
421
+ ? commands.map(criterionCode).join('<span aria-hidden="true">, </span>')
422
+ : '<span class="task-command-empty">None</span>';
423
+ return `<div class="task-metadata task-allowed-commands"><span class="task-meta-label">Allowed commands</span><p>${list}</p></div>`;
424
+ }
425
+ function renderProvidedContext(target) {
426
+ if (!target.contextSources?.length)
427
+ return "";
428
+ const sources = target.contextSources
429
+ .map(criterionCode)
430
+ .join('<span aria-hidden="true">, </span>');
431
+ return `<div class="task-metadata task-provided-context"><span class="task-meta-label">Provided context</span><p>${sources}</p></div>`;
432
+ }
433
+ function renderSuppliedResources(target, showEmpty = false) {
434
+ const servers = target.suppliedMcp ?? [];
435
+ const tools = [...new Set(servers.flatMap((server) => server.tools))];
436
+ const skills = target.suppliedSkills ?? [];
437
+ const value = (items) => items.length
438
+ ? items.map(criterionCode).join('<span aria-hidden="true">, </span>')
439
+ : '<span class="task-resource-empty">None</span>';
440
+ if (!showEmpty && servers.length === 0 && skills.length === 0)
441
+ return "";
442
+ return `<div class="task-resources">
443
+ <div class="task-metadata"><span class="task-meta-label">Supplied MCP servers</span><p>${value(servers.map((server) => server.name))}</p></div>
444
+ <div class="task-metadata"><span class="task-meta-label">Supplied MCP tools</span><p>${value(tools)}</p></div>
445
+ <div class="task-metadata"><span class="task-meta-label">Supplied skills</span><p>${value(skills)}</p></div>
446
+ </div>`;
447
+ }
448
+ function renderPassCriteria(target) {
449
+ const groups = [];
450
+ const rubric = target.rubric;
451
+ const reply = [];
452
+ for (const text of rubric.must ?? []) {
453
+ reply.push(`Final reply must include ${criterionCode(text)}.`);
454
+ }
455
+ for (const text of rubric.mustNot ?? []) {
456
+ reply.push(`The run must not contain ${criterionCode(text)}.`);
457
+ }
458
+ if (reply.length)
459
+ groups.push({ title: "Reply", items: reply });
460
+ if (rubric.mustRun?.length || rubric.mustRunSuccessfully?.length) {
461
+ groups.push({
462
+ title: "Required commands",
463
+ items: [
464
+ ...(rubric.mustRun ?? []).map((command) => `Must run a command matching ${criterionCode(command)}.`),
465
+ ...(rubric.mustRunSuccessfully ?? []).map((command) => `Must run a command matching ${criterionCode(command)} successfully.`),
466
+ ],
467
+ });
300
468
  }
301
- if (rubric.judge) {
302
- parts.push("judge");
469
+ const tools = [
470
+ ...(rubric.mustCallTool ?? []).map((tool) => `Must call ${criterionCode(tool)}.`),
471
+ ...(rubric.mustCallToolsInOrder ?? []).map((tool, index) => `${index + 1}. Call ${criterionCode(tool)}.`),
472
+ ...(rubric.mustNotCallTool ?? []).map((tool) => `Must not call ${criterionCode(tool)}.`),
473
+ ];
474
+ if (tools.length) {
475
+ groups.push({
476
+ title: "Tools",
477
+ intro: rubric.mustCallToolsInOrder?.length ? "Ordered tool calls are numbered." : undefined,
478
+ items: tools,
479
+ });
303
480
  }
304
- if (scenario.contextSources?.length) {
305
- parts.push(`contextSources ${scenario.contextSources.join(", ")}`);
481
+ const files = [
482
+ ...(rubric.mustReadPath ?? []).map((path) => `Must read a path matching ${criterionCode(path)}.`),
483
+ ...(rubric.mustNotReadPath ?? []).map((path) => `Must not read a path matching ${criterionCode(path)}.`),
484
+ ];
485
+ if (files.length)
486
+ groups.push({ title: "Files", items: files });
487
+ const skills = [
488
+ ...(rubric.mustInvokeSkill ?? []).map((skill) => `Must use ${criterionCode(skill)}.`),
489
+ ...(rubric.mustNotInvokeSkill ?? []).map((skill) => `Must not use ${criterionCode(skill)}.`),
490
+ ];
491
+ if (skills.length)
492
+ groups.push({ title: "Skills", items: skills });
493
+ const routing = [
494
+ ...(rubric.handsOnRouting
495
+ ? ["Must announce hands-on routing before the first tool call."]
496
+ : []),
497
+ ...(rubric.tier ? [`Must announce the ${criterionCode(rubric.tier)} tier.`] : []),
498
+ ...(rubric.reviewDepth
499
+ ? [`Must announce ${criterionCode(rubric.reviewDepth)} review depth.`]
500
+ : []),
501
+ ...(rubric.routingBlock ? ["Must announce a routing block."] : []),
502
+ ];
503
+ if (routing.length)
504
+ groups.push({ title: "Routing", items: routing });
505
+ if (rubric.judge?.length) {
506
+ groups.push({
507
+ title: "Judge",
508
+ intro: "A judge evaluates these questions after the run.",
509
+ items: rubric.judge.map((item) => escapeHtml(typeof item === "string" ? item : item.question)),
510
+ });
306
511
  }
307
- return parts.join(" · ");
512
+ const count = groups.reduce((total, group) => total + Math.max(group.items.length, 1), 0);
513
+ const html = groups
514
+ .map((group) => `<section class="criterion-group"><h4>${escapeHtml(group.title)}</h4>${group.intro ? `<p>${escapeHtml(group.intro)}</p>` : ""}${group.items.length ? `<ul class="criterion-list">${group.items.map((item) => `<li>${item}</li>`).join("")}</ul>` : ""}</section>`)
515
+ .join("");
516
+ return { count, html };
517
+ }
518
+ function armLabel(scenario, id) {
519
+ return scenario.compare?.find((arm) => arm.id === id)?.label ?? id;
520
+ }
521
+ function renderComparisonCriteria(scenario) {
522
+ const items = scenario.gates?.length
523
+ ? scenario.gates.map((gate) => `${escapeHtml(describeCompareGate(gate, (id) => armLabel(scenario, id)))}.`)
524
+ : (scenario.compare ?? []).map((arm) => `${escapeHtml(arm.label)} must pass all of its criteria.`);
525
+ return {
526
+ count: items.length,
527
+ html: `<section class="criterion-group"><h4>Arm results</h4><ul class="criterion-list">${items.map((item) => `<li>${item}</li>`).join("")}</ul></section>`,
528
+ };
529
+ }
530
+ function renderCompareArm(arm) {
531
+ const criteria = renderPassCriteria(arm);
532
+ return `<article class="compare-arm-definition" data-compare-arm-definition="${escapeHtml(arm.id)}">
533
+ <header class="compare-arm-header">
534
+ <p class="section-label">Arm</p>
535
+ <h3>${escapeHtml(arm.label)}</h3>
536
+ ${arm.description ? `<p class="compare-arm-description">${escapeHtml(arm.description)}</p>` : ""}
537
+ </header>
538
+ <section class="test-intent">
539
+ <p class="section-label">Task</p>
540
+ <p class="prompt-preview">${escapeHtml(arm.prompt)}</p>
541
+ ${renderAllowedCommands(arm)}
542
+ ${renderProvidedContext(arm)}
543
+ ${renderSuppliedResources(arm, true)}
544
+ </section>
545
+ <details class="criteria-panel" open>
546
+ <summary><span>Pass criteria</span><span class="criteria-summary">${criteria.count ? `${criteria.count} ${criteria.count === 1 ? "check" : "checks"}` : "Completion only"}</span></summary>
547
+ ${criteria.html ? `<div class="criterion-groups">${criteria.html}</div>` : '<p class="criteria-empty">This arm passes when it completes without a runner error.</p>'}
548
+ </details>
549
+ </article>`;
550
+ }
551
+ function comparisonDefinitionGroupId(suite, scenario) {
552
+ return `cd-${suite.name}-${scenario.name}`
553
+ .toLowerCase()
554
+ .replace(/[^a-z0-9]+/g, "-")
555
+ .replace(/^-+|-+$/g, "");
556
+ }
557
+ function renderCompareDefinition(suite, scenario) {
558
+ const arms = scenario.compare ?? [];
559
+ const group = comparisonDefinitionGroupId(suite, scenario);
560
+ const tabs = arms
561
+ .map((arm, index) => {
562
+ const id = `${group}-${arm.id}`;
563
+ return `<input type="radio" class="compare-definition-tab-input" name="${escapeHtml(group)}" id="${escapeHtml(id)}"${index === 0 ? " checked" : ""}>
564
+ <label class="compare-definition-tab" role="tab" for="${escapeHtml(id)}">${escapeHtml(arm.label)}</label>`;
565
+ })
566
+ .join("");
567
+ const rules = arms
568
+ .map((arm) => `.${group}:has(#${group}-${arm.id}:checked) [data-compare-arm-definition="${arm.id}"]{display:block}`)
569
+ .join("");
570
+ return `<style>${rules}</style>
571
+ <section class="compare-definition compare-definition-tabbed ${escapeHtml(group)}">
572
+ <p class="section-label">Arms</p>
573
+ <div class="compare-definition-tabs" role="tablist" aria-label="Comparison arm definitions">${tabs}</div>
574
+ <div class="compare-definition-grid">${arms.map(renderCompareArm).join("")}</div>
575
+ </section>`;
308
576
  }
309
- function defaultScenarioHost(suite, scenario) {
577
+ function defaultScenarioHost(suite, scenario, defaultSelectedHosts) {
310
578
  if (scenario.host && suite.hosts.includes(scenario.host)) {
311
579
  return scenario.host;
312
580
  }
313
- if (suite.hosts.includes("cursor")) {
314
- return "cursor";
581
+ const configured = suite.hosts.find((host) => defaultSelectedHosts.includes(host));
582
+ if (configured) {
583
+ return configured;
315
584
  }
316
- return suite.hosts[0] ?? "cursor";
585
+ return suite.hosts.length === 1 ? (suite.hosts[0] ?? "") : "";
317
586
  }
318
587
  function hostIsSkipped(scenario, host) {
319
588
  return Boolean(scenario.skip || (scenario.host && scenario.host !== host));
@@ -333,67 +602,159 @@ function renderHostChrome(suite, scenario, selectedHost) {
333
602
  return `<button type="button" class="host-tab" role="tab" aria-label="${escapeHtml(host)}" data-cell="${escapeHtml(`${suite.name}::${scenario.name}::${host}`)}" data-host="${escapeHtml(host)}" aria-selected="${selected ? "true" : "false"}"${skipped ? " disabled" : ""}>${escapeHtml(host)} <span class="cell-status muted" aria-hidden="true">${skipped ? "skip" : "idle"}</span></button>`;
334
603
  })
335
604
  .join("");
336
- return `<div class="host-tablist" role="tablist">${tabs}</div>`;
605
+ return `<div class="host-runs"><span class="host-runs-label">Run host</span><div class="host-tablist" role="tablist">${tabs}</div><span class="host-empty-selection" hidden>No selected host can run this test.</span></div>`;
337
606
  }
338
- function renderHostPanels(suite, selectedHost) {
607
+ function renderHostPanels(suite, scenario, selectedHost, results, canRun) {
339
608
  return suite.hosts
340
609
  .map((host) => {
341
610
  const selected = host === selectedHost;
342
- return `<div class="host-panel" data-host-panel="${escapeHtml(host)}" role="tabpanel"${selected ? "" : " hidden"}><p class="host-empty">No run yet.</p></div>`;
611
+ const result = results.find((item) => item.suite === suite.name && item.scenario === scenario.name && item.host === host);
612
+ const hostHeading = !canRun && suite.hosts.length > 1 ? `<h3>${escapeHtml(host)}</h3>` : "";
613
+ return `<div class="host-panel" data-host-panel="${escapeHtml(host)}" role="tabpanel"${canRun && !selected ? " hidden" : ""}>${hostHeading}${result?.html ?? '<p class="host-empty">No run yet.</p>'}</div>`;
343
614
  })
344
615
  .join("");
345
616
  }
346
- function renderScenarioCard(suite, scenario) {
347
- const selectedHost = defaultScenarioHost(suite, scenario);
348
- const runDisabled = hostIsSkipped(scenario, selectedHost);
349
- const compare = scenario.compare
350
- ? scenario.compare
351
- .map((arm) => `<p class="arm-note">${escapeHtml(arm.label)}: ${escapeHtml(arm.description ?? arm.prompt ?? "")}</p>`)
352
- .join("")
353
- : "";
617
+ function renderScenarioCard(suite, scenario, results, canRun, defaultSelectedHosts, selected) {
618
+ const selectedHost = defaultScenarioHost(suite, scenario, defaultSelectedHosts);
619
+ const runDisabled = !selectedHost || hostIsSkipped(scenario, selectedHost);
620
+ const hasResult = results.some((item) => item.suite === suite.name && item.scenario === scenario.name);
354
621
  const key = escapeHtml(`${suite.name}::${scenario.name}`);
355
- return `<article class="scenario-card" data-scenario-card="${key}">
356
- <div class="scenario-detail">
357
- <strong>${escapeHtml(scenario.name)}</strong>
358
- ${scenario.description ? `<p class="scenario-lede">${escapeHtml(scenario.description)}</p>` : ""}
359
- <p class="prompt-preview">${escapeHtml(scenario.prompt)}</p>
360
- <p class="rubric-line">${escapeHtml(rubricSummary(scenario))}</p>
361
- ${compare}
622
+ const criteria = renderPassCriteria(scenario);
623
+ const comparisonCriteria = scenario.compare ? renderComparisonCriteria(scenario) : undefined;
624
+ const definition = comparisonCriteria
625
+ ? `<section class="test-intent comparison-task">
626
+ <p class="section-label">Comparison task</p>
627
+ <p class="prompt-preview">${escapeHtml(scenario.prompt)}</p>
628
+ </section>
629
+ <section class="comparison-criteria">
630
+ <p class="section-label">Comparison pass criteria</p>
631
+ <p class="comparison-intro">Each arm runs independently. These checks decide whether the comparison passes.</p>
632
+ <div class="criterion-groups">${comparisonCriteria.html}</div>
633
+ </section>
634
+ ${renderCompareDefinition(suite, scenario)}`
635
+ : `<section class="test-intent">
636
+ <p class="section-label">Task</p>
637
+ <p class="prompt-preview">${escapeHtml(scenario.prompt)}</p>
638
+ ${renderAllowedCommands(scenario)}
639
+ ${renderProvidedContext(scenario)}
640
+ ${renderSuppliedResources(scenario)}
641
+ </section>
642
+ <details class="criteria-panel" open>
643
+ <summary><span>Pass criteria</span><span class="criteria-summary">${criteria.count ? `${criteria.count} ${criteria.count === 1 ? "check" : "checks"}` : "Completion only"}</span></summary>
644
+ ${criteria.html ? `<div class="criterion-groups">${criteria.html}</div>` : '<p class="criteria-empty">The scenario passes when it completes without a runner error.</p>'}
645
+ </details>`;
646
+ return `<article class="scenario-card" data-scenario-card="${key}" data-selected="${selected ? "true" : "false"}"${selected ? "" : " hidden"}>
647
+ <header class="scenario-focus-header">
648
+ <div class="scenario-heading-copy">
649
+ <p class="scenario-path">${escapeHtml(suite.name)} <span aria-hidden="true">/</span> test</p>
650
+ ${suite.description ? `<p class="suite-description">${escapeHtml(suite.description)}</p>` : ""}
651
+ <h2>${escapeHtml(scenario.name)}</h2>
652
+ ${scenario.description ? `<p class="scenario-lede">${escapeHtml(scenario.description)}</p>` : ""}
653
+ </div>
654
+ <span class="focus-verdict" data-focus-verdict="${key}" data-status="idle"><span aria-hidden="true"></span>Not run</span>
655
+ </header>
656
+ <div class="scenario-definition">
657
+ ${definition}
362
658
  </div>
363
659
  <div class="scenario-toolbar">
364
660
  ${renderHostChrome(suite, scenario, selectedHost)}
365
661
  <div class="scenario-actions">
366
- <button class="primary run-cell" data-suite="${escapeHtml(suite.name)}" data-scenario="${escapeHtml(scenario.name)}" data-host="${escapeHtml(selectedHost)}"${runDisabled ? " disabled" : ""}>Run</button>
367
- <button class="run-row" data-suite="${escapeHtml(suite.name)}" data-scenario="${escapeHtml(scenario.name)}"${scenario.skip ? " disabled" : ""}>Run selected hosts</button>
662
+ <button class="primary run-toggle run-cell" data-start-label="Start test" data-suite="${escapeHtml(suite.name)}" data-scenario="${escapeHtml(scenario.name)}" data-host="${escapeHtml(selectedHost)}"${scenario.skip || !canRun ? ' disabled data-fixed-disabled="1"' : runDisabled ? " disabled" : ""}>Start test</button>
368
663
  </div>
369
664
  </div>
370
- <div class="scenario-live" hidden data-live-row="${key}" data-live-slot="${key}">
371
- <div class="host-panels">${renderHostPanels(suite, selectedHost)}</div>
665
+ <div class="scenario-live"${hasResult ? "" : " hidden"} data-live-row="${key}" data-live-slot="${key}">
666
+ <div class="host-panels">${renderHostPanels(suite, scenario, selectedHost, results, canRun)}</div>
372
667
  </div>
373
668
  </article>`;
374
669
  }
375
- function renderSuiteSection(suite) {
376
- const cards = suite.scenarios.map((scenario) => renderScenarioCard(suite, scenario)).join("");
670
+ function renderSuiteSection(suite, results, canRun, defaultSelectedHosts, firstScenarioKey) {
671
+ const cards = suite.scenarios
672
+ .map((scenario) => renderScenarioCard(suite, scenario, results, canRun, defaultSelectedHosts, `${suite.name}::${scenario.name}` === firstScenarioKey))
673
+ .join("");
377
674
  return `
378
675
  <section class="suite" data-suite="${escapeHtml(suite.name)}">
379
- <header class="suite-header">
380
- <div class="suite-title">
381
- <h2>${escapeHtml(suite.name)}</h2>
382
- ${suite.description ? `<p class="muted">${escapeHtml(suite.description)}</p>` : ""}
383
- </div>
384
- <button class="run-suite" data-suite="${escapeHtml(suite.name)}">Run suite</button>
385
- </header>
386
676
  <div class="scenario-list">${cards}</div>
387
677
  </section>`;
388
678
  }
679
+ function renderTestNavigator(catalog, firstScenarioKey, canRun) {
680
+ const groups = catalog.suites
681
+ .map((suite) => {
682
+ const tests = suite.scenarios
683
+ .map((scenario) => {
684
+ const key = `${suite.name}::${scenario.name}`;
685
+ const selected = key === firstScenarioKey;
686
+ const status = scenario.skip ? "skipped" : "idle";
687
+ return `<button type="button" class="test-nav-item" data-select-scenario="${escapeHtml(key)}" data-status="${status}" aria-current="${selected ? "true" : "false"}">
688
+ <span class="test-nav-status" aria-hidden="true"></span>
689
+ <span class="test-nav-copy"><strong>${escapeHtml(scenario.name)}</strong>${scenario.description ? `<small>${escapeHtml(scenario.description)}</small>` : ""}</span>
690
+ </button>`;
691
+ })
692
+ .join("");
693
+ return `<section class="test-nav-suite" data-nav-suite="${escapeHtml(suite.name)}">
694
+ <header><div><h2>${escapeHtml(suite.name)}</h2>${suite.description ? `<p>${escapeHtml(suite.description)}</p>` : ""}</div><button class="run-toggle run-suite" data-start-label="Start suite" data-suite="${escapeHtml(suite.name)}"${canRun ? "" : ' disabled data-fixed-disabled="1"'}>Start suite</button></header>
695
+ <div class="test-nav-items">${tests}</div>
696
+ </section>`;
697
+ })
698
+ .join("");
699
+ const options = catalog.suites
700
+ .flatMap((suite) => suite.scenarios.map((scenario) => {
701
+ const key = `${suite.name}::${scenario.name}`;
702
+ return `<option value="${escapeHtml(key)}"${key === firstScenarioKey ? " selected" : ""}>${escapeHtml(suite.name)} / ${escapeHtml(scenario.name)}</option>`;
703
+ }))
704
+ .join("");
705
+ return `<aside class="test-navigator" aria-label="Tests">
706
+ <div class="test-navigator-heading"><div><p class="section-label">Test catalog</p><h2>${catalog.suites.reduce((sum, suite) => sum + suite.scenarios.length, 0)} tests</h2></div></div>
707
+ <label class="test-picker-label" for="test-picker">Selected test</label>
708
+ <select class="test-picker" id="test-picker">${options}</select>
709
+ <nav class="test-tree" aria-label="Test catalog">${groups}</nav>
710
+ </aside>`;
711
+ }
389
712
  function clientScript() {
390
713
  return `
391
714
  (function () {
392
- var catalog = JSON.parse(document.getElementById("catalog-data").textContent);
715
+ var bootstrap = JSON.parse(document.getElementById("bootstrap-data").textContent);
716
+ var catalog = bootstrap.catalog;
393
717
  var source = null;
394
718
  var runId = null;
719
+ var selectedRunId = bootstrap.selectedRunId || null;
395
720
  var cancelRequested = false;
721
+ var activeRunRequest = null;
722
+ var viewedRunHosts = null;
396
723
  var progress = { total: 0, completed: 0, passed: 0, failed: 0, skipped: 0, seen: {}, experiments: {} };
724
+ var selectedScenarioKey = document.querySelector("[data-scenario-card][data-selected='true']")?.getAttribute("data-scenario-card") || null;
725
+
726
+ function selectScenario(key) {
727
+ if (!key) return;
728
+ selectedScenarioKey = key;
729
+ document.querySelectorAll("[data-scenario-card]").forEach(function (card) {
730
+ var selected = card.getAttribute("data-scenario-card") === key;
731
+ card.dataset.selected = selected ? "true" : "false";
732
+ card.hidden = !selected;
733
+ });
734
+ document.querySelectorAll("[data-select-scenario]").forEach(function (item) {
735
+ item.setAttribute("aria-current", item.getAttribute("data-select-scenario") === key ? "true" : "false");
736
+ });
737
+ var picker = document.getElementById("test-picker");
738
+ if (picker) picker.value = key;
739
+ }
740
+
741
+ function syncScenarioStatus(suite, scenario) {
742
+ var key = suite + "::" + scenario;
743
+ var card = document.querySelector('[data-scenario-card="' + key + '"]');
744
+ if (!card) return;
745
+ var labels = Array.prototype.map.call(card.querySelectorAll(".cell-status"), function (node) { return node.textContent; });
746
+ var status = labels.indexOf("failed") !== -1 ? "failed"
747
+ : labels.some(function (label) { return label === "running" || label === "starting" || label === "cancelling"; }) ? "running"
748
+ : labels.indexOf("passed") !== -1 ? "passed"
749
+ : labels.length > 0 && labels.every(function (label) { return label === "skip" || label === "skipped" || label === "cancelled"; }) ? "skipped"
750
+ : "idle";
751
+ document.querySelectorAll('[data-select-scenario="' + key + '"]').forEach(function (item) { item.dataset.status = status; });
752
+ var verdict = document.querySelector('[data-focus-verdict="' + key + '"]');
753
+ if (verdict) {
754
+ verdict.dataset.status = status;
755
+ verdict.lastChild.textContent = status === "idle" ? "Not run" : status.charAt(0).toUpperCase() + status.slice(1);
756
+ }
757
+ }
397
758
 
398
759
  function selectedHosts() {
399
760
  return Array.prototype.map.call(document.querySelectorAll("[data-host-toggle]:checked"), function (box) {
@@ -401,6 +762,80 @@ function clientScript() {
401
762
  });
402
763
  }
403
764
 
765
+ function updateHostSelectionControls() {
766
+ var hasSelectedHost = selectedHosts().length > 0;
767
+ syncScenarioHostChoices();
768
+ document.querySelectorAll(".run-toggle").forEach(function (button) {
769
+ if (button.dataset.runAction !== "stop") {
770
+ var hasTarget = button.classList.contains("run-cell")
771
+ ? !!button.getAttribute("data-host")
772
+ : hasSelectedHost;
773
+ button.disabled = !hasTarget || button.dataset.fixedDisabled === "1";
774
+ }
775
+ });
776
+ }
777
+
778
+ function syncScenarioHostChoices() {
779
+ var hosts = Array.isArray(viewedRunHosts) ? viewedRunHosts : selectedHosts();
780
+ var isFiltered = hosts.length > 0;
781
+ document.querySelectorAll("[data-scenario-card]").forEach(function (card) {
782
+ var tabs = Array.prototype.slice.call(card.querySelectorAll(".host-tab"));
783
+ if (tabs.length === 0) return;
784
+ tabs.forEach(function (tab) {
785
+ var eligible = !tab.disabled && (!isFiltered || hosts.indexOf(tab.getAttribute("data-host")) !== -1);
786
+ tab.hidden = isFiltered && !eligible;
787
+ });
788
+ var current = tabs.find(function (tab) { return tab.getAttribute("aria-selected") === "true" && !tab.hidden && !tab.disabled; });
789
+ var available = tabs.find(function (tab) { return !tab.hidden && !tab.disabled; });
790
+ if (!current && isFiltered && available) {
791
+ selectHostTab(card, available.getAttribute("data-host"));
792
+ current = available;
793
+ }
794
+ var empty = card.querySelector(".host-empty-selection");
795
+ if (empty) empty.hidden = !!available;
796
+ if (!available) {
797
+ tabs.forEach(function (tab) { tab.setAttribute("aria-selected", "false"); });
798
+ card.querySelectorAll("[data-host-panel]").forEach(function (panel) { panel.hidden = true; });
799
+ var run = card.querySelector("button.run-cell");
800
+ if (run) {
801
+ run.setAttribute("data-host", "");
802
+ run.disabled = true;
803
+ }
804
+ }
805
+ });
806
+ }
807
+
808
+ function scopedRunControl(request) {
809
+ if (!request) return null;
810
+ if (request.scenario) {
811
+ return document.querySelector('.run-cell[data-suite="' + request.suite + '"][data-scenario="' + request.scenario + '"]');
812
+ }
813
+ if (request.suite) return document.querySelector('.run-suite[data-suite="' + request.suite + '"]');
814
+ return null;
815
+ }
816
+
817
+ function setRunControls(status, request) {
818
+ activeRunRequest = status === "idle" ? null : request || activeRunRequest;
819
+ document.querySelectorAll(".run-toggle").forEach(function (button) {
820
+ button.textContent = button.dataset.startLabel || "Start run";
821
+ button.dataset.runAction = "start";
822
+ button.classList.remove("danger");
823
+ });
824
+ updateHostSelectionControls();
825
+ if (status === "idle") return;
826
+ document.querySelectorAll(".run-toggle").forEach(function (button) { button.disabled = true; });
827
+ var controls = [document.getElementById("run-selection"), scopedRunControl(activeRunRequest)];
828
+ controls.forEach(function (button) {
829
+ if (!button) return;
830
+ button.dataset.runAction = "stop";
831
+ button.textContent = status === "cancelling"
832
+ ? "Stopping…"
833
+ : button.id === "run-selection" ? "Stop run" : (button.classList.contains("run-suite") ? "Stop suite" : "Stop test");
834
+ button.classList.add("danger");
835
+ button.disabled = status === "cancelling";
836
+ });
837
+ }
838
+
404
839
  function parallelHosts() {
405
840
  return document.getElementById("parallel-hosts").checked;
406
841
  }
@@ -410,7 +845,7 @@ function clientScript() {
410
845
  }
411
846
 
412
847
  function runnableTargets(body) {
413
- var requestedHosts = body.hosts && body.hosts.length ? body.hosts : catalog.defaultSelectedHosts;
848
+ var requestedHosts = Array.isArray(body.hosts) ? body.hosts : catalog.defaultSelectedHosts;
414
849
  var total = 0;
415
850
  catalog.suites.forEach(function (suite) {
416
851
  if (body.suite && suite.name !== body.suite) {
@@ -702,11 +1137,129 @@ function clientScript() {
702
1137
  markRunningIdle();
703
1138
  }
704
1139
 
705
- function setCancelButton(label, disabled) {
706
- var button = document.getElementById("cancel-run");
707
- button.textContent = label;
708
- button.disabled = disabled;
709
- }
1140
+ function resetViewerCards() {
1141
+ closeAllChats();
1142
+ document.querySelectorAll("[data-host-panel]").forEach(function (panel) {
1143
+ panel.innerHTML = '<p class="host-empty">No run yet.</p>';
1144
+ });
1145
+ document.querySelectorAll(".scenario-live").forEach(function (live) { live.hidden = true; });
1146
+ document.querySelectorAll(".cell-status").forEach(function (status) {
1147
+ if (status.textContent !== "skip") {
1148
+ status.textContent = "idle";
1149
+ status.className = "cell-status muted";
1150
+ }
1151
+ });
1152
+ catalog.suites.forEach(function (suite) {
1153
+ suite.scenarios.forEach(function (scenario) { syncScenarioStatus(suite.name, scenario.name); });
1154
+ });
1155
+ }
1156
+
1157
+ function runLabel(run) {
1158
+ var scope = run.request && (run.request.scenario || run.request.suite) || "all tests";
1159
+ var time = new Date(run.startedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1160
+ return time + " · " + scope + " · " + run.status;
1161
+ }
1162
+
1163
+ function renderRunHistory(runs) {
1164
+ bootstrap.runs = runs;
1165
+ var select = document.getElementById("run-history");
1166
+ select.replaceChildren();
1167
+ var idle = document.createElement("option");
1168
+ idle.value = "";
1169
+ idle.textContent = "Tests · no run selected";
1170
+ select.appendChild(idle);
1171
+ runs.forEach(function (run) {
1172
+ var option = document.createElement("option");
1173
+ option.value = run.id;
1174
+ option.textContent = runLabel(run);
1175
+ select.appendChild(option);
1176
+ });
1177
+ select.value = selectedRunId || "";
1178
+ }
1179
+
1180
+ function completedRunBanner(run) {
1181
+ if (run.status === "cancelled") return "Run cancelled.";
1182
+ if (run.status !== "completed") return "Run " + run.id + " · " + run.status + ".";
1183
+ var counts = (run.reports || []).reduce(function (total, report) {
1184
+ total.passed += report.passed || 0;
1185
+ total.failed += report.failed || 0;
1186
+ total.skipped += report.skipped || 0;
1187
+ return total;
1188
+ }, { passed: 0, failed: 0, skipped: 0 });
1189
+ return "Run finished. " + counts.passed + " passed. " + counts.failed + " failed. " + counts.skipped + " skipped.";
1190
+ }
1191
+
1192
+ async function refreshRunHistory() {
1193
+ if (!bootstrap.capabilities.canRun) return;
1194
+ var response = await fetch("/api/runs");
1195
+ if (!response.ok) return;
1196
+ renderRunHistory(await response.json());
1197
+ }
1198
+
1199
+ async function selectRun(id) {
1200
+ selectedRunId = id || null;
1201
+ viewedRunHosts = null;
1202
+ if (source) { source.close(); source = null; }
1203
+ runId = null;
1204
+ cancelRequested = false;
1205
+ resetViewerCards();
1206
+ if (!selectedRunId) {
1207
+ setRunBanner("Choose tests to run.");
1208
+ setRunControls("idle");
1209
+ renderRunHistory(bootstrap.runs || []);
1210
+ return;
1211
+ }
1212
+ var response = await fetch("/api/runs/" + selectedRunId);
1213
+ if (!response.ok) return;
1214
+ var payload = await response.json();
1215
+ viewedRunHosts = Array.isArray(payload.run.request && payload.run.request.hosts) ? payload.run.request.hosts : null;
1216
+ syncScenarioHostChoices();
1217
+ (payload.fragments || []).forEach(function (fragment) {
1218
+ var card = document.querySelector('[data-scenario-card="' + fragment.suite + "::" + fragment.scenario + '"]');
1219
+ var panel = card && card.querySelector('[data-host-panel="' + fragment.host + '"]');
1220
+ if (!card || !panel) return;
1221
+ panel.innerHTML = fragment.html;
1222
+ var live = card.querySelector(".scenario-live");
1223
+ if (live) live.hidden = false;
1224
+ markCell(fragment, fragment.skipped ? "skipped" : fragment.passed ? "passed" : "failed", fragment.skipped ? "status-skipped" : fragment.passed ? "status-passed" : "status-failed");
1225
+ });
1226
+ var preferred = (payload.fragments || []).find(function (fragment) { return !fragment.skipped && !fragment.passed; }) || (payload.fragments || [])[0];
1227
+ if (preferred) selectScenario(preferred.suite + "::" + preferred.scenario, false);
1228
+ if (payload.run.status === "cancelled") {
1229
+ markRunTargets(payload.run.request || {}, "cancelled", "status-skipped");
1230
+ }
1231
+ setRunBanner(completedRunBanner(payload.run));
1232
+ renderRunHistory((bootstrap.runs || []).map(function (item) { return item.id === payload.run.id ? payload.run : item; }));
1233
+ if (payload.run.status === "running" || payload.run.status === "cancelling") {
1234
+ runId = payload.run.id;
1235
+ setRunControls(payload.run.status === "cancelling" ? "cancelling" : "running", payload.run.request);
1236
+ source = new EventSource("/api/runs/" + runId + "/events");
1237
+ source.onmessage = function (message) { handleEvent(JSON.parse(message.data)); };
1238
+ } else {
1239
+ setRunControls("idle");
1240
+ }
1241
+ }
1242
+
1243
+ function requestRunStop() {
1244
+ if (!runId || cancelRequested) return;
1245
+ cancelRequested = true;
1246
+ setRunBanner("Stopping the run.");
1247
+ setRunControls("cancelling", activeRunRequest);
1248
+ cancelOpenChats();
1249
+ fetch("/api/runs/" + runId + "/cancel", { method: "POST" }).then(function (response) {
1250
+ if (!response.ok && cancelRequested) {
1251
+ setRunBanner("Stop failed.");
1252
+ cancelRequested = false;
1253
+ setRunControls("running", activeRunRequest);
1254
+ }
1255
+ }).catch(function () {
1256
+ if (cancelRequested) {
1257
+ setRunBanner("Stop failed.");
1258
+ cancelRequested = false;
1259
+ setRunControls("running", activeRunRequest);
1260
+ }
1261
+ });
1262
+ }
710
1263
 
711
1264
  function showCancelledNote(article, text) {
712
1265
  if (!article) {
@@ -1238,7 +1791,7 @@ function clientScript() {
1238
1791
  return { passed: false, line: gate.metric + ": one arm did not report a value. Fail." };
1239
1792
  }
1240
1793
  var passed = winnerValue < loserValue;
1241
- return { passed: passed, line: winner.label + " must beat " + loser.label + " on " + gate.metric + " (" + winnerValue + " vs " + loserValue + "). " + (passed ? "Pass." : "Fail.") };
1794
+ return { passed: passed, line: winner.label + " must use fewer " + gate.metric + " than " + loser.label + " (" + winnerValue + " vs " + loserValue + "). " + (passed ? "Pass." : "Fail.") };
1242
1795
  });
1243
1796
  }
1244
1797
 
@@ -1272,7 +1825,6 @@ function clientScript() {
1272
1825
  summarizeMetric(rows, "turns", "Turns"),
1273
1826
  summarizeMetric(rows, "tokens", "Tokens"),
1274
1827
  summarizeMetric(rows, "tools", "Tools"),
1275
- summarizeMetric(rows, "durationMs", "Duration"),
1276
1828
  ];
1277
1829
  var gateLines = summarizeGates(rows, scenario.gates);
1278
1830
  var existing = wrap.querySelector(".compare-winners");
@@ -1312,6 +1864,7 @@ function clientScript() {
1312
1864
  node.setAttribute("data-status", label);
1313
1865
  }
1314
1866
  });
1867
+ syncScenarioStatus(event.suite, event.scenario);
1315
1868
  }
1316
1869
 
1317
1870
  function metricPart(count, one, many) {
@@ -1420,10 +1973,11 @@ function clientScript() {
1420
1973
  return;
1421
1974
  }
1422
1975
  setRunBanner("Run " + event.runId + " started.");
1423
- setCancelButton("Cancel run", false);
1976
+ setRunControls("running", activeRunRequest);
1424
1977
  return;
1425
1978
  }
1426
1979
  if (event.type === "run_finished") {
1980
+ var wasCancelled = cancelRequested;
1427
1981
  finishProgress(event);
1428
1982
  hideAllRunning();
1429
1983
  sealAllChats();
@@ -1435,13 +1989,16 @@ function clientScript() {
1435
1989
  "Run finished. " + progress.passed + " passed. " + progress.failed + " failed. " + progress.skipped + " skipped."
1436
1990
  );
1437
1991
  }
1438
- setCancelButton("Cancel run", true);
1992
+ setRunControls("idle");
1439
1993
  cancelRequested = false;
1440
1994
  runId = null;
1441
1995
  if (source) {
1442
1996
  source.close();
1443
1997
  source = null;
1444
1998
  }
1999
+ void refreshRunHistory().then(function () {
2000
+ if (!wasCancelled) return selectRun(event.runId);
2001
+ });
1445
2002
  return;
1446
2003
  }
1447
2004
  if (cancelRequested) {
@@ -1458,6 +2015,21 @@ function clientScript() {
1458
2015
  }
1459
2016
  return;
1460
2017
  }
2018
+ if (event.type === "scenario_result") {
2019
+ fetch("/api/runs/" + (runId || selectedRunId)).then(function (response) {
2020
+ return response.ok ? response.json() : null;
2021
+ }).then(function (payload) {
2022
+ if (!payload || selectedRunId !== payload.run.id) return;
2023
+ var fragment = (payload.fragments || []).find(function (item) {
2024
+ return item.suite === event.suite && item.scenario === event.scenario && item.host === event.host;
2025
+ });
2026
+ if (!fragment) return;
2027
+ var card = scenarioCard(event);
2028
+ var panel = card && card.querySelector('[data-host-panel="' + event.host + '"]');
2029
+ if (panel) panel.innerHTML = fragment.html;
2030
+ }).catch(function () {});
2031
+ return;
2032
+ }
1461
2033
  if (event.type === "cell_started") {
1462
2034
  markCell(event, "running", "status-skipped");
1463
2035
  startChat(event);
@@ -1568,7 +2140,7 @@ function clientScript() {
1568
2140
  }
1569
2141
 
1570
2142
  function markRunTargets(body, label, statusClass) {
1571
- var hosts = body.hosts && body.hosts.length ? body.hosts : catalog.defaultSelectedHosts;
2143
+ var hosts = Array.isArray(body.hosts) ? body.hosts : catalog.defaultSelectedHosts;
1572
2144
  catalog.suites.forEach(function (suite) {
1573
2145
  if (body.suite && suite.name !== body.suite) {
1574
2146
  return;
@@ -1591,6 +2163,11 @@ function clientScript() {
1591
2163
  }
1592
2164
 
1593
2165
  async function startRun(body) {
2166
+ if (!bootstrap.capabilities.canRun) return;
2167
+ if (Array.isArray(body.hosts) && body.hosts.length === 0) {
2168
+ setRunBanner("Select at least one host.");
2169
+ return;
2170
+ }
1594
2171
  if (runId) {
1595
2172
  setRunBanner(
1596
2173
  cancelRequested
@@ -1599,6 +2176,11 @@ function clientScript() {
1599
2176
  );
1600
2177
  return;
1601
2178
  }
2179
+ resetViewerCards();
2180
+ activeRunRequest = body;
2181
+ viewedRunHosts = Array.isArray(body.hosts) ? body.hosts : null;
2182
+ syncScenarioHostChoices();
2183
+ setRunControls("running", body);
1602
2184
  resetProgress(body);
1603
2185
  markRunTargets(body, "starting", "status-skipped");
1604
2186
  var firstCard = document.querySelector(
@@ -1618,6 +2200,7 @@ function clientScript() {
1618
2200
  delete card.dataset.followHost;
1619
2201
  });
1620
2202
  if (firstCard) {
2203
+ selectScenario(firstCard.getAttribute("data-scenario-card"), false);
1621
2204
  var live = firstCard.querySelector(".scenario-live");
1622
2205
  if (live) {
1623
2206
  live.hidden = false;
@@ -1642,12 +2225,15 @@ function clientScript() {
1642
2225
  progress = { total: 0, completed: 0, passed: 0, failed: 0, skipped: 0, seen: {} };
1643
2226
  renderProgress();
1644
2227
  markRunTargets(body, "idle", "muted");
2228
+ setRunControls("idle");
1645
2229
  return;
1646
2230
  }
1647
2231
  runId = payload.runId;
2232
+ selectedRunId = runId;
2233
+ await refreshRunHistory();
1648
2234
  cancelRequested = false;
1649
2235
  setRunBanner("Run " + runId + " started.");
1650
- setCancelButton("Cancel run", false);
2236
+ setRunControls("running", body);
1651
2237
  if (source) {
1652
2238
  source.close();
1653
2239
  }
@@ -1665,39 +2251,29 @@ function clientScript() {
1665
2251
  } catch (error) {
1666
2252
  setRunBanner(error instanceof Error ? error.message : String(error));
1667
2253
  markRunTargets(body, "idle", "muted");
2254
+ setRunControls("idle");
1668
2255
  }
1669
2256
  }
1670
2257
 
1671
- document.getElementById("run-selection").addEventListener("click", function () {
2258
+ document.getElementById("run-selection").addEventListener("click", function (event) {
2259
+ if (event.currentTarget.dataset.runAction === "stop") return requestRunStop();
1672
2260
  startRun({ hosts: selectedHosts(), parallelHosts: parallelHosts() });
1673
2261
  });
1674
- document.getElementById("cancel-run").addEventListener("click", function () {
1675
- if (!runId || cancelRequested) {
1676
- return;
1677
- }
1678
- cancelRequested = true;
1679
- setRunBanner("Cancelling the run.");
1680
- setCancelButton("Cancelling", true);
1681
- cancelOpenChats();
1682
- fetch("/api/runs/" + runId + "/cancel", { method: "POST" }).then(function (response) {
1683
- if (!response.ok && cancelRequested) {
1684
- setRunBanner("Cancel failed.");
1685
- setCancelButton("Cancel run", false);
1686
- cancelRequested = false;
1687
- }
1688
- }).catch(function () {
1689
- if (cancelRequested) {
1690
- setRunBanner("Cancel failed.");
1691
- setCancelButton("Cancel run", false);
1692
- cancelRequested = false;
1693
- }
1694
- });
1695
- });
2262
+ document.querySelectorAll("[data-host-toggle]").forEach(function (toggle) {
2263
+ toggle.addEventListener("change", updateHostSelectionControls);
2264
+ });
2265
+ document.getElementById("run-history").addEventListener("change", function (event) {
2266
+ void selectRun(event.target.value);
2267
+ });
1696
2268
  document.body.addEventListener("click", function (event) {
1697
2269
  var button = event.target.closest("button");
1698
2270
  if (!button) {
1699
2271
  return;
1700
2272
  }
2273
+ if (button.classList.contains("test-nav-item")) {
2274
+ selectScenario(button.getAttribute("data-select-scenario"), false);
2275
+ return;
2276
+ }
1701
2277
  if (button.classList.contains("host-tab")) {
1702
2278
  var card = button.closest("[data-scenario-card]");
1703
2279
  if (card) {
@@ -1706,6 +2282,7 @@ function clientScript() {
1706
2282
  }
1707
2283
  }
1708
2284
  if (button.classList.contains("run-cell")) {
2285
+ if (button.dataset.runAction === "stop") return requestRunStop();
1709
2286
  startRun({
1710
2287
  suite: button.getAttribute("data-suite"),
1711
2288
  scenario: button.getAttribute("data-scenario"),
@@ -1713,67 +2290,103 @@ function clientScript() {
1713
2290
  parallelHosts: false,
1714
2291
  });
1715
2292
  }
1716
- if (button.classList.contains("run-row")) {
1717
- startRun({
1718
- suite: button.getAttribute("data-suite"),
1719
- scenario: button.getAttribute("data-scenario"),
1720
- hosts: selectedHosts(),
1721
- parallelHosts: parallelHosts(),
1722
- });
1723
- }
1724
2293
  if (button.classList.contains("run-suite")) {
2294
+ if (button.dataset.runAction === "stop") return requestRunStop();
1725
2295
  startRun({
1726
2296
  suite: button.getAttribute("data-suite"),
1727
2297
  hosts: selectedHosts(),
1728
2298
  parallelHosts: parallelHosts(),
1729
2299
  });
1730
2300
  }
2301
+ });
2302
+ document.getElementById("test-picker")?.addEventListener("change", function (event) {
2303
+ selectScenario(event.target.value, false);
2304
+ });
2305
+
2306
+ renderRunHistory(bootstrap.runs || []);
2307
+ selectScenario(selectedScenarioKey, false);
2308
+ updateHostSelectionControls();
2309
+ if (selectedRunId && bootstrap.capabilities.canRun) void selectRun(selectedRunId);
2310
+ if (bootstrap.capabilities.canRun) setInterval(function () { void refreshRunHistory(); }, 1000);
2311
+ })();
2312
+ `;
2313
+ }
2314
+ function staticNavigationScript() {
2315
+ return `
2316
+ (function () {
2317
+ function selectScenario(key) {
2318
+ document.querySelectorAll("[data-scenario-card]").forEach(function (card) {
2319
+ var selected = card.getAttribute("data-scenario-card") === key;
2320
+ card.dataset.selected = selected ? "true" : "false";
2321
+ card.hidden = !selected;
2322
+ });
2323
+ document.querySelectorAll("[data-select-scenario]").forEach(function (item) {
2324
+ item.setAttribute("aria-current", item.getAttribute("data-select-scenario") === key ? "true" : "false");
2325
+ });
2326
+ var picker = document.getElementById("test-picker");
2327
+ if (picker) picker.value = key;
2328
+ }
2329
+ document.body.addEventListener("click", function (event) {
2330
+ var item = event.target.closest("[data-select-scenario]");
2331
+ if (item) selectScenario(item.getAttribute("data-select-scenario"));
2332
+ });
2333
+ document.getElementById("test-picker")?.addEventListener("change", function (event) {
2334
+ selectScenario(event.target.value);
1731
2335
  });
1732
2336
  })();
1733
2337
  `;
1734
2338
  }
1735
- export function renderViewerPage(catalog) {
2339
+ export function renderViewerPage(input, options = {}) {
2340
+ const bootstrap = "catalog" in input ? input : { catalog: input, runs: [], capabilities: { canRun: true } };
2341
+ const catalog = bootstrap.catalog;
2342
+ const canRun = bootstrap.capabilities.canRun;
2343
+ const firstScenarioKey = catalog.suites[0]?.scenarios[0]
2344
+ ? `${catalog.suites[0].name}::${catalog.suites[0].scenarios[0].name}`
2345
+ : "";
1736
2346
  const hostToggles = Array.from(new Set(catalog.suites.flatMap((suite) => suite.hosts)))
1737
2347
  .map((host) => {
1738
2348
  const checked = catalog.defaultSelectedHosts.includes(host) ? " checked" : "";
1739
2349
  return `<label><input type="checkbox" data-host-toggle value="${escapeHtml(host)}"${checked}>${escapeHtml(host)}</label>`;
1740
2350
  })
1741
2351
  .join("");
1742
- const suites = catalog.suites.map(renderSuiteSection).join("\n");
2352
+ const suites = catalog.suites
2353
+ .map((suite) => renderSuiteSection(suite, options.results ?? [], canRun, catalog.defaultSelectedHosts, firstScenarioKey))
2354
+ .join("\n");
2355
+ const navigator = renderTestNavigator(catalog, firstScenarioKey, canRun);
2356
+ const historyOptions = [
2357
+ `<option value="">Tests · no run selected</option>`,
2358
+ ...bootstrap.runs.map((run) => {
2359
+ const selected = run.id === bootstrap.selectedRunId ? " selected" : "";
2360
+ return `<option value="${escapeHtml(run.id)}"${selected}>${escapeHtml(run.id)} · ${escapeHtml(run.status)}</option>`;
2361
+ }),
2362
+ ].join("");
1743
2363
  return `<!DOCTYPE html>
1744
2364
  <html lang="en">
1745
2365
  <head>
1746
2366
  <meta charset="utf-8">
1747
2367
  <meta name="viewport" content="width=device-width, initial-scale=1">
1748
- <title>agent-test viewer</title>
2368
+ <title>agent-test ${canRun ? "viewer" : "report"}</title>
1749
2369
  <style>
1750
- ${reportCss()}
2370
+ ${options.baseCss ?? ""}
1751
2371
  ${viewerCss()}
1752
2372
  </style>
1753
2373
  </head>
1754
2374
  <body>
1755
- <main>
1756
- <header class="report-header">
1757
- <p class="brand">agent-test</p>
1758
- <h1>Suite viewer</h1>
1759
- <p class="lede">This page lists every scenario before a run.</p>
1760
- <p class="warn">Run starts a live host agent.</p>
2375
+ <main class="viewer-shell">
2376
+ <header class="viewer-topbar">
2377
+ <div class="viewer-identity"><p class="brand">agent-test</p><h1>${canRun ? "Test viewer" : "Run report"}</h1></div>
2378
+ <div class="run-context">
2379
+ <label class="toolbar-label" for="run-history">Viewing run</label>
2380
+ <select class="run-history" id="run-history" aria-label="Run history"${canRun ? "" : " disabled"}>${historyOptions}</select>
2381
+ </div>
2382
+ <p class="run-banner${canRun ? "" : " lede"}" id="run-banner" aria-live="polite">${canRun ? "Choose a test to inspect or run." : escapeHtml(options.headerLede ?? "Read-only report.")}</p>
1761
2383
  </header>
1762
- <section class="toolbar">
1763
- <div class="toolbar-block">
1764
- <p class="toolbar-label">Hosts</p>
1765
- <div class="host-toggles">${hostToggles}</div>
1766
- </div>
1767
- <div class="toolbar-block">
1768
- <p class="toolbar-label">Run order</p>
1769
- <label><input type="checkbox" id="parallel-hosts"> Run hosts together</label>
1770
- </div>
1771
- <div class="toolbar-actions">
1772
- <button class="primary" id="run-selection">Run selection</button>
1773
- <button class="danger" id="cancel-run" disabled>Cancel run</button>
1774
- </div>
1775
- <p class="run-banner" id="run-banner"></p>
1776
- </section>
2384
+ <div class="viewer-command-row">
2385
+ <div class="toolbar-block"><p class="toolbar-label">Hosts</p><div class="host-toggles">${hostToggles}</div></div>
2386
+ <label class="parallel-control"><input type="checkbox" id="parallel-hosts" checked> Run hosts together</label>
2387
+ <button class="primary run-toggle" id="run-selection" data-start-label="Start run"${canRun ? "" : ' disabled data-fixed-disabled="1"'}>Start run</button>
2388
+ </div>
2389
+ ${options.overviewHtml ?? ""}
1777
2390
  <section class="run-progress" id="run-progress" aria-live="polite" hidden>
1778
2391
  <div class="run-progress-head">
1779
2392
  <p class="run-progress-title">0 of 0 tests finished</p>
@@ -1788,11 +2401,15 @@ ${viewerCss()}
1788
2401
  <span class="progress-remaining">Remaining <strong>0</strong></span>
1789
2402
  </div>
1790
2403
  </section>
1791
- ${suites}
2404
+ <div class="viewer-workspace">
2405
+ ${navigator}
2406
+ <section class="test-stage" aria-label="Selected test">${suites}</section>
2407
+ </div>
1792
2408
  </main>
1793
- <script type="application/json" id="catalog-data">${embedJson(catalog)}</script>
1794
- <script>
1795
- ${clientScript()}
2409
+ <script type="application/json" id="catalog-data">${embedJson(catalog)}</script>
2410
+ <script type="application/json" id="bootstrap-data">${embedJson(bootstrap)}</script>
2411
+ <script type="text/javascript">
2412
+ ${canRun ? clientScript() : staticNavigationScript()}
1796
2413
  </script>
1797
2414
  </body>
1798
2415
  </html>