@ihyeon/flow-qa 0.2.9 → 0.4.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.
@@ -4,7 +4,7 @@
4
4
  const i18n = window.FlowQAI18n;
5
5
  const t = i18n.t;
6
6
  const names = { scenario: 'nav.scenario', library: 'nav.library', history: 'nav.history', settings: 'nav.settings' };
7
- const app = { state: null, connected: false, displayTargets: [], draft: null, script: '', savedFile: '', savedHash: '', origin: 'none', sourceFlow: '', boundSource: null, stale: false, dirty: false, busy: false, planning: false, planRequest: 0, stopping: false, report: null, reportMarkdown: '', selectedRun: '', view: 'scenario' };
7
+ const app = { state: null, connected: false, displayTargets: [], draft: null, selectedCaseIds: new Set(), script: '', savedFile: '', savedHash: '', origin: 'none', sourceFlow: '', boundSource: null, stale: false, dirty: false, busy: false, planning: false, planRequest: 0, stopping: false, report: null, reportMarkdown: '', selectedRun: '', comparison: null, comparisonRequest: 0, comparisonBusy: false, comparisonStatus: 'comparison.selectBoth', view: 'scenario' };
8
8
 
9
9
  function el(tag, className, value) {
10
10
  const node = document.createElement(tag);
@@ -53,7 +53,7 @@
53
53
  });
54
54
  $('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[name]) });
55
55
  $('sidebar').classList.remove('nav-open'); $('nav-toggle').setAttribute('aria-expanded', 'false');
56
- if (name === 'history') renderHistory();
56
+ if (name === 'history') { renderHistory(); renderComparisonOptions(); }
57
57
  }
58
58
  function suitePlatform() { return app.draft?.platform || 'web'; }
59
59
  function targetPlatform(target) { return target?.kind === 'mobile' ? 'mobile' : 'web'; }
@@ -61,6 +61,37 @@
61
61
  function targetLabel(target) { return target?.kind === 'mobile' ? `${target.platform === 'ios' ? 'iOS' : 'Android'} · ${target.bundleId} · ${target.device}` : target?.url || ''; }
62
62
  function targetDisplay(target) { return `${target?.name || t('app.noTarget')} · ${targetLabel(target)}`; }
63
63
  function compatibleTarget() { const target = selectedTarget(); return target && targetPlatform(target) === suitePlatform(); }
64
+ function selectedCases() { return (app.draft?.cases || []).filter((item) => app.selectedCaseIds.has(item.id)); }
65
+ function selectionScope(selection) {
66
+ return selection && Array.isArray(selection.caseIds) && Number.isInteger(selection.totalCases) && selection.caseIds.length < selection.totalCases
67
+ ? t('app.selectedScope', { selected: selection.caseIds.length, total: selection.totalCases }) : '';
68
+ }
69
+ function renderCaseSelection() {
70
+ const host = $('case-selection-list'); replace(host);
71
+ for (const item of app.draft?.cases || []) {
72
+ const label = el('label', 'case-selection-item');
73
+ const input = el('input'); input.type = 'checkbox'; input.dataset.caseId = item.id; input.checked = app.selectedCaseIds.has(item.id);
74
+ label.append(input, el('span', '', `${item.id} · ${item.title}`)); host.append(label);
75
+ }
76
+ updateCaseSelection();
77
+ }
78
+ function updateCaseSelection() {
79
+ const total = app.draft?.cases?.length || 0;
80
+ $('case-selection-summary').textContent = t('scenario.selectionSummary', { selected: selectedCases().length, total });
81
+ const disabled = !app.draft || app.busy || !!app.state?.active;
82
+ for (const input of $('case-selection-list').querySelectorAll('input[data-case-id]')) {
83
+ input.checked = app.selectedCaseIds.has(input.dataset.caseId);
84
+ input.disabled = disabled;
85
+ }
86
+ $('select-all-cases').disabled = disabled || selectedCases().length === total;
87
+ $('clear-case-selection').disabled = disabled || !app.selectedCaseIds.size;
88
+ }
89
+ function changeCaseSelection(ids) {
90
+ if (!app.draft || app.busy || app.state?.active) return;
91
+ const next = new Set(ids);
92
+ if (next.size === app.selectedCaseIds.size && [...next].every((id) => app.selectedCaseIds.has(id))) return;
93
+ app.selectedCaseIds = next; $('review-checkbox').checked = false; readiness();
94
+ }
64
95
  function canDownloadSavedCases() {
65
96
  return Boolean(app.draft && app.script && app.savedFile && app.savedHash && !app.dirty && !app.stale && !app.busy && !hasUnsavedScenario());
66
97
  }
@@ -68,7 +99,8 @@
68
99
  function readiness() {
69
100
  const active = app.state?.active;
70
101
  const ready = !!app.draft && !!app.script && !app.stale && !app.dirty;
71
- $('run-btn').disabled = !app.connected || !ready || !app.savedFile || !app.savedHash || !$('review-checkbox').checked || !compatibleTarget() || !!active || app.busy;
102
+ $('run-btn').disabled = !app.connected || !ready || !app.savedFile || !app.savedHash || !selectedCases().length || !$('review-checkbox').checked || !compatibleTarget() || !!active || app.busy;
103
+ updateCaseSelection();
72
104
  $('save-btn').disabled = !ready || app.busy;
73
105
  $('suite-download').disabled = !canDownloadSavedCases();
74
106
  $('flow-download').disabled = !canDownloadSourceMarkdown();
@@ -95,12 +127,13 @@
95
127
  : app.dirty ? 'app.dirty'
96
128
  : !ready ? 'app.needValidated'
97
129
  : !app.savedFile || !app.savedHash ? 'app.needSaved'
130
+ : !selectedCases().length ? 'app.needCaseSelection'
98
131
  : !$('target-select').value ? 'app.needTarget'
99
132
  : !compatibleTarget() ? 'app.platformMismatch'
100
133
  : !$('review-checkbox').checked ? 'app.needReview' : 'app.ready');
101
- const cases = app.draft?.cases || [];
134
+ const cases = selectedCases();
102
135
  const blocked = cases.filter((item) => item.questions?.length || (item.steps.some((step) => ['act', 'assertAgent'].includes(step.type)) && !app.state?.models?.agent?.keyReady)).length;
103
- $('case-readiness').textContent = ready ? t('app.caseReadiness', { total: cases.length, ready: cases.length - blocked, blocked, hint: blocked ? t('app.blockedHint') : '' }) : '';
136
+ $('case-readiness').textContent = ready ? t(cases.length < (app.draft?.cases.length || 0) ? 'app.selectedCaseReadiness' : 'app.caseReadiness', { total: cases.length, ready: cases.length - blocked, blocked, hint: blocked ? t('app.blockedHint') : '' }) : '';
104
137
  const pill = $('draft-status');
105
138
  pill.textContent = t(app.stale ? 'app.regenerate' : app.dirty ? 'app.revalidate' : ready ? 'app.validated' : 'scenario.noDraft');
106
139
  pill.className = 'status-pill' + (ready ? ' ready' : app.stale || app.dirty ? ' dirty' : '');
@@ -169,7 +202,8 @@
169
202
  $('suite-editor').value = JSON.stringify(suite, null, 2);
170
203
  $('scenario-platform').value = suite.platform || 'web';
171
204
  $('review-checkbox').checked = false;
172
- summarize(suite); readiness();
205
+ app.selectedCaseIds = new Set((suite.cases || []).map((item) => item.id));
206
+ summarize(suite); renderCaseSelection(); readiness();
173
207
  }
