@ihyeon/flow-qa 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -16,7 +16,7 @@ CLI와 `127.0.0.1` 전용 웹 UI를 제공합니다. UI는 영어로 시작하
16
16
  Node.js 24.x(24.22.1 이상) 또는 26.12.1 이상이 필요합니다. 브라우저는 첫 웹 실행 전에 설치합니다.
17
17
 
18
18
  ```sh
19
- npm install -g @ihyeon/flow-qa@0.2.8
19
+ npm install -g @ihyeon/flow-qa@0.3.0
20
20
  flow-qa browser install
21
21
  flow-qa ui --demo
22
22
  ```
@@ -62,6 +62,10 @@ flow-qa plan flows/my-flow.md --workspace my-qa
62
62
 
63
63
  `flow-qa doctor --workspace my-qa`로 로컬 의존성과 모델 인증용 환경 변수의 설정 여부를 확인할 수 있습니다. 키 값은 출력하지 않고 모델 서버도 호출하지 않습니다. 환경 변수가 있어도 실제 인증 성공을 확인한 것은 아닙니다.
64
64
 
65
+ ## 실행 결과 비교
66
+
67
+ **실행 기록 → 실행 2건 비교**에서 같은 스위트와 대상의 두 기록을 선택하면 통과→실패, 실패→통과, 그 외 상태 변경, 동일 상태, 추가·누락 케이스를 확인할 수 있습니다. 분류별로 보거나 전체 비교를 Markdown·JSON으로 내려받으세요. 테스트 내용 변경과 불완전한 실행은 별도로 표시하며, 상태 변화만으로 제품 버그의 발생·해결을 확정하지 않습니다. 자세한 동작은 [UI 가이드](docs/ui.md#compare-two-runs)를 참고하세요.
68
+
65
69
  ## 모바일 대상
66
70
 
67
71
  `0.2.0`부터 사용자가 직접 설정하는 iOS·Android 대상을 지원합니다. 시나리오 화면에서 **모바일**을 선택하면 앱을 정하기 전에도 케이스를 준비할 수 있습니다. 이후 **연결 설정**에서 플랫폼·설치된 앱의 번들/패키지 ID·기기를 등록하고 실행할 때 선택합니다. 설정 저장은 앱 설치나 테스트 실행을 시작하지 않습니다.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Flow QA is available as a CLI and a web UI bound to `127.0.0.1`. Provide a targe
16
16
  Node.js 24.x (24.22.1 or later) or 26.12.1 or later is required. Install Chromium before the first web run:
17
17
 
18
18
  ```sh
19
- npm install -g @ihyeon/flow-qa@0.2.8
19
+ npm install -g @ihyeon/flow-qa@0.3.0
20
20
  flow-qa browser install
21
21
  flow-qa ui --demo
22
22
  ```
@@ -62,6 +62,10 @@ For authenticated models, add `--key-env FLOW_QA_API_KEY` to the model command a
62
62
 
63
63
  `flow-qa doctor --workspace my-qa` checks local dependencies and whether configured model-key environment variables are present. It does not print key values or call the model server; a present key does not confirm remote authentication.
64
64
 
65
+ ## Compare recorded runs
66
+
67
+ In **Run history → Compare two runs**, select two reports from the same suite and target to see passed-to-failed, failed-to-passed, other status changes, unchanged, added, and missing cases. Filter the comparison or download all results as Markdown/JSON. Changed test definitions and incomplete runs carry explicit warnings; observed status changes do not prove a product defect was introduced or fixed. See the [UI guide](docs/ui.md#compare-two-runs).
68
+
65
69
  ## Mobile targets
66
70
 
67
71
  Version `0.2.0` adds user-configured iOS and Android targets. Choose **Mobile** in the scenario screen to prepare cases before choosing an app. Later, register the platform, installed app's bundle/package ID, and device in **Connections**, then select that target for execution. Saving settings does not install an app or start a test.
package/dist/src/i18n.js CHANGED
@@ -62,6 +62,12 @@ const koreanMessages = {
62
62
  'Invalid request origin.': '요청 출처가 올바르지 않습니다.',
63
63
  'A case file name is required.': '케이스 파일 이름이 필요합니다.',
64
64
  'A run ID is required.': '실행 ID가 필요합니다.',
65
+ 'Both run IDs are required.': '비교할 두 실행 ID가 필요합니다.',
66
+ 'A comparison needs two different runs.': '서로 다른 두 실행을 선택해야 합니다.',
67
+ 'Both reports must belong to the same suite.': '두 리포트의 케이스 모음이 같아야 합니다.',
68
+ 'Both reports must use the same target.': '두 리포트의 실행 대상이 같아야 합니다.',
69
+ 'Both reports need a non-empty suite hash.': '두 리포트에 비어 있지 않은 케이스 모음 해시가 있어야 합니다.',
70
+ 'Both reports need unique, non-empty case IDs.': '두 리포트의 케이스 ID는 비어 있지 않고 중복되지 않아야 합니다.',
65
71
  'Invalid evidence selection.': '증거 파일 선택이 올바르지 않습니다.',
66
72
  'The request is not allowed.': '허용되지 않은 요청입니다.',
67
73
  'Invalid request token.': '요청 토큰이 올바르지 않습니다.',
@@ -0,0 +1,152 @@
1
+ import { localized } from "./i18n.js";
2
+ import { targetFingerprint, targetLabel } from "./targets.js";
3
+ export const comparisonCategories = ['newly-failed', 'now-passed', 'changed', 'unchanged', 'added', 'missing'];
4
+ export class ComparisonError extends Error {
5
+ status;
6
+ constructor(status, message) { super(message); this.status = status; }
7
+ }
8
+ function validateCaseIds(report) {
9
+ const cases = new Map();
10
+ for (const item of report.cases) {
11
+ if (!item.id.trim() || cases.has(item.id)) {
12
+ throw new ComparisonError(409, 'Both reports need unique, non-empty case IDs.');
13
+ }
14
+ cases.set(item.id, item);
15
+ }
16
+ return cases;
17
+ }
18
+ function runSummary(report) {
19
+ return {
20
+ runId: report.runId, suiteId: report.suiteId, suiteHash: report.suiteHash,
21
+ target: { ...report.target }, startedAt: report.startedAt,
22
+ finishedAt: report.finishedAt, status: report.status,
23
+ };
24
+ }
25
+ function caseSnapshot(item) {
26
+ if (!item)
27
+ return null;
28
+ return {
29
+ title: item.title, status: item.status, expected: [...item.expected],
30
+ ...(item.reason === undefined ? {} : { reason: item.reason }),
31
+ };
32
+ }
33
+ function category(baseline, current) {
34
+ if (!baseline)
35
+ return 'added';
36
+ if (!current)
37
+ return 'missing';
38
+ if (baseline.status === current.status)
39
+ return 'unchanged';
40
+ if (baseline.status === 'passed' && current.status === 'failed')
41
+ return 'newly-failed';
42
+ if (baseline.status === 'failed' && current.status === 'passed')
43
+ return 'now-passed';
44
+ return 'changed';
45
+ }
46
+ /** Compare only recorded final case statuses and report metadata. Never inspect attempts or artifacts. */
47
+ export function compareReports(baseline, current) {
48
+ if (baseline.runId === current.runId)
49
+ throw new ComparisonError(400, 'A comparison needs two different runs.');
50
+ if (baseline.suiteId !== current.suiteId)
51
+ throw new ComparisonError(409, 'Both reports must belong to the same suite.');
52
+ if (targetFingerprint(baseline.target) !== targetFingerprint(current.target)) {
53
+ throw new ComparisonError(409, 'Both reports must use the same target.');
54
+ }
55
+ if (!baseline.suiteHash.trim() || !current.suiteHash.trim()) {
56
+ throw new ComparisonError(409, 'Both reports need a non-empty suite hash.');
57
+ }
58
+ const before = validateCaseIds(baseline);
59
+ const after = validateCaseIds(current);
60
+ const warnings = [];
61
+ if (baseline.suiteHash !== current.suiteHash)
62
+ warnings.push('suite-changed');
63
+ if (Date.parse(current.startedAt) < Date.parse(baseline.startedAt))
64
+ warnings.push('reverse-order');
65
+ if (baseline.status === 'interrupted' || baseline.status === 'error' || current.status === 'interrupted' || current.status === 'error') {
66
+ warnings.push('incomplete-run');
67
+ }
68
+ if (baseline.target.kind === 'mobile')
69
+ warnings.push('mobile-identity-unverified');
70
+ const counts = Object.fromEntries(comparisonCategories.map((value) => [value, 0]));
71
+ const cases = [...new Set([...before.keys(), ...after.keys()])].sort().map((id) => {
72
+ const baselineCase = before.get(id);
73
+ const currentCase = after.get(id);
74
+ const transition = category(baselineCase, currentCase);
75
+ counts[transition]++;
76
+ return { id, category: transition, baseline: caseSnapshot(baselineCase), current: caseSnapshot(currentCase) };
77
+ });
78
+ return { schemaVersion: 'flow-qa/comparison-1', baseline: runSummary(baseline), current: runSummary(current), warnings, counts, cases };
79
+ }
80
+ function safeInline(value, locale) {
81
+ return value.replace(/(?:https?:\/\/|file:\/\/)[^\s<>"'\])}]+/gi, localized(locale, '[URL hidden]', '[URL 숨김]'))
82
+ .replace(/\[[^\]]*\]\([^)]*\)/g, localized(locale, '[link hidden]', '[링크 숨김]'))
83
+ .replace(/[\x00-\x1f\x7f\u0085\u2028\u2029]/g, (character) => {
84
+ if (character === '\n')
85
+ return '\\n';
86
+ if (character === '\r')
87
+ return '\\r';
88
+ if (character === '\t')
89
+ return '\\t';
90
+ return `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`;
91
+ }).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
92
+ .replace(/([\\`*_{}\[\]()#+.!|~:])/g, '\\$1');
93
+ }
94
+ export function renderComparisonMarkdown(comparison, locale = 'en') {
95
+ const label = (english, korean) => localized(locale, english, korean);
96
+ const safe = (value) => safeInline(value, locale);
97
+ const run = (name, item) => [
98
+ `## ${name}`,
99
+ '',
100
+ `- ${label('Run ID', '실행 ID')}: ${safe(item.runId)}`,
101
+ `- ${label('Suite ID', '케이스 모음 ID')}: ${safe(item.suiteId)}`,
102
+ `- ${label('Suite hash', '케이스 모음 해시')}: ${safe(item.suiteHash)}`,
103
+ `- ${label('Target', '대상')}: ${safe(item.target.name)} ${item.target.kind === 'mobile'
104
+ ? safe(targetLabel(item.target))
105
+ : label('(URL available in the local JSON comparison)', '(주소는 로컬 JSON 비교 결과에서 확인)')}`,
106
+ `- ${label('Started', '시작')}: ${safe(item.startedAt)}`,
107
+ `- ${label('Finished', '종료')}: ${safe(item.finishedAt)}`,
108
+ `- ${label('Status', '상태')}: ${safe(item.status)}`,
109
+ '',
110
+ ];
111
+ const warningText = {
112
+ 'suite-changed': label('Steps or expectations may differ because the suite changed; these outcomes do not prove a regression or fix.', '케이스 모음이 변경되어 단계나 기대 결과가 다를 수 있습니다. 이 결과만으로 회귀나 수정이 입증되지는 않습니다.'),
113
+ 'reverse-order': label('The selected current run started before the baseline run.', '선택한 현재 실행이 기준 실행보다 먼저 시작됐습니다.'),
114
+ 'incomplete-run': label('At least one run ended with error or interruption; case results may be incomplete.', '하나 이상의 실행이 오류 또는 중단으로 끝나 케이스 결과가 불완전할 수 있습니다.'),
115
+ 'mobile-identity-unverified': label('The recorded app and device settings match, but these reports do not verify that both runs used the same physical device.', '기록된 앱과 기기 설정은 같지만, 두 실행이 같은 실제 기기에서 진행됐는지는 이 리포트로 확인할 수 없습니다.'),
116
+ };
117
+ const lines = [
118
+ `# ${label('QA run comparison', 'QA 실행 비교')}`,
119
+ '',
120
+ ...run(label('Baseline', '기준 실행'), comparison.baseline),
121
+ ...run(label('Current', '현재 실행'), comparison.current),
122
+ `## ${label('Transition counts', '상태 전이 개수')}`,
123
+ '',
124
+ ...comparisonCategories.map((category) => `- ${category}: ${comparison.counts[category]}`),
125
+ '',
126
+ `## ${label('Warnings', '주의 사항')}`,
127
+ '',
128
+ ...(comparison.warnings.length
129
+ ? comparison.warnings.map((warning) => `- ${warning}: ${warningText[warning]}`)
130
+ : [`- ${label('None', '없음')}`]),
131
+ '',
132
+ label('These categories describe observed status transitions only. They do not establish the cause of a failure or prove that a defect was fixed.', '이 분류는 관찰된 상태 전이만 나타냅니다. 실패 원인이나 결함 수정 여부를 입증하지 않습니다.'),
133
+ '',
134
+ `## ${label('Cases', '케이스')}`,
135
+ '',
136
+ ];
137
+ for (const item of comparison.cases) {
138
+ lines.push(`### ${safe(item.id)} — ${item.category}`, '');
139
+ for (const [name, entry] of [[label('Baseline', '기준 실행'), item.baseline], [label('Current', '현재 실행'), item.current]]) {
140
+ if (!entry) {
141
+ lines.push(`- ${name}: ${label('Absent', '없음')}`);
142
+ continue;
143
+ }
144
+ lines.push(`- ${name}: ${safe(entry.status)} · ${safe(entry.title)}`);
145
+ lines.push(` - ${label('Expected', '기대 결과')}: ${entry.expected.length ? entry.expected.map(safe).join('; ') : label('None', '없음')}`);
146
+ if (entry.reason !== undefined)
147
+ lines.push(` - ${label('Reason', '실행 상태 근거')}: ${safe(entry.reason)}`);
148
+ }
149
+ lines.push('');
150
+ }
151
+ return lines.join('\n');
152
+ }
@@ -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, 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'; }
@@ -226,13 +226,111 @@
226
226
  const runs = app.state?.runs || [];
227
227
  if (!runs.length) { host.append(el('p', 'empty', t(app.state?.active ? 'app.historyActiveEmpty' : 'app.historyEmpty'))); return; }
228
228
  const status = $('history-status-filter').value;
229
- const matchingRuns = status ? runs.filter((run) => run.status === status) : runs;
230
- if (!matchingRuns.length) { host.append(el('p', 'empty', t('app.historyNoStatusMatches'))); return; }
229
+ const query = $('history-search').value.trim().toLowerCase();
230
+ const matchingRuns = runs.filter((run) => {
231
+ const matchesStatus = !status || run.status === status;
232
+ const matchesSearch = !query || [run.suiteId, run.runId, run.target?.name]
233
+ .some((field) => String(field ?? '').toLowerCase().includes(query));
234
+ return matchesStatus && matchesSearch;
235
+ });
236
+ if (!matchingRuns.length) { host.append(el('p', 'empty', t(query ? 'app.historyNoSearchMatches' : 'app.historyNoStatusMatches'))); return; }
231
237
  for (const run of matchingRuns) {
232
238
  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 })));
233
239
  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);
234
240
  }
