@ihyeon/flow-qa 0.5.0 → 0.7.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 +2 -0
- package/README.md +2 -0
- package/dist/src/i18n.js +4 -0
- package/dist/src/ui/public/app.js +202 -32
- package/dist/src/ui/public/i18n.js +16 -4
- package/dist/src/ui/public/index.html +3 -3
- package/dist/src/ui/public/style.css +7 -0
- package/dist/src/ui/server.js +94 -10
- package/docs/releases.md +24 -0
- package/docs/ui.md +10 -4
- package/package.json +2 -2
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건씩 표시하며 **더 보기**로 이전 기록을 불러옵니다. **실행 기록 새로고침**은 검색·상태 조건을 유지한 채 목록을 갱신합니다. 오래된 보고서도 다운로드와 실패 케이스 준비를 사용할 수 있습니다. `0.7.0`부터 각 기록의 **기준으로 선택**·**현재로 선택**으로 이전 실행끼리 비교할 수 있으며, 검색이나 새로고침 후에도 선택을 유지합니다. 자세한 동작은 [기록 검색·페이지 조회](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. From `0.7.0`, choose **Use as baseline** and **Use as current** on any history rows to compare older runs, retaining the pair through searches and refreshes. 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', comparisonPins: { baseline: null, current: null }, comparisonSelectionGeneration: 0, comparisonValidation: null, 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);
|
|
@@ -38,8 +38,10 @@
|
|
|
38
38
|
try { result = await response.json(); } catch { throw localizedError('app.responseUnreadable', { status: response.status }); }
|
|
39
39
|
if (!response.ok) {
|
|
40
40
|
const issues = Array.isArray(result?.issues) ? result.issues.slice(0, 8).map((issue) => `${issue.path}: ${issue.message}`).join('\n') : '';
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const error = !result?.error && !issues ? localizedError('app.requestFailed', { status: response.status })
|
|
42
|
+
: new Error(`${result?.error || t('app.requestFailed', { status: response.status })}${issues ? `\n${issues}` : ''}`);
|
|
43
|
+
error.status = response.status;
|
|
44
|
+
throw error;
|
|
43
45
|
}
|
|
44
46
|
return result;
|
|
45
47
|
}
|
|
@@ -54,7 +56,11 @@
|
|
|
54
56
|
});
|
|
55
57
|
$('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[name]) });
|
|
56
58
|
$('sidebar').classList.remove('nav-open'); $('nav-toggle').setAttribute('aria-expanded', 'false');
|
|
57
|
-
if (name === 'history') {
|
|
59
|
+
if (name === 'history') {
|
|
60
|
+
if (!app.history.loaded && !app.history.loading) refreshHistory();
|
|
61
|
+
else renderHistory();
|
|
62
|
+
renderComparisonOptions();
|
|
63
|
+
}
|
|
58
64
|
}
|
|
59
65
|
function suitePlatform() { return app.draft?.platform || 'web'; }
|
|
60
66
|
function targetPlatform(target) { return target?.kind === 'mobile' ? 'mobile' : 'web'; }
|
|
@@ -266,30 +272,126 @@
|
|
|
266
272
|
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
273
|
}
|
|
268
274
|
}
|
|
275
|
+
function historyFilters() { return { query: $('history-search').value.trim(), status: $('history-status-filter').value }; }
|
|
269
276
|
function renderHistory() {
|
|
277
|
+
const history = app.history;
|
|
270
278
|
const host = $('history-list'); replace(host);
|
|
271
|
-
|
|
272
|
-
if (!runs.length) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
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 })));
|
|
279
|
+
host.setAttribute('aria-busy', String(history.loading));
|
|
280
|
+
if (!history.runs.length) {
|
|
281
|
+
const { query, status } = historyFilters();
|
|
282
|
+
const key = history.loading ? 'history.loading' : history.error && !history.loaded ? 'history.loadError'
|
|
283
|
+
: query ? 'app.historyNoSearchMatches' : status ? 'app.historyNoStatusMatches'
|
|
284
|
+
: app.state?.active ? 'app.historyActiveEmpty' : 'app.historyEmpty';
|
|
285
|
+
host.append(el('p', 'empty', t(key)));
|
|
286
|
+
}
|
|
287
|
+
const pair = comparisonPair();
|
|
288
|
+
for (const run of history.runs) {
|
|
289
|
+
const row = el('div', 'list-row history-run-row'); row.dataset.runId = run.runId;
|
|
290
|
+
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
291
|
const scope = selectionScope(run.selection); if (scope) copy.append(el('small', 'selection-scope', scope));
|
|
285
|
-
const
|
|
292
|
+
const actions = el('div', 'run-row-actions');
|
|
293
|
+
const button = el('button', 'button', t('app.viewReport')); button.type = 'button'; button.dataset.action = 'view-report'; button.disabled = run.status === 'running' || run.status === 'stopping'; button.addEventListener('click', () => loadReport(run.runId)); actions.append(button);
|
|
294
|
+
for (const [role, label] of [['baseline', 'comparison.useBaseline'], ['current', 'comparison.useCurrent']]) {
|
|
295
|
+
const select = el('button', 'button quiet', t(label)); select.type = 'button'; select.dataset.action = role === 'baseline' ? 'compare-baseline' : 'compare-current';
|
|
296
|
+
select.setAttribute('aria-pressed', String(pair[role] === run.runId));
|
|
297
|
+
select.disabled = run.status === 'running' || run.status === 'stopping';
|
|
298
|
+
select.addEventListener('click', () => selectComparisonRun(role, run)); actions.append(select);
|
|
299
|
+
}
|
|
300
|
+
row.append(copy, actions); host.append(row);
|
|
286
301
|
}
|
|
302
|
+
const messages = [];
|
|
303
|
+
if (history.loaded) messages.push(t('history.pageCount', { count: history.runs.length }));
|
|
304
|
+
if (history.loading) messages.push(t(history.kind === 'more' ? 'history.loadingMore' : 'history.loading'));
|
|
305
|
+
if (history.error) messages.push(t(history.error === 'more' ? 'history.loadMoreError' : 'history.loadError'));
|
|
306
|
+
if (history.updatesAvailable) messages.push(t('history.updatesAvailable'));
|
|
307
|
+
if (history.loaded && !history.loading && !history.error && !history.nextCursor) messages.push(t('history.end'));
|
|
308
|
+
const status = $('history-page-status'); status.textContent = messages.join(' · ');
|
|
309
|
+
status.classList.toggle('report-errors', !!history.error);
|
|
310
|
+
const more = $('history-load-more');
|
|
311
|
+
more.hidden = !history.loaded || !history.nextCursor;
|
|
312
|
+
more.disabled = history.loading;
|
|
313
|
+
more.textContent = t(history.error === 'more' ? 'history.retryMore' : 'history.loadMore');
|
|
314
|
+
}
|
|
315
|
+
async function fetchHistoryPage(request, filters, cursor = null) {
|
|
316
|
+
const history = app.history;
|
|
317
|
+
const append = cursor !== null;
|
|
318
|
+
const params = new URLSearchParams({ query: filters.query, status: filters.status, limit: '50' });
|
|
319
|
+
if (append) params.set('cursor', cursor);
|
|
320
|
+
try {
|
|
321
|
+
const result = await api(`/api/runs?${params}`);
|
|
322
|
+
if (request !== history.request) return;
|
|
323
|
+
if (!Array.isArray(result.runs) || !(result.nextCursor === null || typeof result.nextCursor === 'string')) throw new Error('Invalid run history response.');
|
|
324
|
+
const seen = new Set(append ? history.runs.map((run) => run.runId) : []);
|
|
325
|
+
const incoming = result.runs.filter((run) => {
|
|
326
|
+
if (seen.has(run.runId)) return false;
|
|
327
|
+
seen.add(run.runId); return true;
|
|
328
|
+
});
|
|
329
|
+
history.runs = append ? [...history.runs, ...incoming] : incoming;
|
|
330
|
+
history.nextCursor = result.nextCursor;
|
|
331
|
+
history.pagesLoaded = append ? history.pagesLoaded + 1 : 1;
|
|
332
|
+
history.loaded = true;
|
|
333
|
+
history.error = null;
|
|
334
|
+
if (!append) history.updatesAvailable = false;
|
|
335
|
+
renderComparisonOptions();
|
|
336
|
+
} catch {
|
|
337
|
+
if (request !== history.request) return;
|
|
338
|
+
history.error = append ? 'more' : 'first';
|
|
339
|
+
} finally {
|
|
340
|
+
if (request !== history.request) return;
|
|
341
|
+
history.loading = false; history.kind = null;
|
|
342
|
+
if (history.pendingRecentUpdate) {
|
|
343
|
+
history.pendingRecentUpdate = false;
|
|
344
|
+
if (history.pagesLoaded > 1 || history.error === 'more') history.updatesAvailable = true;
|
|
345
|
+
else if (history.loaded && !history.error) { refreshHistory(); return; }
|
|
346
|
+
}
|
|
347
|
+
renderHistory();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function refreshHistory(delay = 0) {
|
|
351
|
+
const history = app.history;
|
|
352
|
+
if (history.debounce !== null) clearTimeout(history.debounce);
|
|
353
|
+
history.debounce = null;
|
|
354
|
+
const request = ++history.request;
|
|
355
|
+
history.runs = []; history.nextCursor = null; history.pagesLoaded = 0; history.loaded = false;
|
|
356
|
+
history.loading = true; history.kind = 'first'; history.error = null; history.updatesAvailable = false; history.pendingRecentUpdate = false;
|
|
357
|
+
renderHistory(); renderComparisonOptions();
|
|
358
|
+
const filters = historyFilters();
|
|
359
|
+
if (delay) history.debounce = setTimeout(() => { history.debounce = null; void fetchHistoryPage(request, filters); }, delay);
|
|
360
|
+
else void fetchHistoryPage(request, filters);
|
|
361
|
+
}
|
|
362
|
+
function loadMoreHistory() {
|
|
363
|
+
const history = app.history;
|
|
364
|
+
if (!history.loaded || history.loading || !history.nextCursor) return;
|
|
365
|
+
const request = ++history.request;
|
|
366
|
+
history.loading = true; history.kind = 'more'; history.error = null;
|
|
367
|
+
renderHistory();
|
|
368
|
+
void fetchHistoryPage(request, historyFilters(), history.nextCursor);
|
|
369
|
+
}
|
|
370
|
+
function recentRunsChanged() {
|
|
371
|
+
const history = app.history;
|
|
372
|
+
if (history.loading) { history.pendingRecentUpdate = true; return; }
|
|
373
|
+
if (!history.loaded) return;
|
|
374
|
+
if (history.pagesLoaded > 1) {
|
|
375
|
+
history.updatesAvailable = true;
|
|
376
|
+
renderHistory();
|
|
377
|
+
} else refreshHistory();
|
|
287
378
|
}
|
|
288
379
|
const comparisonCategories = ['newly-failed', 'now-passed', 'changed', 'unchanged', 'added', 'missing'];
|
|
289
380
|
const comparisonCategoryKeys = { 'newly-failed': 'comparison.newlyFailed', 'now-passed': 'comparison.nowPassed', changed: 'comparison.changed', unchanged: 'comparison.unchanged', added: 'comparison.added', missing: 'comparison.missing' };
|
|
290
381
|
const comparisonWarningKeys = { 'suite-changed': 'comparison.warningSuite', 'reverse-order': 'comparison.warningReverse', 'incomplete-run': 'comparison.warningIncomplete', 'mobile-identity-unverified': 'comparison.warningMobileIdentity', 'partial-run': 'comparison.warningPartial', 'selection-changed': 'comparison.warningSelectionChanged' };
|
|
291
382
|
function comparisonPair() { return { baseline: $('compare-baseline').value, current: $('compare-current').value }; }
|
|
292
|
-
function
|
|
383
|
+
function comparisonRun(runId) {
|
|
384
|
+
return (app.state?.runs || []).find((run) => run.runId === runId)
|
|
385
|
+
|| app.history.runs.find((run) => run.runId === runId)
|
|
386
|
+
|| Object.values(app.comparisonPins).find((run) => run?.runId === runId);
|
|
387
|
+
}
|
|
388
|
+
function comparisonValid() {
|
|
389
|
+
const { baseline, current } = comparisonPair();
|
|
390
|
+
return !!baseline && !!current && baseline !== current && [baseline, current].every((id) => {
|
|
391
|
+
const run = comparisonRun(id);
|
|
392
|
+
return run && run.status !== 'running' && run.status !== 'stopping';
|
|
393
|
+
});
|
|
394
|
+
}
|
|
293
395
|
function comparisonStatus(key, params = {}, error = false) {
|
|
294
396
|
app.comparisonStatus = key; app.comparisonStatusParams = params;
|
|
295
397
|
const node = $('comparison-status'); node.textContent = t(key, params); node.classList.toggle('report-errors', error);
|
|
@@ -307,23 +409,78 @@
|
|
|
307
409
|
$('comparison-summary').replaceChildren(); $('comparison-warnings').replaceChildren(); $('comparison-content').replaceChildren();
|
|
308
410
|
comparisonStatus('comparison.selectBoth'); comparisonControls();
|
|
309
411
|
}
|
|
412
|
+
function selectComparisonRun(role, run) {
|
|
413
|
+
if (run && (run.status === 'running' || run.status === 'stopping')) return;
|
|
414
|
+
const select = $(role === 'baseline' ? 'compare-baseline' : 'compare-current');
|
|
415
|
+
const runId = run?.runId || '';
|
|
416
|
+
if (select.value === runId && (app.comparisonPins[role]?.runId || '') === runId) return;
|
|
417
|
+
app.comparisonPins[role] = run || null;
|
|
418
|
+
app.comparisonSelectionGeneration += 1;
|
|
419
|
+
renderComparisonOptions();
|
|
420
|
+
select.value = runId;
|
|
421
|
+
clearComparison(); renderHistory();
|
|
422
|
+
}
|
|
310
423
|
function renderComparisonOptions() {
|
|
311
|
-
const runs =
|
|
312
|
-
|
|
424
|
+
const runs = new Map();
|
|
425
|
+
for (const run of [...(app.state?.runs || []).slice(0, 50), ...app.history.runs, ...Object.values(app.comparisonPins)]) {
|
|
426
|
+
if (run?.runId && !runs.has(run.runId)) runs.set(run.runId, run);
|
|
427
|
+
}
|
|
313
428
|
for (const id of ['compare-baseline', 'compare-current']) {
|
|
314
429
|
const select = $(id); const selected = select.value;
|
|
315
430
|
select.replaceChildren(el('option', '', t('comparison.choose'))); select.firstChild.value = '';
|
|
316
|
-
for (const run of runs) {
|
|
431
|
+
for (const run of runs.values()) {
|
|
317
432
|
const option = el('option', '', `${t('comparison.runLabel', { id: run.runId, shortId: run.runId.slice(-8), date: formatDate(run.startedAt), suite: run.suiteId, target: targetDisplay(run.target), status: i18n.status(run.status) })}${selectionScope(run.selection) ? ` · ${selectionScope(run.selection)}` : ''}`);
|
|
318
|
-
option.value = run.runId; select.append(option);
|
|
433
|
+
option.value = run.runId; option.disabled = run.status === 'running' || run.status === 'stopping'; select.append(option);
|
|
319
434
|
}
|
|
320
|
-
select.value = runs.
|
|
321
|
-
if (selected && !select.value) missing = true;
|
|
435
|
+
select.value = runs.has(selected) ? selected : '';
|
|
322
436
|
}
|
|
323
|
-
if (
|
|
324
|
-
if (!runs.length && !app.comparison) comparisonStatus('comparison.noRuns');
|
|
437
|
+
if (!runs.size && !app.comparison) comparisonStatus('comparison.noRuns');
|
|
325
438
|
comparisonControls();
|
|
326
439
|
}
|
|
440
|
+
async function validateComparisonSelection(state) {
|
|
441
|
+
if (app.comparisonValidation) return app.comparisonValidation;
|
|
442
|
+
const pair = comparisonPair();
|
|
443
|
+
const generation = app.comparisonSelectionGeneration;
|
|
444
|
+
const recent = new Set((state.runs || []).map((run) => run.runId));
|
|
445
|
+
const ids = [...new Set(Object.values(pair).filter((id) => id && !recent.has(id)))];
|
|
446
|
+
if (!ids.length) return;
|
|
447
|
+
const validation = Promise.all(ids.map(async (runId) => {
|
|
448
|
+
try {
|
|
449
|
+
const result = await api(`/api/run-summary?runId=${encodeURIComponent(runId)}`);
|
|
450
|
+
if (result?.run?.runId !== runId) throw new Error('Invalid run summary response.');
|
|
451
|
+
return { runId, run: result.run };
|
|
452
|
+
} catch (error) { return { runId, error }; }
|
|
453
|
+
}));
|
|
454
|
+
app.comparisonValidation = validation;
|
|
455
|
+
try {
|
|
456
|
+
const outcomes = await validation;
|
|
457
|
+
const selected = comparisonPair();
|
|
458
|
+
if (generation !== app.comparisonSelectionGeneration || selected.baseline !== pair.baseline || selected.current !== pair.current) return;
|
|
459
|
+
let missing = false;
|
|
460
|
+
let updated = false;
|
|
461
|
+
for (const outcome of outcomes) {
|
|
462
|
+
if (outcome.error?.status === 404) {
|
|
463
|
+
for (const role of ['baseline', 'current']) {
|
|
464
|
+
if (selected[role] === outcome.runId) {
|
|
465
|
+
$(role === 'baseline' ? 'compare-baseline' : 'compare-current').value = '';
|
|
466
|
+
app.comparisonPins[role] = null;
|
|
467
|
+
missing = true;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
app.history.runs = app.history.runs.filter((run) => run.runId !== outcome.runId);
|
|
471
|
+
} else if (outcome.run) {
|
|
472
|
+
for (const role of ['baseline', 'current']) if (selected[role] === outcome.runId) app.comparisonPins[role] = outcome.run;
|
|
473
|
+
updated = true;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (missing) {
|
|
477
|
+
app.comparisonSelectionGeneration += 1;
|
|
478
|
+
clearComparison();
|
|
479
|
+
if (app.history.loading) refreshHistory(); else renderHistory();
|
|
480
|
+
}
|
|
481
|
+
if (missing || updated) renderComparisonOptions();
|
|
482
|
+
} finally { if (app.comparisonValidation === validation) app.comparisonValidation = null; }
|
|
483
|
+
}
|
|
327
484
|
async function compareRuns() {
|
|
328
485
|
if (!comparisonValid()) return;
|
|
329
486
|
const { baseline, current } = comparisonPair(); const locale = i18n.locale(); const request = ++app.comparisonRequest;
|
|
@@ -396,10 +553,11 @@
|
|
|
396
553
|
const libraryChanged = JSON.stringify(app.state?.suites) !== JSON.stringify(state.suites);
|
|
397
554
|
const historyChanged = JSON.stringify(app.state?.runs) !== JSON.stringify(state.runs);
|
|
398
555
|
app.state = state; app.connected = true;
|
|
399
|
-
renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) {
|
|
556
|
+
renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) { recentRunsChanged(); renderComparisonOptions(); }
|
|
400
557
|
if (full || initialize) { targetOptions(); modelForms(); }
|
|
401
558
|
if (previousActive?.kind === 'run' && !state.active) { if (state.lastError) notice(state.lastError, true); else noticeKey('app.runEnded'); }
|
|
402
559
|
if (state.lastError && state.lastError !== app.lastError) { app.lastError = state.lastError; notice(state.lastError, true); }
|
|
560
|
+
await validateComparisonSelection(state);
|
|
403
561
|
}
|
|
404
562
|
async function work(fn) { if (app.busy) return; app.busy = true; readiness(); notice(''); try { await fn(); } catch (error) { showError(error); } finally { app.busy = false; readiness(); } }
|
|
405
563
|
async function loadSuite(file) { await work(async () => { const result = await api(`/api/suite?file=${encodeURIComponent(file)}`); if (!confirmScenarioReplacement('app.confirmReplaceDraft')) return; $('flow-input').value = result.flow || ''; setDraft(result.suite, result.script, 'library', result.flow || '', file, result.suiteHash || ''); noticeKey('app.suiteLoaded'); view('scenario'); }); }
|
|
@@ -578,11 +736,23 @@
|
|
|
578
736
|
window.addEventListener('beforeunload', warnBeforeUnload);
|
|
579
737
|
document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => view(button.dataset.view)));
|
|
580
738
|
$('library-search').addEventListener('input', renderLibrary);
|
|
581
|
-
$('history-search').addEventListener('input',
|
|
582
|
-
$('history-status-filter').addEventListener('change',
|
|
583
|
-
|
|
739
|
+
$('history-search').addEventListener('input', () => refreshHistory(150));
|
|
740
|
+
$('history-status-filter').addEventListener('change', () => refreshHistory());
|
|
741
|
+
$('history-refresh').addEventListener('click', () => refreshHistory());
|
|
742
|
+
$('history-load-more').addEventListener('click', loadMoreHistory);
|
|
743
|
+
for (const [id, role] of [['compare-baseline', 'baseline'], ['compare-current', 'current']]) $(id).addEventListener('change', () => {
|
|
744
|
+
const run = comparisonRun($(id).value);
|
|
745
|
+
selectComparisonRun(role, run || null);
|
|
746
|
+
});
|
|
584
747
|
$('compare-btn').addEventListener('click', compareRuns);
|
|
585
|
-
$('compare-swap').addEventListener('click', () => {
|
|
748
|
+
$('compare-swap').addEventListener('click', () => {
|
|
749
|
+
const pair = comparisonPair();
|
|
750
|
+
app.comparisonPins = { baseline: comparisonRun(pair.current), current: comparisonRun(pair.baseline) };
|
|
751
|
+
app.comparisonSelectionGeneration += 1;
|
|
752
|
+
renderComparisonOptions();
|
|
753
|
+
$('compare-baseline').value = pair.current; $('compare-current').value = pair.baseline;
|
|
754
|
+
clearComparison(); renderHistory();
|
|
755
|
+
});
|
|
586
756
|
$('comparison-filter').addEventListener('change', renderComparison);
|
|
587
757
|
$('comparison-md').addEventListener('click', downloadComparisonMarkdown);
|
|
588
758
|
$('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'); } });
|
|
@@ -84,18 +84,30 @@
|
|
|
84
84
|
'history.eyebrow': ['03 / RUNS', '03 / 실행'],
|
|
85
85
|
'history.intro': ['Only runs that actually started appear here. Once results are saved, you can view the detailed report.', '실제로 시작된 실행만 표시합니다. 결과가 저장되면 상세 보고서를 볼 수 있습니다.'],
|
|
86
86
|
'history.title': ['Runs', '실행 목록'],
|
|
87
|
-
'history.help': ['
|
|
87
|
+
'history.help': ['View a completed report or select a run as the comparison baseline or current run.', '완료된 보고서를 보거나 실행을 비교 기준 또는 현재 실행으로 선택하세요.'],
|
|
88
88
|
'history.searchLabel': ['Search runs', '실행 기록 검색'],
|
|
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
|
-
'comparison.help': ['
|
|
103
|
+
'comparison.help': ['Select a baseline and current run from history or the lists below. Inspect detailed reports for evidence.', '실행 기록이나 아래 목록에서 기준 실행과 현재 실행을 선택하세요. 증거는 상세 보고서에서 확인하세요.'],
|
|
94
104
|
'comparison.baseline': ['Baseline run', '기준 실행'],
|
|
95
105
|
'comparison.current': ['Current run', '현재 실행'],
|
|
96
106
|
'comparison.choose': ['Choose a run', '실행 선택'],
|
|
97
107
|
'comparison.compare': ['Compare', '비교하기'],
|
|
98
108
|
'comparison.swap': ['Swap runs', '실행 순서 바꾸기'],
|
|
109
|
+
'comparison.useBaseline': ['Use as baseline', '기준으로 선택'],
|
|
110
|
+
'comparison.useCurrent': ['Use as current', '현재로 선택'],
|
|
99
111
|
'comparison.filter': ['Show cases', '케이스 표시'],
|
|
100
112
|
'comparison.all': ['All categories', '전체 분류'],
|
|
101
113
|
'comparison.newlyFailed': ['Newly failed', '새로 실패'],
|
|
@@ -109,9 +121,9 @@
|
|
|
109
121
|
'comparison.openBaseline': ['View baseline report', '기준 실행 보고서 보기'],
|
|
110
122
|
'comparison.openCurrent': ['View current report', '현재 실행 보고서 보기'],
|
|
111
123
|
'comparison.selectBoth': ['Select two different runs to compare.', '서로 다른 실행 2건을 선택하세요.'],
|
|
112
|
-
'comparison.noRuns': ['No
|
|
124
|
+
'comparison.noRuns': ['No runs available to compare.', '비교할 실행이 없습니다.'],
|
|
113
125
|
'comparison.loading': ['Comparing selected runs…', '선택한 실행을 비교하고 있습니다…'],
|
|
114
|
-
'comparison.limit': ['
|
|
126
|
+
'comparison.limit': ['Search or load more in Run history to select from all saved runs. The lists include the 50 most recent runs, loaded history, and your selections. Comparison counts describe observed statuses; they do not establish failure cause or prove a defect was fixed.', '실행 기록을 검색하거나 더 불러와 저장된 전체 실행 중에서 선택하세요. 아래 목록에는 최근 50건, 불러온 기록, 현재 선택이 표시됩니다. 비교 수치는 관찰된 상태만 나타내며 실패 원인이나 결함 수정 여부를 입증하지 않습니다.'],
|
|
115
127
|
'comparison.ready': ['Comparison ready. Categories describe observed case status changes.', '비교 결과가 준비됐습니다. 분류는 관찰된 케이스 상태 차이입니다.'],
|
|
116
128
|
'comparison.noCases': ['No cases in this category.', '이 분류에 해당하는 케이스가 없습니다.'],
|
|
117
129
|
'comparison.warningSuite': ['Suite content changed. Steps or expectations may differ, so these counts do not establish equivalent regressions or fixes.', '스위트 내용이 바뀌었습니다. 단계나 기대 결과가 다를 수 있으므로 이 수치만으로 같은 조건의 회귀나 수정이라고 판단할 수 없습니다.'],
|
|
@@ -93,11 +93,11 @@
|
|
|
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">
|
|
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">View a completed report or select a run as the comparison baseline or current run.</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
|
-
<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">
|
|
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">Select a baseline and current run from history or the lists below. Inspect detailed reports for evidence.</p></div></div>
|
|
99
99
|
<div class="section-body">
|
|
100
|
-
<p class="hint" data-i18n="comparison.limit">
|
|
100
|
+
<p class="hint" data-i18n="comparison.limit">Search or load more in Run history to select from all saved runs. The lists include the 50 most recent runs, loaded history, and your selections. Comparison counts describe observed statuses; they do not establish failure cause or prove a defect was fixed.</p>
|
|
101
101
|
<div class="comparison-selects"><div><label for="compare-baseline" data-i18n="comparison.baseline">Baseline run</label><select id="compare-baseline" aria-controls="comparison-content"><option value="" data-i18n="comparison.choose">Choose a run</option></select></div><div><label for="compare-current" data-i18n="comparison.current">Current run</label><select id="compare-current" aria-controls="comparison-content"><option value="" data-i18n="comparison.choose">Choose a run</option></select></div></div>
|
|
102
102
|
<div class="actions comparison-actions"><button type="button" id="compare-btn" class="button primary" data-i18n="comparison.compare" disabled>Compare</button><button type="button" id="compare-swap" class="button quiet" data-i18n="comparison.swap" disabled>Swap runs</button></div>
|
|
103
103
|
<p id="comparison-status" class="hint" role="status" aria-live="polite"></p>
|
|
@@ -147,6 +147,11 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
|
|
147
147
|
.list-row > div { min-width: 0; }
|
|
148
148
|
.list-row strong { display: block; font-size: 15px; overflow-wrap: anywhere; }
|
|
149
149
|
.list-row small { display: block; margin-top: 6px; color: var(--muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
|
|
150
|
+
.history-run-row { flex-wrap: wrap; }
|
|
151
|
+
.history-run-row > div:first-child { flex: 1 1 260px; }
|
|
152
|
+
.run-row-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
|
153
|
+
.run-row-actions .button[aria-pressed='true'] { border-color: var(--accent-solid); background: var(--accent-solid); color: var(--on-accent); opacity: 1; }
|
|
154
|
+
.run-row-actions .button[aria-pressed='true']:hover:enabled { color: var(--on-accent); filter: brightness(.91); }
|
|
150
155
|
.empty { max-width: 50ch; margin: 0; padding: 32px 0; color: var(--muted); line-height: 1.7; }
|
|
151
156
|
|
|
152
157
|
.comparison-selects { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
|
@@ -218,6 +223,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
|
|
218
223
|
.execution-grid { grid-template-columns: 1fr; gap: 16px; }
|
|
219
224
|
.run-bar { align-items: start; flex-direction: column; }
|
|
220
225
|
.list-row { align-items: start; flex-direction: column; gap: 12px; }
|
|
226
|
+
.history-run-row > div:first-child { flex: 0 1 auto; width: 100%; }
|
|
227
|
+
.run-row-actions { justify-content: flex-start; }
|
|
221
228
|
.comparison-selects { grid-template-columns: 1fr; }
|
|
222
229
|
.comparison-count { padding: 9px 5px; }
|
|
223
230
|
.comparison-count strong { font-size: 19px; }
|
package/dist/src/ui/server.js
CHANGED
|
@@ -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
|
|
412
|
+
async function runCandidates() {
|
|
383
413
|
const root = await directory(['.flowqa', 'runs']);
|
|
384
414
|
if (!root)
|
|
385
415
|
return [];
|
|
@@ -394,17 +424,28 @@ 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
|
+
function runSummary(item) {
|
|
430
|
+
return { runId: item.runId, suiteId: item.suiteId, status: item.status, startedAt: item.startedAt,
|
|
431
|
+
finishedAt: item.finishedAt, caseCount: item.cases.length,
|
|
432
|
+
passedCount: item.cases.filter((entry) => entry.status === 'passed' || entry.status === 'flaky').length,
|
|
433
|
+
target: item.target, ...(item.selection ? { selection: item.selection } : {}) };
|
|
434
|
+
}
|
|
435
|
+
async function runSummaries(limit, query = '', status = '', after) {
|
|
398
436
|
const history = [];
|
|
399
|
-
for (const candidate of
|
|
400
|
-
if (
|
|
401
|
-
|
|
437
|
+
for (const candidate of await runCandidates()) {
|
|
438
|
+
if (after && (candidate.mtime > after.mtime || (candidate.mtime === after.mtime && candidate.id <= after.id)))
|
|
439
|
+
continue;
|
|
402
440
|
try {
|
|
403
441
|
const { report: item } = await report(candidate.id, false);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
442
|
+
if (status && item.status !== status)
|
|
443
|
+
continue;
|
|
444
|
+
if (query && ![item.suiteId, item.runId, item.target.name].some((field) => field.toLowerCase().includes(query)))
|
|
445
|
+
continue;
|
|
446
|
+
history.push({ candidate, summary: runSummary(item) });
|
|
447
|
+
if (history.length === limit)
|
|
448
|
+
break;
|
|
408
449
|
}
|
|
409
450
|
catch { /* incomplete or unsafe run */ }
|
|
410
451
|
}
|
|
@@ -446,7 +487,7 @@ export async function startUIServer(options) {
|
|
|
446
487
|
if (request.method === 'GET') {
|
|
447
488
|
if (url.pathname === '/api/state') {
|
|
448
489
|
const settings = await config();
|
|
449
|
-
const history = await
|
|
490
|
+
const history = (await runSummaries(RUN_HISTORY_LIMIT)).map(({ summary }) => summary);
|
|
450
491
|
sendJSON(response, 200, { csrfToken: token,
|
|
451
492
|
targets: Object.entries(settings.targets).map(([name, target]) => ({ name, ...target, targetFingerprint: targetFingerprint(target) })),
|
|
452
493
|
models: { planner: modelState(settings.planner), agent: modelState(settings.agent) },
|
|
@@ -457,6 +498,49 @@ export async function startUIServer(options) {
|
|
|
457
498
|
...(lastError ? { lastError: localizeProductMessage(lastError, locale) } : {}) });
|
|
458
499
|
return;
|
|
459
500
|
}
|
|
501
|
+
if (url.pathname === '/api/runs') {
|
|
502
|
+
for (const key of ['query', 'status', 'limit', 'cursor']) {
|
|
503
|
+
if (url.searchParams.getAll(key).length > 1)
|
|
504
|
+
invalid();
|
|
505
|
+
}
|
|
506
|
+
const query = (url.searchParams.get('query') ?? '').trim();
|
|
507
|
+
if (query.length > 200)
|
|
508
|
+
invalid('Run query is too long.');
|
|
509
|
+
const normalizedQuery = query.toLowerCase();
|
|
510
|
+
const status = url.searchParams.get('status') ?? '';
|
|
511
|
+
if (status && !RUN_STATUSES.has(status))
|
|
512
|
+
invalid('Invalid run status.');
|
|
513
|
+
const rawLimit = url.searchParams.get('limit');
|
|
514
|
+
if (rawLimit !== null && !/^(?:[1-9]|[1-4][0-9]|50)$/.test(rawLimit))
|
|
515
|
+
invalid('Invalid run limit.');
|
|
516
|
+
const limit = rawLimit === null ? RUN_HISTORY_LIMIT : Number(rawLimit);
|
|
517
|
+
const filterHash = runFilterHash(normalizedQuery, status);
|
|
518
|
+
const rawCursor = url.searchParams.get('cursor');
|
|
519
|
+
const after = rawCursor === null ? undefined : decodeRunCursor(rawCursor, filterHash);
|
|
520
|
+
const rows = await runSummaries(limit + 1, normalizedQuery, status, after);
|
|
521
|
+
const runs = rows.slice(0, limit);
|
|
522
|
+
sendJSON(response, 200, { runs: runs.map(({ summary }) => summary),
|
|
523
|
+
nextCursor: rows.length > limit ? encodeRunCursor(runs.at(-1).candidate, filterHash) : null });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (url.pathname === '/api/run-summary') {
|
|
527
|
+
if (url.searchParams.getAll('runId').length !== 1)
|
|
528
|
+
invalid('A run ID is required.');
|
|
529
|
+
const runId = url.searchParams.get('runId');
|
|
530
|
+
if (!RUN_ID.test(runId))
|
|
531
|
+
invalid('Invalid run ID.');
|
|
532
|
+
let item;
|
|
533
|
+
try {
|
|
534
|
+
({ report: item } = await report(runId, false));
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
if (error instanceof HTTPError && error.status === 400)
|
|
538
|
+
notFound();
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
sendJSON(response, 200, { run: runSummary(item) });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
460
544
|
if (url.pathname === '/api/suite') {
|
|
461
545
|
const file = url.searchParams.get('file');
|
|
462
546
|
if (!file)
|
package/docs/releases.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
The alpha entries below are historical release records. They document earlier package versions and checks.
|
|
4
4
|
|
|
5
|
+
## 0.7.0: compare archived runs directly from history
|
|
6
|
+
|
|
7
|
+
History rows now offer **Use as baseline** and **Use as current**, so older runs found by search or paging can be compared. The chosen pair stays available through query/status changes, history refresh, polling, navigation, and language changes. Selected row actions expose their pressed state, and changing a selection or swapping roles clears the old comparison and exports until compared again.
|
|
8
|
+
|
|
9
|
+
A read-only summary lookup validates selected runs outside the recent 50 using the existing safe JSON report loader; comparison does not require a saved Markdown file. A confirmed deleted, invalid, or unsafe report clears its selection and comparison. Temporary request failures preserve the pair, and stale validation responses cannot change a newer selection. Choosing reports preserves the scenario draft, review state, and open detailed report, and never starts QA execution.
|
|
10
|
+
|
|
11
|
+
Locally verified on macOS with Node.js 24.22.1 on 2026-11-06: 118 tests, type checking, UI syntax/design-system audit, all existing UI checks, and the real Chromium demo passed. A dedicated browser smoke used 61 synthetic records to verify comparison beyond the latest 50, JSON-only reports, row selection, filtering/paging, polling, navigation/language preservation, swaps, temporary failures, stale missing-file responses after changing away and back to the same selection, deleted archived files, and a 390px layout. A fresh tarball installation ran seven passing browser cases and verified archived summary lookup and comparison. No model calls, external logins, or native-device runs were used.
|
|
12
|
+
|
|
13
|
+
- Package: [`@ihyeon/flow-qa@0.7.0`](https://www.npmjs.com/package/@ihyeon/flow-qa/v/0.7.0)
|
|
14
|
+
- Release: [GitHub 0.7.0](https://github.com/hyeonse-swing/flow-qa/releases/tag/v0.7.0)
|
|
15
|
+
|
|
16
|
+
## 0.6.0: search and page through the full run history
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
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.
|
|
25
|
+
|
|
26
|
+
- Package: [`@ihyeon/flow-qa@0.6.0`](https://www.npmjs.com/package/@ihyeon/flow-qa/v/0.6.0)
|
|
27
|
+
- Release: [GitHub 0.6.0](https://github.com/hyeonse-swing/flow-qa/releases/tag/v0.6.0)
|
|
28
|
+
|
|
5
29
|
## 0.5.0: prepare failed cases from a report
|
|
6
30
|
|
|
7
31
|
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
|
-
|
|
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
|
-
|
|
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. Use the row actions to select any listed report for comparison, including older results.
|
|
74
80
|
|
|
75
81
|
### Prepare failed cases
|
|
76
82
|
|
|
@@ -84,13 +90,13 @@ The preparation notice identifies the original run. The next execution creates a
|
|
|
84
90
|
|
|
85
91
|
### Compare two runs
|
|
86
92
|
|
|
87
|
-
From version `0.3.0`, open **Run history → Compare two runs**, choose a **Baseline run** and **Current run**, then compare.
|
|
93
|
+
From version `0.3.0`, open **Run history → Compare two runs**, choose a **Baseline run** and **Current run**, then compare. From `0.7.0`, each history row offers **Use as baseline** and **Use as current**. Search or load older pages to choose any saved run, then press **Compare**. The selectors include the most recent 50 reports, currently loaded history rows, and the two selected runs. Selected runs stay available when searches or filters hide their rows. 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.
|
|
88
94
|
|
|
89
95
|
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.
|
|
90
96
|
|
|
91
97
|
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`.
|
|
92
98
|
|
|
93
|
-
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
|
|
99
|
+
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; leaving the latest 50 or the visible history page does not clear a selected run. Background checks validate selected older records directly; a confirmed missing, invalid, or unsafe report clears that selection and invalidates the comparison. Temporary request failures preserve the pair for the next check. Choices reset on page reload. Markdown product labels follow the selected language, while user-written case content and status codes are preserved.
|
|
94
100
|
|
|
95
101
|
### Import existing case files
|
|
96
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ihyeon/flow-qa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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 && node scripts/ui-archive-comparison-smoke.mjs"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@ai-sdk/openai-compatible": "3.0.53",
|