174
208
  function targetOptions() {
175
209
  const selected = $('target-select').value;
@@ -236,9 +270,102 @@
236
270
  if (!matchingRuns.length) { host.append(el('p', 'empty', t(query ? 'app.historyNoSearchMatches' : 'app.historyNoStatusMatches'))); return; }
237
271
  for (const run of matchingRuns) {
238
272
  const row = el('div', 'list-row'); const copy = el('div'); copy.append(el('strong', '', `${run.suiteId} · ${i18n.status(run.status)}`), el('small', '', t('app.runMeta', { date: formatDate(run.startedAt), target: targetDisplay(run.target), passed: run.passedCount ?? 0, total: run.caseCount ?? 0, id: run.runId })));
273
+ const scope = selectionScope(run.selection); if (scope) copy.append(el('small', 'selection-scope', scope));
239
274
  const button = el('button', 'button', t('app.viewReport')); button.type = 'button'; button.disabled = run.status === 'running' || run.status === 'stopping'; button.addEventListener('click', () => loadReport(run.runId)); row.append(copy, button); host.append(row);
240
275
  }
241
276
  }
277
+ const comparisonCategories = ['newly-failed', 'now-passed', 'changed', 'unchanged', 'added', 'missing'];
278
+ const comparisonCategoryKeys = { 'newly-failed': 'comparison.newlyFailed', 'now-passed': 'comparison.nowPassed', changed: 'comparison.changed', unchanged: 'comparison.unchanged', added: 'comparison.added', missing: 'comparison.missing' };
279
+ const comparisonWarningKeys = { 'suite-changed': 'comparison.warningSuite', 'reverse-order': 'comparison.warningReverse', 'incomplete-run': 'comparison.warningIncomplete', 'mobile-identity-unverified': 'comparison.warningMobileIdentity', 'partial-run': 'comparison.warningPartial', 'selection-changed': 'comparison.warningSelectionChanged' };
280
+ function comparisonPair() { return { baseline: $('compare-baseline').value, current: $('compare-current').value }; }
281
+ function comparisonValid() { const { baseline, current } = comparisonPair(); return !!baseline && !!current && baseline !== current; }
282
+ function comparisonStatus(key, params = {}, error = false) {
283
+ app.comparisonStatus = key; app.comparisonStatusParams = params;
284
+ const node = $('comparison-status'); node.textContent = t(key, params); node.classList.toggle('report-errors', error);
285
+ }
286
+ function comparisonControls() {
287
+ const { baseline, current } = comparisonPair();
288
+ $('compare-btn').disabled = !comparisonValid() || app.comparisonBusy;
289
+ $('compare-swap').disabled = !baseline || !current || baseline === current;
290
+ const ready = !!app.comparison && comparisonValid() && app.comparison.baseline.runId === baseline && app.comparison.current.runId === current;
291
+ for (const id of ['comparison-md', 'comparison-json', 'compare-open-baseline', 'compare-open-current']) $(id).disabled = !ready;
292
+ $('comparison-results').hidden = !ready;
293
+ }
294
+ function clearComparison() {
295
+ app.comparisonRequest += 1; app.comparisonBusy = false; app.comparison = null; app.comparisonMarkdown = '';
296
+ $('comparison-summary').replaceChildren(); $('comparison-warnings').replaceChildren(); $('comparison-content').replaceChildren();
297
+ comparisonStatus('comparison.selectBoth'); comparisonControls();
298
+ }
299
+ function renderComparisonOptions() {
300
+ const runs = (app.state?.runs || []).slice(0, 50);
301
+ let missing = false;
302
+ for (const id of ['compare-baseline', 'compare-current']) {
303
+ const select = $(id); const selected = select.value;
304
+ select.replaceChildren(el('option', '', t('comparison.choose'))); select.firstChild.value = '';
305
+ for (const run of runs) {
306
+ const option = el('option', '', `${t('comparison.runLabel', { id: run.runId, shortId: run.runId.slice(-8), date: formatDate(run.startedAt), suite: run.suiteId, target: targetDisplay(run.target), status: i18n.status(run.status) })}${selectionScope(run.selection) ? ` · ${selectionScope(run.selection)}` : ''}`);
307
+ option.value = run.runId; select.append(option);
308
+ }
309
+ select.value = runs.some((run) => run.runId === selected) ? selected : '';
310
+ if (selected && !select.value) missing = true;
311
+ }
312
+ if (missing) clearComparison();
313
+ if (!runs.length && !app.comparison) comparisonStatus('comparison.noRuns');
314
+ comparisonControls();
315
+ }
316
+ async function compareRuns() {
317
+ if (!comparisonValid()) return;
318
+ const { baseline, current } = comparisonPair(); const locale = i18n.locale(); const request = ++app.comparisonRequest;
319
+ app.comparisonBusy = true; app.comparison = null; app.comparisonMarkdown = '';
320
+ comparisonStatus('comparison.loading'); comparisonControls();
321
+ try {
322
+ const result = await api(`/api/comparison?baseline=${encodeURIComponent(baseline)}&current=${encodeURIComponent(current)}`);
323
+ if (request !== app.comparisonRequest || locale !== i18n.locale() || baseline !== $('compare-baseline').value || current !== $('compare-current').value) return;
324
+ app.comparison = result.comparison; app.comparisonMarkdown = result.markdown;
325
+ comparisonStatus('comparison.ready'); renderComparison();
326
+ } catch (error) {
327
+ if (request === app.comparisonRequest && locale === i18n.locale() && baseline === $('compare-baseline').value && current === $('compare-current').value) comparisonStatus('comparison.error', { message: error instanceof Error ? error.message : String(error) }, true);
328
+ } finally { if (request === app.comparisonRequest) { app.comparisonBusy = false; comparisonControls(); } }
329
+ }
330
+ function renderComparison() {
331
+ const result = app.comparison; if (!result) { comparisonControls(); return; }
332
+ const warnings = $('comparison-warnings'); replace(warnings);
333
+ for (const code of result.warnings || []) warnings.append(el('p', 'comparison-warning', t(comparisonWarningKeys[code] || 'comparison.warningUnknown', { code })));
334
+ const summary = $('comparison-summary'); replace(summary);
335
+ const meta = el('p', 'comparison-meta', `${result.baseline.runId} · ${formatDate(result.baseline.startedAt)}${selectionScope(result.baseline.selection) ? ` · ${selectionScope(result.baseline.selection)}` : ''} → ${result.current.runId} · ${formatDate(result.current.startedAt)}${selectionScope(result.current.selection) ? ` · ${selectionScope(result.current.selection)}` : ''}`); summary.append(meta);
336
+ const counts = el('div', 'comparison-counts');
337
+ for (const category of comparisonCategories) {
338
+ const item = el('div', 'comparison-count');
339
+ item.append(el('strong', '', String(result.counts?.[category] ?? 0)), el('span', '', t(comparisonCategoryKeys[category]))); counts.append(item);
340
+ }
341
+ summary.append(counts);
342
+ const host = $('comparison-content'); replace(host);
343
+ const selected = $('comparison-filter').value;
344
+ const cases = (result.cases || []).filter((item) => !selected || item.category === selected);
345
+ if (!cases.length) host.append(el('p', 'empty', t('comparison.noCases')));
346
+ for (const item of cases) {
347
+ const section = el('section', 'case-report comparison-case'); section.dataset.category = item.category;
348
+ section.append(el('h3', '', `${item.id} · ${t(comparisonCategoryKeys[item.category] || 'comparison.changed')} · ${t('comparison.pair', { baseline: item.baseline ? i18n.status(item.baseline.status) : t('comparison.notPresent'), current: item.current ? i18n.status(item.current.status) : t('comparison.notPresent') })}`));
349
+ for (const [side, entry] of [['baseline', item.baseline], ['current', item.current]]) {
350
+ if (!entry) continue;
351
+ section.append(el('p', '', t(side === 'baseline' ? 'comparison.baselineCase' : 'comparison.currentCase', { title: entry.title, status: i18n.status(entry.status) })));
352
+ if (entry.expected?.length) { const list = el('ul', 'compact-list'); for (const value of entry.expected) list.append(el('li', '', String(value))); section.append(el('p', '', t('app.expected')), list); }
353
+ if (entry.reason) section.append(el('p', '', t('comparison.reason', { value: entry.reason })));
354
+ }
355
+ host.append(section);
356
+ }
357
+ comparisonControls();
358
+ }
359
+ async function downloadComparisonMarkdown() {
360
+ if (!app.comparison || !comparisonValid()) return;
361
+ const { baseline, current } = comparisonPair(); const locale = i18n.locale(); const request = app.comparisonRequest;
362
+ try {
363
+ const result = await api(`/api/comparison?baseline=${encodeURIComponent(baseline)}&current=${encodeURIComponent(current)}`);
364
+ if (request !== app.comparisonRequest || locale !== i18n.locale() || baseline !== $('compare-baseline').value || current !== $('compare-current').value || !app.comparison) return;
365
+ app.comparisonMarkdown = result.markdown;
366
+ download(`${baseline}-to-${current}.md`, result.markdown, 'text/markdown');
367
+ } catch (error) { if (request === app.comparisonRequest && locale === i18n.locale()) comparisonStatus('comparison.error', { message: error instanceof Error ? error.message : String(error) }, true); }
368
+ }
242
369
  function renderActive() {
243
370
  const active = app.state?.active;
244
371
  $('active-indicator').textContent = !app.connected ? t('app.disconnected') : active ? `${t(active.status === 'stopping' ? 'app.activeStopping' : active.kind === 'plan' ? 'app.activePlanning' : 'app.activeRunning')}${active.kind === 'run' ? ` · ${active.title}` : ''}` : t(app.planning ? 'app.requestingPlan' : 'top.waiting');
@@ -258,7 +385,7 @@
258
385
  const libraryChanged = JSON.stringify(app.state?.suites) !== JSON.stringify(state.suites);
259
386
  const historyChanged = JSON.stringify(app.state?.runs) !== JSON.stringify(state.runs);
260
387
  app.state = state; app.connected = true;
261
- renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) renderHistory();
388
+ renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) { renderHistory(); renderComparisonOptions(); }
262
389
  if (full || initialize) { targetOptions(); modelForms(); }