235
241
  }
242
+ const comparisonCategories = ['newly-failed', 'now-passed', 'changed', 'unchanged', 'added', 'missing'];
243
+ const comparisonCategoryKeys = { 'newly-failed': 'comparison.newlyFailed', 'now-passed': 'comparison.nowPassed', changed: 'comparison.changed', unchanged: 'comparison.unchanged', added: 'comparison.added', missing: 'comparison.missing' };
244
+ const comparisonWarningKeys = { 'suite-changed': 'comparison.warningSuite', 'reverse-order': 'comparison.warningReverse', 'incomplete-run': 'comparison.warningIncomplete', 'mobile-identity-unverified': 'comparison.warningMobileIdentity' };
245
+ function comparisonPair() { return { baseline: $('compare-baseline').value, current: $('compare-current').value }; }
246
+ function comparisonValid() { const { baseline, current } = comparisonPair(); return !!baseline && !!current && baseline !== current; }
247
+ function comparisonStatus(key, params = {}, error = false) {
248
+ app.comparisonStatus = key; app.comparisonStatusParams = params;
249
+ const node = $('comparison-status'); node.textContent = t(key, params); node.classList.toggle('report-errors', error);
250
+ }
251
+ function comparisonControls() {
252
+ const { baseline, current } = comparisonPair();
253
+ $('compare-btn').disabled = !comparisonValid() || app.comparisonBusy;
254
+ $('compare-swap').disabled = !baseline || !current || baseline === current;
255
+ const ready = !!app.comparison && comparisonValid() && app.comparison.baseline.runId === baseline && app.comparison.current.runId === current;
256
+ for (const id of ['comparison-md', 'comparison-json', 'compare-open-baseline', 'compare-open-current']) $(id).disabled = !ready;
257
+ $('comparison-results').hidden = !ready;
258
+ }
259
+ function clearComparison() {
260
+ app.comparisonRequest += 1; app.comparisonBusy = false; app.comparison = null; app.comparisonMarkdown = '';
261
+ $('comparison-summary').replaceChildren(); $('comparison-warnings').replaceChildren(); $('comparison-content').replaceChildren();
262
+ comparisonStatus('comparison.selectBoth'); comparisonControls();
263
+ }
264
+ function renderComparisonOptions() {
265
+ const runs = (app.state?.runs || []).slice(0, 50);
266
+ let missing = false;
267
+ for (const id of ['compare-baseline', 'compare-current']) {
268
+ const select = $(id); const selected = select.value;
269
+ select.replaceChildren(el('option', '', t('comparison.choose'))); select.firstChild.value = '';
270
+ for (const run of runs) {
271
+ 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) }));
272
+ option.value = run.runId; select.append(option);
273
+ }
274
+ select.value = runs.some((run) => run.runId === selected) ? selected : '';
275
+ if (selected && !select.value) missing = true;
276
+ }
277
+ if (missing) clearComparison();
278
+ if (!runs.length && !app.comparison) comparisonStatus('comparison.noRuns');
279
+ comparisonControls();
280
+ }
281
+ async function compareRuns() {
282
+ if (!comparisonValid()) return;
283
+ const { baseline, current } = comparisonPair(); const locale = i18n.locale(); const request = ++app.comparisonRequest;
284
+ app.comparisonBusy = true; app.comparison = null; app.comparisonMarkdown = '';
285
+ comparisonStatus('comparison.loading'); comparisonControls();
286
+ try {
287
+ const result = await api(`/api/comparison?baseline=${encodeURIComponent(baseline)}&current=${encodeURIComponent(current)}`);
288
+ if (request !== app.comparisonRequest || locale !== i18n.locale() || baseline !== $('compare-baseline').value || current !== $('compare-current').value) return;
289
+ app.comparison = result.comparison; app.comparisonMarkdown = result.markdown;
290
+ comparisonStatus('comparison.ready'); renderComparison();
291
+ } catch (error) {
292
+ 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);
293
+ } finally { if (request === app.comparisonRequest) { app.comparisonBusy = false; comparisonControls(); } }
294
+ }
295
+ function renderComparison() {
296
+ const result = app.comparison; if (!result) { comparisonControls(); return; }
297
+ const warnings = $('comparison-warnings'); replace(warnings);
298
+ for (const code of result.warnings || []) warnings.append(el('p', 'comparison-warning', t(comparisonWarningKeys[code] || 'comparison.warningUnknown', { code })));
299
+ const summary = $('comparison-summary'); replace(summary);
300
+ const meta = el('p', 'comparison-meta', `${result.baseline.runId} · ${formatDate(result.baseline.startedAt)} → ${result.current.runId} · ${formatDate(result.current.startedAt)}`); summary.append(meta);
301
+ const counts = el('div', 'comparison-counts');
302
+ for (const category of comparisonCategories) {
303
+ const item = el('div', 'comparison-count');
304
+ item.append(el('strong', '', String(result.counts?.[category] ?? 0)), el('span', '', t(comparisonCategoryKeys[category]))); counts.append(item);
305
+ }
306
+ summary.append(counts);
307
+ const host = $('comparison-content'); replace(host);
308
+ const selected = $('comparison-filter').value;
309
+ const cases = (result.cases || []).filter((item) => !selected || item.category === selected);
310
+ if (!cases.length) host.append(el('p', 'empty', t('comparison.noCases')));
311
+ for (const item of cases) {
312
+ const section = el('section', 'case-report comparison-case'); section.dataset.category = item.category;
313
+ 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') })}`));
314
+ for (const [side, entry] of [['baseline', item.baseline], ['current', item.current]]) {
315
+ if (!entry) continue;
316
+ section.append(el('p', '', t(side === 'baseline' ? 'comparison.baselineCase' : 'comparison.currentCase', { title: entry.title, status: i18n.status(entry.status) })));
317
+ 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); }
318
+ if (entry.reason) section.append(el('p', '', t('comparison.reason', { value: entry.reason })));
319
+ }
320
+ host.append(section);
321
+ }
322
+ comparisonControls();
323
+ }
324
+ async function downloadComparisonMarkdown() {
325
+ if (!app.comparison || !comparisonValid()) return;
326
+ const { baseline, current } = comparisonPair(); const locale = i18n.locale(); const request = app.comparisonRequest;
327
+ try {
328
+ const result = await api(`/api/comparison?baseline=${encodeURIComponent(baseline)}&current=${encodeURIComponent(current)}`);
329
+ if (request !== app.comparisonRequest || locale !== i18n.locale() || baseline !== $('compare-baseline').value || current !== $('compare-current').value || !app.comparison) return;
330
+ app.comparisonMarkdown = result.markdown;
331
+ download(`${baseline}-to-${current}.md`, result.markdown, 'text/markdown');
332
+ } catch (error) { if (request === app.comparisonRequest && locale === i18n.locale()) comparisonStatus('comparison.error', { message: error instanceof Error ? error.message : String(error) }, true); }
333
+ }
236
334
  function renderActive() {
237
335
  const active = app.state?.active;
238
336
  $('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');
@@ -252,7 +350,7 @@
252
350
  const libraryChanged = JSON.stringify(app.state?.suites) !== JSON.stringify(state.suites);
253
351
  const historyChanged = JSON.stringify(app.state?.runs) !== JSON.stringify(state.runs);
254
352
  app.state = state; app.connected = true;
255
- renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) renderHistory();
353
+ renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) { renderHistory(); renderComparisonOptions(); }
256
354
  if (full || initialize) { targetOptions(); modelForms(); }
