@ihyeon/flow-qa 0.2.9 → 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.9
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.9
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'; }
@@ -239,6 +239,98 @@
239
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);
240
240
  }
241
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
+ }
242
334
  function renderActive() {
243
335
  const active = app.state?.active;
244
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');
@@ -258,7 +350,7 @@
258
350
  const libraryChanged = JSON.stringify(app.state?.suites) !== JSON.stringify(state.suites);
259
351
  const historyChanged = JSON.stringify(app.state?.runs) !== JSON.stringify(state.runs);
260
352
  app.state = state; app.connected = true;
261
- renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) renderHistory();
353
+ renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) { renderHistory(); renderComparisonOptions(); }
262
354
  if (full || initialize) { targetOptions(); modelForms(); }
263
355
  if (previousActive?.kind === 'run' && !state.active) { if (state.lastError) notice(state.lastError, true); else noticeKey('app.runEnded'); }
264
356
  if (state.lastError && state.lastError !== app.lastError) { app.lastError = state.lastError; notice(state.lastError, true); }
@@ -406,7 +498,8 @@
406
498
  if (input) { input.focus(); if (Number.isInteger(focus.start)) input.setSelectionRange(focus.start, focus.end); }
407
499
  }
408
500
  summarize(app.draft, true);
409
- 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');
410
503
  app.reportMarkdown = '';
411
504
  if (app.notice) $('notice').textContent = t(app.notice.key, app.notice.params);
412
505
  else if (!$('notice').hidden) notice(''); // A server error has no stable code to translate; the next request uses the selected language.
@@ -417,6 +510,14 @@
417
510
  $('library-search').addEventListener('input', renderLibrary);
418
511
  $('history-search').addEventListener('input', renderHistory);
419
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); });
420
521
  $('nav-toggle').addEventListener('click', () => { const open = $('sidebar').classList.toggle('nav-open'); $('nav-toggle').setAttribute('aria-expanded', String(open)); });
421
522
  $('library-shortcut').addEventListener('click', () => view('library'));
422
523
  $('new-draft-btn').addEventListener('click', () => {
@@ -447,7 +548,7 @@
447
548
  $('target-form').addEventListener('submit', saveTarget);
448
549
  $('target-kind').addEventListener('change', targetFields); targetFields();
449
550
  $('language-select').addEventListener('change', (event) => i18n.setLocale(event.currentTarget.value));
450
- 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(); });
451
552
  let refreshing = false;
452
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; } };
453
554
  document.addEventListener('visibilitychange', poll);
@@ -83,6 +83,45 @@
83
83
  'history.searchPlaceholder': ['Suite ID, run ID, or target name', '스위트 ID, 실행 ID 또는 대상 이름'],
84
84
  'history.statusFilter': ['Run status', '실행 상태'],
85
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}'],
86
125
  'history.report': ['Detailed report', '상세 보고서'],
87
126
  'history.downloadMarkdown': ['Download Markdown', 'Markdown 다운로드'],
88
127
  'history.downloadJson': ['Download JSON', 'JSON 다운로드'],
@@ -93,7 +93,17 @@
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
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="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>
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.9
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,17 @@
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
+
5
16
  ## 0.2.9: search run history
6
17
 
7
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.
@@ -100,7 +111,7 @@ Locally verified on macOS with Node.js 24.14.1 on 2026-10-12: 89 tests, type che
100
111
 
101
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.
102
113
 
103
- 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.
104
115
 
105
116
  For the next release, update the release notes and current installation examples, then bump the version before committing and pushing:
106
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.9
10
+ npm install -g @ihyeon/flow-qa@0.3.0
11
11
  flow-qa browser install
12
12
  flow-qa ui --demo
13
13
  ```
@@ -62,6 +62,16 @@ From version `0.2.7`, **Run history → Run status** filters the loaded reports
62
62
 
63
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
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
+
65
75
  ### Import existing case files
66
76
 
67
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.9",
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",