263
390
  if (previousActive?.kind === 'run' && !state.active) { if (state.lastError) notice(state.lastError, true); else noticeKey('app.runEnded'); }
264
391
  if (state.lastError && state.lastError !== app.lastError) { app.lastError = state.lastError; notice(state.lastError, true); }
@@ -331,7 +458,7 @@
331
458
  noticeKey('app.compileComplete');
332
459
  }); }
333
460
  async function save() { await work(async () => { if (!app.draft || app.dirty || app.stale) throw localizedError('app.needValidated'); const body = { suite: app.draft }; if (!app.boundSource && app.sourceFlow && (app.origin === 'imported' ? editableFlow(app.sourceFlow) === $('flow-input').value : app.sourceFlow === $('flow-input').value.trim())) body.flow = app.sourceFlow; const result = await api('/api/save', body); const saved = await api(`/api/suite?file=${encodeURIComponent(result.file)}`); $('flow-input').value = saved.flow || ''; setDraft(saved.suite, saved.script, 'library', saved.flow || '', result.file, saved.suiteHash || ''); noticeKey('app.savedSuite', { file: result.file }); await refresh(false); }); }
334
- async function run() { await work(async () => { if (!app.draft || !app.script || !app.savedFile || !app.savedHash || app.dirty || app.stale || !$('review-checkbox').checked || !compatibleTarget()) throw localizedError('app.runNotReady'); const target = selectedTarget(); const result = await api('/api/run', { suite: app.savedFile, suiteHash: app.savedHash, targetName: target.name, targetFingerprint: target.targetFingerprint }); noticeKey('app.runStarted', { id: result.operationId }); await refresh(false); }); }
461
+ async function run() { await work(async () => { if (!app.draft || !app.script || !app.savedFile || !app.savedHash || app.dirty || app.stale || !selectedCases().length || !$('review-checkbox').checked || !compatibleTarget()) throw localizedError('app.runNotReady'); const target = selectedTarget(); const body = { suite: app.savedFile, suiteHash: app.savedHash, targetName: target.name, targetFingerprint: target.targetFingerprint }; if (selectedCases().length < app.draft.cases.length) body.caseIds = selectedCases().map((item) => item.id); const result = await api('/api/run', body); noticeKey('app.runStarted', { id: result.operationId }); await refresh(false); }); }
335
462
  async function stop() {
336
463
  if (app.stopping || !app.state?.active) return;
337
464
  if (app.state.active.kind === 'plan' && app.planning) app.planRequest += 1;
@@ -355,7 +482,7 @@
355
482
  const report = app.report; if (!report) return;
356
483
  $('report-heading').textContent = `${report.runId} · ${i18n.status(report.status)} · ${targetDisplay(report.target)}`;
357
484
  const host = $('report-content'); replace(host);
358
- const meta = el('div', 'report-meta'); for (const value of [t('app.reportStatus', { status: i18n.status(report.status) }), t('app.reportTarget', { target: targetDisplay(report.target) }), t('app.reportStarted', { date: formatDate(report.startedAt) }), t('app.reportFinished', { date: formatDate(report.finishedAt) })]) meta.append(el('span', '', value)); host.append(meta);
485
+ const meta = el('div', 'report-meta'); for (const value of [t('app.reportStatus', { status: i18n.status(report.status) }), t('app.reportTarget', { target: targetDisplay(report.target) }), t('app.reportStarted', { date: formatDate(report.startedAt) }), t('app.reportFinished', { date: formatDate(report.finishedAt) })]) meta.append(el('span', '', value)); const scope = selectionScope(report.selection); if (scope) meta.append(el('span', 'selection-scope', scope)); host.append(meta);
359
486
  if (report.usage) host.append(el('p', 'hint', t('app.usage', { tokens: report.usage.modelTokens ?? t('app.unavailable'), cost: typeof report.usage.estimatedCostUsd === 'number' ? `$${report.usage.estimatedCostUsd.toFixed(4)}` : t('app.unavailable') })));
360
487
  if (report.errors?.length) host.append(el('p', 'report-errors', report.errors.join('\n')));
361
488
  for (const item of report.cases || []) {
@@ -405,8 +532,9 @@
405
532
  const input = form?.elements.namedItem(focus.name);
406
533
  if (input) { input.focus(); if (Number.isInteger(focus.start)) input.setSelectionRange(focus.start, focus.end); }
407
534
  }
408
- summarize(app.draft, true);
409
- renderActive(); targetDescription(); renderLibrary(); renderHistory(); renderReport();
535
+ summarize(app.draft, true); renderCaseSelection();
536
+ renderActive(); targetDescription(); renderLibrary(); renderHistory(); renderComparisonOptions(); renderComparison(); renderReport();
537
+ if (app.comparisonStatus) comparisonStatus(app.comparisonStatus, app.comparisonStatusParams || {}, app.comparisonStatus === 'comparison.error');
410
538
  app.reportMarkdown = '';
411
539
  if (app.notice) $('notice').textContent = t(app.notice.key, app.notice.params);
412
540
  else if (!$('notice').hidden) notice(''); // A server error has no stable code to translate; the next request uses the selected language.
@@ -417,6 +545,14 @@
417
545
  $('library-search').addEventListener('input', renderLibrary);
418
546
  $('history-search').addEventListener('input', renderHistory);
419
547
  $('history-status-filter').addEventListener('change', renderHistory);
548
+ for (const id of ['compare-baseline', 'compare-current']) $(id).addEventListener('change', clearComparison);
549
+ $('compare-btn').addEventListener('click', compareRuns);
550
+ $('compare-swap').addEventListener('click', () => { const pair = comparisonPair(); $('compare-baseline').value = pair.current; $('compare-current').value = pair.baseline; clearComparison(); });
551
+ $('comparison-filter').addEventListener('change', renderComparison);
552
+ $('comparison-md').addEventListener('click', downloadComparisonMarkdown);
553
+ $('comparison-json').addEventListener('click', () => { if (app.comparison && comparisonValid()) { const { baseline, current } = comparisonPair(); download(`${baseline}-to-${current}.json`, JSON.stringify(app.comparison, null, 2), 'application/json'); } });
554
+ $('compare-open-baseline').addEventListener('click', () => { if (app.comparison && comparisonValid()) loadReport(app.comparison.baseline.runId); });
555
+ $('compare-open-current').addEventListener('click', () => { if (app.comparison && comparisonValid()) loadReport(app.comparison.current.runId); });
420
556
  $('nav-toggle').addEventListener('click', () => { const open = $('sidebar').classList.toggle('nav-open'); $('nav-toggle').setAttribute('aria-expanded', String(open)); });
421
557
  $('library-shortcut').addEventListener('click', () => view('library'));
422
558
  $('new-draft-btn').addEventListener('click', () => {
@@ -424,10 +560,10 @@
424
560
  const json = $('suite-editor').value;
425
561
  const unsavedJson = json.trim() && (!app.savedFile || json !== JSON.stringify(app.draft, null, 2));
426
562
  if (unsavedJson && !confirmScenarioReplacement('app.confirmNewDraft')) return;
427
- app.draft = null; app.script = ''; app.savedFile = ''; app.savedHash = '';
563
+ app.draft = null; app.selectedCaseIds = new Set(); app.script = ''; app.savedFile = ''; app.savedHash = '';
428
564
  app.origin = 'none'; app.sourceFlow = ''; app.boundSource = null; app.stale = false; app.dirty = false;
429
565
  $('suite-editor').value = ''; $('review-checkbox').checked = false;
430
- summarize(null); readiness();
566
+ summarize(null); renderCaseSelection(); readiness();
431
567
  noticeKey('app.newDraftNotice');
432
568
  $('suite-editor').focus();
433
569
  });
@@ -435,6 +571,9 @@
435
571
  $('scenario-platform').addEventListener('change', () => { app.planRequest += 1; if (app.draft) { app.stale = true; app.savedHash = ''; } $('review-checkbox').checked = false; readiness(); });
436
572
  $('suite-editor').addEventListener('input', () => { app.dirty = true; app.script = ''; app.savedFile = ''; app.savedHash = ''; if (app.origin === 'none') app.origin = 'manual'; $('review-checkbox').checked = false; summarize(null); readiness(); });
437
573
  $('review-checkbox').addEventListener('change', readiness); $('target-select').addEventListener('change', () => { $('review-checkbox').checked = false; targetDescription(); });
574
+ $('case-selection-list').addEventListener('change', (event) => { const input = event.target.closest('input[data-case-id]'); if (!input) return; const next = new Set(app.selectedCaseIds); if (input.checked) next.add(input.dataset.caseId); else next.delete(input.dataset.caseId); changeCaseSelection(next); });
575
+ $('select-all-cases').addEventListener('click', () => changeCaseSelection((app.draft?.cases || []).map((item) => item.id)));
576
+ $('clear-case-selection').addEventListener('click', () => changeCaseSelection([]));
438
577
  $('plan-btn').addEventListener('click', generate); $('prompt-btn').addEventListener('click', prompt); $('compile-btn').addEventListener('click', compile); $('save-btn').addEventListener('click', save); $('run-btn').addEventListener('click', run); $('stop-btn').addEventListener('click', stop);
439
578
  $('import-btn').addEventListener('click', importFiles);
440
579
  $('global-stop-btn').addEventListener('click', stop);
@@ -447,7 +586,7 @@
447
586
  $('target-form').addEventListener('submit', saveTarget);
448
587
  $('target-kind').addEventListener('change', targetFields); targetFields();
449
588
  $('language-select').addEventListener('change', (event) => i18n.setLocale(event.currentTarget.value));
450
- i18n.onChange(renderLanguage);
589
+ i18n.onChange(() => { app.comparisonRequest += 1; if (app.comparisonBusy || app.comparisonStatus === 'comparison.error') comparisonStatus(app.comparison ? 'comparison.ready' : 'comparison.selectBoth'); app.comparisonBusy = false; renderLanguage(); comparisonControls(); });
451
590
  let refreshing = false;
452
591
  const poll = async () => { if (document.hidden || refreshing) return; refreshing = true; try { await refresh(false); } catch { /* Connection state offers explicit retry; avoid repeated alerts. */ } finally { refreshing = false; } };
453
592
  document.addEventListener('visibilitychange', poll);
@@ -56,6 +56,12 @@
56
56
  'scenario.newDraft': ['New JSON draft', '새 JSON 초안'],
57
57
  'scenario.compile': ['Validate JSON · Generate script', 'JSON 검증 · 스크립트 생성'],
58
58
  'scenario.script': ['Run script', '실행 스크립트'],
59
+ 'scenario.fullSuiteScript': ['Script preview and download include the full suite, regardless of the run selection.', '스크립트 미리보기와 다운로드에는 실행 선택과 관계없이 전체 스위트가 포함됩니다.'],
60
+ 'scenario.selectionTitle': ['Cases to run', '실행할 케이스'],
61
+ 'scenario.selectionSummary': ['{selected} selected / {total} total', '선택 {selected}개 / 전체 {total}개'],
62
+ 'scenario.selectionHint': ['Each selected case must establish its own starting state. Excluded cases are not assessed.', '선택한 각 케이스는 자체 시작 상태를 설정해야 합니다. 제외한 케이스는 평가하지 않습니다.'],
63
+ 'scenario.selectAllCases': ['Select all', '전체 선택'],
64
+ 'scenario.clearSelection': ['Clear selection', '선택 해제'],
59
65
  'scenario.downloadScript': ['Download script ↓', '스크립트 다운로드 ↓'],
60
66
  'scenario.scriptPlaceholder': ['The script for validated cases will appear here.', '검증된 케이스의 스크립트가 여기에 표시됩니다.'],
61
67
  'scenario.runTitle': ['Review, then run', '검토 후 실행'],
@@ -83,6 +89,47 @@
83
89
  'history.searchPlaceholder': ['Suite ID, run ID, or target name', '스위트 ID, 실행 ID 또는 대상 이름'],
84
90
  'history.statusFilter': ['Run status', '실행 상태'],
85
91
  'history.statusAll': ['All statuses', '전체 상태'],
92
+ 'comparison.title': ['Compare two runs', '실행 2건 비교'],
93
+ 'comparison.help': ['Choose a baseline and current run to compare case outcomes. Inspect detailed reports for evidence.', '기준 실행과 현재 실행을 선택해 케이스 결과를 비교하세요. 증거는 상세 보고서에서 확인하세요.'],
94
+ 'comparison.baseline': ['Baseline run', '기준 실행'],
95
+ 'comparison.current': ['Current run', '현재 실행'],
96
+ 'comparison.choose': ['Choose a run', '실행 선택'],
97
+ 'comparison.compare': ['Compare', '비교하기'],
98
+ 'comparison.swap': ['Swap runs', '실행 순서 바꾸기'],
99
+ 'comparison.filter': ['Show cases', '케이스 표시'],
100
+ 'comparison.all': ['All categories', '전체 분류'],
101
+ 'comparison.newlyFailed': ['Newly failed', '새로 실패'],
102
+ 'comparison.nowPassed': ['Now passed', '이제 통과'],
103
+ 'comparison.changed': ['Changed status', '상태 변경'],
104
+ 'comparison.unchanged': ['Same status', '상태 동일'],
105
+ 'comparison.added': ['Added to current', '현재에만 있음'],
106
+ 'comparison.missing': ['Missing from current', '현재 기록에 없음'],
107
+ 'comparison.downloadMarkdown': ['Download comparison Markdown', '비교 Markdown 다운로드'],
108
+ 'comparison.downloadJson': ['Download comparison JSON', '비교 JSON 다운로드'],
109
+ 'comparison.openBaseline': ['View baseline report', '기준 실행 보고서 보기'],
110
+ 'comparison.openCurrent': ['View current report', '현재 실행 보고서 보기'],
111
+ 'comparison.selectBoth': ['Select two different runs to compare.', '서로 다른 실행 2건을 선택하세요.'],
112
+ 'comparison.noRuns': ['No recent runs available to compare.', '비교할 최근 실행이 없습니다.'],
113
+ 'comparison.loading': ['Comparing selected runs…', '선택한 실행을 비교하고 있습니다…'],
114
+ 'comparison.limit': ['Choose from the 50 most recent runs. Comparison counts describe observed statuses; they do not establish failure cause or prove a defect was fixed.', '최근 실행 50건에서 선택하세요. 비교 수치는 관찰된 상태만 나타내며 실패 원인이나 결함 수정 여부를 입증하지 않습니다.'],
115
+ 'comparison.ready': ['Comparison ready. Categories describe observed case status changes.', '비교 결과가 준비됐습니다. 분류는 관찰된 케이스 상태 차이입니다.'],
116
+ 'comparison.noCases': ['No cases in this category.', '이 분류에 해당하는 케이스가 없습니다.'],
117
+ 'comparison.warningSuite': ['Suite content changed. Steps or expectations may differ, so these counts do not establish equivalent regressions or fixes.', '스위트 내용이 바뀌었습니다. 단계나 기대 결과가 다를 수 있으므로 이 수치만으로 같은 조건의 회귀나 수정이라고 판단할 수 없습니다.'],
118
+ 'comparison.warningReverse': ['The selected current run started before the baseline run.', '선택한 현재 실행이 기준 실행보다 먼저 시작됐습니다.'],
119
+ 'comparison.warningIncomplete': ['At least one run is incomplete. Interpret case outcomes with its detailed report.', '완료되지 않은 실행이 있습니다. 상세 보고서와 함께 결과를 해석하세요.'],
120
+ 'comparison.warningMobileIdentity': ['Mobile comparison matches recorded app and device settings. It does not verify that both runs used the same physical device.', '모바일 비교는 기록된 앱과 기기 설정을 기준으로 합니다. 두 실행이 같은 실제 기기를 사용했는지는 확인하지 않습니다.'],
121
+ 'comparison.warningPartial': ['At least one run assessed only selected cases. Missing outcomes do not mean those cases passed.', '하나 이상의 실행에서 선택한 케이스만 평가했습니다. 결과가 없는 케이스를 통과로 해석할 수 없습니다.'],
122
+ 'comparison.warningSelectionChanged': ['The runs selected different case sets. Compare only outcomes for cases assessed in both runs.', '두 실행에서 선택한 케이스가 다릅니다. 두 실행 모두에서 평가한 케이스의 결과만 비교하세요.'],
123
+ 'comparison.warningUnknown': ['Comparison warning: {code}', '비교 주의: {code}'],
124
+ 'comparison.pair': ['{baseline} → {current}', '{baseline} → {current}'],
125
+ 'comparison.notPresent': ['not present', '없음'],
126
+ 'comparison.baselineCase': ['Baseline: {title} · {status}', '기준: {title} · {status}'],
127
+ 'comparison.currentCase': ['Current: {title} · {status}', '현재: {title} · {status}'],
128
+ 'comparison.expected': ['Expected: {value}', '기대 결과: {value}'],
129
+ 'comparison.reason': ['Reason: {value}', '사유: {value}'],
130
+ 'comparison.count': ['{category}: {count}', '{category}: {count}'],
131
+ 'comparison.error': ['Comparison failed: {message}', '비교 실패: {message}'],
132
+ 'comparison.runLabel': ['{date} · {suite} · {shortId} · {target} · {status} · {id}', '{date} · {suite} · {shortId} · {target} · {status} · {id}'],
86
133
  'history.report': ['Detailed report', '상세 보고서'],
87
134
  'history.downloadMarkdown': ['Download Markdown', 'Markdown 다운로드'],
88
135
  'history.downloadJson': ['Download JSON', 'JSON 다운로드'],
@@ -153,7 +200,10 @@
153
200
  'app.needTarget': ['Select a run target.', '실행 대상을 선택하세요.'],
154
201
  'app.platformMismatch': ['The case platform and target type must match.', '케이스 플랫폼과 대상 유형이 일치해야 합니다.'],
155
202
  'app.needReview': ['Review the contents, then select the checkbox.', '내용 검토 후 확인란을 선택하세요.'],
203
+ 'app.needCaseSelection': ['Select at least one case to run.', '실행할 케이스를 하나 이상 선택하세요.'],
156
204
  'app.ready': ['Ready to run.', '실행할 준비가 됐습니다.'],
205
+ 'app.selectedScope': ['Selected {selected}/{total} cases · excluded cases not assessed', '선택 {selected}/{total}개 · 제외한 케이스는 평가하지 않음'],
206
+ 'app.selectedCaseReadiness': ['{total} selected · {ready} runnable · {blocked} blocked before run{hint}', '선택 {total}개 · 실행 가능 {ready}개 · 사전 차단 {blocked}개{hint}'],
157
207
  'app.caseReadiness': ['{total} total · {ready} runnable · {blocked} blocked before run{hint}', '전체 {total}개 · 실행 가능 {ready}개 · 사전 차단 {blocked}개{hint}'],
158
208
  'app.blockedHint': [' — Check open questions or run agent settings.', ' — 확인 질문이나 실행 모델 설정을 확인하세요.'],
159
209
  'app.regenerate': ['Regenerate required', '재생성 필요'],
@@ -72,6 +72,7 @@
72
72
  <textarea id="suite-editor" class="code-editor" rows="13" spellcheck="false" placeholder="Load a local demo case from the library or enter suite JSON." data-i18n-placeholder="scenario.suitePlaceholder"></textarea>
73
73
  <div class="actions"><button id="new-draft-btn" type="button" class="button quiet" data-i18n="scenario.newDraft">New JSON draft</button><button id="compile-btn" type="button" class="button" data-i18n="scenario.compile">Validate JSON · Generate script</button></div>
74
74
  <div class="script-head"><h3 data-i18n="scenario.script">Run script</h3><button type="button" id="script-download" class="text-button" disabled data-i18n="scenario.downloadScript">Download script ↓</button></div>
75
+ <p class="hint" data-i18n="scenario.fullSuiteScript">Script preview and download include the full suite, regardless of the run selection.</p>
75
76
  <pre id="script-preview" class="script-preview" data-i18n="scenario.scriptPlaceholder">The script for validated cases will appear here.</pre>
76
77
  </div>
77
78
  </details>
@@ -81,7 +82,7 @@
81
82
  <div class="section-head"><span class="section-number">03</span><div><h2 id="run-title" data-i18n="scenario.runTitle">Review, then run</h2><p data-i18n="scenario.runHelp">Select a target and confirm you have reviewed the cases.</p></div></div>
82
83
  <div class="execution-grid">
83
84
  <div class="target-field"><label for="target-select" class="field-label" data-i18n="scenario.target">Run target</label><select id="target-select"><option value="" data-i18n="scenario.selectTarget">Select a target</option></select><p id="target-description" class="hint" data-i18n="scenario.targetHint">You can add a target in Connections.</p><p id="mobile-setup-hint" class="hint" hidden data-i18n="scenario.mobileSetup">For iOS, prepare Xcode, a simulator or signed device, and app signing. For Android, prepare the Android SDK and an emulator or authorized device. Configuration alone does not verify device access.</p></div>
84
- <div class="execution-confirm"><p id="case-readiness" class="hint"></p><label class="review-line"><input type="checkbox" id="review-checkbox"><span data-i18n="scenario.reviewConfirm">I have reviewed the requirements, case steps, and expected results.</span></label><div class="run-bar"><span id="run-readiness" data-i18n="scenario.runInitial">Validated cases and a run target are required.</span><div class="actions"><button type="button" id="stop-btn" class="button quiet" hidden data-i18n="scenario.stop">Stop run</button><button type="button" id="run-btn" class="button primary" disabled><span data-i18n="scenario.run">Run QA</span><span aria-hidden="true">→</span></button></div></div></div>
85
+ <div class="execution-confirm"><div class="case-selection"><div class="case-selection-head"><h3 id="case-selection-title" data-i18n="scenario.selectionTitle">Cases to run</h3><span id="case-selection-summary" role="status" aria-live="polite"></span></div><p class="hint" data-i18n="scenario.selectionHint">Each selected case must establish its own starting state. Excluded cases are not assessed.</p><div class="actions"><button type="button" id="select-all-cases" class="button quiet" data-i18n="scenario.selectAllCases">Select all</button><button type="button" id="clear-case-selection" class="button quiet" data-i18n="scenario.clearSelection">Clear selection</button></div><div id="case-selection-list" role="group" aria-labelledby="case-selection-title"></div></div><p id="case-readiness" class="hint"></p><label class="review-line"><input type="checkbox" id="review-checkbox"><span data-i18n="scenario.reviewConfirm">I have reviewed the requirements, case steps, and expected results.</span></label><div class="run-bar"><span id="run-readiness" data-i18n="scenario.runInitial">Validated cases and a run target are required.</span><div class="actions"><button type="button" id="stop-btn" class="button quiet" hidden data-i18n="scenario.stop">Stop run</button><button type="button" id="run-btn" class="button primary" disabled><span data-i18n="scenario.run">Run QA</span><span aria-hidden="true">→</span></button></div></div></div>
85
86
  </div>
86
87
  </section>
87
88
  </div>
@@ -93,7 +94,17 @@
93
94
  <section id="view-history" class="view" hidden>
94
95
  <div class="page-heading"><p class="eyebrow" data-i18n="history.eyebrow">03 / RUNS</p><h1 data-i18n="nav.history">Run history</h1><p data-i18n="history.intro">Only runs that actually started appear here. Once results are saved, you can view the detailed report.</p></div>
95
96
  <section class="list-section"><div class="section-head"><span class="section-number">01</span><div><h2 data-i18n="history.title">Runs</h2><p data-i18n="history.help">Check the completed report for the final status.</p></div></div><div class="section-body form-stack"><label for="history-search" data-i18n="history.searchLabel">Search runs</label><input id="history-search" type="search" aria-controls="history-list" placeholder="Suite ID, run ID, or target name" data-i18n-placeholder="history.searchPlaceholder"><label for="history-status-filter" data-i18n="history.statusFilter">Run status</label><select id="history-status-filter" aria-controls="history-list"><option value="" data-i18n="history.statusAll">All statuses</option><option value="passed" data-i18n="status.passed">passed</option><option value="failed" data-i18n="status.failed">failed</option><option value="blocked" data-i18n="status.blocked">blocked</option><option value="error" data-i18n="status.error">error</option><option value="interrupted" data-i18n="status.interrupted">interrupted</option></select><div id="history-list" class="list-body"></div></div></section>
96
- <section id="report-panel" class="report-panel" hidden><div class="section-head"><span class="section-number">02</span><div><h2 data-i18n="history.report">Detailed report</h2><p id="report-heading"></p></div></div><div class="section-body"><div class="actions"><button type="button" id="report-md" class="button" data-i18n="history.downloadMarkdown">Download Markdown</button><button type="button" id="report-json" class="button quiet" data-i18n="history.downloadJson">Download JSON</button></div><p class="hint" data-i18n="history.evidenceHint">The report summarizes results. Open or download screenshots and recordings from each attempt. Evidence files are not included in report downloads.</p><div id="report-content"></div></div></section>
97
+ <section id="comparison-panel" class="comparison-panel" aria-labelledby="comparison-title">
98
+ <div class="section-head"><span class="section-number">02</span><div><h2 id="comparison-title" data-i18n="comparison.title">Compare two runs</h2><p data-i18n="comparison.help">Choose a baseline and current run to compare case outcomes. This does not replace either detailed report.</p></div></div>
99
+ <div class="section-body">
100
+ <p class="hint" data-i18n="comparison.limit">Choose from the 50 most recent runs. Comparison counts describe observed statuses; they do not establish failure cause or prove a defect was fixed.</p>
101
+ <div class="comparison-selects"><div><label for="compare-baseline" data-i18n="comparison.baseline">Baseline run</label><select id="compare-baseline" aria-controls="comparison-content"><option value="" data-i18n="comparison.choose">Choose a run</option></select></div><div><label for="compare-current" data-i18n="comparison.current">Current run</label><select id="compare-current" aria-controls="comparison-content"><option value="" data-i18n="comparison.choose">Choose a run</option></select></div></div>
102
+ <div class="actions comparison-actions"><button type="button" id="compare-btn" class="button primary" data-i18n="comparison.compare" disabled>Compare</button><button type="button" id="compare-swap" class="button quiet" data-i18n="comparison.swap" disabled>Swap runs</button></div>
103
+ <p id="comparison-status" class="hint" role="status" aria-live="polite"></p>
104
+ <div id="comparison-results" hidden><div id="comparison-warnings"></div><div id="comparison-summary"></div><div class="comparison-filter-row"><label for="comparison-filter" data-i18n="comparison.filter">Show cases</label><select id="comparison-filter" aria-controls="comparison-content"><option value="" data-i18n="comparison.all">All categories</option><option value="newly-failed" data-i18n="comparison.newlyFailed">Newly failed</option><option value="now-passed" data-i18n="comparison.nowPassed">Now passed</option><option value="changed" data-i18n="comparison.changed">Changed status</option><option value="unchanged" data-i18n="comparison.unchanged">Same status</option><option value="added" data-i18n="comparison.added">Added to current</option><option value="missing" data-i18n="comparison.missing">Missing from current</option></select></div><div class="actions comparison-actions"><button type="button" id="comparison-md" class="button" data-i18n="comparison.downloadMarkdown" disabled>Download comparison Markdown</button><button type="button" id="comparison-json" class="button quiet" data-i18n="comparison.downloadJson" disabled>Download comparison JSON</button><button type="button" id="compare-open-baseline" class="button quiet" data-i18n="comparison.openBaseline" disabled>View baseline report</button><button type="button" id="compare-open-current" class="button quiet" data-i18n="comparison.openCurrent" disabled>View current report</button></div><div id="comparison-content"></div></div>
105
+ </div>
106
+ </section>
107
+ <section id="report-panel" class="report-panel" hidden><div class="section-head"><span class="section-number">03</span><div><h2 data-i18n="history.report">Detailed report</h2><p id="report-heading"></p></div></div><div class="section-body"><div class="actions"><button type="button" id="report-md" class="button" data-i18n="history.downloadMarkdown">Download Markdown</button><button type="button" id="report-json" class="button quiet" data-i18n="history.downloadJson">Download JSON</button></div><p class="hint" data-i18n="history.evidenceHint">The report summarizes results. Open or download screenshots and recordings from each attempt. Evidence files are not included in report downloads.</p><div id="report-content"></div></div></section>
97
108
  </section>
98
109
  <section id="view-settings" class="view" hidden>
99
110
  <div class="page-heading"><p class="eyebrow" data-i18n="settings.eyebrow">04 / CONNECTIONS</p><h1 data-i18n="nav.settings">Connections</h1><p data-i18n="settings.intro">Configure run targets and models separately. Key values are read only from server environment variables.</p></div>
@@ -52,7 +52,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
52
52
  .page-heading h1 em { color: var(--accent-text); font-style: normal; }
53
53
  .page-heading > p:last-child { max-width: 56ch; margin: 0; color: var(--muted); font-size: 15px; line-height: 1.7; }
54
54
  .workflow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: clamp(26px, 3vw, 46px); row-gap: calc(var(--space-factor) * 34px); }