257
355
  if (previousActive?.kind === 'run' && !state.active) { if (state.lastError) notice(state.lastError, true); else noticeKey('app.runEnded'); }
258
356
  if (state.lastError && state.lastError !== app.lastError) { app.lastError = state.lastError; notice(state.lastError, true); }
@@ -400,7 +498,8 @@
400
498
  if (input) { input.focus(); if (Number.isInteger(focus.start)) input.setSelectionRange(focus.start, focus.end); }
401
499
  }
402
500
  summarize(app.draft, true);
403
- renderActive(); targetDescription(); renderLibrary(); renderHistory(); renderReport();
501
+ renderActive(); targetDescription(); renderLibrary(); renderHistory(); renderComparisonOptions(); renderComparison(); renderReport();
502
+ if (app.comparisonStatus) comparisonStatus(app.comparisonStatus, app.comparisonStatusParams || {}, app.comparisonStatus === 'comparison.error');
404
503
  app.reportMarkdown = '';
405
504
  if (app.notice) $('notice').textContent = t(app.notice.key, app.notice.params);
406
505
  else if (!$('notice').hidden) notice(''); // A server error has no stable code to translate; the next request uses the selected language.
@@ -409,7 +508,16 @@
409
508
  window.addEventListener('beforeunload', warnBeforeUnload);
