@ihyeon/flow-qa 0.5.0 → 0.6.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
@@ -72,6 +72,8 @@ CLI에서는 `flow-qa run cases/task-flow.json --target demo --case TC-001 --cas
72
72
 
73
73
  ## 실행 결과 비교
74
74
 
75
+ `0.6.0`부터 **실행 기록**에서 저장된 전체 보고서를 스위트 ID·실행 ID·대상 이름으로 검색하고 실행 상태로 함께 좁힐 수 있습니다. 결과는 50건씩 표시하며 **더 보기**로 이전 기록을 불러옵니다. **실행 기록 새로고침**은 검색·상태 조건을 유지한 채 목록을 갱신합니다. 오래된 보고서도 다운로드와 실패 케이스 준비를 사용할 수 있습니다. 자세한 동작은 [기록 검색·페이지 조회](docs/ui.md#search-run-history)를 참고하세요.
76
+
75
77
  **실행 기록 → 실행 2건 비교**에서 같은 스위트와 대상의 두 기록을 선택하면 통과→실패, 실패→통과, 그 외 상태 변경, 동일 상태, 추가·누락 케이스를 확인할 수 있습니다. 분류별로 보거나 전체 비교를 Markdown·JSON으로 내려받으세요. 테스트 내용 변경과 불완전한 실행은 별도로 표시하며, 상태 변화만으로 제품 버그의 발생·해결을 확정하지 않습니다. 자세한 동작은 [UI 가이드](docs/ui.md#compare-two-runs)를 참고하세요.
76
78
 
77
79
  ## 모바일 대상
package/README.md CHANGED
@@ -72,6 +72,8 @@ From `0.5.0`, open a detailed report and choose **Prepare failed/error cases** t
72
72
 
73
73
  ## Compare recorded runs
74
74
 
75
+ From `0.6.0`, **Run history** searches all valid saved reports by suite ID, run ID, or target name, combined with the run-status filter. Results load 50 at a time; use **Load more** for older results and **Refresh history** to update the list while keeping your filters. Older reports support the same downloads and failed-case preparation. See [history search and paging](docs/ui.md#search-run-history).
76
+
75
77
  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).
76
78
 
77
79
  ## Mobile targets
package/dist/src/i18n.js CHANGED
@@ -62,6 +62,10 @@ const koreanMessages = {
62
62
  'Invalid request origin.': '요청 출처가 올바르지 않습니다.',
63
63
  'A case file name is required.': '케이스 파일 이름이 필요합니다.',
64
64
  'A run ID is required.': '실행 ID가 필요합니다.',
65
+ 'Invalid run limit.': '조회할 실행 기록 수는 1~50 사이의 정수여야 합니다.',
66
+ 'Run query is too long.': '실행 기록 검색어는 200자 이하여야 합니다.',
67
+ 'Invalid run status.': '실행 상태 필터가 올바르지 않습니다.',
68
+ 'Invalid run cursor.': '이전 기록 조회 위치가 올바르지 않습니다. 기록을 새로고침한 뒤 다시 시도하세요.',
65
69
  'Both run IDs are required.': '비교할 두 실행 ID가 필요합니다.',
66
70
  'A comparison needs two different runs.': '서로 다른 두 실행을 선택해야 합니다.',
67
71
  'Both reports must belong to the same suite.': '두 리포트의 케이스 모음이 같아야 합니다.',
@@ -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, selectedCaseIds: new Set(), script: '', savedFile: '', savedHash: '', origin: 'none', sourceFlow: '', boundSource: null, stale: false, dirty: false, busy: false, planning: false, planRequest: 0, stopping: false, report: null, reportMarkdown: '', selectedRun: '', rerunOrigin: null, rerunTargetMismatch: false, navigationVersion: 0, comparison: null, comparisonRequest: 0, comparisonBusy: false, comparisonStatus: 'comparison.selectBoth', view: 'scenario' };
7
+ const app = { state: null, connected: false, displayTargets: [], draft: null, selectedCaseIds: new Set(), script: '', savedFile: '', savedHash: '', origin: 'none', sourceFlow: '', boundSource: null, stale: false, dirty: false, busy: false, planning: false, planRequest: 0, stopping: false, report: null, reportMarkdown: '', selectedRun: '', rerunOrigin: null, rerunTargetMismatch: false, navigationVersion: 0, comparison: null, comparisonRequest: 0, comparisonBusy: false, comparisonStatus: 'comparison.selectBoth', history: { runs: [], nextCursor: null, pagesLoaded: 0, loaded: false, loading: false, request: 0, debounce: null, kind: null, error: null, updatesAvailable: false, pendingRecentUpdate: false }, view: 'scenario' };
8
8
 
9
9
  function el(tag, className, value) {
10
10
  const node = document.createElement(tag);
@@ -54,7 +54,11 @@
54
54
  });
55
55
  $('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[name]) });
56
56
  $('sidebar').classList.remove('nav-open'); $('nav-toggle').setAttribute('aria-expanded', 'false');
57
- if (name === 'history') { renderHistory(); renderComparisonOptions(); }
57
+ if (name === 'history') {
58
+ if (!app.history.loaded && !app.history.loading) refreshHistory();
59
+ else renderHistory();
60
+ renderComparisonOptions();
61
+ }
58
62
  }
59
63
  function suitePlatform() { return app.draft?.platform || 'web'; }
60
64
  function targetPlatform(target) { return target?.kind === 'mobile' ? 'mobile' : 'web'; }
@@ -266,24 +270,98 @@
266
270
  const button = el('button', 'button', t('app.load')); button.type = 'button'; button.addEventListener('click', () => loadSuite(suite.file)); row.append(copy, button); host.append(row);
267
271
  }
268
272
  }
273
+ function historyFilters() { return { query: $('history-search').value.trim(), status: $('history-status-filter').value }; }
269
274
  function renderHistory() {
275
+ const history = app.history;
270
276
  const host = $('history-list'); replace(host);
271
- const runs = app.state?.runs || [];
272
- if (!runs.length) { host.append(el('p', 'empty', t(app.state?.active ? 'app.historyActiveEmpty' : 'app.historyEmpty'))); return; }
273
- const status = $('history-status-filter').value;
274
- const query = $('history-search').value.trim().toLowerCase();
275
- const matchingRuns = runs.filter((run) => {
276
- const matchesStatus = !status || run.status === status;
277
- const matchesSearch = !query || [run.suiteId, run.runId, run.target?.name]
278
- .some((field) => String(field ?? '').toLowerCase().includes(query));
279
- return matchesStatus && matchesSearch;
280
- });
281
- if (!matchingRuns.length) { host.append(el('p', 'empty', t(query ? 'app.historyNoSearchMatches' : 'app.historyNoStatusMatches'))); return; }
282
- for (const run of matchingRuns) {
277
+ host.setAttribute('aria-busy', String(history.loading));
278
+ if (!history.runs.length) {
279
+ const { query, status } = historyFilters();
280
+ const key = history.loading ? 'history.loading' : history.error && !history.loaded ? 'history.loadError'
281
+ : query ? 'app.historyNoSearchMatches' : status ? 'app.historyNoStatusMatches'
282
+ : app.state?.active ? 'app.historyActiveEmpty' : 'app.historyEmpty';
283
+ host.append(el('p', 'empty', t(key)));
284
+ }
285
+ for (const run of history.runs) {
283
286
  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 })));
284
287
  const scope = selectionScope(run.selection); if (scope) copy.append(el('small', 'selection-scope', scope));
285
288
  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);
286
289
  }
290
+ const messages = [];
291
+ if (history.loaded) messages.push(t('history.pageCount', { count: history.runs.length }));
292
+ if (history.loading) messages.push(t(history.kind === 'more' ? 'history.loadingMore' : 'history.loading'));
293
+ if (history.error) messages.push(t(history.error === 'more' ? 'history.loadMoreError' : 'history.loadError'));
294
+ if (history.updatesAvailable) messages.push(t('history.updatesAvailable'));
295
+ if (history.loaded && !history.loading && !history.error && !history.nextCursor) messages.push(t('history.end'));
296
+ const status = $('history-page-status'); status.textContent = messages.join(' · ');
297
+ status.classList.toggle('report-errors', !!history.error);
298
+ const more = $('history-load-more');
299
+ more.hidden = !history.loaded || !history.nextCursor;
300
+ more.disabled = history.loading;
301
+ more.textContent = t(history.error === 'more' ? 'history.retryMore' : 'history.loadMore');
302
+ }
303
+ async function fetchHistoryPage(request, filters, cursor = null) {
304
+ const history = app.history;
305
+ const append = cursor !== null;
306
+ const params = new URLSearchParams({ query: filters.query, status: filters.status, limit: '50' });
307
+ if (append) params.set('cursor', cursor);
308
+ try {
309
+ const result = await api(`/api/runs?${params}`);
310
+ if (request !== history.request) return;
311
+ if (!Array.isArray(result.runs) || !(result.nextCursor === null || typeof result.nextCursor === 'string')) throw new Error('Invalid run history response.');
312
+ const seen = new Set(append ? history.runs.map((run) => run.runId) : []);
313
+ const incoming = result.runs.filter((run) => {
314
+ if (seen.has(run.runId)) return false;
315
+ seen.add(run.runId); return true;
316
+ });
317
+ history.runs = append ? [...history.runs, ...incoming] : incoming;
318
+ history.nextCursor = result.nextCursor;
319
+ history.pagesLoaded = append ? history.pagesLoaded + 1 : 1;
320
+ history.loaded = true;
321
+ history.error = null;
322
+ if (!append) history.updatesAvailable = false;
323
+ } catch {
324
+ if (request !== history.request) return;
325
+ history.error = append ? 'more' : 'first';
326
+ } finally {
327
+ if (request !== history.request) return;
328
+ history.loading = false; history.kind = null;
329
+ if (history.pendingRecentUpdate) {
330
+ history.pendingRecentUpdate = false;
331
+ if (history.pagesLoaded > 1 || history.error === 'more') history.updatesAvailable = true;
332
+ else if (history.loaded && !history.error) { refreshHistory(); return; }
333
+ }
334
+ renderHistory();
335
+ }
336
+ }
337
+ function refreshHistory(delay = 0) {
338
+ const history = app.history;
339
+ if (history.debounce !== null) clearTimeout(history.debounce);
340
+ history.debounce = null;
341
+ const request = ++history.request;
342
+ history.runs = []; history.nextCursor = null; history.pagesLoaded = 0; history.loaded = false;
343
+ history.loading = true; history.kind = 'first'; history.error = null; history.updatesAvailable = false; history.pendingRecentUpdate = false;
344
+ renderHistory();
345
+ const filters = historyFilters();
346
+ if (delay) history.debounce = setTimeout(() => { history.debounce = null; void fetchHistoryPage(request, filters); }, delay);
347
+ else void fetchHistoryPage(request, filters);
348
+ }
349
+ function loadMoreHistory() {
350
+ const history = app.history;
351
+ if (!history.loaded || history.loading || !history.nextCursor) return;
352
+ const request = ++history.request;
353
+ history.loading = true; history.kind = 'more'; history.error = null;
354
+ renderHistory();
355
+ void fetchHistoryPage(request, historyFilters(), history.nextCursor);
356
+ }
357
+ function recentRunsChanged() {
358
+ const history = app.history;
359
+ if (history.loading) { history.pendingRecentUpdate = true; return; }
360
+ if (!history.loaded) return;
361
+ if (history.pagesLoaded > 1) {
362
+ history.updatesAvailable = true;
363
+ renderHistory();
364
+ } else refreshHistory();
287
365
  }
288
366
  const comparisonCategories = ['newly-failed', 'now-passed', 'changed', 'unchanged', 'added', 'missing'];
289
367
  const comparisonCategoryKeys = { 'newly-failed': 'comparison.newlyFailed', 'now-passed': 'comparison.nowPassed', changed: 'comparison.changed', unchanged: 'comparison.unchanged', added: 'comparison.added', missing: 'comparison.missing' };
@@ -396,7 +474,7 @@
396
474
  const libraryChanged = JSON.stringify(app.state?.suites) !== JSON.stringify(state.suites);
397
475
  const historyChanged = JSON.stringify(app.state?.runs) !== JSON.stringify(state.runs);
398
476
  app.state = state; app.connected = true;
399
- renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) { renderHistory(); renderComparisonOptions(); }
477
+ renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) { recentRunsChanged(); renderComparisonOptions(); }
400
478
  if (full || initialize) { targetOptions(); modelForms(); }
401
479
  if (previousActive?.kind === 'run' && !state.active) { if (state.lastError) notice(state.lastError, true); else noticeKey('app.runEnded'); }
402
480
  if (state.lastError && state.lastError !== app.lastError) { app.lastError = state.lastError; notice(state.lastError, true); }
@@ -578,8 +656,10 @@
578
656
  window.addEventListener('beforeunload', warnBeforeUnload);
579
657
  document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => view(button.dataset.view)));
580
658
  $('library-search').addEventListener('input', renderLibrary);
581
- $('history-search').addEventListener('input', renderHistory);
582
- $('history-status-filter').addEventListener('change', renderHistory);
659
+ $('history-search').addEventListener('input', () => refreshHistory(150));
660
+ $('history-status-filter').addEventListener('change', () => refreshHistory());
661
+ $('history-refresh').addEventListener('click', () => refreshHistory());
662
+ $('history-load-more').addEventListener('click', loadMoreHistory);
583
663
  for (const id of ['compare-baseline', 'compare-current']) $(id).addEventListener('change', clearComparison);
584
664
  $('compare-btn').addEventListener('click', compareRuns);
585
665
  $('compare-swap').addEventListener('click', () => { const pair = comparisonPair(); $('compare-baseline').value = pair.current; $('compare-current').value = pair.baseline; clearComparison(); });
@@ -89,6 +89,16 @@
89
89
  'history.searchPlaceholder': ['Suite ID, run ID, or target name', '스위트 ID, 실행 ID 또는 대상 이름'],
90
90
  'history.statusFilter': ['Run status', '실행 상태'],
91
91
  'history.statusAll': ['All statuses', '전체 상태'],
92
+ 'history.loadMore': ['Load more', '더 불러오기'],
93
+ 'history.retryMore': ['Retry loading more', '더 불러오기 재시도'],
94
+ 'history.refresh': ['Refresh history', '실행 기록 새로고침'],
95
+ 'history.pageCount': ['Showing {count} runs', '{count}건 표시 중'],
96
+ 'history.end': ['End of results', '모든 결과를 표시했습니다'],
97
+ 'history.loading': ['Loading run history…', '실행 기록을 불러오는 중…'],
98
+ 'history.loadingMore': ['Loading more runs…', '실행 기록을 더 불러오는 중…'],
99
+ 'history.loadError': ['Could not load run history. Try Refresh history.', '실행 기록을 불러오지 못했습니다. 실행 기록 새로고침을 눌러 다시 시도하세요.'],
100
+ 'history.loadMoreError': ['Could not load more runs. Retry loading more.', '실행 기록을 더 불러오지 못했습니다. 다시 시도하세요.'],
101
+ 'history.updatesAvailable': ['Recent runs changed. Refresh history to update this list.', '최근 실행 기록이 변경됐습니다. 실행 기록을 새로고침해 목록을 갱신하세요.'],
92
102
  'comparison.title': ['Compare two runs', '실행 2건 비교'],
93
103
  'comparison.help': ['Choose a baseline and current run to compare case outcomes. Inspect detailed reports for evidence.', '기준 실행과 현재 실행을 선택해 케이스 결과를 비교하세요. 증거는 상세 보고서에서 확인하세요.'],
94
104
  'comparison.baseline': ['Baseline run', '기준 실행'],
@@ -93,7 +93,7 @@
93
93
  </section>
94
94
  <section id="view-history" class="view" hidden>
95
95
  <div class="page-heading"><p class="eyebrow" data-i18n="history.eyebrow">03 / RUNS</p><h1 data-i18n="nav.history">Run history</h1><p data-i18n="history.intro">Only runs that actually started appear here. Once results are saved, you can view the detailed report.</p></div>
96
- <section class="list-section"><div class="section-head"><span class="section-number">01</span><div><h2 data-i18n="history.title">Runs</h2><p data-i18n="history.help">Check the completed report for the final status.</p></div></div><div class="section-body form-stack"><label for="history-search" data-i18n="history.searchLabel">Search runs</label><input id="history-search" type="search" aria-controls="history-list" placeholder="Suite ID, run ID, or target name" data-i18n-placeholder="history.searchPlaceholder"><label for="history-status-filter" data-i18n="history.statusFilter">Run status</label><select id="history-status-filter" aria-controls="history-list"><option value="" data-i18n="history.statusAll">All statuses</option><option value="passed" data-i18n="status.passed">passed</option><option value="failed" data-i18n="status.failed">failed</option><option value="blocked" data-i18n="status.blocked">blocked</option><option value="error" data-i18n="status.error">error</option><option value="interrupted" data-i18n="status.interrupted">interrupted</option></select><div id="history-list" class="list-body"></div></div></section>
96
+ <section 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" maxlength="200" 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" aria-busy="false"></div><p id="history-page-status" class="hint" role="status" aria-live="polite"></p><div class="actions"><button type="button" id="history-load-more" class="button" data-i18n="history.loadMore" hidden>Load more</button><button type="button" id="history-refresh" class="button quiet" data-i18n="history.refresh">Refresh history</button></div></div></section>
97
97
  <section id="comparison-panel" class="comparison-panel" aria-labelledby="comparison-title">
98
98
  <div class="section-head"><span class="section-number">02</span><div><h2 id="comparison-title" data-i18n="comparison.title">Compare two runs</h2><p data-i18n="comparison.help">Choose a baseline and current run to compare case outcomes. This does not replace either detailed report.</p></div></div>
99
99
  <div class="section-body">
@@ -28,6 +28,13 @@ const PLAN_TIMEOUT_MS = 65_000;
28
28
  const CLOSE_WAIT_MS = 10_000;
29
29
  const BROWSER_INSTALL_COMMAND = 'flow-qa browser install';
30
30
  const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
31
+ const RUN_STATUSES = new Set(['passed', 'failed', 'blocked', 'error', 'interrupted']);
32
+ const RUN_HISTORY_LIMIT = 50;
33
+ const RUN_CURSOR_MAX_LENGTH = 512;
34
+ const runCursorSchema = z.object({
35
+ version: z.literal(1), mtime: z.number().finite().nonnegative(), id: z.string().regex(RUN_ID),
36
+ filterHash: z.string().regex(/^[0-9a-f]{64}$/),
37
+ }).strict();
31
38
  const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
32
39
  const reportEnvelope = z.object({
33
40
  schemaVersion: z.literal('flow-qa/report-1'), runId: z.string().regex(RUN_ID), suiteId: idSchema,
@@ -69,6 +76,29 @@ class HTTPError extends Error {
69
76
  }
70
77
  function invalid(message = 'Invalid input.') { throw new HTTPError(400, message); }
71
78
  function notFound() { throw new HTTPError(404, 'The requested item was not found.'); }
79
+ function runFilterHash(query, status) {
80
+ return sha256(JSON.stringify([query, status]));
81
+ }
82
+ // The cursor is independent of page size, so clients may change limit between pages.
83
+ function encodeRunCursor(candidate, filterHash) {
84
+ return Buffer.from(JSON.stringify({ version: 1, mtime: candidate.mtime, id: candidate.id, filterHash })).toString('base64url');
85
+ }
86
+ function decodeRunCursor(value, filterHash) {
87
+ if (value.length === 0 || value.length > RUN_CURSOR_MAX_LENGTH || !/^[A-Za-z0-9_-]+$/.test(value))
88
+ invalid('Invalid run cursor.');
89
+ try {
90
+ const bytes = Buffer.from(value, 'base64url');
91
+ if (bytes.toString('base64url') !== value)
92
+ invalid('Invalid run cursor.');
93
+ const parsed = runCursorSchema.safeParse(JSON.parse(bytes.toString('utf8')));
94
+ if (!parsed.success || parsed.data.filterHash !== filterHash)
95
+ invalid('Invalid run cursor.');
96
+ return { id: parsed.data.id, mtime: parsed.data.mtime };
97
+ }
98
+ catch {
99
+ return invalid('Invalid run cursor.');
100
+ }
101
+ }
72
102
  const SCHEMA_FIELDS = new Set([
73
103
  'suite', 'schemaVersion', 'id', 'title', 'source', 'file', 'sha256', 'requirements', 'cases',
74
104
  'requirementIds', 'preconditions', 'questions', 'steps', 'type', 'path', 'instruction',
@@ -379,7 +409,7 @@ export async function startUIServer(options) {
379
409
  }
380
410
  return { report: raw, ...(markdown === undefined ? {} : { markdown }) };
381
411
  }
382
- async function runs() {
412
+ async function runCandidates() {
383
413
  const root = await directory(['.flowqa', 'runs']);
384
414
  if (!root)
385
415
  return [];
@@ -394,17 +424,26 @@ export async function startUIServer(options) {
394
424
  }
395
425
  catch { /* incomplete run */ }
396
426
  }
397
- candidates.sort((a, b) => b.mtime - a.mtime);
427
+ return candidates.sort((a, b) => b.mtime - a.mtime || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
428
+ }
429
+ async function runSummaries(limit, query = '', status = '', after) {
398
430
  const history = [];
399
- for (const candidate of candidates) {
400
- if (history.length === 50)
401
- break;
431
+ for (const candidate of await runCandidates()) {
432
+ if (after && (candidate.mtime > after.mtime || (candidate.mtime === after.mtime && candidate.id <= after.id)))
433
+ continue;
402
434
  try {
403
435
  const { report: item } = await report(candidate.id, false);
404
- history.push({ runId: item.runId, suiteId: item.suiteId, status: item.status, startedAt: item.startedAt,
436
+ if (status && item.status !== status)
437
+ continue;
438
+ if (query && ![item.suiteId, item.runId, item.target.name].some((field) => field.toLowerCase().includes(query)))
439
+ continue;
440
+ const summary = { runId: item.runId, suiteId: item.suiteId, status: item.status, startedAt: item.startedAt,
405
441
  finishedAt: item.finishedAt, caseCount: item.cases.length,
406
442
  passedCount: item.cases.filter((entry) => entry.status === 'passed' || entry.status === 'flaky').length,
407
- target: item.target, ...(item.selection ? { selection: item.selection } : {}) });
443
+ target: item.target, ...(item.selection ? { selection: item.selection } : {}) };
444
+ history.push({ candidate, summary });
445
+ if (history.length === limit)
446
+ break;
408
447
  }
409
448
  catch { /* incomplete or unsafe run */ }
410
449
  }
@@ -446,7 +485,7 @@ export async function startUIServer(options) {
446
485
  if (request.method === 'GET') {
447
486
  if (url.pathname === '/api/state') {
448
487
  const settings = await config();
449
- const history = await runs();
488
+ const history = (await runSummaries(RUN_HISTORY_LIMIT)).map(({ summary }) => summary);
450
489
  sendJSON(response, 200, { csrfToken: token,
451
490
  targets: Object.entries(settings.targets).map(([name, target]) => ({ name, ...target, targetFingerprint: targetFingerprint(target) })),
452
491
  models: { planner: modelState(settings.planner), agent: modelState(settings.agent) },
@@ -457,6 +496,31 @@ export async function startUIServer(options) {
457
496
  ...(lastError ? { lastError: localizeProductMessage(lastError, locale) } : {}) });
458
497
  return;
459
498
  }
499
+ if (url.pathname === '/api/runs') {
500
+ for (const key of ['query', 'status', 'limit', 'cursor']) {
501
+ if (url.searchParams.getAll(key).length > 1)
502
+ invalid();
503
+ }
504
+ const query = (url.searchParams.get('query') ?? '').trim();
505
+ if (query.length > 200)
506
+ invalid('Run query is too long.');
507
+ const normalizedQuery = query.toLowerCase();
508
+ const status = url.searchParams.get('status') ?? '';
509
+ if (status && !RUN_STATUSES.has(status))
510
+ invalid('Invalid run status.');
511
+ const rawLimit = url.searchParams.get('limit');
512
+ if (rawLimit !== null && !/^(?:[1-9]|[1-4][0-9]|50)$/.test(rawLimit))
513
+ invalid('Invalid run limit.');
514
+ const limit = rawLimit === null ? RUN_HISTORY_LIMIT : Number(rawLimit);
515
+ const filterHash = runFilterHash(normalizedQuery, status);
516
+ const rawCursor = url.searchParams.get('cursor');
517
+ const after = rawCursor === null ? undefined : decodeRunCursor(rawCursor, filterHash);
518
+ const rows = await runSummaries(limit + 1, normalizedQuery, status, after);
519
+ const runs = rows.slice(0, limit);
520
+ sendJSON(response, 200, { runs: runs.map(({ summary }) => summary),
521
+ nextCursor: rows.length > limit ? encodeRunCursor(runs.at(-1).candidate, filterHash) : null });
522
+ return;
523
+ }
460
524
  if (url.pathname === '/api/suite') {
461
525
  const file = url.searchParams.get('file');
462
526
  if (!file)
package/docs/releases.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  The alpha entries below are historical release records. They document earlier package versions and checks.
4
4
 
5
+ ## 0.6.0: search and page through the full run history
6
+
7
+ Run-history search and status filters now cover every valid saved report instead of only the latest 50. **Load more** appends up to 50 older matches, and **Refresh history** restarts the list with the same filters. Loaded counts, end-of-results, loading, and retry messages make the current scope explicit. Opening an older report supports the existing downloads and failed/error-case preparation.
8
+
9
+ The read-only `/api/runs` endpoint applies search and status before pagination, reuses the safe report loader, and uses a bounded cursor tied to the normalized filters. Ordering is by run-directory modification time with a UUID tie-breaker; new records ahead of the cursor do not duplicate earlier pages. External changes to an existing directory's modification time can reorder it, so this remains a live view rather than a snapshot. `/api/state` and comparison selectors retain their most recent 50-report contract.
10
+
11
+ The UI retains loaded pages through navigation and language changes, ignores late responses for previous queries, and preserves rows/cursors on a failed append. A first page updates when recent records change; multiple loaded pages stay visible with refresh guidance. Updates arriving during a pending first-page request trigger a follow-up refresh. Search and paging preserve the open report and scenario draft.
12
+
13
+ Locally verified on macOS with Node.js 24.22.1 on 2026-11-06: 118 tests, type checking, UI syntax/design-system audit, existing demo and UI checks passed. A dedicated Chromium smoke created 72 synthetic reports to check older-record search, combined filters, page boundaries, retry, duplicate prevention, polling/query races, old-report download/preparation, English/Korean, and 390px layout. A fresh tarball installation verified full-history search and paging beyond 50 alongside the existing seven passing browser cases. No model calls, external logins, or native-device runs were used.
14
+
15
+ - Package: [`@ihyeon/flow-qa@0.6.0`](https://www.npmjs.com/package/@ihyeon/flow-qa/v/0.6.0)
16
+ - Release: [GitHub 0.6.0](https://github.com/hyeonse-swing/flow-qa/releases/tag/v0.6.0)
17
+
5
18
  ## 0.5.0: prepare failed cases from a report
6
19
 
7
20
  Adds **Prepare failed/error cases** to detailed reports. It loads the unchanged saved suite, selects only recorded `failed` and `error` cases in suite order, and opens Scenario with a fresh review requirement. Passed, flaky, blocked, and skipped cases are excluded. Preparation is read-only and never starts a run or calls a model. Unsaved drafts require confirmation; failed preparation and late responses after navigation preserve the draft.
package/docs/ui.md CHANGED
@@ -66,11 +66,17 @@ From version `0.2.6`, **Case library** filters saved suites as you type in **Sea
66
66
 
67
67
  ### Filter run history
68
68
 
69
- 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.
69
+ **Run history → Run status** filters reports by passed, failed, blocked, error, or interrupted. Choose **All statuses** to remove the status condition. From `0.6.0`, filtering searches all valid saved reports before pagination, including older records beyond the most recent 50. The selection stays while navigating, changing language, or receiving updated reports, and resets on page reload. Filtering keeps the report already open and its downloads available.
70
70
 
71
71
  ### Search run history
72
72
 
73
- 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.
73
+ **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. From `0.6.0`, search covers all valid saved reports, including older records. Target URLs and individual case content are not searched. Queries are limited to 200 characters.
74
+
75
+ Results load 50 at a time, newest first by run-directory modification time, with run ID as a stable tie-breaker. **Load more** appends older matches. The displayed count describes loaded matches, not a global total. End-of-results, loading, and retry guidance distinguish an empty result from an unavailable request. A failed page request preserves loaded rows so you can retry.
76
+
77
+ Changing search or status restarts at the first page. The query, status, and loaded pages stay through navigation and language changes, and reset on page reload. The open report, its downloads, and scenario draft stay intact. A delayed response from an older query cannot replace newer results. Older reports support the same report downloads and failed/error-case preparation.
78
+
79
+ When recent run records change, a single loaded page refreshes automatically. If several pages are loaded, the UI keeps them and shows an update hint. **Refresh history** restarts at the first page with the current filters and also picks up external edits or deletions of older records. Pagination is a live view, not a frozen snapshot: newly added records appear when refreshed, and reports changed externally while paging may move. Invalid, incomplete, or unsafe report files are excluded. The comparison selectors still offer the latest 50 reports independently of this search.
74
80
 
75
81
  ### Prepare failed cases
76
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ihyeon/flow-qa",
3
- "version": "0.5.0",
3
+ "version": "0.6.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 && node scripts/ui-comparison-smoke.mjs && node scripts/ui-selection-smoke.mjs && node scripts/ui-rerun-smoke.mjs"
60
+ "smoke:ui": "node scripts/ui-i18n-smoke.mjs && node scripts/ui-comparison-smoke.mjs && node scripts/ui-selection-smoke.mjs && node scripts/ui-rerun-smoke.mjs && node scripts/ui-history-smoke.mjs"
61
61
  },
62
62
  "dependencies": {
63
63
  "@ai-sdk/openai-compatible": "3.0.53",