55
- .work-section, .execution-section, .list-section, .report-panel, .settings-section { min-width: 0; border-top: 2px solid var(--ink); }
55
+ .work-section, .execution-section, .list-section, .report-panel, .comparison-panel, .settings-section { min-width: 0; border-top: 2px solid var(--ink); }
56
56
  .section-head { display: flex; align-items: flex-start; gap: 13px; min-width: 0; padding: calc(var(--space-factor) * 19px) 0 calc(var(--space-factor) * 18px); border-bottom: 1px solid var(--line); }
57
57
  .section-head > div { min-width: 0; }
58
58
  .section-number { flex: none; min-width: 26px; padding-top: 4px; color: var(--accent-text); font-family: 'DM Sans', monospace; font-size: 11px; font-weight: 800; }
@@ -126,17 +126,46 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
126
126
  .target-field { min-width: 0; }
127
127
  .target-field select { min-height: 45px; }
128
128
  .execution-confirm { display: flex; flex-direction: column; min-width: 0; }
129
+ .case-selection { min-width: 0; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
130
+ .case-selection-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; }
131
+ .case-selection-head h3 { margin: 0; font-size: 14px; }
132
+ #case-selection-summary { color: var(--muted); font-size: 12px; }
133
+ .case-selection .actions { margin: 12px 0; }
134
+ #case-selection-list { display: grid; gap: 0; max-height: 250px; overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius-control); }
135
+ .case-selection-item { display: flex; align-items: flex-start; gap: 10px; min-width: 0; padding: 10px 12px; border-bottom: 1px solid var(--line); cursor: pointer; font-size: 13px; }
136
+ .case-selection-item:last-child { border-bottom: 0; }
137
+ .case-selection-item input { width: 18px; height: 18px; flex: none; margin: 1px 0 0; accent-color: var(--accent-solid); }
138
+ .case-selection-item span { min-width: 0; overflow-wrap: anywhere; }
139
+ .selection-scope { color: var(--accent-text) !important; font-weight: 700; }
129
140
  .review-line { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.55; cursor: pointer; }
