@ihyeon/flow-qa 0.4.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 +4 -0
- package/README.md +4 -0
- package/dist/src/i18n.js +12 -0
- package/dist/src/ui/public/app.js +137 -21
- package/dist/src/ui/public/i18n.js +15 -0
- package/dist/src/ui/public/index.html +4 -4
- package/dist/src/ui/server.js +125 -8
- package/docs/releases.md +25 -1
- package/docs/ui.md +18 -2
- package/package.json +2 -2
package/README.ko.md
CHANGED
|
@@ -68,8 +68,12 @@ flow-qa plan flows/my-flow.md --workspace my-qa
|
|
|
68
68
|
|
|
69
69
|
CLI에서는 `flow-qa run cases/task-flow.json --target demo --case TC-001 --case TC-003`처럼 ID를 반복합니다. `--case`를 생략하면 전체를 실행합니다. 부분 실행의 통과는 선택한 케이스에만 해당하며, 보고서와 비교 화면에도 선택 범위를 표시합니다. 자세한 동작은 [UI 가이드](docs/ui.md#run-selected-cases)를 참고하세요.
|
|
70
70
|
|
|
71
|
+
`0.5.0`부터 상세 보고서에서 **실패·오류 케이스 준비**를 누르면 실행 당시와 같은 저장된 케이스를 불러와 `실패`와 `실행 오류`만 선택합니다. 준비 후 케이스와 대상을 다시 검토하고 직접 실행하세요. 케이스·원본 파일이 없거나 바뀌었으면 준비를 중단하며, 실행 대상이 없거나 바뀌었으면 다시 선택해야 합니다. 자세한 동작은 [실패 케이스 준비](docs/ui.md#prepare-failed-cases)를 참고하세요.
|
|
72
|
+
|
|
71
73
|
## 실행 결과 비교
|
|
72
74
|
|
|
75
|
+
`0.6.0`부터 **실행 기록**에서 저장된 전체 보고서를 스위트 ID·실행 ID·대상 이름으로 검색하고 실행 상태로 함께 좁힐 수 있습니다. 결과는 50건씩 표시하며 **더 보기**로 이전 기록을 불러옵니다. **실행 기록 새로고침**은 검색·상태 조건을 유지한 채 목록을 갱신합니다. 오래된 보고서도 다운로드와 실패 케이스 준비를 사용할 수 있습니다. 자세한 동작은 [기록 검색·페이지 조회](docs/ui.md#search-run-history)를 참고하세요.
|
|
76
|
+
|
|
73
77
|
**실행 기록 → 실행 2건 비교**에서 같은 스위트와 대상의 두 기록을 선택하면 통과→실패, 실패→통과, 그 외 상태 변경, 동일 상태, 추가·누락 케이스를 확인할 수 있습니다. 분류별로 보거나 전체 비교를 Markdown·JSON으로 내려받으세요. 테스트 내용 변경과 불완전한 실행은 별도로 표시하며, 상태 변화만으로 제품 버그의 발생·해결을 확정하지 않습니다. 자세한 동작은 [UI 가이드](docs/ui.md#compare-two-runs)를 참고하세요.
|
|
74
78
|
|
|
75
79
|
## 모바일 대상
|
package/README.md
CHANGED
|
@@ -68,8 +68,12 @@ From `0.4.0`, choose cases under **Scenario → Review, then run → Cases to ru
|
|
|
68
68
|
|
|
69
69
|
The CLI supports repeated IDs: `flow-qa run cases/task-flow.json --target demo --case TC-001 --case TC-003`. Omit `--case` to run the complete suite. Reports and comparisons label partial runs explicitly; a selected-run pass does not evaluate omitted cases. See the [UI guide](docs/ui.md#run-selected-cases).
|
|
70
70
|
|
|
71
|
+
From `0.5.0`, open a detailed report and choose **Prepare failed/error cases** to load its unchanged saved suite with only `failed` and `error` cases selected. Preparation never starts a run: review the cases and target, then run explicitly. Changed or missing case/source files block preparation; a changed or missing target must be selected again. See [preparing failed cases](docs/ui.md#prepare-failed-cases).
|
|
72
|
+
|
|
71
73
|
## Compare recorded runs
|
|
72
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
|
+
|
|
73
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).
|
|
74
78
|
|
|
75
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.': '두 리포트의 케이스 모음이 같아야 합니다.',
|
|
@@ -70,6 +74,14 @@ const koreanMessages = {
|
|
|
70
74
|
'Both reports need unique, non-empty case IDs.': '두 리포트의 케이스 ID는 비어 있지 않고 중복되지 않아야 합니다.',
|
|
71
75
|
'The report selection does not match its recorded cases.': '보고서의 선택 범위가 기록된 케이스와 일치하지 않습니다.',
|
|
72
76
|
'Select one or more unique cases from this suite.': '이 케이스 모음에서 중복 없이 하나 이상의 케이스를 선택하세요.',
|
|
77
|
+
'No failed or errored cases are available to rerun.': '다시 실행할 실패 또는 실행 오류 케이스가 없습니다.',
|
|
78
|
+
'No saved case file matches this report.': '이 보고서에 해당하는 저장된 케이스 파일이 없습니다.',
|
|
79
|
+
'Multiple saved case files match this report.': '이 보고서에 해당하는 케이스 파일이 여러 개입니다. 보관함에서 사용할 파일을 직접 선택하세요.',
|
|
80
|
+
'The saved case file or source flow was not found.': '저장된 케이스 파일 또는 원본 플로우를 찾을 수 없습니다.',
|
|
81
|
+
'The saved case file or source flow is invalid.': '저장된 케이스 파일 또는 원본 플로우가 올바르지 않습니다.',
|
|
82
|
+
'The saved case file changed since this run. Review it before rerunning.': '실행 이후 케이스 파일이 변경됐습니다. 보관함에서 다시 열어 검토하세요.',
|
|
83
|
+
'The report cases do not match the saved case file.': '보고서의 케이스가 저장된 케이스 파일과 일치하지 않습니다.',
|
|
84
|
+
'The report selection does not match the saved case file.': '보고서의 선택 범위가 저장된 케이스 파일과 일치하지 않습니다.',
|
|
73
85
|
'Invalid evidence selection.': '증거 파일 선택이 올바르지 않습니다.',
|
|
74
86
|
'The request is not allowed.': '허용되지 않은 요청입니다.',
|
|
75
87
|
'Invalid request token.': '요청 토큰이 올바르지 않습니다.',
|
|
@@ -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: '', 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);
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
return result;
|
|
45
45
|
}
|
|
46
46
|
function view(name) {
|
|
47
|
+
app.navigationVersion += 1;
|
|
47
48
|
app.view = name;
|
|
48
49
|
for (const key of Object.keys(names)) $('view-' + key).hidden = key !== name;
|
|
49
50
|
document.querySelectorAll('[data-view]').forEach((button) => {
|
|
@@ -53,7 +54,11 @@
|
|
|
53
54
|
});
|
|
54
55
|
$('breadcrumb').textContent = t('app.breadcrumb', { name: t(names[name]) });
|
|
55
56
|
$('sidebar').classList.remove('nav-open'); $('nav-toggle').setAttribute('aria-expanded', 'false');
|
|
56
|
-
if (name === 'history') {
|
|
57
|
+
if (name === 'history') {
|
|
58
|
+
if (!app.history.loaded && !app.history.loading) refreshHistory();
|
|
59
|
+
else renderHistory();
|
|
60
|
+
renderComparisonOptions();
|
|
61
|
+
}
|
|
57
62
|
}
|
|
58
63
|
function suitePlatform() { return app.draft?.platform || 'web'; }
|
|
59
64
|
function targetPlatform(target) { return target?.kind === 'mobile' ? 'mobile' : 'web'; }
|
|
@@ -120,6 +125,9 @@
|
|
|
120
125
|
$('suite-editor').disabled = app.busy;
|
|
121
126
|
$('prompt-btn').disabled = app.busy;
|
|
122
127
|
$('scenario-platform').disabled = app.busy;
|
|
128
|
+
$('target-select').disabled = app.busy;
|
|
129
|
+
$('review-checkbox').disabled = app.busy;
|
|
130
|
+
$('report-rerun').disabled = !app.connected || !!active || app.busy || !app.selectedRun || !(app.report?.cases || []).some((item) => item.status === 'failed' || item.status === 'error');
|
|
123
131
|
$('mobile-setup-hint').hidden = suitePlatform() !== 'mobile' && $('scenario-platform').value !== 'mobile';
|
|
124
132
|
$('run-readiness').textContent = t(!app.connected ? 'app.connectionRequired'
|
|
125
133
|
: active ? (active.status === 'stopping' ? 'app.stopping' : active.kind === 'plan' ? 'app.planning' : 'app.running')
|
|
@@ -198,6 +206,7 @@
|
|
|
198
206
|
}
|
|
199
207
|
}
|
|
200
208
|
function setDraft(suite, script, origin, sourceFlow = '', savedFile = '', savedHash = '') {
|
|
209
|
+
app.rerunOrigin = null; app.rerunTargetMismatch = false; renderRerunHints();
|
|
201
210
|
app.draft = suite; app.script = script; app.origin = origin; app.sourceFlow = sourceFlow; app.boundSource = suite.source ? { ...suite.source } : null; app.savedFile = savedFile; app.savedHash = savedHash; app.stale = false; app.dirty = false;
|
|
202
211
|
$('suite-editor').value = JSON.stringify(suite, null, 2);
|
|
203
212
|
$('scenario-platform').value = suite.platform || 'web';
|
|
@@ -219,6 +228,12 @@
|
|
|
219
228
|
for (const target of app.state?.targets || []) { const row = el('div', 'settings-entry'); row.append(el('strong', '', target.name), document.createTextNode(` · ${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}`)); list.append(row); }
|
|
220
229
|
}
|
|
221
230
|
function targetDescription() { const target = selectedTarget(); $('target-description').textContent = target ? `${targetLabel(target)}${target.description ? ` · ${target.description}` : ''}` : t('scenario.targetHint'); readiness(); }
|
|
231
|
+
function renderRerunHints() {
|
|
232
|
+
$('rerun-origin-hint').hidden = !app.rerunOrigin;
|
|
233
|
+
if (app.rerunOrigin) $('rerun-origin-hint').textContent = t('app.rerunPrepared', app.rerunOrigin);
|
|
234
|
+
$('rerun-target-hint').hidden = !app.rerunTargetMismatch;
|
|
235
|
+
if (app.rerunTargetMismatch) $('rerun-target-hint').textContent = t('app.rerunTargetMismatch');
|
|
236
|
+
}
|
|
222
237
|
function modelDescription(role) {
|
|
223
238
|
const model = app.state?.models?.[role] || {};
|
|
224
239
|
const explanation = t(role === 'planner' ? 'app.modelPlannerHelp' : 'app.modelAgentHelp');
|
|
@@ -255,24 +270,98 @@
|
|
|
255
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);
|
|
256
271
|
}
|
|
257
272
|
}
|
|
273
|
+
function historyFilters() { return { query: $('history-search').value.trim(), status: $('history-status-filter').value }; }
|
|
258
274
|
function renderHistory() {
|
|
275
|
+
const history = app.history;
|
|
259
276
|
const host = $('history-list'); replace(host);
|
|
260
|
-
|
|
261
|
-
if (!runs.length) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
});
|
|
270
|
-
if (!matchingRuns.length) { host.append(el('p', 'empty', t(query ? 'app.historyNoSearchMatches' : 'app.historyNoStatusMatches'))); return; }
|
|
271
|
-
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) {
|
|
272
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 })));
|
|
273
287
|
const scope = selectionScope(run.selection); if (scope) copy.append(el('small', 'selection-scope', scope));
|
|
274
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);
|
|
275
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();
|
|
276
365
|
}
|
|
277
366
|
const comparisonCategories = ['newly-failed', 'now-passed', 'changed', 'unchanged', 'added', 'missing'];
|
|
278
367
|
const comparisonCategoryKeys = { 'newly-failed': 'comparison.newlyFailed', 'now-passed': 'comparison.nowPassed', changed: 'comparison.changed', unchanged: 'comparison.unchanged', added: 'comparison.added', missing: 'comparison.missing' };
|
|
@@ -385,7 +474,7 @@
|
|
|
385
474
|
const libraryChanged = JSON.stringify(app.state?.suites) !== JSON.stringify(state.suites);
|
|
386
475
|
const historyChanged = JSON.stringify(app.state?.runs) !== JSON.stringify(state.runs);
|
|
387
476
|
app.state = state; app.connected = true;
|
|
388
|
-
renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) {
|
|
477
|
+
renderActive(); if (libraryChanged) renderLibrary(); if (historyChanged) { recentRunsChanged(); renderComparisonOptions(); }
|
|
389
478
|
if (full || initialize) { targetOptions(); modelForms(); }
|
|
390
479
|
if (previousActive?.kind === 'run' && !state.active) { if (state.lastError) notice(state.lastError, true); else noticeKey('app.runEnded'); }
|
|
391
480
|
if (state.lastError && state.lastError !== app.lastError) { app.lastError = state.lastError; notice(state.lastError, true); }
|
|
@@ -467,7 +556,31 @@
|
|
|
467
556
|
catch (error) { showError(error); }
|
|
468
557
|
finally { app.stopping = false; readiness(); }
|
|
469
558
|
}
|
|
470
|
-
async function loadReport(runId) { await work(async () => { const result = await api(`/api/report?runId=${encodeURIComponent(runId)}`); app.report = result.report; app.reportMarkdown = result.markdown; app.selectedRun = runId; renderReport(); $('report-panel').hidden = false; $('report-panel').scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }
|
|
559
|
+
async function loadReport(runId) { await work(async () => { const result = await api(`/api/report?runId=${encodeURIComponent(runId)}`); app.report = result.report; app.reportMarkdown = result.markdown; app.selectedRun = runId; renderReport(); readiness(); $('report-panel').hidden = false; $('report-panel').scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }
|
|
560
|
+
async function prepareFailedCases() { await work(async () => {
|
|
561
|
+
const runId = app.selectedRun;
|
|
562
|
+
const report = app.report;
|
|
563
|
+
const navigationVersion = app.navigationVersion;
|
|
564
|
+
if (!app.connected || app.state?.active || !runId || !report?.cases?.some((item) => item.status === 'failed' || item.status === 'error')) return;
|
|
565
|
+
const result = await api(`/api/rerun?runId=${encodeURIComponent(runId)}`);
|
|
566
|
+
if (app.selectedRun !== runId || app.report !== report || app.view !== 'history' || app.navigationVersion !== navigationVersion) return;
|
|
567
|
+
await refresh(false);
|
|
568
|
+
if (app.selectedRun !== runId || app.report !== report || app.view !== 'history' || app.navigationVersion !== navigationVersion || !app.connected || app.state?.active) return;
|
|
569
|
+
if (!confirmScenarioReplacement('app.confirmReplaceDraft')) return;
|
|
570
|
+
targetOptions();
|
|
571
|
+
$('flow-input').value = result.flow || '';
|
|
572
|
+
setDraft(result.suite, result.script, 'library', result.flow || '', result.file, result.suiteHash);
|
|
573
|
+
app.selectedCaseIds = new Set(result.caseIds);
|
|
574
|
+
renderCaseSelection();
|
|
575
|
+
const target = app.displayTargets.find((item) => item.name === result.targetName);
|
|
576
|
+
const sameTarget = result.targetMatches === true && target?.targetFingerprint === result.targetFingerprint;
|
|
577
|
+
$('target-select').value = sameTarget ? target.name : '';
|
|
578
|
+
app.rerunTargetMismatch = !sameTarget;
|
|
579
|
+
app.rerunOrigin = { runId: result.originRunId, count: result.caseIds.length };
|
|
580
|
+
renderRerunHints(); targetDescription();
|
|
581
|
+
noticeKey(sameTarget ? 'app.rerunPrepared' : 'app.rerunPreparedTarget', app.rerunOrigin);
|
|
582
|
+
view('scenario');
|
|
583
|
+
}); }
|
|
471
584
|
async function downloadReportMarkdown() {
|
|
472
585
|
const runId = app.selectedRun; const locale = i18n.locale();
|
|
473
586
|
if (!runId || !app.report) return;
|
|
@@ -533,7 +646,7 @@
|
|
|
533
646
|
if (input) { input.focus(); if (Number.isInteger(focus.start)) input.setSelectionRange(focus.start, focus.end); }
|
|
534
647
|
}
|
|
535
648
|
summarize(app.draft, true); renderCaseSelection();
|
|
536
|
-
renderActive(); targetDescription(); renderLibrary(); renderHistory(); renderComparisonOptions(); renderComparison(); renderReport();
|
|
649
|
+
renderActive(); targetDescription(); renderRerunHints(); renderLibrary(); renderHistory(); renderComparisonOptions(); renderComparison(); renderReport();
|
|
537
650
|
if (app.comparisonStatus) comparisonStatus(app.comparisonStatus, app.comparisonStatusParams || {}, app.comparisonStatus === 'comparison.error');
|
|
538
651
|
app.reportMarkdown = '';
|
|
539
652
|
if (app.notice) $('notice').textContent = t(app.notice.key, app.notice.params);
|
|
@@ -543,8 +656,10 @@
|
|
|
543
656
|
window.addEventListener('beforeunload', warnBeforeUnload);
|
|
544
657
|
document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => view(button.dataset.view)));
|
|
545
658
|
$('library-search').addEventListener('input', renderLibrary);
|
|
546
|
-
$('history-search').addEventListener('input',
|
|
547
|
-
$('history-status-filter').addEventListener('change',
|
|
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);
|
|
548
663
|
for (const id of ['compare-baseline', 'compare-current']) $(id).addEventListener('change', clearComparison);
|
|
549
664
|
$('compare-btn').addEventListener('click', compareRuns);
|
|
550
665
|
$('compare-swap').addEventListener('click', () => { const pair = comparisonPair(); $('compare-baseline').value = pair.current; $('compare-current').value = pair.baseline; clearComparison(); });
|
|
@@ -561,7 +676,7 @@
|
|
|
561
676
|
const unsavedJson = json.trim() && (!app.savedFile || json !== JSON.stringify(app.draft, null, 2));
|
|
562
677
|
if (unsavedJson && !confirmScenarioReplacement('app.confirmNewDraft')) return;
|
|
563
678
|
app.draft = null; app.selectedCaseIds = new Set(); app.script = ''; app.savedFile = ''; app.savedHash = '';
|
|
564
|
-
app.origin = 'none'; app.sourceFlow = ''; app.boundSource = null; app.stale = false; app.dirty = false;
|
|
679
|
+
app.origin = 'none'; app.sourceFlow = ''; app.boundSource = null; app.stale = false; app.dirty = false; app.rerunOrigin = null; app.rerunTargetMismatch = false; renderRerunHints();
|
|
565
680
|
$('suite-editor').value = ''; $('review-checkbox').checked = false;
|
|
566
681
|
summarize(null); renderCaseSelection(); readiness();
|
|
567
682
|
noticeKey('app.newDraftNotice');
|
|
@@ -570,7 +685,7 @@
|
|
|
570
685
|
$('flow-input').addEventListener('input', () => { if (app.origin === 'imported' ? $('flow-input').value !== editableFlow(app.sourceFlow) : (app.sourceFlow || app.boundSource) && $('flow-input').value.trim() !== editableFlow(app.sourceFlow).trim()) { app.stale = true; app.savedHash = ''; $('review-checkbox').checked = false; } readiness(); });
|
|
571
686
|
$('scenario-platform').addEventListener('change', () => { app.planRequest += 1; if (app.draft) { app.stale = true; app.savedHash = ''; } $('review-checkbox').checked = false; readiness(); });
|
|
572
687
|
$('suite-editor').addEventListener('input', () => { app.dirty = true; app.script = ''; app.savedFile = ''; app.savedHash = ''; if (app.origin === 'none') app.origin = 'manual'; $('review-checkbox').checked = false; summarize(null); readiness(); });
|
|
573
|
-
$('review-checkbox').addEventListener('change', readiness); $('target-select').addEventListener('change', () => { $('review-checkbox').checked = false; targetDescription(); });
|
|
688
|
+
$('review-checkbox').addEventListener('change', readiness); $('target-select').addEventListener('change', () => { $('review-checkbox').checked = false; if ($('target-select').value) { app.rerunTargetMismatch = false; renderRerunHints(); } targetDescription(); });
|
|
574
689
|
$('case-selection-list').addEventListener('change', (event) => { const input = event.target.closest('input[data-case-id]'); if (!input) return; const next = new Set(app.selectedCaseIds); if (input.checked) next.add(input.dataset.caseId); else next.delete(input.dataset.caseId); changeCaseSelection(next); });
|
|
575
690
|
$('select-all-cases').addEventListener('click', () => changeCaseSelection((app.draft?.cases || []).map((item) => item.id)));
|
|
576
691
|
$('clear-case-selection').addEventListener('click', () => changeCaseSelection([]));
|
|
@@ -583,6 +698,7 @@
|
|
|
583
698
|
$('script-download').addEventListener('click', () => { if (app.draft && !app.dirty && !app.stale) download(`${app.draft.id || 'flow-qa'}.test.mjs`, app.script, 'text/javascript'); });
|
|
584
699
|
$('report-md').addEventListener('click', downloadReportMarkdown);
|
|
585
700
|
$('report-json').addEventListener('click', () => { if (app.report) download(`${app.selectedRun}.json`, JSON.stringify(app.report, null, 2), 'application/json'); });
|
|
701
|
+
$('report-rerun').addEventListener('click', prepareFailedCases);
|
|
586
702
|
$('target-form').addEventListener('submit', saveTarget);
|
|
587
703
|
$('target-kind').addEventListener('change', targetFields); targetFields();
|
|
588
704
|
$('language-select').addEventListener('change', (event) => i18n.setLocale(event.currentTarget.value));
|
|
@@ -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', '기준 실행'],
|
|
@@ -131,6 +141,8 @@
|
|
|
131
141
|
'comparison.error': ['Comparison failed: {message}', '비교 실패: {message}'],
|
|
132
142
|
'comparison.runLabel': ['{date} · {suite} · {shortId} · {target} · {status} · {id}', '{date} · {suite} · {shortId} · {target} · {status} · {id}'],
|
|
133
143
|
'history.report': ['Detailed report', '상세 보고서'],
|
|
144
|
+
'history.prepareFailed': ['Prepare failed/error cases', '실패·오류 케이스 준비'],
|
|
145
|
+
'history.prepareHint': ['Only failed and error cases are selected. Passed, flaky, blocked, and skipped cases are excluded. Preparation never starts a run.', '실패·오류 케이스만 선택합니다. 통과·간헐 실패·차단·건너뜀 케이스는 제외됩니다. 준비만으로 실행은 시작되지 않습니다.'],
|
|
134
146
|
'history.downloadMarkdown': ['Download Markdown', 'Markdown 다운로드'],
|
|
135
147
|
'history.downloadJson': ['Download JSON', 'JSON 다운로드'],
|
|
136
148
|
'history.evidenceHint': ['The report summarizes results. Open or download screenshots and recordings from each attempt. Evidence files are not included in report downloads.', '보고서는 결과 요약입니다. 스크린샷·화면 기록은 각 시도에서 열거나 개별 다운로드하세요. 증거 파일은 보고서 다운로드에 포함되지 않습니다.'],
|
|
@@ -306,6 +318,9 @@
|
|
|
306
318
|
'app.savedSuite': ['Cases saved to a separate file: {file}. Review them again before running.', '케이스를 별도 파일로 저장했습니다: {file}. 실행 전 내용을 다시 검토하세요.'],
|
|
307
319
|
'app.runNotReady': ['Save or reload cases, select a target, and review the contents before running.', '케이스 저장 또는 다시 불러오기, 대상 선택, 직접 검토가 필요합니다.'],
|
|
308
320
|
'app.runStarted': ['Run started. Task ID: {id}', '실행을 시작했습니다. 작업 ID: {id}'],
|
|
321
|
+
'app.rerunPrepared': ['Prepared {count} failed/error cases from run {runId}. Review the cases and target before starting a new run.', '실행 {runId}의 실패·오류 케이스 {count}개를 준비했습니다. 새 실행 전 케이스와 대상을 검토하세요.'],
|
|
322
|
+
'app.rerunPreparedTarget': ['Prepared {count} failed/error cases from run {runId}. The target changed or is unavailable. Select and review a target before starting a new run.', '실행 {runId}의 실패·오류 케이스 {count}개를 준비했습니다. 대상이 변경되었거나 없습니다. 새 실행 전 대상을 선택하고 검토하세요.'],
|
|
323
|
+
'app.rerunTargetMismatch': ['The report target changed or is unavailable. Select a current target and review it before running.', '보고서의 대상이 변경되었거나 없습니다. 현재 대상을 선택하고 검토한 뒤 실행하세요.'],
|
|
309
324
|
'app.stopRequested': ['Stop request sent. Checking the final state.', '중지 요청을 보냈습니다. 실제 종료 상태를 확인 중입니다.'],
|
|
310
325
|
'app.reportStatus': ['Status {status}', '상태 {status}'],
|
|
311
326
|
'app.reportTarget': ['Target {target}', '대상 {target}'],
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
<section class="execution-section" aria-labelledby="run-title">
|
|
82
82
|
<div class="section-head"><span class="section-number">03</span><div><h2 id="run-title" data-i18n="scenario.runTitle">Review, then run</h2><p data-i18n="scenario.runHelp">Select a target and confirm you have reviewed the cases.</p></div></div>
|
|
83
83
|
<div class="execution-grid">
|
|
84
|
-
<div class="target-field"><label for="target-select" class="field-label" data-i18n="scenario.target">Run target</label><select id="target-select"><option value="" data-i18n="scenario.selectTarget">Select a target</option></select><p id="target-description" class="hint" data-i18n="scenario.targetHint">You can add a target in Connections.</p><p id="mobile-setup-hint" class="hint" hidden data-i18n="scenario.mobileSetup">For iOS, prepare Xcode, a simulator or signed device, and app signing. For Android, prepare the Android SDK and an emulator or authorized device. Configuration alone does not verify device access.</p></div>
|
|
85
|
-
<div class="execution-confirm"><div class="case-selection"><div class="case-selection-head"><h3 id="case-selection-title" data-i18n="scenario.selectionTitle">Cases to run</h3><span id="case-selection-summary" role="status" aria-live="polite"></span></div><p class="hint" data-i18n="scenario.selectionHint">Each selected case must establish its own starting state. Excluded cases are not assessed.</p><div class="actions"><button type="button" id="select-all-cases" class="button quiet" data-i18n="scenario.selectAllCases">Select all</button><button type="button" id="clear-case-selection" class="button quiet" data-i18n="scenario.clearSelection">Clear selection</button></div><div id="case-selection-list" role="group" aria-labelledby="case-selection-title"></div></div><p id="case-readiness" class="hint"></p><label class="review-line"><input type="checkbox" id="review-checkbox"><span data-i18n="scenario.reviewConfirm">I have reviewed the requirements, case steps, and expected results.</span></label><div class="run-bar"><span id="run-readiness" data-i18n="scenario.runInitial">Validated cases and a run target are required.</span><div class="actions"><button type="button" id="stop-btn" class="button quiet" hidden data-i18n="scenario.stop">Stop run</button><button type="button" id="run-btn" class="button primary" disabled><span data-i18n="scenario.run">Run QA</span><span aria-hidden="true">→</span></button></div></div></div>
|
|
84
|
+
<div class="target-field"><label for="target-select" class="field-label" data-i18n="scenario.target">Run target</label><select id="target-select"><option value="" data-i18n="scenario.selectTarget">Select a target</option></select><p id="target-description" class="hint" data-i18n="scenario.targetHint">You can add a target in Connections.</p><p id="rerun-target-hint" class="hint" hidden></p><p id="mobile-setup-hint" class="hint" hidden data-i18n="scenario.mobileSetup">For iOS, prepare Xcode, a simulator or signed device, and app signing. For Android, prepare the Android SDK and an emulator or authorized device. Configuration alone does not verify device access.</p></div>
|
|
85
|
+
<div class="execution-confirm"><div class="case-selection"><div class="case-selection-head"><h3 id="case-selection-title" data-i18n="scenario.selectionTitle">Cases to run</h3><span id="case-selection-summary" role="status" aria-live="polite"></span></div><p id="rerun-origin-hint" class="hint" hidden></p><p class="hint" data-i18n="scenario.selectionHint">Each selected case must establish its own starting state. Excluded cases are not assessed.</p><div class="actions"><button type="button" id="select-all-cases" class="button quiet" data-i18n="scenario.selectAllCases">Select all</button><button type="button" id="clear-case-selection" class="button quiet" data-i18n="scenario.clearSelection">Clear selection</button></div><div id="case-selection-list" role="group" aria-labelledby="case-selection-title"></div></div><p id="case-readiness" class="hint"></p><label class="review-line"><input type="checkbox" id="review-checkbox"><span data-i18n="scenario.reviewConfirm">I have reviewed the requirements, case steps, and expected results.</span></label><div class="run-bar"><span id="run-readiness" data-i18n="scenario.runInitial">Validated cases and a run target are required.</span><div class="actions"><button type="button" id="stop-btn" class="button quiet" hidden data-i18n="scenario.stop">Stop run</button><button type="button" id="run-btn" class="button primary" disabled><span data-i18n="scenario.run">Run QA</span><span aria-hidden="true">→</span></button></div></div></div>
|
|
86
86
|
</div>
|
|
87
87
|
</section>
|
|
88
88
|
</div>
|
|
@@ -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">
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
<div id="comparison-results" hidden><div id="comparison-warnings"></div><div id="comparison-summary"></div><div class="comparison-filter-row"><label for="comparison-filter" data-i18n="comparison.filter">Show cases</label><select id="comparison-filter" aria-controls="comparison-content"><option value="" data-i18n="comparison.all">All categories</option><option value="newly-failed" data-i18n="comparison.newlyFailed">Newly failed</option><option value="now-passed" data-i18n="comparison.nowPassed">Now passed</option><option value="changed" data-i18n="comparison.changed">Changed status</option><option value="unchanged" data-i18n="comparison.unchanged">Same status</option><option value="added" data-i18n="comparison.added">Added to current</option><option value="missing" data-i18n="comparison.missing">Missing from current</option></select></div><div class="actions comparison-actions"><button type="button" id="comparison-md" class="button" data-i18n="comparison.downloadMarkdown" disabled>Download comparison Markdown</button><button type="button" id="comparison-json" class="button quiet" data-i18n="comparison.downloadJson" disabled>Download comparison JSON</button><button type="button" id="compare-open-baseline" class="button quiet" data-i18n="comparison.openBaseline" disabled>View baseline report</button><button type="button" id="compare-open-current" class="button quiet" data-i18n="comparison.openCurrent" disabled>View current report</button></div><div id="comparison-content"></div></div>
|
|
105
105
|
</div>
|
|
106
106
|
</section>
|
|
107
|
-
<section id="report-panel" class="report-panel" hidden><div class="section-head"><span class="section-number">03</span><div><h2 data-i18n="history.report">Detailed report</h2><p id="report-heading"></p></div></div><div class="section-body"><div class="actions"><button type="button" id="report-md" class="button" data-i18n="history.downloadMarkdown">Download Markdown</button><button type="button" id="report-json" class="button quiet" data-i18n="history.downloadJson">Download JSON</button></div><p class="hint" data-i18n="history.evidenceHint">The report summarizes results. Open or download screenshots and recordings from each attempt. Evidence files are not included in report downloads.</p><div id="report-content"></div></div></section>
|
|
107
|
+
<section id="report-panel" class="report-panel" hidden><div class="section-head"><span class="section-number">03</span><div><h2 data-i18n="history.report">Detailed report</h2><p id="report-heading"></p></div></div><div class="section-body"><div class="actions"><button type="button" id="report-md" class="button" data-i18n="history.downloadMarkdown">Download Markdown</button><button type="button" id="report-json" class="button quiet" data-i18n="history.downloadJson">Download JSON</button><button type="button" id="report-rerun" class="button primary" disabled data-i18n="history.prepareFailed">Prepare failed/error cases</button></div><p class="hint" data-i18n="history.prepareHint">Passed, flaky, blocked, and skipped cases are excluded. Preparation does not start a run.</p><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>
|
|
108
108
|
</section>
|
|
109
109
|
<section id="view-settings" class="view" hidden>
|
|
110
110
|
<div class="page-heading"><p class="eyebrow" data-i18n="settings.eyebrow">04 / CONNECTIONS</p><h1 data-i18n="nav.settings">Connections</h1><p data-i18n="settings.intro">Configure run targets and models separately. Key values are read only from server environment variables.</p></div>
|
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,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
|
|
400
|
-
if (
|
|
401
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
@@ -471,6 +535,59 @@ export async function startUIServer(options) {
|
|
|
471
535
|
sendJSON(response, 200, await report(runId, true, locale));
|
|
472
536
|
return;
|
|
473
537
|
}
|
|
538
|
+
if (url.pathname === '/api/rerun') {
|
|
539
|
+
const runId = url.searchParams.get('runId');
|
|
540
|
+
if (!runId)
|
|
541
|
+
invalid('A run ID is required.');
|
|
542
|
+
const { report: previous } = await report(runId, false);
|
|
543
|
+
const matches = (await suites()).filter((item) => item.id === previous.suiteId);
|
|
544
|
+
if (matches.length === 0)
|
|
545
|
+
throw new HTTPError(409, 'No saved case file matches this report.');
|
|
546
|
+
if (matches.length > 1)
|
|
547
|
+
throw new HTTPError(409, 'Multiple saved case files match this report.');
|
|
548
|
+
const file = matches[0].file;
|
|
549
|
+
let selected;
|
|
550
|
+
try {
|
|
551
|
+
selected = await getSuite(file, true);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error instanceof HTTPError && error.status === 404) {
|
|
555
|
+
throw new HTTPError(409, 'The saved case file or source flow was not found.');
|
|
556
|
+
}
|
|
557
|
+
if (error instanceof HTTPError && error.status === 400) {
|
|
558
|
+
throw new HTTPError(409, 'The saved case file or source flow is invalid.');
|
|
559
|
+
}
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
const { suite } = selected;
|
|
563
|
+
if (sha256(`${JSON.stringify(suite, null, 2)}\n`) !== previous.suiteHash) {
|
|
564
|
+
throw new HTTPError(409, 'The saved case file changed since this run. Review it before rerunning.');
|
|
565
|
+
}
|
|
566
|
+
const available = new Set(suite.cases.map((item) => item.id));
|
|
567
|
+
if (previous.cases.some((item) => !item.id.trim() || !available.has(item.id)) ||
|
|
568
|
+
new Set(previous.cases.map((item) => item.id)).size !== previous.cases.length ||
|
|
569
|
+
(!previous.selection && previous.cases.length !== suite.cases.length)) {
|
|
570
|
+
throw new HTTPError(409, 'The report cases do not match the saved case file.');
|
|
571
|
+
}
|
|
572
|
+
if (previous.selection && (previous.selection.totalCases !== suite.cases.length ||
|
|
573
|
+
!isReportSelectionValid(previous.cases, previous.selection))) {
|
|
574
|
+
throw new HTTPError(409, 'The report selection does not match the saved case file.');
|
|
575
|
+
}
|
|
576
|
+
const failing = new Set(previous.cases.filter((item) => item.status === 'failed' || item.status === 'error').map((item) => item.id));
|
|
577
|
+
const caseIds = suite.cases.filter((item) => failing.has(item.id)).map((item) => item.id);
|
|
578
|
+
if (caseIds.length === 0)
|
|
579
|
+
throw new HTTPError(409, 'No failed or errored cases are available to rerun.');
|
|
580
|
+
const settings = await config();
|
|
581
|
+
const targetName = previous.target.name;
|
|
582
|
+
const expectedFingerprint = targetFingerprint(previous.target);
|
|
583
|
+
const currentTarget = Object.hasOwn(settings.targets, targetName) ? settings.targets[targetName] : undefined;
|
|
584
|
+
sendJSON(response, 200, { originRunId: runId, file, ...selected, caseIds, targetName,
|
|
585
|
+
targetFingerprint: expectedFingerprint,
|
|
586
|
+
targetMatches: Boolean(currentTarget &&
|
|
587
|
+
(suite.platform ?? 'web') === (currentTarget.kind === 'mobile' ? 'mobile' : 'web') &&
|
|
588
|
+
targetFingerprint(currentTarget) === expectedFingerprint) });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
474
591
|
if (url.pathname === '/api/comparison') {
|
|
475
592
|
const baseline = url.searchParams.get('baseline');
|
|
476
593
|
const current = url.searchParams.get('current');
|
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.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
|
+
|
|
18
|
+
## 0.5.0: prepare failed cases from a report
|
|
19
|
+
|
|
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.
|
|
21
|
+
|
|
22
|
+
Preparation requires one matching saved suite, the exact parsed suite snapshot hash, a valid linked source, and coherent full/partial report IDs. Missing, changed, or ambiguous inputs stop preparation with guidance. A changed or missing target is cleared for explicit selection; even a matching target requires renewed review. Existing execution-time checks remain in force. The next run writes its own ordinary selected-scope report; the original report remains unchanged, and no persistent parent-run link is added.
|
|
23
|
+
|
|
24
|
+
Locally verified on macOS with Node.js 24.22.1 on 2026-11-03: 117 tests, type checking, UI syntax and design-system audit, and demo/UI checks passed. The dedicated Chromium smoke covered all six case statuses, unsaved-draft acceptance/cancellation, changed suite and target, navigation during a delayed response, English/Korean, and a 390px layout. It executed two prepared cases successfully with zero requests from the excluded sentinel. A fresh tarball installation prepared a synthetic failed report and ran that selection in a real browser, alongside full-run/comparison checks (seven passing browser cases). The existing import smoke now waits for save completion before asserting download readiness. No model calls, external logins, or native-device runs were used.
|
|
25
|
+
|
|
26
|
+
- Package: [`@ihyeon/flow-qa@0.5.0`](https://www.npmjs.com/package/@ihyeon/flow-qa/v/0.5.0)
|
|
27
|
+
- Release: [GitHub 0.5.0](https://github.com/hyeonse-swing/flow-qa/releases/tag/v0.5.0)
|
|
28
|
+
|
|
5
29
|
## 0.4.0: run selected cases
|
|
6
30
|
|
|
7
31
|
Adds case selection to the execution panel and repeated `--case <id>` flags to CLI runs. Selecting or clearing cases resets UI review, and execution still requires the reviewed file and target fingerprints. Unknown, duplicate, and empty selections are rejected. Selection is preserved across navigation, language changes, and polling, while a new draft starts with all cases selected.
|
|
@@ -122,7 +146,7 @@ Locally verified on macOS with Node.js 24.14.1 on 2026-10-12: 89 tests, type che
|
|
|
122
146
|
|
|
123
147
|
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.
|
|
124
148
|
|
|
125
|
-
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 full Chromium demo runs plus a one-case selected run (seven 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.
|
|
149
|
+
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 full Chromium demo runs plus a one-case selected run prepared from a synthetic failed report (seven 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.
|
|
126
150
|
|
|
127
151
|
For the next release, update the release notes and current installation examples, then bump the version before committing and pushing:
|
|
128
152
|
|
package/docs/ui.md
CHANGED
|
@@ -66,11 +66,27 @@ 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. The comparison selectors still offer the latest 50 reports independently of this search.
|
|
80
|
+
|
|
81
|
+
### Prepare failed cases
|
|
82
|
+
|
|
83
|
+
From `0.5.0`, open **Run history → Detailed report → Prepare failed/error cases**. Only cases whose recorded status is `failed` or `error` are selected; `passed`, `flaky`, `blocked`, and `skipped` are excluded. The button is unavailable when no eligible cases exist or an operation is active. Preparation does not execute tests, call a model, or change files.
|
|
84
|
+
|
|
85
|
+
The UI finds exactly one saved case file with the report's suite ID, verifies its complete parsed content against the run's snapshot hash, and verifies its linked source Markdown. Missing, changed, ambiguous, or unreadable inputs stop preparation without replacing your draft. Open the current case file from the library and review it manually when the test definition has changed. An unsaved draft requires confirmation before replacement.
|
|
86
|
+
|
|
87
|
+
Successful preparation opens Scenario with the original source and failed/error selection. The prior target is selected only when its current name and settings still match the recorded target. Otherwise the target is cleared for explicit selection. Review confirmation is always cleared; inspect the selected cases and target before pressing **Run QA**. The normal execution-time file, source, and target checks still apply. Each selected case must establish its own starting state. Mobile target matching compares configured selectors, not physical device identity.
|
|
88
|
+
|
|
89
|
+
The preparation notice identifies the original run. The next execution creates a separate ordinary report with its selected scope; it does not overwrite the original report or store a persistent parent-run link. Selection stays through navigation, language changes, and polling, but resets on page reload.
|
|
74
90
|
|
|
75
91
|
### Compare two runs
|
|
76
92
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ihyeon/flow-qa",
|
|
3
|
-
"version": "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"
|
|
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",
|