@ihyeon/flow-qa 0.0.0-stage → 0.1.0-alpha.2

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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +73 -0
  3. package/README.md +72 -2
  4. package/THIRD_PARTY_NOTICES.md +9 -0
  5. package/dist/bin/flow-qa.mjs +3 -0
  6. package/dist/examples/demo/server.js +31 -0
  7. package/dist/src/cli.js +285 -0
  8. package/dist/src/compiler.js +62 -0
  9. package/dist/src/engine.js +246 -0
  10. package/dist/src/i18n.js +98 -0
  11. package/dist/src/planner.js +112 -0
  12. package/dist/src/report.js +315 -0
  13. package/dist/src/schema.js +71 -0
  14. package/dist/src/ui/entry.js +109 -0
  15. package/dist/src/ui/evidence.js +85 -0
  16. package/dist/src/ui/public/app.js +360 -0
  17. package/dist/src/ui/public/display.js +56 -0
  18. package/dist/src/ui/public/fonts/dm-sans.woff2 +0 -0
  19. package/dist/src/ui/public/fonts/noto-kr-subset.woff2 +0 -0
  20. package/dist/src/ui/public/i18n.js +290 -0
  21. package/dist/src/ui/public/index.html +117 -0
  22. package/dist/src/ui/public/style.css +200 -0
  23. package/dist/src/ui/server.js +785 -0
  24. package/dist/src/workspace.js +88 -0
  25. package/docs/architecture.md +38 -0
  26. package/docs/releases.md +54 -0
  27. package/docs/ui.md +71 -0
  28. package/licenses/DM-Sans-LICENSE.txt +93 -0
  29. package/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
  30. package/node_modules/@hyeonse/design-system/.github/workflows/ci.yml +21 -0
  31. package/node_modules/@hyeonse/design-system/.npmignore +5 -0
  32. package/node_modules/@hyeonse/design-system/AGENTS.md +10 -0
  33. package/node_modules/@hyeonse/design-system/LICENSE +21 -0
  34. package/node_modules/@hyeonse/design-system/README.md +67 -0
  35. package/node_modules/@hyeonse/design-system/THIRD_PARTY_NOTICES.md +5 -0
  36. package/node_modules/@hyeonse/design-system/dist/components.css +105 -0
  37. package/node_modules/@hyeonse/design-system/dist/preferences.d.ts +21 -0
  38. package/node_modules/@hyeonse/design-system/dist/preferences.js +69 -0
  39. package/node_modules/@hyeonse/design-system/dist/react.d.ts +21 -0
  40. package/node_modules/@hyeonse/design-system/dist/react.js +100 -0
  41. package/node_modules/@hyeonse/design-system/dist/tokens.css +75 -0
  42. package/node_modules/@hyeonse/design-system/docs/api.md +33 -0
  43. package/node_modules/@hyeonse/design-system/docs/assets/fonts/dm-sans.woff2 +0 -0
  44. package/node_modules/@hyeonse/design-system/docs/assets/fonts/noto-kr-subset.woff2 +0 -0
  45. package/node_modules/@hyeonse/design-system/docs/audit.md +29 -0
  46. package/node_modules/@hyeonse/design-system/docs/foundations.md +30 -0
  47. package/node_modules/@hyeonse/design-system/docs/verification.md +13 -0
  48. package/node_modules/@hyeonse/design-system/index.html +51 -0
  49. package/node_modules/@hyeonse/design-system/licenses/DM-Sans-LICENSE.txt +93 -0
  50. package/node_modules/@hyeonse/design-system/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
  51. package/node_modules/@hyeonse/design-system/licenses/arla6ka-MIT.txt +21 -0
  52. package/node_modules/@hyeonse/design-system/package.json +35 -0
  53. package/node_modules/@hyeonse/design-system/scripts/audit-tokens.mjs +48 -0
  54. package/node_modules/@hyeonse/design-system/scripts/build.mjs +12 -0
  55. package/node_modules/@hyeonse/design-system/showcase.css +75 -0
  56. package/node_modules/@hyeonse/design-system/showcase.mjs +63 -0
  57. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/SKILL.md +18 -0
  58. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/api.md +7 -0
  59. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/build.md +7 -0
  60. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/foundations.md +7 -0
  61. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/mapping.md +7 -0
  62. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/migration.md +5 -0
  63. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/review-docs.md +5 -0
  64. package/node_modules/@hyeonse/design-system/src/components.css +105 -0
  65. package/node_modules/@hyeonse/design-system/src/preferences.ts +87 -0
  66. package/node_modules/@hyeonse/design-system/src/react.tsx +161 -0
  67. package/node_modules/@hyeonse/design-system/src/tokens.css +75 -0
  68. package/node_modules/@hyeonse/design-system/tests/audit.test.mjs +30 -0
  69. package/node_modules/@hyeonse/design-system/tests/preferences.test.mjs +86 -0
  70. package/node_modules/@hyeonse/design-system/tsconfig.json +16 -0
  71. package/package.json +73 -4