130
141
  .review-line input { width: 18px; height: 18px; flex: none; margin: 2px 0 0; accent-color: var(--accent-solid); }
131
142
  .run-bar { display: flex; align-items: end; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: auto; padding-top: 18px; }
132
143
  .run-bar > span { max-width: 37ch; color: var(--muted); font-size: 12px; line-height: 1.5; }
133
- .list-section, .report-panel { margin-top: calc(var(--space-factor) * 28px); }
144
+ .list-section, .report-panel, .comparison-panel { margin-top: calc(var(--space-factor) * 28px); }
134
145
  .list-body { min-width: 0; }
135
146
  .list-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: calc(var(--space-factor) * 19px) 0; border-bottom: 1px solid var(--line); }
136
147
  .list-row > div { min-width: 0; }
137
148
  .list-row strong { display: block; font-size: 15px; overflow-wrap: anywhere; }
138
149
  .list-row small { display: block; margin-top: 6px; color: var(--muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
139
150
  .empty { max-width: 50ch; margin: 0; padding: 32px 0; color: var(--muted); line-height: 1.7; }
151
+
152
+ .comparison-selects { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
153
+ .comparison-selects > div { min-width: 0; }
154
+ .comparison-selects label, .comparison-filter-row label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 750; }
155
+ .comparison-selects select, .comparison-filter-row select { display: block; width: 100%; min-width: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius-control); background: var(--paper); color: var(--ink); }
156
+ .comparison-actions { margin: 18px 0; }
157
+ #comparison-status { min-height: 20px; }
158
+ .comparison-warning { padding: 12px 14px; border-left: 3px solid var(--accent-solid); background: var(--surface); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
159
+ .comparison-meta { font-size: 13px; overflow-wrap: anywhere; }
160
+ .comparison-counts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-block: 1px solid var(--line); margin: 18px 0; }
161
+ .comparison-count { min-width: 0; padding: 12px; border-right: 1px solid var(--line); overflow-wrap: anywhere; }
162
+ .comparison-count:nth-child(3n) { border-right: 0; }
163
+ .comparison-count:nth-child(n+4) { border-top: 1px solid var(--line); }
164
+ .comparison-count strong, .comparison-count span { display: block; }
165
+ .comparison-count strong { font-size: 22px; }
166
+ .comparison-count span { color: var(--muted); font-size: 12px; }
167
+ .comparison-filter-row { max-width: 320px; }
168
+ #comparison-content, .comparison-panel { min-width: 0; }
140
169
  .report-panel .actions { margin-bottom: 25px; }
141
170
  .report-meta { display: flex; flex-wrap: wrap; gap: 9px; margin: 0 0 23px; }
142
171
  .report-meta span { padding: 5px 9px; border: 1px solid var(--line); border-radius: var(--radius-control); font-size: 12px; }
@@ -189,6 +218,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
189
218
  .execution-grid { grid-template-columns: 1fr; gap: 16px; }
190
219
  .run-bar { align-items: start; flex-direction: column; }
191
220
  .list-row { align-items: start; flex-direction: column; gap: 12px; }
221
+ .comparison-selects { grid-template-columns: 1fr; }
222
+ .comparison-count { padding: 9px 5px; }
223
+ .comparison-count strong { font-size: 19px; }
192
224
  .draft-topline { align-items: start; flex-direction: column; }
193
225
  .summary { padding: 17px; }
194
226
  .summary-stat { padding-inline: 5px; }
@@ -8,11 +8,13 @@ import { z } from 'zod';
8
8
  import { createPreferenceScript } from '@hyeonse/design-system/preferences';
9
9
  import { chromium } from 'playwright';
10
10
  import { compileSuite } from "../compiler.js";
11
+ import { CaseSelectionError, isReportSelectionValid, selectCases } from "../case-selection.js";
11
12
  import { runSuite } from "../engine.js";
12
13
  import { localeFromAcceptLanguage, localizeProductMessage } from "../i18n.js";
13
14
  import { EvidenceError, readEvidence } from "./evidence.js";
14
15
  import { buildPlanningPrompt, planFlow, PlannerError } from "../planner.js";
15
16
  import { renderMarkdown } from "../report.js";
17
+ import { compareReports, ComparisonError, renderComparisonMarkdown } from "../run-comparison.js";
16
18
  import { configSchema, idSchema, modelSchema, suiteSchema, targetSchema } from "../schema.js";
17
19
  import { targetFingerprint } from "../targets.js";
18
20
  import { CONFIG_FILE, sha256 } from "../workspace.js";
@@ -37,7 +39,8 @@ const reportEnvelope = z.object({
37
39
  cases: z.array(z.object({ id: z.string(), title: z.string(), status: z.enum(['passed', 'failed', 'flaky', 'blocked', 'skipped', 'error']),
38
40
  expected: z.array(z.string()), attempts: z.array(z.unknown()), reason: z.string().optional() }).passthrough()),
39
41
  errors: z.array(z.string()), usage: z.object({ modelTokens: z.number(), modelCachedTokens: z.number().optional(), estimatedCostUsd: z.number().optional() }).optional(),
40
- }).passthrough();
42
+ selection: z.unknown().optional(),
43
+ }).passthrough().refine((report) => isReportSelectionValid(report.cases, report.selection));
41
44
  const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