410
509
  document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => view(button.dataset.view)));
411
510
  $('library-search').addEventListener('input', renderLibrary);
511
+ $('history-search').addEventListener('input', renderHistory);
412
512
  $('history-status-filter').addEventListener('change', renderHistory);
513
+ for (const id of ['compare-baseline', 'compare-current']) $(id).addEventListener('change', clearComparison);
514
+ $('compare-btn').addEventListener('click', compareRuns);
515
+ $('compare-swap').addEventListener('click', () => { const pair = comparisonPair(); $('compare-baseline').value = pair.current; $('compare-current').value = pair.baseline; clearComparison(); });
516
+ $('comparison-filter').addEventListener('change', renderComparison);
517
+ $('comparison-md').addEventListener('click', downloadComparisonMarkdown);
518
+ $('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'); } });
519
+ $('compare-open-baseline').addEventListener('click', () => { if (app.comparison && comparisonValid()) loadReport(app.comparison.baseline.runId); });
520
+ $('compare-open-current').addEventListener('click', () => { if (app.comparison && comparisonValid()) loadReport(app.comparison.current.runId); });
413
521
  $('nav-toggle').addEventListener('click', () => { const open = $('sidebar').classList.toggle('nav-open'); $('nav-toggle').setAttribute('aria-expanded', String(open)); });
