@ihyeon/flow-qa 0.2.9 → 0.4.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 +11 -1
- package/README.md +11 -1
- package/dist/src/case-selection.js +50 -0
- package/dist/src/cli.js +25 -4
- package/dist/src/engine.js +15 -9
- package/dist/src/i18n.js +8 -0
- package/dist/src/report.js +20 -5
- package/dist/src/run-comparison.js +165 -0
- package/dist/src/ui/public/app.js +153 -14
- package/dist/src/ui/public/i18n.js +50 -0
- package/dist/src/ui/public/index.html +13 -2
- package/dist/src/ui/public/style.css +34 -2
- package/dist/src/ui/server.js +38 -5
- package/docs/architecture.md +3 -1
- package/docs/mobile.md +1 -1
- package/docs/releases.md +23 -1
- package/docs/ui.md +21 -1
- package/package.json +2 -2
package/README.ko.md
CHANGED
|
@@ -16,7 +16,7 @@ CLI와 `127.0.0.1` 전용 웹 UI를 제공합니다. UI는 영어로 시작하
|
|
|
16
16
|
Node.js 24.x(24.22.1 이상) 또는 26.12.1 이상이 필요합니다. 브라우저는 첫 웹 실행 전에 설치합니다.
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
npm install -g @ihyeon/flow-qa@0.
|
|
19
|
+
npm install -g @ihyeon/flow-qa@0.4.0
|
|
20
20
|
flow-qa browser install
|
|
21
21
|
flow-qa ui --demo
|
|
22
22
|
```
|
|
@@ -62,6 +62,16 @@ flow-qa plan flows/my-flow.md --workspace my-qa
|
|
|
62
62
|
|
|
63
63
|
`flow-qa doctor --workspace my-qa`로 로컬 의존성과 모델 인증용 환경 변수의 설정 여부를 확인할 수 있습니다. 키 값은 출력하지 않고 모델 서버도 호출하지 않습니다. 환경 변수가 있어도 실제 인증 성공을 확인한 것은 아닙니다.
|
|
64
64
|
|
|
65
|
+
## 선택한 케이스만 실행
|
|
66
|
+
|
|
67
|
+
`0.4.0`부터 **시나리오 → 검토 후 실행 → 실행할 케이스**에서 필요한 케이스만 고를 수 있습니다. 처음에는 모두 선택됩니다. 선택을 바꾸면 검토 확인을 다시 해야 하며, 저장한 원본 케이스 파일은 그대로 유지됩니다. 다른 케이스를 준비 단계로 자동 실행하지 않으므로 각 케이스에 필요한 시작 상태를 직접 구성하세요.
|
|
68
|
+
|
|
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
|
+
|
|
71
|
+
## 실행 결과 비교
|
|
72
|
+
|
|
73
|
+
**실행 기록 → 실행 2건 비교**에서 같은 스위트와 대상의 두 기록을 선택하면 통과→실패, 실패→통과, 그 외 상태 변경, 동일 상태, 추가·누락 케이스를 확인할 수 있습니다. 분류별로 보거나 전체 비교를 Markdown·JSON으로 내려받으세요. 테스트 내용 변경과 불완전한 실행은 별도로 표시하며, 상태 변화만으로 제품 버그의 발생·해결을 확정하지 않습니다. 자세한 동작은 [UI 가이드](docs/ui.md#compare-two-runs)를 참고하세요.
|
|
74
|
+
|
|
65
75
|
## 모바일 대상
|
|
66
76
|
|
|
67
77
|
`0.2.0`부터 사용자가 직접 설정하는 iOS·Android 대상을 지원합니다. 시나리오 화면에서 **모바일**을 선택하면 앱을 정하기 전에도 케이스를 준비할 수 있습니다. 이후 **연결 설정**에서 플랫폼·설치된 앱의 번들/패키지 ID·기기를 등록하고 실행할 때 선택합니다. 설정 저장은 앱 설치나 테스트 실행을 시작하지 않습니다.
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Flow QA is available as a CLI and a web UI bound to `127.0.0.1`. Provide a targe
|
|
|
16
16
|
Node.js 24.x (24.22.1 or later) or 26.12.1 or later is required. Install Chromium before the first web run:
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
npm install -g @ihyeon/flow-qa@0.
|
|
19
|
+
npm install -g @ihyeon/flow-qa@0.4.0
|
|
20
20
|
flow-qa browser install
|
|
21
21
|
flow-qa ui --demo
|
|
22
22
|
```
|
|
@@ -62,6 +62,16 @@ For authenticated models, add `--key-env FLOW_QA_API_KEY` to the model command a
|
|
|
62
62
|
|
|
63
63
|
`flow-qa doctor --workspace my-qa` checks local dependencies and whether configured model-key environment variables are present. It does not print key values or call the model server; a present key does not confirm remote authentication.
|
|
64
64
|
|
|
65
|
+
## Run selected cases
|
|
66
|
+
|
|
67
|
+
From `0.4.0`, choose cases under **Scenario → Review, then run → Cases to run**. Every case starts selected. Clear or change the selection, review it, then run against your target. Selecting cases never edits the saved suite or implicitly runs prerequisite cases. Each case must establish its own starting state.
|
|
68
|
+
|
|
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
|
+
|
|
71
|
+
## Compare recorded runs
|
|
72
|
+
|
|
73
|
+
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
|
+
|
|
65
75
|
## Mobile targets
|
|
66
76
|
|
|
67
77
|
Version `0.2.0` adds user-configured iOS and Android targets. Choose **Mobile** in the scenario screen to prepare cases before choosing an app. Later, register the platform, installed app's bundle/package ID, and device in **Connections**, then select that target for execution. Saving settings does not install an app or start a test.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class CaseSelectionError extends Error {
|
|
2
|
+
}
|
|
3
|
+
/** Resolve requested IDs against the complete suite without changing its order or contents. */
|
|
4
|
+
export function selectCases(suite, caseIds) {
|
|
5
|
+
if (caseIds === undefined)
|
|
6
|
+
return { suite };
|
|
7
|
+
if (!Array.isArray(caseIds) || caseIds.length === 0)
|
|
8
|
+
throw new CaseSelectionError('Select at least one case ID.');
|
|
9
|
+
const available = new Set(suite.cases.map((testCase) => testCase.id));
|
|
10
|
+
const requested = new Set();
|
|
11
|
+
for (const id of caseIds) {
|
|
12
|
+
if (typeof id !== 'string' || !available.has(id))
|
|
13
|
+
throw new CaseSelectionError(`Unknown case ID: ${String(id)}`);
|
|
14
|
+
if (requested.has(id))
|
|
15
|
+
throw new CaseSelectionError(`Duplicate selected case ID: ${id}`);
|
|
16
|
+
requested.add(id);
|
|
17
|
+
}
|
|
18
|
+
if (requested.size === suite.cases.length)
|
|
19
|
+
return { suite };
|
|
20
|
+
const cases = suite.cases.filter((testCase) => requested.has(testCase.id));
|
|
21
|
+
const requirementIds = new Set(cases.flatMap((testCase) => testCase.requirementIds));
|
|
22
|
+
return {
|
|
23
|
+
suite: { ...suite, cases, requirements: suite.requirements.filter((requirement) => requirementIds.has(requirement.id)) },
|
|
24
|
+
selection: { caseIds: cases.map((testCase) => testCase.id), totalCases: suite.cases.length },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Check optional additive report metadata before treating a report as comparable. */
|
|
28
|
+
export function isReportSelectionValid(cases, selection) {
|
|
29
|
+
const caseIds = cases.map((item) => item.id);
|
|
30
|
+
if (caseIds.some((id) => typeof id !== 'string' || !id.trim()) || new Set(caseIds).size !== caseIds.length)
|
|
31
|
+
return false;
|
|
32
|
+
if (selection === undefined)
|
|
33
|
+
return true;
|
|
34
|
+
if (selection === null || typeof selection !== 'object' || Array.isArray(selection))
|
|
35
|
+
return false;
|
|
36
|
+
const prototype = Object.getPrototypeOf(selection);
|
|
37
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
38
|
+
return false;
|
|
39
|
+
if (Reflect.ownKeys(selection).length !== 2 || !Object.hasOwn(selection, 'caseIds') || !Object.hasOwn(selection, 'totalCases'))
|
|
40
|
+
return false;
|
|
41
|
+
const value = selection;
|
|
42
|
+
if (!Number.isInteger(value.totalCases) || value.totalCases > 100 || value.totalCases <= caseIds.length)
|
|
43
|
+
return false;
|
|
44
|
+
if (!Array.isArray(value.caseIds) || value.caseIds.length === 0 || value.caseIds.length !== caseIds.length)
|
|
45
|
+
return false;
|
|
46
|
+
if (value.caseIds.some((id) => typeof id !== 'string' || !id.trim()))
|
|
47
|
+
return false;
|
|
48
|
+
const selected = new Set(value.caseIds);
|
|
49
|
+
return selected.size === value.caseIds.length && caseIds.every((id) => selected.has(id));
|
|
50
|
+
}
|
package/dist/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { configSchema, idSchema, modelSchema, targetSchema } from "./schema.js";
|
|
|
10
10
|
import { initWorkspace, readConfig, readSuite, sha256, verifySuiteSource, writeConfig, writeJSON } from "./workspace.js";
|
|
11
11
|
import { buildPlanningPrompt, planFlow } from "./planner.js";
|
|
12
12
|
import { compileSuite } from "./compiler.js";
|
|
13
|
+
import { CaseSelectionError, isReportSelectionValid, selectCases } from "./case-selection.js";
|
|
13
14
|
import { runSuite } from "./engine.js";
|
|
14
15
|
import { renderMarkdown } from "./report.js";
|
|
15
16
|
import { targetFingerprint, targetLabel } from "./targets.js";
|
|
@@ -29,6 +30,7 @@ Usage: flow-qa <command> [arguments] [options]
|
|
|
29
30
|
validate <cases.json> Check case structure and assertions
|
|
30
31
|
generate <cases.json> [--out test.ts] Generate e2e test code
|
|
31
32
|
run <cases.json> --target <name> Run against a registered target and save a QA report
|
|
33
|
+
[--case <id> ...] Run only the selected case IDs (repeat --case for more)
|
|
32
34
|
report <run-directory> View a saved QA report
|
|
33
35
|
compare <run-a> <run-b> Compare case results
|
|
34
36
|
ui [--demo] [--port 4310] Start the local QA UI
|
|
@@ -54,6 +56,7 @@ Local example: flow-qa ui --demo
|
|
|
54
56
|
validate <cases.json> 케이스 구조와 검증 조건 확인
|
|
55
57
|
generate <cases.json> [--out test.ts] e2e 테스트 코드 생성
|
|
56
58
|
run <cases.json> --target <name> 연결한 대상에서 실행하고 QA 리포트 저장
|
|
59
|
+
[--case <id> ...] 선택한 케이스만 실행 (--case를 반복해 여러 개 선택)
|
|
57
60
|
report <run-directory> 저장된 QA 리포트 보기
|
|
58
61
|
compare <run-a> <run-b> 케이스별 결과 비교
|
|
59
62
|
ui [--demo] [--port 4310] 로컬 QA 화면 실행
|
|
@@ -126,6 +129,7 @@ export async function main(args) {
|
|
|
126
129
|
const { positionals, values } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
127
130
|
workspace: { type: 'string' }, url: { type: 'string' }, model: { type: 'string' },
|
|
128
131
|
'key-env': { type: 'string' }, out: { type: 'string' }, target: { type: 'string' },
|
|
132
|
+
case: { type: 'string', multiple: true },
|
|
129
133
|
platform: { type: 'string' }, 'bundle-id': { type: 'string' }, device: { type: 'string' },
|
|
130
134
|
'prompt-only': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
|
|
131
135
|
demo: { type: 'boolean' }, port: { type: 'string' }, 'with-deps': { type: 'boolean' }, lang: { type: 'string' },
|
|
@@ -145,7 +149,7 @@ export async function main(args) {
|
|
|
145
149
|
(values['bundle-id'] !== undefined || values.device !== undefined) && command !== 'connect') {
|
|
146
150
|
throw new Error(localized(locale, 'Platform and device options are only supported by connect and plan. See --help.', '플랫폼·기기 옵션은 connect와 plan에서만 사용할 수 있습니다. --help를 참고하세요.'));
|
|
147
151
|
}
|
|
148
|
-
if (values.demo && command !== 'ui' || values.port && command !== 'ui' ||
|
|
152
|
+
if (values.case !== undefined && command !== 'run' || values.demo && command !== 'ui' || values.port && command !== 'ui' ||
|
|
149
153
|
values['with-deps'] && (command !== 'browser' || arg !== 'install'))
|
|
150
154
|
throw new Error(localized(locale, 'This option is not supported for the command. See --help.', '이 명령에서 사용할 수 없는 옵션입니다. --help를 참고하세요.'));
|
|
151
155
|
if (command === 'ui') {
|
|
@@ -246,12 +250,24 @@ export async function main(args) {
|
|
|
246
250
|
}
|
|
247
251
|
const config = await readConfig(workspace);
|
|
248
252
|
const targetName = values.target ?? (Object.keys(config.targets).length === 1 ? Object.keys(config.targets)[0] : undefined);
|
|
253
|
+
let caseIds;
|
|
254
|
+
try {
|
|
255
|
+
caseIds = selectCases(suite, values.case).selection?.caseIds;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error instanceof CaseSelectionError)
|
|
259
|
+
throw new Error(localizeProductMessage('Select one or more unique cases from this suite.', locale));
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
249
262
|
const controller = new AbortController();
|
|
250
263
|
const interrupt = () => controller.abort();
|
|
251
264
|
process.once('SIGINT', interrupt);
|
|
252
265
|
process.once('SIGTERM', interrupt);
|
|
253
266
|
try {
|
|
254
|
-
const { report, runDir } = await runSuite({ workspace, suite, config, targetName: required(targetName, '--target', locale), signal: controller.signal
|
|
267
|
+
const { report, runDir } = await runSuite({ workspace, suite, config, targetName: required(targetName, '--target', locale), signal: controller.signal,
|
|
268
|
+
...(caseIds ? { caseIds } : {}) });
|
|
269
|
+
if (report.selection)
|
|
270
|
+
console.log(localized(locale, `Selected run: ${report.selection.caseIds.length}/${report.selection.totalCases} cases; the full suite was not evaluated.`, `선택 실행: ${report.selection.totalCases}개 중 ${report.selection.caseIds.length}개. 전체 케이스를 검증한 결과가 아닙니다.`));
|
|
255
271
|
console.log(localized(locale, `Run result: ${report.status}\nQA report: ${resolve(runDir, 'qa-report.md')}\nRaw result: ${resolve(runDir, 'qa-report.json')}`, `실행 결과: ${report.status}\nQA 리포트: ${resolve(runDir, 'qa-report.md')}\n원본 결과: ${resolve(runDir, 'qa-report.json')}`));
|
|
256
272
|
process.exitCode = report.status === 'passed' ? 0 : report.status === 'interrupted' ? 130 : 1;
|
|
257
273
|
}
|
|
@@ -283,11 +299,16 @@ export async function main(args) {
|
|
|
283
299
|
const { name: _targetName, ...identity } = data.target;
|
|
284
300
|
if (!targetSchema.safeParse(identity).success)
|
|
285
301
|
throw new Error(localized(locale, 'This is not a valid QA report.', '유효한 QA 리포트가 아닙니다.'));
|
|
302
|
+
if (!isReportSelectionValid(data.cases, data.selection))
|
|
303
|
+
throw new Error(localized(locale, 'This is not a valid QA report.', '유효한 QA 리포트가 아닙니다.'));
|
|
286
304
|
return data;
|
|
287
305
|
};
|
|
288
306
|
const before = await load(arg), after = await load(second);
|
|
289
|
-
const
|
|
290
|
-
|
|
307
|
+
const sameSelection = JSON.stringify(before.cases.map((item) => item.id).sort()) === JSON.stringify(after.cases.map((item) => item.id).sort());
|
|
308
|
+
const comparable = before.suiteId === after.suiteId && before.suiteHash === after.suiteHash && targetFingerprint(before.target) === targetFingerprint(after.target) && sameSelection;
|
|
309
|
+
console.log(comparable ? localized(locale, 'Comparing the same suite, target and case selection (check external data state separately)', '동일 명세·대상·케이스 범위 비교 (외부 데이터 상태는 별도 확인)') : localized(locale, 'Suite, target or case selection differs; no direct regression verdict is made.', '명세·대상 또는 케이스 범위가 달라 직접적인 회귀 판정은 하지 않습니다.'));
|
|
310
|
+
if (before.selection || after.selection)
|
|
311
|
+
console.log(localized(locale, 'This comparison includes selected cases only; omitted cases were not evaluated.', '이 비교에는 선택 실행이 포함되어 있습니다. 미선택 케이스는 검증되지 않았습니다.'));
|
|
291
312
|
const previous = new Map(before.cases.map((c) => [c.id, c.status]));
|
|
292
313
|
const current = new Map(after.cases.map((c) => [c.id, c.status]));
|
|
293
314
|
for (const id of new Set([...previous.keys(), ...current.keys()]))
|
package/dist/src/engine.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createWriteStream } from 'node:fs';
|
|
|
4
4
|
import { lstat, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { selectCases } from "./case-selection.js";
|
|
7
8
|
import { compileSuite } from "./compiler.js";
|
|
8
9
|
import { acquireMobileDevice, mobileReadiness } from "./mobile-runtime.js";
|
|
9
10
|
import { failureReport, normalizeReport1, renderMarkdown, stoppedReport } from "./report.js";
|
|
@@ -158,6 +159,10 @@ function runnableSuite(suite, blocked) {
|
|
|
158
159
|
}
|
|
159
160
|
/** Run one immutable suite snapshot through the locally installed e2e CLI. */
|
|
160
161
|
export async function runSuite(options) {
|
|
162
|
+
// A malformed selection must not leave a run directory behind.
|
|
163
|
+
const selected = selectCases(options.suite, options.caseIds);
|
|
164
|
+
let executionSuite = selected.suite;
|
|
165
|
+
let selection = selected.selection;
|
|
161
166
|
const workspace = await realpath(options.workspace);
|
|
162
167
|
const qaRoot = await ownedDirectory(workspace, '.flowqa');
|
|
163
168
|
const runsRoot = await ownedDirectory(qaRoot, 'runs');
|
|
@@ -183,7 +188,7 @@ export async function runSuite(options) {
|
|
|
183
188
|
let blocked = new Map();
|
|
184
189
|
let target = { name: options.targetName, url: '' };
|
|
185
190
|
let releaseMobileDevice;
|
|
186
|
-
const context = () => ({ suite:
|
|
191
|
+
const context = () => ({ suite: executionSuite, selection, runId, suiteHash, target, startedAt, finishedAt: new Date().toISOString(), blockedReasons: blocked });
|
|
187
192
|
const blockAll = (suite, reason) => {
|
|
188
193
|
for (const testCase of suite.cases)
|
|
189
194
|
if (!blocked.has(testCase.id))
|
|
@@ -193,6 +198,7 @@ export async function runSuite(options) {
|
|
|
193
198
|
try {
|
|
194
199
|
const suite = suiteSchema.parse(options.suite);
|
|
195
200
|
await verifySuiteSource(suite, workspace);
|
|
201
|
+
({ suite: executionSuite, selection } = selectCases(suite, options.caseIds));
|
|
196
202
|
const rawTarget = options.config?.targets?.[options.targetName];
|
|
197
203
|
if (rawTarget && typeof rawTarget === 'object' && !Array.isArray(rawTarget)) {
|
|
198
204
|
const raw = rawTarget;
|
|
@@ -200,8 +206,8 @@ export async function runSuite(options) {
|
|
|
200
206
|
if ((raw.platform === 'ios' || raw.platform === 'android') && typeof raw.bundleId === 'string') {
|
|
201
207
|
target = { name: options.targetName, kind: 'mobile', platform: raw.platform, bundleId: raw.bundleId, device: '' };
|
|
202
208
|
}
|
|
203
|
-
blocked = blockedCases(
|
|
204
|
-
blockAll(
|
|
209
|
+
blocked = blockedCases(executionSuite, options.config);
|
|
210
|
+
blockAll(executionSuite, 'A mobile target requires an explicit device.');
|
|
205
211
|
}
|
|
206
212
|
}
|
|
207
213
|
const config = configSchema.parse(options.config);
|
|
@@ -209,23 +215,23 @@ export async function runSuite(options) {
|
|
|
209
215
|
if (!selectedTarget)
|
|
210
216
|
throw new Error(`Target not found: ${options.targetName}`);
|
|
211
217
|
target = reportTarget(options.targetName, selectedTarget);
|
|
212
|
-
blocked = blockedCases(
|
|
218
|
+
blocked = blockedCases(executionSuite, config);
|
|
213
219
|
if ((suite.platform ?? 'web') !== targetKind(selectedTarget))
|
|
214
|
-
blockAll(
|
|
215
|
-
const runnable =
|
|
220
|
+
blockAll(executionSuite, 'The suite platform does not match the selected target.');
|
|
221
|
+
const runnable = executionSuite.cases.filter((testCase) => !blocked.has(testCase.id));
|
|
216
222
|
let sdkDevice;
|
|
217
223
|
if (selectedTarget.kind === 'mobile' && runnable.length > 0 && !options.signal?.aborted) {
|
|
218
224
|
const readiness = await mobileReadiness(selectedTarget, options.listMobileDevices);
|
|
219
225
|
if (!readiness.ready)
|
|
220
|
-
blockAll(
|
|
226
|
+
blockAll(executionSuite, readiness.reason);
|
|
221
227
|
else {
|
|
222
228
|
sdkDevice = readiness.sdkDevice;
|
|
223
229
|
releaseMobileDevice = acquireMobileDevice(selectedTarget.platform, readiness.deviceId);
|
|
224
230
|
}
|
|
225
231
|
if (!releaseMobileDevice)
|
|
226
|
-
blockAll(
|
|
232
|
+
blockAll(executionSuite, 'Selected mobile device is in use.');
|
|
227
233
|
}
|
|
228
|
-
const executable = runnableSuite(
|
|
234
|
+
const executable = runnableSuite(executionSuite, blocked);
|
|
229
235
|
const testFile = path.join(runDir, TEST_FILE);
|
|
230
236
|
const configFile = path.join(runDir, CONFIG_FILE);
|
|
231
237
|
const agentAvailable = runnable.some(agentCase);
|
package/dist/src/i18n.js
CHANGED
|
@@ -62,6 +62,14 @@ const koreanMessages = {
|
|
|
62
62
|
'Invalid request origin.': '요청 출처가 올바르지 않습니다.',
|
|
63
63
|
'A case file name is required.': '케이스 파일 이름이 필요합니다.',
|
|
64
64
|
'A run ID is required.': '실행 ID가 필요합니다.',
|
|
65
|
+
'Both run IDs are required.': '비교할 두 실행 ID가 필요합니다.',
|
|
66
|
+
'A comparison needs two different runs.': '서로 다른 두 실행을 선택해야 합니다.',
|
|
67
|
+
'Both reports must belong to the same suite.': '두 리포트의 케이스 모음이 같아야 합니다.',
|
|
68
|
+
'Both reports must use the same target.': '두 리포트의 실행 대상이 같아야 합니다.',
|
|
69
|
+
'Both reports need a non-empty suite hash.': '두 리포트에 비어 있지 않은 케이스 모음 해시가 있어야 합니다.',
|
|
70
|
+
'Both reports need unique, non-empty case IDs.': '두 리포트의 케이스 ID는 비어 있지 않고 중복되지 않아야 합니다.',
|
|
71
|
+
'The report selection does not match its recorded cases.': '보고서의 선택 범위가 기록된 케이스와 일치하지 않습니다.',
|
|
72
|
+
'Select one or more unique cases from this suite.': '이 케이스 모음에서 중복 없이 하나 이상의 케이스를 선택하세요.',
|
|
65
73
|
'Invalid evidence selection.': '증거 파일 선택이 올바르지 않습니다.',
|
|
66
74
|
'The request is not allowed.': '허용되지 않은 요청입니다.',
|
|
67
75
|
'Invalid request token.': '요청 토큰이 올바르지 않습니다.',
|
package/dist/src/report.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { expectedChecks } from "./compiler.js";
|
|
4
|
+
import { isReportSelectionValid } from "./case-selection.js";
|
|
4
5
|
import { localized, localizeProductMessage } from "./i18n.js";
|
|
5
6
|
import { targetLabel } from "./targets.js";
|
|
7
|
+
function scopedCases(context) {
|
|
8
|
+
if (!context.selection)
|
|
9
|
+
return context.suite.cases;
|
|
10
|
+
const selected = new Set(context.selection.caseIds);
|
|
11
|
+
const cases = context.suite.cases.filter((testCase) => selected.has(testCase.id));
|
|
12
|
+
if (!isReportSelectionValid(cases, context.selection))
|
|
13
|
+
throw new Error('Report selection does not match the current suite.');
|
|
14
|
+
return cases;
|
|
15
|
+
}
|
|
16
|
+
function reportSelection(context) {
|
|
17
|
+
return context.selection ? { selection: { caseIds: [...context.selection.caseIds], totalCases: context.selection.totalCases } } : {};
|
|
18
|
+
}
|
|
6
19
|
function object(value, label) {
|
|
7
20
|
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
8
21
|
throw new Error(`Invalid raw report field: ${label}.`);
|
|
@@ -151,7 +164,8 @@ export function normalizeReport1(raw, context) {
|
|
|
151
164
|
const rawResults = array(run.results, 'run.results');
|
|
152
165
|
const rawErrors = array(run.errors, 'run.errors').map(rawError);
|
|
153
166
|
const sourceName = path.basename(context.testFile);
|
|
154
|
-
const
|
|
167
|
+
const selectedCases = scopedCases(context);
|
|
168
|
+
const knownTitles = new Map(selectedCases.map((testCase) => [`[${testCase.id}] ${testCase.title}`, testCase.id]));
|
|
155
169
|
const mapped = new Map();
|
|
156
170
|
for (const value of rawResults) {
|
|
157
171
|
const result = object(value, 'result');
|
|
@@ -185,7 +199,7 @@ export function normalizeReport1(raw, context) {
|
|
|
185
199
|
mapped.set(id, { status, attempts, ...(skip === undefined ? {} : { reason: string(skip.reason, 'result.skip.reason') }) });
|
|
186
200
|
}
|
|
187
201
|
const errors = rawErrors.map((error) => `${error.code}: ${error.message}`);
|
|
188
|
-
const cases =
|
|
202
|
+
const cases = selectedCases.map((testCase) => {
|
|
189
203
|
const expected = expectedChecks(testCase);
|
|
190
204
|
const result = mapped.get(testCase.id);
|
|
191
205
|
const blockedReason = context.blockedReasons.get(testCase.id);
|
|
@@ -220,7 +234,7 @@ export function normalizeReport1(raw, context) {
|
|
|
220
234
|
return {
|
|
221
235
|
schemaVersion: 'flow-qa/report-1', runId: context.runId, suiteId: context.suite.id,
|
|
222
236
|
suiteHash: context.suiteHash, target: context.target, startedAt: context.startedAt,
|
|
223
|
-
finishedAt: context.finishedAt, status, cases, errors, usage: reportUsage,
|
|
237
|
+
finishedAt: context.finishedAt, status, cases, errors, usage: reportUsage, ...reportSelection(context),
|
|
224
238
|
};
|
|
225
239
|
}
|
|
226
240
|
/** Keep every recorded attempt when the adapter stops a still-running child. */
|
|
@@ -236,13 +250,13 @@ export function failureReport(context, status, error) {
|
|
|
236
250
|
schemaVersion: 'flow-qa/report-1', runId: context.runId, suiteId: context.suite.id,
|
|
237
251
|
suiteHash: context.suiteHash, target: context.target, startedAt: context.startedAt,
|
|
238
252
|
finishedAt: context.finishedAt, status,
|
|
239
|
-
cases: context.
|
|
253
|
+
cases: scopedCases(context).map((testCase) => ({
|
|
240
254
|
id: testCase.id, title: testCase.title,
|
|
241
255
|
status: context.blockedReasons.has(testCase.id) ? 'blocked' : 'error',
|
|
242
256
|
expected: expectedChecks(testCase), attempts: [],
|
|
243
257
|
reason: context.blockedReasons.get(testCase.id) ?? error,
|
|
244
258
|
})),
|
|
245
|
-
errors: [error],
|
|
259
|
+
errors: [error], ...reportSelection(context),
|
|
246
260
|
};
|
|
247
261
|
}
|
|
248
262
|
function safeText(value, locale) {
|
|
@@ -277,6 +291,7 @@ export function renderMarkdown(report, locale = 'en') {
|
|
|
277
291
|
? safe(targetLabel(report.target))
|
|
278
292
|
: label('(URL available in the local JSON report)', '(주소는 로컬 JSON 보고서에서 확인)')}`,
|
|
279
293
|
`- ${label('Status', '상태')}: ${safe(report.status)}`,
|
|
294
|
+
...(report.selection ? [`- ${label('Scope', '범위')}: ${label(`Selected ${report.selection.caseIds.length} / ${report.selection.totalCases} cases; full suite not evaluated.`, `선택 ${report.selection.caseIds.length} / 전체 ${report.selection.totalCases}개 케이스; 전체 케이스 모음은 평가하지 않았습니다.`)}`] : []),
|
|
280
295
|
`- ${label('Started', '시작')}: ${safe(report.startedAt)}`,
|
|
281
296
|
`- ${label('Finished', '종료')}: ${safe(report.finishedAt)}`,
|
|
282
297
|
`- ${label('Results', '결과')}: ${label('passed', '통과')} ${counts.get('passed') ?? 0}, ${label('failed', '실패')} ${counts.get('failed') ?? 0}, ${label('flaky', '재시도 후 통과')} ${counts.get('flaky') ?? 0}, ${label('blocked', '차단')} ${counts.get('blocked') ?? 0}, ${label('skipped', '건너뜀')} ${counts.get('skipped') ?? 0}, ${label('error', '오류')} ${counts.get('error') ?? 0}`,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { localized } from "./i18n.js";
|
|
2
|
+
import { isReportSelectionValid } from "./case-selection.js";
|
|
3
|
+
import { targetFingerprint, targetLabel } from "./targets.js";
|
|
4
|
+
export const comparisonCategories = ['newly-failed', 'now-passed', 'changed', 'unchanged', 'added', 'missing'];
|
|
5
|
+
export class ComparisonError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
8
|
+
}
|
|
9
|
+
function validateCaseIds(report) {
|
|
10
|
+
const cases = new Map();
|
|
11
|
+
for (const item of report.cases) {
|
|
12
|
+
if (!item.id.trim() || cases.has(item.id)) {
|
|
13
|
+
throw new ComparisonError(409, 'Both reports need unique, non-empty case IDs.');
|
|
14
|
+
}
|
|
15
|
+
cases.set(item.id, item);
|
|
16
|
+
}
|
|
17
|
+
return cases;
|
|
18
|
+
}
|
|
19
|
+
function runSummary(report) {
|
|
20
|
+
return {
|
|
21
|
+
runId: report.runId, suiteId: report.suiteId, suiteHash: report.suiteHash,
|
|
22
|
+
target: { ...report.target }, startedAt: report.startedAt,
|
|
23
|
+
finishedAt: report.finishedAt, status: report.status,
|
|
24
|
+
...(report.selection ? { selection: { caseIds: [...report.selection.caseIds], totalCases: report.selection.totalCases } } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function caseSnapshot(item) {
|
|
28
|
+
if (!item)
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
title: item.title, status: item.status, expected: [...item.expected],
|
|
32
|
+
...(item.reason === undefined ? {} : { reason: item.reason }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function category(baseline, current) {
|
|
36
|
+
if (!baseline)
|
|
37
|
+
return 'added';
|
|
38
|
+
if (!current)
|
|
39
|
+
return 'missing';
|
|
40
|
+
if (baseline.status === current.status)
|
|
41
|
+
return 'unchanged';
|
|
42
|
+
if (baseline.status === 'passed' && current.status === 'failed')
|
|
43
|
+
return 'newly-failed';
|
|
44
|
+
if (baseline.status === 'failed' && current.status === 'passed')
|
|
45
|
+
return 'now-passed';
|
|
46
|
+
return 'changed';
|
|
47
|
+
}
|
|
48
|
+
/** Compare only recorded final case statuses and report metadata. Never inspect attempts or artifacts. */
|
|
49
|
+
export function compareReports(baseline, current) {
|
|
50
|
+
if (baseline.runId === current.runId)
|
|
51
|
+
throw new ComparisonError(400, 'A comparison needs two different runs.');
|
|
52
|
+
if (baseline.suiteId !== current.suiteId)
|
|
53
|
+
throw new ComparisonError(409, 'Both reports must belong to the same suite.');
|
|
54
|
+
if (targetFingerprint(baseline.target) !== targetFingerprint(current.target)) {
|
|
55
|
+
throw new ComparisonError(409, 'Both reports must use the same target.');
|
|
56
|
+
}
|
|
57
|
+
if (!baseline.suiteHash.trim() || !current.suiteHash.trim()) {
|
|
58
|
+
throw new ComparisonError(409, 'Both reports need a non-empty suite hash.');
|
|
59
|
+
}
|
|
60
|
+
const before = validateCaseIds(baseline);
|
|
61
|
+
const after = validateCaseIds(current);
|
|
62
|
+
if (!isReportSelectionValid(baseline.cases, baseline.selection) || !isReportSelectionValid(current.cases, current.selection)) {
|
|
63
|
+
throw new ComparisonError(409, 'The report selection does not match its recorded cases.');
|
|
64
|
+
}
|
|
65
|
+
const warnings = [];
|
|
66
|
+
if (baseline.suiteHash !== current.suiteHash)
|
|
67
|
+
warnings.push('suite-changed');
|
|
68
|
+
if (baseline.selection || current.selection) {
|
|
69
|
+
warnings.push('partial-run');
|
|
70
|
+
if (before.size !== after.size || [...before.keys()].some((id) => !after.has(id)))
|
|
71
|
+
warnings.push('selection-changed');
|
|
72
|
+
}
|
|
73
|
+
if (Date.parse(current.startedAt) < Date.parse(baseline.startedAt))
|
|
74
|
+
warnings.push('reverse-order');
|
|
75
|
+
if (baseline.status === 'interrupted' || baseline.status === 'error' || current.status === 'interrupted' || current.status === 'error') {
|
|
76
|
+
warnings.push('incomplete-run');
|
|
77
|
+
}
|
|
78
|
+
if (baseline.target.kind === 'mobile')
|
|
79
|
+
warnings.push('mobile-identity-unverified');
|
|
80
|
+
const counts = Object.fromEntries(comparisonCategories.map((value) => [value, 0]));
|
|
81
|
+
const cases = [...new Set([...before.keys(), ...after.keys()])].sort().map((id) => {
|
|
82
|
+
const baselineCase = before.get(id);
|
|
83
|
+
const currentCase = after.get(id);
|
|
84
|
+
const transition = category(baselineCase, currentCase);
|
|
85
|
+
counts[transition]++;
|
|
86
|
+
return { id, category: transition, baseline: caseSnapshot(baselineCase), current: caseSnapshot(currentCase) };
|
|
87
|
+
});
|
|
88
|
+
return { schemaVersion: 'flow-qa/comparison-1', baseline: runSummary(baseline), current: runSummary(current), warnings, counts, cases };
|
|
89
|
+
}
|
|
90
|
+
function safeInline(value, locale) {
|
|
91
|
+
return value.replace(/(?:https?:\/\/|file:\/\/)[^\s<>"'\])}]+/gi, localized(locale, '[URL hidden]', '[URL 숨김]'))
|
|
92
|
+
.replace(/\[[^\]]*\]\([^)]*\)/g, localized(locale, '[link hidden]', '[링크 숨김]'))
|
|
93
|
+
.replace(/[\x00-\x1f\x7f\u0085\u2028\u2029]/g, (character) => {
|
|
94
|
+
if (character === '\n')
|
|
95
|
+
return '\\n';
|
|
96
|
+
if (character === '\r')
|
|
97
|
+
return '\\r';
|
|
98
|
+
if (character === '\t')
|
|
99
|
+
return '\\t';
|
|
100
|
+
return `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
101
|
+
}).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
102
|
+
.replace(/([\\`*_{}\[\]()#+.!|~:])/g, '\\$1');
|
|
103
|
+
}
|
|
104
|
+
export function renderComparisonMarkdown(comparison, locale = 'en') {
|
|
105
|
+
const label = (english, korean) => localized(locale, english, korean);
|
|
106
|
+
const safe = (value) => safeInline(value, locale);
|
|
107
|
+
const run = (name, item) => [
|
|
108
|
+
`## ${name}`,
|
|
109
|
+
'',
|
|
110
|
+
`- ${label('Run ID', '실행 ID')}: ${safe(item.runId)}`,
|
|
111
|
+
`- ${label('Suite ID', '케이스 모음 ID')}: ${safe(item.suiteId)}`,
|
|
112
|
+
`- ${label('Suite hash', '케이스 모음 해시')}: ${safe(item.suiteHash)}`,
|
|
113
|
+
`- ${label('Target', '대상')}: ${safe(item.target.name)} ${item.target.kind === 'mobile'
|
|
114
|
+
? safe(targetLabel(item.target))
|
|
115
|
+
: label('(URL available in the local JSON comparison)', '(주소는 로컬 JSON 비교 결과에서 확인)')}`,
|
|
116
|
+
`- ${label('Started', '시작')}: ${safe(item.startedAt)}`,
|
|
117
|
+
`- ${label('Finished', '종료')}: ${safe(item.finishedAt)}`,
|
|
118
|
+
`- ${label('Status', '상태')}: ${safe(item.status)}`,
|
|
119
|
+
...(item.selection ? [`- ${label('Scope', '범위')}: ${label(`Selected ${item.selection.caseIds.length} / ${item.selection.totalCases} cases; full suite not evaluated.`, `선택 ${item.selection.caseIds.length} / 전체 ${item.selection.totalCases}개 케이스; 전체 케이스 모음은 평가하지 않았습니다.`)}`] : []),
|
|
120
|
+
'',
|
|
121
|
+
];
|
|
122
|
+
const warningText = {
|
|
123
|
+
'suite-changed': label('Steps or expectations may differ because the suite changed; these outcomes do not prove a regression or fix.', '케이스 모음이 변경되어 단계나 기대 결과가 다를 수 있습니다. 이 결과만으로 회귀나 수정이 입증되지는 않습니다.'),
|
|
124
|
+
'partial-run': label('At least one run evaluated only selected cases; the full suite was not evaluated.', '하나 이상의 실행에서 선택한 케이스만 평가했습니다. 전체 케이스 모음은 평가하지 않았습니다.'),
|
|
125
|
+
'selection-changed': label('The runs selected different case sets; added or missing entries may reflect scope rather than a changed outcome.', '실행마다 선택한 케이스가 다릅니다. 추가 또는 누락 항목은 결과 변화가 아닌 실행 범위 차이일 수 있습니다.'),
|
|
126
|
+
'reverse-order': label('The selected current run started before the baseline run.', '선택한 현재 실행이 기준 실행보다 먼저 시작됐습니다.'),
|
|
127
|
+
'incomplete-run': label('At least one run ended with error or interruption; case results may be incomplete.', '하나 이상의 실행이 오류 또는 중단으로 끝나 케이스 결과가 불완전할 수 있습니다.'),
|
|
128
|
+
'mobile-identity-unverified': label('The recorded app and device settings match, but these reports do not verify that both runs used the same physical device.', '기록된 앱과 기기 설정은 같지만, 두 실행이 같은 실제 기기에서 진행됐는지는 이 리포트로 확인할 수 없습니다.'),
|
|
129
|
+
};
|
|
130
|
+
const lines = [
|
|
131
|
+
`# ${label('QA run comparison', 'QA 실행 비교')}`,
|
|
132
|
+
'',
|
|
133
|
+
...run(label('Baseline', '기준 실행'), comparison.baseline),
|
|
134
|
+
...run(label('Current', '현재 실행'), comparison.current),
|
|
135
|
+
`## ${label('Transition counts', '상태 전이 개수')}`,
|
|
136
|
+
'',
|
|
137
|
+
...comparisonCategories.map((category) => `- ${category}: ${comparison.counts[category]}`),
|
|
138
|
+
'',
|
|
139
|
+
`## ${label('Warnings', '주의 사항')}`,
|
|
140
|
+
'',
|
|
141
|
+
...(comparison.warnings.length
|
|
142
|
+
? comparison.warnings.map((warning) => `- ${warning}: ${warningText[warning]}`)
|
|
143
|
+
: [`- ${label('None', '없음')}`]),
|
|
144
|
+
'',
|
|
145
|
+
label('These categories describe observed status transitions only. They do not establish the cause of a failure or prove that a defect was fixed.', '이 분류는 관찰된 상태 전이만 나타냅니다. 실패 원인이나 결함 수정 여부를 입증하지 않습니다.'),
|
|
146
|
+
'',
|
|
147
|
+
`## ${label('Cases', '케이스')}`,
|
|
148
|
+
'',
|
|
149
|
+
];
|
|
150
|
+
for (const item of comparison.cases) {
|
|
151
|
+
lines.push(`### ${safe(item.id)} — ${item.category}`, '');
|
|
152
|
+
for (const [name, entry] of [[label('Baseline', '기준 실행'), item.baseline], [label('Current', '현재 실행'), item.current]]) {
|
|
153
|
+
if (!entry) {
|
|
154
|
+
lines.push(`- ${name}: ${label('Absent', '없음')}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
lines.push(`- ${name}: ${safe(entry.status)} · ${safe(entry.title)}`);
|
|
158
|
+
lines.push(` - ${label('Expected', '기대 결과')}: ${entry.expected.length ? entry.expected.map(safe).join('; ') : label('None', '없음')}`);
|
|
159
|
+
if (entry.reason !== undefined)
|
|
160
|
+
lines.push(` - ${label('Reason', '실행 상태 근거')}: ${safe(entry.reason)}`);
|
|
161
|
+
}
|
|
162
|
+
lines.push('');
|
|
163
|
+
}
|
|
164
|
+
return lines.join('\n');
|
|
165
|
+
}
|