42
45
  const STATIC_FILES = {
43
46
  '/': { file: path.join(PUBLIC_DIR, 'index.html'), type: 'text/html; charset=utf-8' },
@@ -71,7 +74,7 @@ const SCHEMA_FIELDS = new Set([
71
74
  'requirementIds', 'preconditions', 'questions', 'steps', 'type', 'path', 'instruction',
72
75
  'locator', 'by', 'value', 'name', 'visible', 'expected', 'match', 'flow', 'role', 'url',
73
76
  'description', 'baseURL', 'model', 'apiKeyEnv', 'clear', 'targetName', 'targetURL', 'targetFingerprint', 'suiteHash',
74
- 'kind', 'platform', 'bundleId', 'device', 'direction',
77
+ 'kind', 'platform', 'bundleId', 'device', 'direction', 'caseIds',
75
78
  ]);
76
79
  const KNOWN_SCHEMA_MESSAGES = new Map([
77
80
  ['IDs must be unique.', 'IDs must be unique.'],
@@ -401,7 +404,7 @@ export async function startUIServer(options) {
401
404
  history.push({ runId: item.runId, suiteId: item.suiteId, status: item.status, startedAt: item.startedAt,
402
405
  finishedAt: item.finishedAt, caseCount: item.cases.length,
403
406
  passedCount: item.cases.filter((entry) => entry.status === 'passed' || entry.status === 'flaky').length,
404
- target: item.target });
407
+ target: item.target, ...(item.selection ? { selection: item.selection } : {}) });
405
408
  }
406
409
  catch { /* incomplete or unsafe run */ }
407
410
  }