414
522
  $('library-shortcut').addEventListener('click', () => view('library'));
415
523
  $('new-draft-btn').addEventListener('click', () => {
@@ -440,7 +548,7 @@
440
548
  $('target-form').addEventListener('submit', saveTarget);
441
549
  $('target-kind').addEventListener('change', targetFields); targetFields();
442
550
  $('language-select').addEventListener('change', (event) => i18n.setLocale(event.currentTarget.value));
443
- i18n.onChange(renderLanguage);
551
+ i18n.onChange(() => { app.comparisonRequest += 1; if (app.comparisonBusy || app.comparisonStatus === 'comparison.error') comparisonStatus(app.comparison ? 'comparison.ready' : 'comparison.selectBoth'); app.comparisonBusy = false; renderLanguage(); comparisonControls(); });
444
552
  let refreshing = false;
445
553
  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; } };
446
554
  document.addEventListener('visibilitychange', poll);
@@ -79,8 +79,49 @@
79
79
  'history.intro': ['Only runs that actually started appear here. Once results are saved, you can view the detailed report.', '실제로 시작된 실행만 표시합니다. 결과가 저장되면 상세 보고서를 볼 수 있습니다.'],
80
80
  'history.title': ['Runs', '실행 목록'],
81
81
  'history.help': ['Check the completed report for the final status.', '상태는 완료된 보고서 기준으로 확인하세요.'],
82
+ 'history.searchLabel': ['Search runs', '실행 기록 검색'],
83
+ 'history.searchPlaceholder': ['Suite ID, run ID, or target name', '스위트 ID, 실행 ID 또는 대상 이름'],
82
84
  'history.statusFilter': ['Run status', '실행 상태'],
83
85
  'history.statusAll': ['All statuses', '전체 상태'],
86
+ 'comparison.title': ['Compare two runs', '실행 2건 비교'],
87
+ 'comparison.help': ['Choose a baseline and current run to compare case outcomes. Inspect detailed reports for evidence.', '기준 실행과 현재 실행을 선택해 케이스 결과를 비교하세요. 증거는 상세 보고서에서 확인하세요.'],
88
+ 'comparison.baseline': ['Baseline run', '기준 실행'],
89
+ 'comparison.current': ['Current run', '현재 실행'],
90
+ 'comparison.choose': ['Choose a run', '실행 선택'],
91
+ 'comparison.compare': ['Compare', '비교하기'],
92
+ 'comparison.swap': ['Swap runs', '실행 순서 바꾸기'],
93
+ 'comparison.filter': ['Show cases', '케이스 표시'],
94
+ 'comparison.all': ['All categories', '전체 분류'],
95
+ 'comparison.newlyFailed': ['Newly failed', '새로 실패'],
96
+ 'comparison.nowPassed': ['Now passed', '이제 통과'],
97
+ 'comparison.changed': ['Changed status', '상태 변경'],
98
+ 'comparison.unchanged': ['Same status', '상태 동일'],
99
+ 'comparison.added': ['Added to current', '현재에만 있음'],
100
+ 'comparison.missing': ['Missing from current', '현재 기록에 없음'],
101
+ 'comparison.downloadMarkdown': ['Download comparison Markdown', '비교 Markdown 다운로드'],
102
+ 'comparison.downloadJson': ['Download comparison JSON', '비교 JSON 다운로드'],
103
+ 'comparison.openBaseline': ['View baseline report', '기준 실행 보고서 보기'],
104
+ 'comparison.openCurrent': ['View current report', '현재 실행 보고서 보기'],
105
+ 'comparison.selectBoth': ['Select two different runs to compare.', '서로 다른 실행 2건을 선택하세요.'],
106
+ 'comparison.noRuns': ['No recent runs available to compare.', '비교할 최근 실행이 없습니다.'],
107
+ 'comparison.loading': ['Comparing selected runs…', '선택한 실행을 비교하고 있습니다…'],
108
+ '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건에서 선택하세요. 비교 수치는 관찰된 상태만 나타내며 실패 원인이나 결함 수정 여부를 입증하지 않습니다.'],
109
+ 'comparison.ready': ['Comparison ready. Categories describe observed case status changes.', '비교 결과가 준비됐습니다. 분류는 관찰된 케이스 상태 차이입니다.'],
110
+ 'comparison.noCases': ['No cases in this category.', '이 분류에 해당하는 케이스가 없습니다.'],
111
+ 'comparison.warningSuite': ['Suite content changed. Steps or expectations may differ, so these counts do not establish equivalent regressions or fixes.', '스위트 내용이 바뀌었습니다. 단계나 기대 결과가 다를 수 있으므로 이 수치만으로 같은 조건의 회귀나 수정이라고 판단할 수 없습니다.'],
112
+ 'comparison.warningReverse': ['The selected current run started before the baseline run.', '선택한 현재 실행이 기준 실행보다 먼저 시작됐습니다.'],
113
+ 'comparison.warningIncomplete': ['At least one run is incomplete. Interpret case outcomes with its detailed report.', '완료되지 않은 실행이 있습니다. 상세 보고서와 함께 결과를 해석하세요.'],
114
+ 'comparison.warningMobileIdentity': ['Mobile comparison matches recorded app and device settings. It does not verify that both runs used the same physical device.', '모바일 비교는 기록된 앱과 기기 설정을 기준으로 합니다. 두 실행이 같은 실제 기기를 사용했는지는 확인하지 않습니다.'],
115
+ 'comparison.warningUnknown': ['Comparison warning: {code}', '비교 주의: {code}'],
116
+ 'comparison.pair': ['{baseline} → {current}', '{baseline} → {current}'],
117
+ 'comparison.notPresent': ['not present', '없음'],
118
+ 'comparison.baselineCase': ['Baseline: {title} · {status}', '기준: {title} · {status}'],
119
+ 'comparison.currentCase': ['Current: {title} · {status}', '현재: {title} · {status}'],
120
+ 'comparison.expected': ['Expected: {value}', '기대 결과: {value}'],
121
+ 'comparison.reason': ['Reason: {value}', '사유: {value}'],
122
+ 'comparison.count': ['{category}: {count}', '{category}: {count}'],
123
+ 'comparison.error': ['Comparison failed: {message}', '비교 실패: {message}'],
124
+ 'comparison.runLabel': ['{date} · {suite} · {shortId} · {target} · {status} · {id}', '{date} · {suite} · {shortId} · {target} · {status} · {id}'],
84
125
  'history.report': ['Detailed report', '상세 보고서'],