@@ -0,0 +1,315 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { expectedChecks } from "./compiler.js";
4
+ import { localized, localizeProductMessage } from "./i18n.js";
5
+ function object(value, label) {
6
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
7
+ throw new Error(`Invalid raw report field: ${label}.`);
8
+ return value;
9
+ }
10
+ function string(value, label) {
11
+ if (typeof value !== 'string')
12
+ throw new Error(`Invalid raw report field: ${label}.`);
13
+ return value;
14
+ }
15
+ function array(value, label) {
16
+ if (!Array.isArray(value))
17
+ throw new Error(`Invalid raw report field: ${label}.`);
18
+ return value;
19
+ }
20
+ function number(value, label) {
21
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
22
+ throw new Error(`Invalid raw report field: ${label}.`);
23
+ return value;
24
+ }
25
+ function integer(value, label) {
26
+ const checked = number(value, label);
27
+ if (!Number.isInteger(checked))
28
+ throw new Error(`Invalid raw report field: ${label}.`);
29
+ return checked;
30
+ }
31
+ function oneOf(value, choices, label) {
32
+ if (typeof value !== 'string' || !choices.includes(value))
33
+ throw new Error(`Unsupported raw report value: ${label}.`);
34
+ return value;
35
+ }
36
+ function rawError(value) {
37
+ const item = object(value, 'error');
38
+ return { code: string(item.code, 'error.code'), message: string(item.message, 'error.message'),
39
+ ...(item.category === undefined ? {} : { category: oneOf(item.category, ['test', 'configuration', 'infrastructure', 'internal', 'interrupted'], 'error.category') }) };
40
+ }
41
+ const attemptStatuses = ['passed', 'failed', 'timed-out', 'interrupted', 'skipped'];
42
+ const resultStatuses = ['passed', 'flaky', 'failed', 'timed-out', 'interrupted', 'skipped'];
43
+ const runStatuses = ['passed', 'failed', 'blocked', 'error', 'interrupted'];
44
+ const redactionStatuses = ['complete', 'not-required', 'incomplete'];
45
+ // e2e 0.15.1's documented blockedCategory codes. Only a final attempt carrying
46
+ // one of these codes proves that a failed/timed-out result was blocked.
47
+ const blockableCodes = new Set([
48
+ 'AUTH_CREDENTIAL_UNAVAILABLE', 'AUTH_CREDENTIAL_INVALID', 'SECRET_UNAVAILABLE',
49
+ 'ENVIRONMENT_UNAVAILABLE', 'APP_UNREACHABLE', 'APP_ALREADY_RUNNING',
50
+ 'SEED_DATA_MISSING', 'TEST_SETUP_FAILED', 'APP_NOT_OPEN', 'POLICY_DENIED',
51
+ 'MODEL_UNAVAILABLE', 'AUTOMATION_UNSUPPORTED', 'STEP_BUDGET_EXHAUSTED',
52
+ 'STEP_TIMEOUT', 'CONTEXT_OVERFLOW',
53
+ ]);
54
+ function resultCaseStatus(status, attempts) {
55
+ if (status === 'passed' || status === 'flaky' || status === 'skipped')
56
+ return status;
57
+ const lastError = attempts.at(-1)?.error;
58
+ if (lastError && blockableCodes.has(lastError.code))
59
+ return 'blocked';
60
+ if (lastError?.category === 'configuration' || lastError?.category === 'infrastructure' || lastError?.category === 'internal' || lastError?.category === 'interrupted')
61
+ return 'error';
62
+ return status === 'interrupted' ? 'error' : 'failed';
63
+ }
64
+ /** Only paths inside the current run's artifact directory can become links. */
65
+ function localArtifactPath(rawPath, rawDir) {
66
+ if (typeof rawPath !== 'string' || !/^[^\\\x00-\x1f?#]+$/.test(rawPath))
67
+ return undefined;
68
+ const parts = rawPath.split('/');
69
+ if (parts.length < 1 || parts.some((part) => part === '' || part === '.' || part === '..' || part.startsWith('.')))
70
+ return undefined;
71
+ const candidate = path.resolve(rawDir, 'artifacts', ...parts);
72
+ const artifactRoot = path.resolve(rawDir, 'artifacts');
73
+ if (!candidate.startsWith(`${artifactRoot}${path.sep}`))
74
+ return undefined;
75
+ try {
76
+ const realRoot = realpathSync(artifactRoot);
77
+ const realFile = realpathSync(candidate);
78
+ if (!realFile.startsWith(`${realRoot}${path.sep}`))
79
+ return undefined;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ return `raw/artifacts/${parts.join('/')}`;
85
+ }
86
+ function normalizeAttempt(value, rawDir) {
87
+ const item = object(value, 'attempt');
88
+ const steps = array(item.steps, 'attempt.steps').map((value) => {
89
+ const step = object(value, 'step');
90
+ const model = step.model === undefined ? undefined : object(step.model, 'step.model');
91
+ return {
92
+ api: string(step.api, 'step.api'),
93
+ label: string(step.label, 'step.label'),
94
+ status: oneOf(step.status, ['passed', 'failed', 'blocked', 'timed-out', 'cancelled'], 'step.status'),
95
+ ...(step.error === undefined ? {} : { error: rawError(step.error) }),
96
+ ...(model === undefined ? {} : { model: {
97
+ provider: string(model.provider, 'step.model.provider'),
98
+ model: string(model.model, 'step.model.model'),
99
+ inputTokens: number(model.inputTokens, 'step.model.inputTokens'),
100
+ outputTokens: number(model.outputTokens, 'step.model.outputTokens'),
101
+ ...(model.estimatedCostUsd === undefined ? {} : { estimatedCostUsd: number(model.estimatedCostUsd, 'step.model.estimatedCostUsd') }),
102
+ } }),
103
+ };
104
+ });
105
+ const artifacts = array(item.artifacts, 'attempt.artifacts').map((value) => {
106
+ const artifact = object(value, 'artifact');
107
+ const redaction = oneOf(artifact.redaction, redactionStatuses, 'artifact.redaction');
108
+ const safePath = redaction === 'incomplete' ? undefined : localArtifactPath(artifact.path, rawDir);
109
+ return {
110
+ kind: string(artifact.kind, 'artifact.kind'),
111
+ redaction,
112
+ ...(safePath === undefined ? {} : { path: safePath }),
113
+ };
114
+ });
115
+ return {
116
+ index: integer(item.index, 'attempt.index'),
117
+ status: oneOf(item.status, attemptStatuses, 'attempt.status'),
118
+ startedAt: string(item.startedAt, 'attempt.startedAt'),
119
+ durationMs: number(item.durationMs, 'attempt.durationMs'),
120
+ ...(item.error === undefined ? {} : { error: rawError(item.error) }),
121
+ secondaryErrors: array(item.secondaryErrors, 'attempt.secondaryErrors').map(rawError),
122
+ steps,
123
+ artifacts,
124
+ };
125
+ }
126
+ /** Adapt e2e's report-1 document. Any unsupported or mismatched result fails closed. */
127
+ export function normalizeReport1(raw, context) {
128
+ const document = object(raw, 'root');
129
+ if (document.schemaVersion !== 'report-1')
130
+ throw new Error('Unsupported e2e report version.');
131
+ const run = object(document.run, 'run');
132
+ string(run.id, 'run.id');
133
+ if (run.specVersion !== '0.1' || object(run.runner, 'run.runner').name !== 'e2e')
134
+ throw new Error('The raw report runner contract does not match.');
135
+ const rawStatus = oneOf(run.status, runStatuses, 'run.status');
136
+ if (![0, 1, 2, 3, 4, 130].includes(run.exitCode))
137
+ throw new Error('Unsupported raw report exit code.');
138
+ if (rawStatus === 'passed' && run.exitCode !== 0)
139
+ throw new Error('The raw report passed status and exit code disagree.');
140
+ string(run.startedAt, 'run.startedAt');
141
+ string(run.finishedAt, 'run.finishedAt');
142
+ const project = object(run.project, 'run.project');
143
+ const targetEntries = array(run.targets, 'run.targets');
144
+ if (rawStatus !== 'error' && project.id !== context.suite.id)
145
+ throw new Error('The raw report project does not match the current suite.');
146
+ if (rawStatus !== 'error' && !targetEntries.some((item) => object(item, 'target').id === context.target.name)) {
147
+ throw new Error('The raw report target does not match the current run target.');
148
+ }
149
+ object(run.summary, 'run.summary');
150
+ const rawResults = array(run.results, 'run.results');
151
+ const rawErrors = array(run.errors, 'run.errors').map(rawError);
152
+ const sourceName = path.basename(context.testFile);
153
+ const knownTitles = new Map(context.suite.cases.map((testCase) => [`[${testCase.id}] ${testCase.title}`, testCase.id]));
154
+ const mapped = new Map();
155
+ for (const value of rawResults) {
156
+ const result = object(value, 'result');
157
+ const titlePath = array(result.titlePath, 'result.titlePath');
158
+ const title = titlePath.length === 1 ? string(titlePath[0], 'result.titlePath[0]') : '';
159
+ const id = knownTitles.get(title);
160
+ if (id === undefined || result.file !== sourceName || result.targetId !== context.target.name || result.kind !== 'test' || result.selected !== true) {
161
+ throw new Error('The raw report test does not match the current suite, target, or test file.');
162
+ }
163
+ if (mapped.has(id))
164
+ throw new Error(`Duplicate test result in the raw report: ${id}`);
165
+ const status = oneOf(result.status, resultStatuses, 'result.status');
166
+ const attempts = array(result.attempts, 'result.attempts').map((attempt) => normalizeAttempt(attempt, context.rawDir));
167
+ if (status !== 'skipped' && attempts.length === 0)
168
+ throw new Error(`Test result has no attempts: ${id}`);
169
+ if (status === 'passed' && attempts.at(-1)?.status !== 'passed')
170
+ throw new Error(`Passed status and attempts disagree: ${id}`);
171
+ if (status === 'flaky' && (attempts.at(-1)?.status !== 'passed' || !attempts.slice(0, -1).some((attempt) => attempt.status !== 'passed'))) {
172
+ throw new Error(`Flaky status and attempts disagree: ${id}`);
173
+ }
174
+ if (context.blockedReasons.has(id) && status !== 'skipped')
175
+ throw new Error(`A blocked case was recorded as executed: ${id}`);
176
+ const skip = result.skip === undefined ? undefined : object(result.skip, 'result.skip');
177
+ mapped.set(id, { status, attempts, ...(skip === undefined ? {} : { reason: string(skip.reason, 'result.skip.reason') }) });
178
+ }
179
+ const errors = rawErrors.map((error) => `${error.code}: ${error.message}`);
180
+ const cases = context.suite.cases.map((testCase) => {
181
+ const expected = expectedChecks(testCase);
182
+ const result = mapped.get(testCase.id);
183
+ const blockedReason = context.blockedReasons.get(testCase.id);
184
+ if (blockedReason !== undefined)
185
+ return { id: testCase.id, title: testCase.title, status: 'blocked', expected, attempts: result?.attempts ?? [], reason: blockedReason };
186
+ if (result === undefined)
187
+ return { id: testCase.id, title: testCase.title, status: 'error', expected, attempts: [], reason: 'The raw report has no result for this case.' };
188
+ const status = resultCaseStatus(result.status, result.attempts);
189
+ const reason = status === 'blocked' ? result.attempts.at(-1)?.error?.message ?? result.reason : result.reason;
190
+ return { id: testCase.id, title: testCase.title, status, expected, attempts: result.attempts, ...(reason === undefined ? {} : { reason }) };
191
+ });
192
+ if (cases.some((item) => item.status === 'error' && !context.blockedReasons.has(item.id)))
193
+ errors.push('One or more cases ended without a result or were interrupted.');
194
+ const usage = object(run.usage, 'run.usage');
195
+ const modelTokens = number(usage.modelTokens, 'run.usage.modelTokens');
196
+ const reportUsage = {
197
+ modelTokens,
198
+ ...(usage.modelCachedTokens === undefined ? {} : { modelCachedTokens: number(usage.modelCachedTokens, 'run.usage.modelCachedTokens') }),
199
+ ...(usage.estimatedCostUsd === undefined ? {} : { estimatedCostUsd: number(usage.estimatedCostUsd, 'run.usage.estimatedCostUsd') }),
200
+ };
201
+ const blockedMismatch = rawStatus === 'blocked' && cases.some((item) => item.status === 'failed');
202
+ if (blockedMismatch) {
203
+ errors.push('The e2e blocked status conflicts with a failed case result.');
204
+ }
205
+ const status = rawStatus === 'interrupted' ? 'interrupted'
206
+ : rawStatus === 'error' || rawErrors.length > 0 || cases.some((item) => item.status === 'error') || blockedMismatch ? 'error'
207
+ : rawStatus === 'blocked' ? 'blocked'
208
+ : cases.some((item) => item.status === 'failed' || item.status === 'flaky') ? 'failed'
209
+ : cases.some((item) => item.status === 'blocked' || item.status === 'skipped') ? 'blocked'
210
+ : rawStatus === 'failed' ? 'failed'
211
+ : 'passed';
212
+ return {
213
+ schemaVersion: 'flow-qa/report-1', runId: context.runId, suiteId: context.suite.id,
214
+ suiteHash: context.suiteHash, target: context.target, startedAt: context.startedAt,
215
+ finishedAt: context.finishedAt, status, cases, errors, usage: reportUsage,
216
+ };
217
+ }
218
+ /** Keep every recorded attempt when the adapter stops a still-running child. */
219
+ export function stoppedReport(raw, context, status, reason) {
220
+ const report = normalizeReport1(raw, context);
221
+ report.status = status;
222
+ report.errors.push(reason);
223
+ return report;
224
+ }
225
+ /** An explicit failure report still enumerates every expected case. */
226
+ export function failureReport(context, status, error) {
227
+ return {
228
+ schemaVersion: 'flow-qa/report-1', runId: context.runId, suiteId: context.suite.id,
229
+ suiteHash: context.suiteHash, target: context.target, startedAt: context.startedAt,
230
+ finishedAt: context.finishedAt, status,
231
+ cases: context.suite.cases.map((testCase) => ({
232
+ id: testCase.id, title: testCase.title,
233
+ status: context.blockedReasons.has(testCase.id) ? 'blocked' : 'error',
234
+ expected: expectedChecks(testCase), attempts: [],
235
+ reason: context.blockedReasons.get(testCase.id) ?? error,
236
+ })),
237
+ errors: [error],
238
+ };
239
+ }
240
+ function safeText(value, locale) {
241
+ return value.replace(/(?:https?:\/\/|file:\/\/)[^\s<>"'\])}]+/gi, localized(locale, '[URL hidden]', '[URL 숨김]'))
242
+ .replace(/\[[^\]]*\]\([^)]*\)/g, localized(locale, '[link hidden]', '[링크 숨김]'))
243
+ .replace(/[\r\n\t]+/g, ' ')
244
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
245
+ .replace(/([\\`*_{}\[\]()#+\-.!|>])/g, '\\$1');
246
+ }
247
+ function evidenceLink(artifact) {
248
+ if (artifact.redaction !== 'complete' && artifact.redaction !== 'not-required')
249
+ return undefined;
250
+ const file = artifact.path;
251
+ if (typeof file !== 'string' || !file.startsWith('raw/artifacts/'))
252
+ return undefined;
253
+ const parts = file.split('/');
254
+ if (parts.some((part) => !part || part === '.' || part === '..' || /[\\\x00-\x1f]/.test(part)))
255
+ return undefined;
256
+ return parts.map(encodeURIComponent).join('/');
257
+ }
258
+ export function renderMarkdown(report, locale = 'en') {
259
+ const safe = (value) => safeText(value, locale);
260
+ const label = (english, korean) => localized(locale, english, korean);
261
+ const counts = new Map();
262
+ for (const item of report.cases)
263
+ counts.set(item.status, (counts.get(item.status) ?? 0) + 1);
264
+ const lines = [
265
+ `# ${label('QA results', 'QA 결과')}: ${safe(report.suiteId)}`,
266
+ '',
267
+ `- ${label('Run ID', '실행 ID')}: ${safe(report.runId)}`,
268
+ `- ${label('Target', '대상')}: ${safe(report.target.name)} ${label('(URL available in the local JSON report)', '(주소는 로컬 JSON 보고서에서 확인)')}`,
269
+ `- ${label('Status', '상태')}: ${safe(report.status)}`,
270
+ `- ${label('Started', '시작')}: ${safe(report.startedAt)}`,
271
+ `- ${label('Finished', '종료')}: ${safe(report.finishedAt)}`,
272
+ `- ${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}`,
273
+ '',
274
+ label('## Case results', '## 케이스별 확인'),
275
+ '',
276
+ ];
277
+ for (const item of report.cases) {
278
+ lines.push(`### ${safe(item.id)} · ${safe(item.title)} — ${safe(item.status)}`, '');
279
+ lines.push(`- ${label('Expected checks', '기대 확인')}: ${item.expected.length ? item.expected.map(safe).join('; ') : label('No explicit assertion', '명시된 검증 없음')}`);
280
+ if (item.reason)
281
+ lines.push(`- ${label('Reason', '실행 상태 근거')}: ${safe(localizeProductMessage(item.reason, locale))}`);
282
+ if (item.attempts.length === 0)
283
+ lines.push(`- ${label('Observed', '실제 확인')}: ${label('No execution attempts', '실행 시도 기록 없음')}`);
284
+ for (const attempt of item.attempts) {
285
+ const observed = attempt.steps.filter((step) => step.status !== 'passed').map((step) => `${safe(step.api)}: ${safe(step.label)} (${safe(step.status)})`);
286
+ lines.push(`- ${label('Attempt', '시도')} ${attempt.index + 1}: ${safe(attempt.status)} · ${attempt.durationMs}ms`);
287
+ if (observed.length)
288
+ lines.push(` - ${label('Observed evidence', '실제 근거')}: ${observed.join('; ')}`);
289
+ if (attempt.error)
290
+ lines.push(` - ${label('Error', '실제 오류')}: ${safe(attempt.error.code)}: ${safe(attempt.error.message)}`);
291
+ for (const artifact of attempt.artifacts) {
292
+ const href = evidenceLink(artifact);
293
+ if (href)
294
+ lines.push(` - ${label('Evidence', '증거')}: [${safe(artifact.kind)}](${href})`);
295
+ }
296
+ }
297
+ lines.push('');
298
+ }
299
+ if (report.errors.length) {
300
+ lines.push(label('## Run errors', '## 실행 오류'), '');
301
+ for (const error of report.errors)
302
+ lines.push(`- ${safe(localizeProductMessage(error, locale))}`);
303
+ lines.push('');
304
+ }
305
+ if (report.usage) {
306
+ lines.push(label('## Model usage', '## 모델 사용량'), '', `- ${label('Tokens', '토큰')}: ${report.usage.modelTokens}`);
307
+ if (report.usage.modelCachedTokens !== undefined)
308
+ lines.push(`- ${label('Cached tokens', '캐시 토큰')}: ${report.usage.modelCachedTokens}`);
309
+ if (report.usage.estimatedCostUsd !== undefined)
310
+ lines.push(`- ${label('Estimated cost (USD)', '추정 비용(USD)')}: ${report.usage.estimatedCostUsd}`);
311
+ lines.push('');
312
+ }
313
+ lines.push(label('## Possible causes', '## 가능한 원인'), '', label('The observed evidence alone does not establish the cause. Check the raw report and local evidence.', '실패의 원인은 위 실제 근거만으로 단정하지 않습니다. 원본 보고서와 로컬 증거를 확인하세요.'), '');
314
+ return lines.join('\n');
315
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ const text = z.string().trim().min(1).max(8192);
3
+ export const idSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/);
4
+ const httpURL = z.string().url().refine((value) => {
5
+ const url = new URL(value);
6
+ const loopback = url.hostname === 'localhost' || url.hostname.endsWith('.localhost') || url.hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(url.hostname);
7
+ return (url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) && !url.username && !url.password && !url.search && !url.hash;
8
+ }, 'Use HTTPS (HTTP only on loopback), without credentials, query parameters, or fragments.');
9
+ export const locatorSchema = z.discriminatedUnion('by', [
10
+ z.object({ by: z.literal('role'), value: z.enum(['button', 'link', 'textbox', 'heading', 'checkbox', 'radio', 'combobox', 'option', 'status', 'alert', 'dialog', 'tab', 'row', 'cell', 'list', 'listitem', 'navigation', 'searchbox', 'switch']), name: text.optional() }).strict(),
11
+ z.object({ by: z.enum(['text', 'label', 'testId']), value: text }).strict(),
12
+ ]);
13
+ export const stepSchema = z.discriminatedUnion('type', [
14
+ z.object({ type: z.literal('open'), path: text.refine((v) => v.startsWith('/') && !v.startsWith('//') && !v.includes('\\'), 'Use an app-relative path starting with /.') }).strict(),
15
+ z.object({ type: z.literal('act'), instruction: text }).strict(),
16
+ z.object({ type: z.literal('click'), locator: locatorSchema }).strict(),
17
+ z.object({ type: z.literal('fill'), locator: locatorSchema, value: z.string().max(8192) }).strict(),
18
+ z.object({ type: z.literal('assertVisible'), locator: locatorSchema, visible: z.boolean().default(true) }).strict(),
19
+ z.object({ type: z.literal('assertText'), locator: locatorSchema, expected: text, match: z.enum(['equals', 'contains']).default('equals') }).strict(),
20
+ z.object({ type: z.literal('assertUrl'), expected: text }).strict(),
21
+ z.object({ type: z.literal('assertAgent'), instruction: text }).strict(),
22
+ ]);
23
+ const caseSchema = z.object({
24
+ id: idSchema,
25
+ title: text,
26
+ requirementIds: z.array(idSchema).min(1),
27
+ preconditions: z.array(text).default([]),
28
+ questions: z.array(text).default([]),
29
+ steps: z.array(stepSchema).min(1).max(100),
30
+ }).strict();
31
+ export const suiteSchema = z.object({
32
+ schemaVersion: z.literal('flow-qa/suite-1'),
33
+ id: idSchema,
34
+ title: text,
35
+ source: z.object({ file: text, sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict().optional(),
36
+ requirements: z.array(z.object({ id: idSchema, text }).strict()).min(1).max(100),
37
+ cases: z.array(caseSchema).min(1).max(100),
38
+ }).strict().superRefine((suite, ctx) => {
39
+ for (const [name, items] of [['requirements', suite.requirements], ['cases', suite.cases]]) {
40
+ if (new Set(items.map((item) => item.id)).size !== items.length)
41
+ ctx.addIssue({ code: 'custom', path: [name], message: 'IDs must be unique.' });
42
+ }
43
+ const ids = new Set(suite.requirements.map((r) => r.id));
44
+ const covered = new Set(suite.cases.flatMap((test) => test.requirementIds));
45
+ if (suite.requirements.some((r) => !covered.has(r.id)))
46
+ ctx.addIssue({ code: 'custom', path: ['requirements'], message: 'Every requirement must be covered by a case, including blocked cases.' });
47
+ suite.cases.forEach((test, index) => {
48
+ if (test.requirementIds.some((id) => !ids.has(id)))
49
+ ctx.addIssue({ code: 'custom', path: ['cases', index, 'requirementIds'], message: 'Unknown requirement ID.' });
50
+ if (!test.questions.length && !test.steps.some((s) => s.type.startsWith('assert')))
51
+ ctx.addIssue({ code: 'custom', path: ['cases', index, 'steps'], message: 'A runnable case needs an explicit assertion.' });
52
+ });
53
+ });
54
+ export const modelSchema = z.object({
55
+ baseURL: httpURL,
56
+ model: text,
57
+ apiKeyEnv: z.string().regex(/^[A-Z][A-Z0-9_]*$/).optional(),
58
+ }).strict();
59
+ export const plannerSchema = modelSchema;
60
+ export const targetSchema = z.object({
61
+ url: httpURL,
62
+ description: z.string().max(2000).default(''),
63
+ }).strict();
64
+ export const configSchema = z.object({
65
+ schemaVersion: z.literal('flow-qa/config-1'),
66
+ targets: z.record(idSchema, targetSchema),
67
+ planner: plannerSchema.optional(),
68
+ agent: modelSchema.optional(),
69
+ timeoutMs: z.number().int().min(1000).max(900000).default(120000),
70
+ retries: z.number().int().min(0).max(2).default(0),
71
+ }).strict();
@@ -0,0 +1,109 @@
1
+ import { lstat, mkdir, realpath } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { startUIServer } from "./server.js";
6
+ import { startDemo } from "../../examples/demo/server.js";
7
+ import { CONFIG_FILE, initWorkspace, writeJSON } from "../workspace.js";
8
+ import { configSchema } from "../schema.js";
9
+ import { localeFromEnv, localized } from "../i18n.js";
10
+ class StartupError extends Error {
11
+ }
12
+ async function privateDirectory(parent, name) {
13
+ const directory = join(parent, name);
14
+ await mkdir(directory, { recursive: true, mode: 0o700 });
15
+ const info = await lstat(directory);
16
+ if (!info.isDirectory() || info.isSymbolicLink())
17
+ throw new Error('The demo workspace must be a regular directory.');
18
+ return directory;
19
+ }
20
+ async function start(args, locale) {
21
+ const { values } = parseArgs({ args, options: {
22
+ workspace: { type: 'string' }, port: { type: 'string' },
23
+ demo: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
24
+ lang: { type: 'string' },
25
+ } });
26
+ if (values.lang && values.lang !== 'en' && values.lang !== 'ko')
27
+ throw new StartupError(localized(locale, 'Unsupported language. Use en or ko.', '지원하지 않는 언어입니다. en 또는 ko를 사용하세요.'));
28
+ if (values.help) {
29
+ console.log(localized(locale, 'Flow QA UI\n\nflow-qa ui [--workspace <directory>] [--port 4310] [--lang en|ko]\nflow-qa ui --demo [--port 4310] [--lang en|ko]\n\n--demo starts a separate local demo app and uses the .flowqa/ui-demo-en workspace.', 'Flow QA UI\n\nflow-qa ui [--workspace <directory>] [--port 4310] [--lang en|ko]\nflow-qa ui --demo [--port 4310] [--lang en|ko]\n\n--demo는 별도 로컬 데모 앱과 .flowqa/ui-demo-en 작업 공간을 사용합니다.'));
30
+ return;
31
+ }
32
+ const port = Number(values.port ?? 4310);
33
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
34
+ throw new StartupError(localized(locale, 'Port must be an integer from 0 to 65535. Example: flow-qa ui --port 4311', '포트는 0~65535 사이 정수여야 합니다. 예: flow-qa ui --port 4311'));
35
+ if (values.demo && values.workspace)
36
+ throw new StartupError(localized(locale, '--demo and --workspace cannot be used together.', '--demo와 --workspace는 함께 사용할 수 없습니다.'));
37
+ let workspace;
38
+ try {
39
+ workspace = await realpath(resolve(values.workspace ?? '.'));
40
+ }
41
+ catch {
42
+ throw new StartupError(localized(locale, 'Workspace directory not found. Run flow-qa init /path/to/my-qa first, then pass --workspace.', '작업 공간 디렉터리를 찾을 수 없습니다. 먼저 flow-qa init /path/to/my-qa로 준비한 뒤 --workspace로 지정하세요.'));
43
+ }
44
+ let demo;
45
+ let ui;
46
+ try {
47
+ if (values.demo) {
48
+ const privateRoot = await privateDirectory(workspace, '.flowqa');
49
+ workspace = await privateDirectory(privateRoot, 'ui-demo-en');
50
+ await initWorkspace(workspace);
51
+ demo = await startDemo(0);
52
+ const configPath = join(workspace, CONFIG_FILE);
53
+ const configInfo = await lstat(configPath);
54
+ if (!configInfo.isFile() || configInfo.isSymbolicLink())
55
+ throw new Error('The demo configuration must be a regular file.');
56
+ await writeJSON(configPath, configSchema.parse({
57
+ schemaVersion: 'flow-qa/config-1',
58
+ targets: { demo: { url: demo.url, description: 'Local task example · temporary data' } },
59
+ timeoutMs: 15000, retries: 0,
60
+ }));
61
+ }
62
+ if (!values.demo) {
63
+ try {
64
+ await lstat(join(workspace, CONFIG_FILE));
65
+ }
66
+ catch (error) {
67
+ if (error.code === 'ENOENT')
68
+ throw new StartupError(localized(locale, 'flowqa.config.json is missing. Run flow-qa init /path/to/my-qa or start the local example with flow-qa ui --demo.', 'flowqa.config.json이 없습니다. flow-qa init /path/to/my-qa로 작업 공간을 준비하거나 flow-qa ui --demo로 로컬 예제를 시작하세요.'));
69
+ throw error;
70
+ }
71
+ }
72
+ ui = await startUIServer({ workspace, port });
73
+ console.log(localized(locale, `Flow QA: ${ui.url}\nWorkspace: ${workspace}`, `Flow QA: ${ui.url}\n작업 공간: ${workspace}`));
74
+ if (demo)
75
+ console.log(localized(locale, `Local demo app: ${demo.url}\nOpen the example in the case library, review it, then run it in a real browser. No model call is made.`, `로컬 데모 앱: ${demo.url}\n케이스 보관함에서 예제를 열고 검토한 뒤 실제 브라우저로 실행하세요. 모델 호출은 없습니다.`));
76
+ const shutdown = async () => {
77
+ process.off('SIGINT', onSignal);
78
+ process.off('SIGTERM', onSignal);
79
+ await ui?.close();
80
+ await demo?.close();
81
+ };
82
+ const onSignal = () => { void shutdown().catch(() => { process.exitCode = 1; }); };
83
+ process.once('SIGINT', onSignal);
84
+ process.once('SIGTERM', onSignal);
85
+ }
86
+ catch (error) {
87
+ await ui?.close();
88
+ await demo?.close();
89
+ throw error;
90
+ }
91
+ }
92
+ export async function main(args) {
93
+ const flag = args.find((value) => value.startsWith('--lang='))?.slice('--lang='.length);
94
+ const index = args.indexOf('--lang');
95
+ const requested = index >= 0 ? args[index + 1] : flag;
96
+ const locale = requested === 'en' || requested === 'ko' ? requested : localeFromEnv();
97
+ try {
98
+ await start(args, locale);
99
+ }
100
+ catch (error) {
101
+ const message = error instanceof StartupError ? error.message
102
+ : error?.code === 'EADDRINUSE' ? localized(locale, 'Port is already in use. Choose another one, such as flow-qa ui --port 4311.', '이미 사용 중인 포트입니다. flow-qa ui --port 4311처럼 다른 포트를 지정하세요.')
103
+ : localized(locale, 'Could not read the workspace configuration or start the server. Check flowqa.config.json and file permissions. See --help for usage.', '작업 공간 설정을 읽거나 서버를 시작하지 못했습니다. flowqa.config.json의 형식과 파일 접근 권한을 확인하세요. --help로 사용법을 볼 수 있습니다.');
104
+ console.error(`Flow QA UI: ${message}`);
105
+ process.exitCode = 1;
106
+ }
107
+ }
108
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href)
109
+ void main(process.argv.slice(2));
@@ -0,0 +1,85 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ const MAX_EVIDENCE = 10 * 1024 * 1024;
5
+ export class EvidenceError extends Error {
6
+ status;
7
+ constructor(status = 404) {
8
+ super(status === 413 ? 'Evidence files must be 10 MiB or smaller.' : 'No readable evidence file was found.');
9
+ this.status = status;
10
+ }
11
+ }
12
+ /** Only a report-selected, redaction-approved artifact can leave its run directory. */
13
+ export async function readEvidence(runDir, report, selection) {
14
+ try {
15
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(selection.caseId) ||
16
+ !Number.isInteger(selection.attempt) || selection.attempt < 0 || !Number.isInteger(selection.artifact) || selection.artifact < 0)
17
+ throw new EvidenceError();
18
+ const testCase = Array.isArray(report.cases) ? report.cases.find((item) => item?.id === selection.caseId) : undefined;
19
+ const attempt = Array.isArray(testCase?.attempts) ? testCase.attempts[selection.attempt] : undefined;
20
+ const artifact = Array.isArray(attempt?.artifacts) ? attempt.artifacts[selection.artifact] : undefined;
21
+ if (!artifact || !['complete', 'not-required'].includes(artifact.redaction) || typeof artifact.path !== 'string')
22
+ throw new EvidenceError();
23
+ const relative = artifact.path;
24
+ if (!relative.startsWith('raw/artifacts/') || /[\\\x00-\x1f\x7f?#]/.test(relative))
25
+ throw new EvidenceError();
26
+ const parts = relative.split('/');
27
+ if (parts.some((part) => !part || part.startsWith('.')))
28
+ throw new EvidenceError();
29
+ const extension = path.extname(relative).toLowerCase();
30
+ const types = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.txt': 'text/plain; charset=utf-8', '.json': 'text/plain; charset=utf-8', '.log': 'text/plain; charset=utf-8' };
31
+ const contentType = types[extension];
32
+ if (!contentType)
33
+ throw new EvidenceError();
34
+ const rootInfo = await lstat(runDir);
35
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
36
+ throw new EvidenceError();
37
+ const root = await realpath(runDir);
38
+ let current = root;
39
+ for (let index = 0; index < parts.length; index++) {
40
+ current = path.join(current, parts[index]);
41
+ const info = await lstat(current);
42
+ if (info.isSymbolicLink() || (index < parts.length - 1 ? !info.isDirectory() : !info.isFile()) || await realpath(current) !== current)
43
+ throw new EvidenceError();
44
+ }
45
+ const handle = await open(current, constants.O_RDONLY | constants.O_NOFOLLOW);
46
+ let body;
47
+ try {
48
+ const info = await handle.stat();
49
+ if (!info.isFile())
50
+ throw new EvidenceError();
51
+ if (info.size > MAX_EVIDENCE)
52
+ throw new EvidenceError(413);
53
+ // Bounded reads also cover a file growing after stat.
54
+ body = Buffer.alloc(Math.min(info.size + 1, MAX_EVIDENCE + 1));
55
+ let offset = 0;
56
+ while (offset < body.length) {
57
+ const { bytesRead } = await handle.read(body, offset, body.length - offset, offset);
58
+ if (!bytesRead)
59
+ break;
60
+ offset += bytesRead;
61
+ }
62
+ if (offset > MAX_EVIDENCE || offset > info.size)
63
+ throw new EvidenceError(413);
64
+ body = body.subarray(0, offset);
65
+ }
66
+ finally {
67
+ await handle.close();
68
+ }
69
+ const image = contentType.startsWith('image/');
70
+ if (contentType === 'image/png' && !body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
71
+ throw new EvidenceError();
72
+ if (contentType === 'image/jpeg' && !body.subarray(0, 3).equals(Buffer.from([255, 216, 255])))
73
+ throw new EvidenceError();
74
+ if (contentType === 'image/webp' && (body.toString('ascii', 0, 4) !== 'RIFF' || body.toString('ascii', 8, 12) !== 'WEBP'))
75
+ throw new EvidenceError();
76
+ if (!image)
77
+ new TextDecoder('utf-8', { fatal: true }).decode(body);
78
+ return { body, contentType, filename: `${selection.caseId}-attempt-${selection.attempt + 1}-evidence-${selection.artifact + 1}${extension}`, preview: image ? 'image' : 'text' };
79
+ }
80
+ catch (error) {
81
+ if (error instanceof EvidenceError)
82
+ throw error;
83
+ throw new EvidenceError();
84
+ }
85
+ }