@@ -468,6 +471,25 @@ export async function startUIServer(options) {
468
471
  sendJSON(response, 200, await report(runId, true, locale));
469
472
  return;
470
473
  }
474
+ if (url.pathname === '/api/comparison') {
475
+ const baseline = url.searchParams.get('baseline');
476
+ const current = url.searchParams.get('current');
477
+ if (!baseline || !current)
478
+ invalid('Both run IDs are required.');
479
+ const baselineReport = (await report(baseline, false)).report;
480
+ const currentReport = (await report(current, false)).report;
481
+ let comparison;
482
+ try {
483
+ comparison = compareReports(baselineReport, currentReport);
484
+ }
485
+ catch (error) {
486
+ if (error instanceof ComparisonError)
487
+ throw new HTTPError(error.status, error.message);
488
+ throw error;
489
+ }
490
+ sendJSON(response, 200, { comparison, markdown: renderComparisonMarkdown(comparison, locale) });
491
+ return;
492
+ }
471
493
  if (url.pathname === '/api/artifact') {
472
494
  const runId = url.searchParams.get('runId');
473
495
  const caseId = url.searchParams.get('caseId');
@@ -688,14 +710,24 @@ export async function startUIServer(options) {
688
710
  if (url.pathname === '/api/run') {
689
711
  enterMutation();
690
712
  try {
691
- const { suite: file, suiteHash, targetName, targetURL, targetFingerprint: reviewedFingerprint } = parse(z.object({
713
+ const { suite: file, suiteHash, targetName, targetURL, targetFingerprint: reviewedFingerprint, caseIds } = parse(z.object({
692
714
  suite: z.string(), suiteHash: z.string().regex(/^[a-f0-9]{64}$/), targetName: idSchema,
693
715
  targetURL: z.string().optional(), targetFingerprint: z.string().regex(/^[a-f0-9]{64}$/).optional(),
716
+ caseIds: z.array(idSchema).min(1).max(100).optional(),
694
717
  }).strict(), body);
695
718
  const selected = await getSuite(file, true);
696
719
  if (selected.suiteHash !== suiteHash)
697
720
  throw new HTTPError(409, 'The reviewed case file changed. Open it again to review.');
698
721
  const { suite } = selected;
722
+ let selectedIds;
723
+ try {
724
+ selectedIds = selectCases(suite, caseIds).selection?.caseIds;
725
+ }
726
+ catch (error) {
727
+ if (error instanceof CaseSelectionError)
728
+ invalid('Select one or more unique cases from this suite.');
729
+ throw error;
730
+ }
699
731
  const settings = await config();
700
732
  const target = settings.targets[targetName];
701
733
  if (!target)
@@ -719,7 +751,8 @@ export async function startUIServer(options) {
719
751
  lastError = undefined;
720
752
  const suiteSnapshot = structuredClone(suite);
721
753
  const configSnapshot = structuredClone(settings);
722
- runTask = Promise.resolve().then(() => run({ workspace, suite: suiteSnapshot, config: configSnapshot, targetName, signal: abort.signal }))
754
+ runTask = Promise.resolve().then(() => run({ workspace, suite: suiteSnapshot, config: configSnapshot, targetName, signal: abort.signal,
755
+ ...(selectedIds ? { caseIds: [...selectedIds] } : {}) }))
723
756
  .then(() => undefined)
724
757
  .catch(() => { lastError = 'Could not complete the run. Check the local records.'; })
725
758
  .finally(() => { active = null; controller = undefined; busy = false; runTask = undefined; });