85
126
  'history.downloadMarkdown': ['Download Markdown', 'Markdown 다운로드'],
86
127
  'history.downloadJson': ['Download JSON', 'JSON 다운로드'],
@@ -216,6 +257,7 @@
216
257
  'app.historyActiveEmpty': ['First run in progress. Its report will appear here when complete.', '첫 실행을 처리 중입니다. 완료되면 이곳에 보고서가 표시됩니다.'],
217
258
  'app.historyEmpty': ['No runs yet. Validate cases and select a target to start the first run.', '아직 실행 기록이 없습니다. 케이스를 검증하고 대상을 선택해 첫 실행을 시작하세요.'],
218
259
  'app.historyNoStatusMatches': ['No runs match this status. Choose another status or All statuses.', '이 상태에 해당하는 실행이 없습니다. 다른 상태나 전체 상태를 선택하세요.'],
260
+ 'app.historyNoSearchMatches': ['No runs match your search and status filters. Change the search or status.', '검색어와 상태 조건에 맞는 실행이 없습니다. 검색어나 상태를 바꿔보세요.'],
219
261
  'app.noTarget': ['no target', '대상 없음'],
220
262
  'app.runMeta': ['{date} · {target} · {passed}/{total} passed · {id}', '{date} · {target} · {passed}/{total} 통과 · {id}'],
221
263
  'app.viewReport': ['View report', '보고서 보기'],
@@ -92,8 +92,18 @@
92
92
  </section>
93
93
  <section id="view-history" class="view" hidden>
94
94
  <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
- <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-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>
95
+ <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="comparison-panel" class="comparison-panel" aria-labelledby="comparison-title">
97
+ <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>
98
+ <div class="section-body">
99
+ <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>
100
+ <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>
101
+ <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>
102
+ <p id="comparison-status" class="hint" role="status" aria-live="polite"></p>
103
+ <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>
104
+ </div>
105
+ </section>
106
+ <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
107
  </section>
98
108
  <section id="view-settings" class="view" hidden>
99
109
  <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; }
@@ -130,13 +130,31 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
130
130
  .review-line input { width: 18px; height: 18px; flex: none; margin: 2px 0 0; accent-color: var(--accent-solid); }
131
131
  .run-bar { display: flex; align-items: end; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: auto; padding-top: 18px; }
132
132
  .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); }
133
+ .list-section, .report-panel, .comparison-panel { margin-top: calc(var(--space-factor) * 28px); }
134
134
  .list-body { min-width: 0; }
135
135
  .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
136
  .list-row > div { min-width: 0; }
137
137
  .list-row strong { display: block; font-size: 15px; overflow-wrap: anywhere; }
138
138
  .list-row small { display: block; margin-top: 6px; color: var(--muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
139
139
  .empty { max-width: 50ch; margin: 0; padding: 32px 0; color: var(--muted); line-height: 1.7; }
140
+
141
+ .comparison-selects { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
142
+ .comparison-selects > div { min-width: 0; }
143
+ .comparison-selects label, .comparison-filter-row label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 750; }
144
+ .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); }
145
+ .comparison-actions { margin: 18px 0; }
146
+ #comparison-status { min-height: 20px; }
147
+ .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; }
148
+ .comparison-meta { font-size: 13px; overflow-wrap: anywhere; }
149
+ .comparison-counts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-block: 1px solid var(--line); margin: 18px 0; }
150
+ .comparison-count { min-width: 0; padding: 12px; border-right: 1px solid var(--line); overflow-wrap: anywhere; }
151
+ .comparison-count:nth-child(3n) { border-right: 0; }
152
+ .comparison-count:nth-child(n+4) { border-top: 1px solid var(--line); }
153
+ .comparison-count strong, .comparison-count span { display: block; }
154
+ .comparison-count strong { font-size: 22px; }
155
+ .comparison-count span { color: var(--muted); font-size: 12px; }
156
+ .comparison-filter-row { max-width: 320px; }
157
+ #comparison-content, .comparison-panel { min-width: 0; }
140
158
  .report-panel .actions { margin-bottom: 25px; }
141
159
  .report-meta { display: flex; flex-wrap: wrap; gap: 9px; margin: 0 0 23px; }
142
160
  .report-meta span { padding: 5px 9px; border: 1px solid var(--line); border-radius: var(--radius-control); font-size: 12px; }
@@ -189,6 +207,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
189
207
  .execution-grid { grid-template-columns: 1fr; gap: 16px; }
190
208
  .run-bar { align-items: start; flex-direction: column; }
191
209
  .list-row { align-items: start; flex-direction: column; gap: 12px; }
210
+ .comparison-selects { grid-template-columns: 1fr; }
211
+ .comparison-count { padding: 9px 5px; }
212
+ .comparison-count strong { font-size: 19px; }
192
213
  .draft-topline { align-items: start; flex-direction: column; }
193
214
  .summary { padding: 17px; }
194
215
  .summary-stat { padding-inline: 5px; }
@@ -13,6 +13,7 @@ import { localeFromAcceptLanguage, localizeProductMessage } from "../i18n.js";
13
13
  import { EvidenceError, readEvidence } from "./evidence.js";
14
14
  import { buildPlanningPrompt, planFlow, PlannerError } from "../planner.js";
15
15
  import { renderMarkdown } from "../report.js";
16
+ import { compareReports, ComparisonError, renderComparisonMarkdown } from "../run-comparison.js";
16
17
  import { configSchema, idSchema, modelSchema, suiteSchema, targetSchema } from "../schema.js";
17
18
  import { targetFingerprint } from "../targets.js";
18
19
  import { CONFIG_FILE, sha256 } from "../workspace.js";
@@ -468,6 +469,25 @@ export async function startUIServer(options) {
468
469
  sendJSON(response, 200, await report(runId, true, locale));
469
470
  return;
470
471
  }
472
+ if (url.pathname === '/api/comparison') {
473
+ const baseline = url.searchParams.get('baseline');
474
+ const current = url.searchParams.get('current');
475
+ if (!baseline || !current)
476
+ invalid('Both run IDs are required.');
477
+ const baselineReport = (await report(baseline, false)).report;
478
+ const currentReport = (await report(current, false)).report;
479
+ let comparison;
480
+ try {
481
+ comparison = compareReports(baselineReport, currentReport);
482
+ }
483
+ catch (error) {
484
+ if (error instanceof ComparisonError)
485
+ throw new HTTPError(error.status, error.message);
486
+ throw error;
487
+ }
488
+ sendJSON(response, 200, { comparison, markdown: renderComparisonMarkdown(comparison, locale) });
489
+ return;
490
+ }
471
491
  if (url.pathname === '/api/artifact') {
472
492
  const runId = url.searchParams.get('runId');
473
493
  const caseId = url.searchParams.get('caseId');
package/docs/mobile.md CHANGED
@@ -9,7 +9,7 @@ Choose **Mobile** on the scenario screen, describe the flow, and generate or dow
9
9
  With the installed CLI:
10
10
 
11
11
  ```sh
12
- npm install -g @ihyeon/flow-qa@0.2.8
12
+ npm install -g @ihyeon/flow-qa@0.3.0
13
13
  flow-qa init ./my-qa
14
14
  # Write your mobile flow in my-qa/flows/my-flow.md first.
15
15
  flow-qa plan flows/my-flow.md --platform mobile --prompt-only --workspace ./my-qa
package/docs/releases.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  The alpha entries below are historical release records. They document earlier package versions and checks.
4
4
 
5
+ ## 0.3.0: compare recorded runs
6
+
7
+ Adds a two-run comparison workspace to Run history. Select a baseline and current report, swap their direction, filter by case transition, open either detailed report, and export the complete comparison as Markdown or JSON. Cases are matched by ID, with distinct categories for passed-to-failed, failed-to-passed, other changes, equal statuses, added, and missing. Flaky, blocked, skipped, and execution-error results retain their meaning.
8
+
9
+ The read-only comparison endpoint requires the same suite ID and recorded target settings, rejects ambiguous case IDs, and uses the existing safe report loader. Suite changes, reversed dates, and interrupted/error runs are explicitly flagged. Mobile comparisons also warn that recorded device selectors do not establish physical device identity. Comparisons describe saved outcomes, not proof of a regression or fixed defect. Shared Markdown hides URLs and links under the existing report policy; JSON retains recorded metadata. Selections remain across navigation, language changes, and history updates; selection changes or disappeared runs invalidate old exports, and late responses cannot replace a newer comparison. The existing report format is unchanged.
10
+
11
+ Locally verified on macOS with Node.js 24.22.1 on 2026-11-02: 101 tests, type checking, UI syntax and design-system audit, existing browser/demo checks, and comparison UI checks using synthetic report fixtures. All 36 terminal-status combinations, compatibility gates, URL/link hiding, English/Korean exports, filtering, polling, navigation, 390px layout, late responses, and stale downloads were covered. A fresh tarball installation ran two real Chromium demo runs (six passing cases) and compared their saved reports. No model calls or native-device execution were used.
12
+
13
+ - Package: [`@ihyeon/flow-qa@0.3.0`](https://www.npmjs.com/package/@ihyeon/flow-qa/v/0.3.0)
14
+ - Release: [GitHub 0.3.0](https://github.com/hyeonse-swing/flow-qa/releases/tag/v0.3.0)
15
+
16
+ ## 0.2.9: search run history
17
+
18
+ Adds Search runs to run history, matching suite IDs, run IDs, and target names with case-insensitive partial search. Surrounding whitespace is ignored, and the search combines with the existing status filter. Clearing the query preserves the selected status. The current query stays through navigation, language changes, and automatic history updates; open reports and downloads remain available. Search covers only the most recent 50 reports already loaded by the UI.
19
+
20
+ Locally verified on macOS with Node.js 24.22.1 on 2026-11-02: 92 tests, type checking, UI syntax and design-system audit, and real Chromium search checks using report fixtures alongside a real passing run. All three fields, Korean target names, case and whitespace handling, combined status filtering, empty results, clearing, navigation, language changes, new reports, filtered report opening and download, and a 390px viewport passed. Existing UI and demo checks, package validation, and a fresh tarball installation with three passing browser cases also passed. No model calls were used.
21
+
22
+ - Package: [`@ihyeon/flow-qa@0.2.9`](https://www.npmjs.com/package/@ihyeon/flow-qa/v/0.2.9)
23
+ - Release: [GitHub 0.2.9](https://github.com/hyeonse-swing/flow-qa/releases/tag/v0.2.9)
24
+
5
25
  ## 0.2.8: expand and collapse all case details
6
26
 
7
27
  Adds Expand all cases and Collapse all cases to Scenario case review. The controls appear when cases are available and change only the case details. Individual toggles, the first-case-open default on loading, and expansion state across language changes and navigation remain available. Bulk actions preserve the draft, JSON editor disclosure, review confirmation, and execution readiness.
@@ -91,7 +111,7 @@ Locally verified on macOS with Node.js 24.14.1 on 2026-10-12: 89 tests, type che
91
111
 
92
112
  A push to `main` publishes only when `package.json` changes to a new plain `X.Y.Z` version. Pull requests and pushes without a version change run verification without publishing. The package and lockfile versions must agree; the version must increase, and registry errors stop the release. Rerunning a successful publication skips the existing immutable npm version.
93
113
 
94
- The `publish` job in [ci.yml](../.github/workflows/ci.yml) waits for the full verification matrix on Ubuntu and macOS, then publishes through npm trusted publishing (OIDC). It uses the `latest` tag, requires the registry's `gitHead` to match the workflow commit, and checks the published package with a fresh consumer installation and three real Chromium demo cases. The commit check also applies when retrying an already published version. No npm token is stored in GitHub secrets. After the published package passes its consumer smoke check, a separate job creates the matching GitHub tag and release at the exact verified commit. It preserves existing releases and rejects a tag pointing to another commit. Rerunning an older release does not replace a newer latest release.
114
+ The `publish` job in [ci.yml](../.github/workflows/ci.yml) waits for the full verification matrix on Ubuntu and macOS, then publishes through npm trusted publishing (OIDC). It uses the `latest` tag, requires the registry's `gitHead` to match the workflow commit, and checks the published package with a fresh consumer installation, two real Chromium demo runs (six cases), and comparison of their saved reports. The commit check also applies when retrying an already published version. No npm token is stored in GitHub secrets. After the published package passes its consumer smoke check, a separate job creates the matching GitHub tag and release at the exact verified commit. It preserves existing releases and rejects a tag pointing to another commit. Rerunning an older release does not replace a newer latest release.
95
115
 
96
116
  For the next release, update the release notes and current installation examples, then bump the version before committing and pushing:
97
117
 
package/docs/ui.md CHANGED
@@ -7,7 +7,7 @@ Describe a user flow, review cases, and configure a target when ready to run. We
7
7
  Node.js 24.x (24.22.1 or later) or 26.12.1 or later is required. Node 25 is unsupported. Upgrade the Node runtime and restart any running UI process; updating Flow QA alone does not patch Node. Install Flow QA and start the web demo:
8
8
 
9
9
  ```sh
10
- npm install -g @ihyeon/flow-qa@0.2.8
10
+ npm install -g @ihyeon/flow-qa@0.3.0
11
11
  flow-qa browser install
12
12
  flow-qa ui --demo
13
13
  ```
@@ -32,7 +32,7 @@ The UI starts in **English**, regardless of browser language. Switch to **한국
32
32
  2. **Connections:** When ready, register a target name and web URL, or choose iOS/Android and enter an installed app's bundle/package ID and device. Planner and execution agent models have separate settings. Saving settings does not start an app or model request.
33
33
  3. **Review and save cases:** Expand each case, or use **Expand all cases**, to read preconditions, actions, expected results, and unresolved questions. **Collapse all cases** returns to the case titles. Editing the JSON keeps the linked flow; if that source flow changes, design the cases again. Planning and running are separate actions. Editing inputs or connection settings alone does not call a model.
34
34
  4. **Run QA:** Review the saved case file, select a matching web/mobile target, and check the review confirmation before running. If the file or target settings change after review, reload and review them again. Fixed steps need no agent model. `act` and `assertAgent` require a compatible agent; missing models and unresolved questions are reported as blocked cases.
35
- 5. **Case library and Run history:** Save cases as new files, search by title, suite ID, or file name, and load them again. Filter run history by status, then open run details to see each case, attempt, and evidence; download the Markdown or JSON report.
35
+ 5. **Case library and Run history:** Save cases as new files, search by title, suite ID, or file name, and load them again. Search run history by suite ID, run ID, or target name, combine it with a status filter, then open run details to see each case, attempt, and evidence; download the Markdown or JSON report.
36
36
 
37
37
  The top bar shows planning, running, and stopping states, plus a cancel button. A late planning response cannot change the draft after cancellation. The UI does not estimate progress inside an individual browser action. If the server disconnects, reconnect; runs started in another tab also appear. Refreshing settings rereads targets and models, so save edits first.
38
38
 
@@ -58,6 +58,20 @@ From version `0.2.6`, **Case library** filters saved suites as you type in **Sea
58
58
 
59
59
  From version `0.2.7`, **Run history → Run status** filters the loaded reports by passed, failed, blocked, error, or interrupted. Choose **All statuses** to restore the list. The selection stays while navigating, changing language, or receiving updated reports, and resets on page reload. The filter applies to the most recent 50 reports loaded by the UI; it does not search older history or individual case results. Filtering keeps the report already open and its downloads available.
60
60
 
61
+ ### Search run history
62
+
63
+ From version `0.2.9`, **Run history → Search runs** matches part of a suite ID, run ID, or target name. Matching ignores letter case and surrounding whitespace, and supports Korean target names. Search combines with **Run status**; clear the search to return to the selected status. The query stays through navigation, language changes, and automatic report updates, and resets on page reload. Search covers the most recent 50 reports loaded by the UI, not older records, target URLs, or individual case content. Changing the query keeps the open report and its downloads available.
64
+
65
+ ### Compare two runs
66
+
67
+ From version `0.3.0`, open **Run history → Compare two runs**, choose a **Baseline run** and **Current run**, then compare. Both selectors use the most recent 50 loaded reports, independently of the history search and status filter. The reports must belong to the same suite ID and recorded target settings: a matching web URL, or mobile platform, app ID, and device. Target display names may differ. Mobile reports record the configured device selector, which can be a name; all mobile comparisons warn that matching selectors do not establish that the same physical device was used. Comparing does not run tests, call a model, or change case review.
68
+
69
+ Cases are matched by ID. **Newly failed** means only `passed → failed`; **Now passed** means only `failed → passed`. Other status transitions, including flaky, blocked, skipped, and execution errors, appear under **Changed status**. Equal statuses are **Same status**. **Added to current** and **Missing from current** mean present in only one selected report; missing does not mean passed or deleted from the suite. Filter the visible rows by category, open either detailed report for its evidence, or download the complete comparison as Markdown or JSON. Downloads include every category, regardless of the display filter, and do not include evidence files. Markdown follows the existing report URL/link hiding policy; JSON retains the recorded target settings and case text.
70
+
71
+ When suite hashes differ, the UI and exports warn that steps or expectations may have changed. These are recorded outcomes, not proof of equivalent tests, a product regression, or a fixed defect. Additional warnings identify reversed chronological order and interrupted/error runs. The comparison never treats a flaky pass as a clean pass. The existing report format remains unchanged; comparison JSON uses `flow-qa/comparison-1`.
72
+
73
+ Swap reverses the baseline and current selections. Changing either selection clears the old result and downloads until a new comparison succeeds. Navigation, language changes, and history updates preserve valid selections and results; a selected run disappearing from the loaded list invalidates the comparison. Choices reset on page reload. Markdown product labels follow the selected language, while user-written case content and status codes are preserved.
74
+
61
75
  ### Import existing case files
62
76
 
63
77
  From version `0.2.1`, select a `flow-qa/suite-1` JSON file and, when it declares a source, its Markdown file in case review, then choose **Import case files**. The two file controls let you select files from different folders. For an error-to-test export, select `cases/error-to-test.json` and `flows/error-to-test.md`. A JSON file without a source can be imported alone or with a Markdown file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ihyeon/flow-qa",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "author": "Hyeonse Im",
6
6
  "repository": {
@@ -57,7 +57,7 @@
57
57
  "check:ui": "node --check src/ui/public/app.js && node --check src/ui/public/display.js && node --check src/ui/public/i18n.js && hds-audit .",
58
58
  "demo:serve": "node examples/demo/server.ts",
59
59
  "demo:smoke": "node scripts/demo-smoke.ts",
60
- "smoke:ui": "node scripts/ui-i18n-smoke.mjs"
60
+ "smoke:ui": "node scripts/ui-i18n-smoke.mjs && node scripts/ui-comparison-smoke.mjs"
61
61
  },
62
62
  "dependencies": {
63
63
  "@ai-sdk/openai-compatible": "3.0.53",