@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.
- package/LICENSE +21 -0
- package/README.ko.md +73 -0
- package/README.md +72 -2
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/dist/bin/flow-qa.mjs +3 -0
- package/dist/examples/demo/server.js +31 -0
- package/dist/src/cli.js +285 -0
- package/dist/src/compiler.js +62 -0
- package/dist/src/engine.js +246 -0
- package/dist/src/i18n.js +98 -0
- package/dist/src/planner.js +112 -0
- package/dist/src/report.js +315 -0
- package/dist/src/schema.js +71 -0
- package/dist/src/ui/entry.js +109 -0
- package/dist/src/ui/evidence.js +85 -0
- package/dist/src/ui/public/app.js +360 -0
- package/dist/src/ui/public/display.js +56 -0
- package/dist/src/ui/public/fonts/dm-sans.woff2 +0 -0
- package/dist/src/ui/public/fonts/noto-kr-subset.woff2 +0 -0
- package/dist/src/ui/public/i18n.js +290 -0
- package/dist/src/ui/public/index.html +117 -0
- package/dist/src/ui/public/style.css +200 -0
- package/dist/src/ui/server.js +785 -0
- package/dist/src/workspace.js +88 -0
- package/docs/architecture.md +38 -0
- package/docs/releases.md +54 -0
- package/docs/ui.md +71 -0
- package/licenses/DM-Sans-LICENSE.txt +93 -0
- package/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
- package/node_modules/@hyeonse/design-system/.github/workflows/ci.yml +21 -0
- package/node_modules/@hyeonse/design-system/.npmignore +5 -0
- package/node_modules/@hyeonse/design-system/AGENTS.md +10 -0
- package/node_modules/@hyeonse/design-system/LICENSE +21 -0
- package/node_modules/@hyeonse/design-system/README.md +67 -0
- package/node_modules/@hyeonse/design-system/THIRD_PARTY_NOTICES.md +5 -0
- package/node_modules/@hyeonse/design-system/dist/components.css +105 -0
- package/node_modules/@hyeonse/design-system/dist/preferences.d.ts +21 -0
- package/node_modules/@hyeonse/design-system/dist/preferences.js +69 -0
- package/node_modules/@hyeonse/design-system/dist/react.d.ts +21 -0
- package/node_modules/@hyeonse/design-system/dist/react.js +100 -0
- package/node_modules/@hyeonse/design-system/dist/tokens.css +75 -0
- package/node_modules/@hyeonse/design-system/docs/api.md +33 -0
- package/node_modules/@hyeonse/design-system/docs/assets/fonts/dm-sans.woff2 +0 -0
- package/node_modules/@hyeonse/design-system/docs/assets/fonts/noto-kr-subset.woff2 +0 -0
- package/node_modules/@hyeonse/design-system/docs/audit.md +29 -0
- package/node_modules/@hyeonse/design-system/docs/foundations.md +30 -0
- package/node_modules/@hyeonse/design-system/docs/verification.md +13 -0
- package/node_modules/@hyeonse/design-system/index.html +51 -0
- package/node_modules/@hyeonse/design-system/licenses/DM-Sans-LICENSE.txt +93 -0
- package/node_modules/@hyeonse/design-system/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
- package/node_modules/@hyeonse/design-system/licenses/arla6ka-MIT.txt +21 -0
- package/node_modules/@hyeonse/design-system/package.json +35 -0
- package/node_modules/@hyeonse/design-system/scripts/audit-tokens.mjs +48 -0
- package/node_modules/@hyeonse/design-system/scripts/build.mjs +12 -0
- package/node_modules/@hyeonse/design-system/showcase.css +75 -0
- package/node_modules/@hyeonse/design-system/showcase.mjs +63 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/SKILL.md +18 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/api.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/build.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/foundations.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/mapping.md +7 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/migration.md +5 -0
- package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/review-docs.md +5 -0
- package/node_modules/@hyeonse/design-system/src/components.css +105 -0
- package/node_modules/@hyeonse/design-system/src/preferences.ts +87 -0
- package/node_modules/@hyeonse/design-system/src/react.tsx +161 -0
- package/node_modules/@hyeonse/design-system/src/tokens.css +75 -0
- package/node_modules/@hyeonse/design-system/tests/audit.test.mjs +30 -0
- package/node_modules/@hyeonse/design-system/tests/preferences.test.mjs +86 -0
- package/node_modules/@hyeonse/design-system/tsconfig.json +16 -0
- package/package.json +73 -4
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access, chmod, lstat, mkdir, open, readdir, realpath, rename, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { createPreferenceScript } from '@hyeonse/design-system/preferences';
|
|
9
|
+
import { chromium } from 'playwright';
|
|
10
|
+
import { compileSuite } from "../compiler.js";
|
|
11
|
+
import { runSuite } from "../engine.js";
|
|
12
|
+
import { localeFromAcceptLanguage, localizeProductMessage } from "../i18n.js";
|
|
13
|
+
import { EvidenceError, readEvidence } from "./evidence.js";
|
|
14
|
+
import { buildPlanningPrompt, planFlow, PlannerError } from "../planner.js";
|
|
15
|
+
import { renderMarkdown } from "../report.js";
|
|
16
|
+
import { configSchema, idSchema, modelSchema, suiteSchema, targetSchema } from "../schema.js";
|
|
17
|
+
import { CONFIG_FILE, sha256 } from "../workspace.js";
|
|
18
|
+
const MAX_BODY = 256 * 1024;
|
|
19
|
+
const MAX_FLOW = 128 * 1024;
|
|
20
|
+
const MAX_CONFIG = 256 * 1024;
|
|
21
|
+
const MAX_SUITE = 512 * 1024;
|
|
22
|
+
const MAX_REPORT = 2 * 1024 * 1024;
|
|
23
|
+
const MAX_STATIC = 4 * 1024 * 1024;
|
|
24
|
+
const PLAN_TIMEOUT_MS = 65_000;
|
|
25
|
+
const CLOSE_WAIT_MS = 10_000;
|
|
26
|
+
const BROWSER_INSTALL_COMMAND = 'flow-qa browser install';
|
|
27
|
+
const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
|
29
|
+
const reportEnvelope = z.object({
|
|
30
|
+
schemaVersion: z.literal('flow-qa/report-1'), runId: z.string().regex(RUN_ID), suiteId: idSchema,
|
|
31
|
+
suiteHash: z.string(), target: z.object({ name: z.string(), url: z.string() }),
|
|
32
|
+
startedAt: z.string(), finishedAt: z.string(), status: z.enum(['passed', 'failed', 'blocked', 'error', 'interrupted']),
|
|
33
|
+
cases: z.array(z.object({ id: z.string(), title: z.string(), status: z.enum(['passed', 'failed', 'flaky', 'blocked', 'skipped', 'error']),
|
|
34
|
+
expected: z.array(z.string()), attempts: z.array(z.unknown()), reason: z.string().optional() }).passthrough()),
|
|
35
|
+
errors: z.array(z.string()), usage: z.object({ modelTokens: z.number(), modelCachedTokens: z.number().optional(), estimatedCostUsd: z.number().optional() }).optional(),
|
|
36
|
+
}).passthrough();
|
|
37
|
+
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
|
|
38
|
+
const STATIC_FILES = {
|
|
39
|
+
'/': { file: path.join(PUBLIC_DIR, 'index.html'), type: 'text/html; charset=utf-8' },
|
|
40
|
+
'/index.html': { file: path.join(PUBLIC_DIR, 'index.html'), type: 'text/html; charset=utf-8' },
|
|
41
|
+
'/app.js': { file: path.join(PUBLIC_DIR, 'app.js'), type: 'application/javascript; charset=utf-8' },
|
|
42
|
+
'/display.js': { file: path.join(PUBLIC_DIR, 'display.js'), type: 'application/javascript; charset=utf-8' },
|
|
43
|
+
'/i18n.js': { file: path.join(PUBLIC_DIR, 'i18n.js'), type: 'application/javascript; charset=utf-8' },
|
|
44
|
+
'/style.css': { file: path.join(PUBLIC_DIR, 'style.css'), type: 'text/css; charset=utf-8' },
|
|
45
|
+
'/design-system/tokens.css': { file: fileURLToPath(import.meta.resolve('@hyeonse/design-system/tokens.css')), type: 'text/css; charset=utf-8' },
|
|
46
|
+
'/design-system/components.css': { file: fileURLToPath(import.meta.resolve('@hyeonse/design-system/components.css')), type: 'text/css; charset=utf-8' },
|
|
47
|
+
'/design-system/preferences.js': { file: fileURLToPath(import.meta.resolve('@hyeonse/design-system/preferences')), type: 'application/javascript; charset=utf-8' },
|
|
48
|
+
'/fonts/dm-sans.woff2': { file: path.join(PUBLIC_DIR, 'fonts', 'dm-sans.woff2'), type: 'font/woff2' },
|
|
49
|
+
'/fonts/noto-kr-subset.woff2': { file: path.join(PUBLIC_DIR, 'fonts', 'noto-kr-subset.woff2'), type: 'font/woff2' },
|
|
50
|
+
};
|
|
51
|
+
const INIT_SCRIPT = createPreferenceScript('flowqa-display-preferences');
|
|
52
|
+
class HTTPError extends Error {
|
|
53
|
+
status;
|
|
54
|
+
issues;
|
|
55
|
+
category;
|
|
56
|
+
constructor(status, message, details = {}) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.status = status;
|
|
59
|
+
this.issues = details.issues;
|
|
60
|
+
this.category = details.category;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function invalid(message = 'Invalid input.') { throw new HTTPError(400, message); }
|
|
64
|
+
function notFound() { throw new HTTPError(404, 'The requested item was not found.'); }
|
|
65
|
+
const SCHEMA_FIELDS = new Set([
|
|
66
|
+
'suite', 'schemaVersion', 'id', 'title', 'source', 'file', 'sha256', 'requirements', 'cases',
|
|
67
|
+
'requirementIds', 'preconditions', 'questions', 'steps', 'type', 'path', 'instruction',
|
|
68
|
+
'locator', 'by', 'value', 'name', 'visible', 'expected', 'match', 'flow', 'role', 'url',
|
|
69
|
+
'description', 'baseURL', 'model', 'apiKeyEnv', 'clear', 'targetName', 'targetURL', 'suiteHash',
|
|
70
|
+
]);
|
|
71
|
+
const KNOWN_SCHEMA_MESSAGES = new Map([
|
|
72
|
+
['IDs must be unique.', 'IDs must be unique.'],
|
|
73
|
+
['Every requirement must be covered by a case, including blocked cases.', 'Every requirement must be covered by a case, including blocked cases.'],
|
|
74
|
+
['Unknown requirement ID.', 'Unknown requirement ID.'],
|
|
75
|
+
['A runnable case needs an explicit assertion.', 'A runnable case needs an explicit assertion.'],
|
|
76
|
+
]);
|
|
77
|
+
function safeIssues(issues) {
|
|
78
|
+
return issues.slice(0, 8).map((issue) => {
|
|
79
|
+
let location = '$';
|
|
80
|
+
for (const part of issue.path.slice(0, 10)) {
|
|
81
|
+
if (typeof part === 'number' && Number.isInteger(part) && part >= 0 && part <= 100)
|
|
82
|
+
location += `[${part}]`;
|
|
83
|
+
else if (typeof part === 'string' && SCHEMA_FIELDS.has(part))
|
|
84
|
+
location += `.${part}`;
|
|
85
|
+
else {
|
|
86
|
+
location = '$';
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const message = KNOWN_SCHEMA_MESSAGES.get(issue.message) ?? {
|
|
91
|
+
invalid_type: 'A required value is missing or has the wrong type.',
|
|
92
|
+
too_small: 'The value is shorter or smaller than allowed.',
|
|
93
|
+
too_big: 'The value is longer or larger than allowed.',
|
|
94
|
+
invalid_format: 'The value has an invalid format.',
|
|
95
|
+
invalid_value: 'The value is not allowed.',
|
|
96
|
+
unrecognized_keys: 'An unsupported field is present.',
|
|
97
|
+
}[issue.code] ?? 'The value does not meet the schema rules.';
|
|
98
|
+
return { path: location, message };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function plannerFailure(error) {
|
|
102
|
+
if (error.code === 'http') {
|
|
103
|
+
const message = error.status === 401 || error.status === 403 ? 'Check model authentication and access permissions.'
|
|
104
|
+
: error.status === 429 ? 'Check the model request limit.'
|
|
105
|
+
: error.status && error.status >= 500 ? 'The model service is unavailable. Try again later.'
|
|
106
|
+
: 'Check the model URL and request settings.';
|
|
107
|
+
return new HTTPError(502, message, { category: 'http' });
|
|
108
|
+
}
|
|
109
|
+
if (error.code === 'invalid-suite') {
|
|
110
|
+
const allowedMessages = new Set(['A JSON object response is required.', 'A runnable case needs an expected-result assertion.',
|
|
111
|
+
'Check requirement links, duplicate IDs, and assertions.', 'Check required values, types, and limits in the case schema.']);
|
|
112
|
+
const allowedPaths = new Set([...SCHEMA_FIELDS, 'response', 'suite', '?']);
|
|
113
|
+
const issues = error.issues?.slice(0, 8).map((issue) => ({
|
|
114
|
+
path: issue.path.split('.').slice(0, 8).every((part) => allowedPaths.has(part) || /^(?:0|[1-9][0-9]?)$/.test(part)) ? issue.path : 'suite',
|
|
115
|
+
message: allowedMessages.has(issue.message) ? issue.message : 'Check the case schema.',
|
|
116
|
+
}));
|
|
117
|
+
return new HTTPError(502, 'The model returned an invalid case suite.', { category: 'invalid-suite', ...(issues ? { issues } : {}) });
|
|
118
|
+
}
|
|
119
|
+
if (error.code === 'cancelled')
|
|
120
|
+
return new HTTPError(504, 'Planning was cancelled or timed out.', { category: 'cancelled' });
|
|
121
|
+
return new HTTPError(502, 'Could not reach the model service. Check its URL and network connection.', { category: 'request' });
|
|
122
|
+
}
|
|
123
|
+
function parse(schema, value) {
|
|
124
|
+
const result = schema.safeParse(value);
|
|
125
|
+
if (!result.success)
|
|
126
|
+
throw new HTTPError(400, 'Invalid input.', { issues: safeIssues(result.error.issues) });
|
|
127
|
+
return result.data;
|
|
128
|
+
}
|
|
129
|
+
function jsonObject(text) {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(text);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return invalid('Invalid JSON.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function safeBasename(value, extension) {
|
|
138
|
+
if (!FILE_NAME.test(value) || value.includes('..') || !value.endsWith(extension))
|
|
139
|
+
invalid('Invalid file name.');
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function sourceName(suite) {
|
|
143
|
+
if (!suite.source)
|
|
144
|
+
return undefined;
|
|
145
|
+
const match = /^flows\/([^/\\]+)$/.exec(suite.source.file);
|
|
146
|
+
if (!match)
|
|
147
|
+
invalid('Invalid source flow path.');
|
|
148
|
+
return safeBasename(match[1], '.md');
|
|
149
|
+
}
|
|
150
|
+
function headers(response) {
|
|
151
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
152
|
+
response.setHeader('Vary', 'Accept-Language');
|
|
153
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
|
|
154
|
+
response.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
155
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
156
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
157
|
+
response.setHeader('X-Frame-Options', 'DENY');
|
|
158
|
+
}
|
|
159
|
+
function sendJSON(response, status, value) {
|
|
160
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
161
|
+
response.end(JSON.stringify(value));
|
|
162
|
+
}
|
|
163
|
+
async function bodyJSON(request) {
|
|
164
|
+
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers['content-type'] ?? '')) {
|
|
165
|
+
throw new HTTPError(415, 'Only JSON requests are supported.');
|
|
166
|
+
}
|
|
167
|
+
const declared = Number(request.headers['content-length']);
|
|
168
|
+
if (Number.isFinite(declared) && declared > MAX_BODY)
|
|
169
|
+
throw new HTTPError(413, 'The request body is too large.');
|
|
170
|
+
const chunks = [];
|
|
171
|
+
let size = 0;
|
|
172
|
+
for await (const chunk of request) {
|
|
173
|
+
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
174
|
+
size += part.length;
|
|
175
|
+
if (size > MAX_BODY)
|
|
176
|
+
throw new HTTPError(413, 'The request body is too large.');
|
|
177
|
+
chunks.push(part);
|
|
178
|
+
}
|
|
179
|
+
return jsonObject(Buffer.concat(chunks).toString('utf8'));
|
|
180
|
+
}
|
|
181
|
+
/** A local-only UI over the same config, suite, run and report contracts as the CLI. */
|
|
182
|
+
export async function startUIServer(options) {
|
|
183
|
+
const workspace = await realpath(options.workspace);
|
|
184
|
+
if (!(await lstat(workspace)).isDirectory())
|
|
185
|
+
throw new Error('Flow QA workspace does not exist.');
|
|
186
|
+
const port = options.port ?? 4310;
|
|
187
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
188
|
+
throw new Error('Invalid UI port.');
|
|
189
|
+
const token = randomBytes(32).toString('base64url');
|
|
190
|
+
const run = options.run ?? runSuite;
|
|
191
|
+
const plan = options.plan ?? planFlow;
|
|
192
|
+
let busy = false;
|
|
193
|
+
let controller;
|
|
194
|
+
let active = null;
|
|
195
|
+
let runTask;
|
|
196
|
+
let lastError;
|
|
197
|
+
let closing = false;
|
|
198
|
+
let closeTask;
|
|
199
|
+
const requestTasks = new Set();
|
|
200
|
+
let origin = '';
|
|
201
|
+
async function browserInstalled() {
|
|
202
|
+
try {
|
|
203
|
+
if (options.runtime)
|
|
204
|
+
return Boolean(await options.runtime());
|
|
205
|
+
const executable = chromium.executablePath();
|
|
206
|
+
if (!(await stat(executable)).isFile())
|
|
207
|
+
return false;
|
|
208
|
+
await access(executable, constants.X_OK);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function directory(parts, create = false) {
|
|
216
|
+
let current = workspace;
|
|
217
|
+
for (const part of parts) {
|
|
218
|
+
current = path.join(current, part);
|
|
219
|
+
let info;
|
|
220
|
+
try {
|
|
221
|
+
info = await lstat(current);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (error.code !== 'ENOENT')
|
|
225
|
+
throw error;
|
|
226
|
+
if (!create)
|
|
227
|
+
return undefined;
|
|
228
|
+
await mkdir(current, { mode: 0o700 });
|
|
229
|
+
info = await lstat(current);
|
|
230
|
+
}
|
|
231
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(current) !== current) {
|
|
232
|
+
throw new HTTPError(400, 'Invalid workspace path.');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return current;
|
|
236
|
+
}
|
|
237
|
+
async function readRegularBytes(root, name, limit) {
|
|
238
|
+
const file = path.join(root, name);
|
|
239
|
+
let info;
|
|
240
|
+
try {
|
|
241
|
+
info = await lstat(file);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error.code === 'ENOENT')
|
|
245
|
+
notFound();
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > limit || await realpath(file) !== file)
|
|
249
|
+
notFound();
|
|
250
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
251
|
+
try {
|
|
252
|
+
const opened = await handle.stat();
|
|
253
|
+
if (!opened.isFile() || opened.size > limit)
|
|
254
|
+
notFound();
|
|
255
|
+
return await handle.readFile();
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await handle.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function readRegular(root, name, limit) {
|
|
262
|
+
return (await readRegularBytes(root, name, limit)).toString('utf8');
|
|
263
|
+
}
|
|
264
|
+
async function writeNew(root, name, content) {
|
|
265
|
+
const handle = await open(path.join(root, name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
266
|
+
try {
|
|
267
|
+
await handle.writeFile(content, 'utf8');
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await handle.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function config() {
|
|
274
|
+
const text = await readRegular(workspace, CONFIG_FILE, MAX_CONFIG);
|
|
275
|
+
try {
|
|
276
|
+
return configSchema.parse(jsonObject(text));
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
throw new HTTPError(500, 'Could not read the Flow QA configuration.');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function updateConfig(next) {
|
|
283
|
+
const file = path.join(workspace, CONFIG_FILE);
|
|
284
|
+
const info = await lstat(file);
|
|
285
|
+
if (!info.isFile() || info.isSymbolicLink() || await realpath(file) !== file)
|
|
286
|
+
invalid('Invalid configuration file path.');
|
|
287
|
+
const temp = `.flowqa-config-${randomUUID()}.tmp`;
|
|
288
|
+
await writeNew(workspace, temp, `${JSON.stringify(configSchema.parse(next), null, 2)}\n`);
|
|
289
|
+
try {
|
|
290
|
+
await rename(path.join(workspace, temp), file);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
try {
|
|
294
|
+
await unlink(path.join(workspace, temp));
|
|
295
|
+
}
|
|
296
|
+
catch { /* renamed */ }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function readSource(suite, verify) {
|
|
300
|
+
const name = sourceName(suite);
|
|
301
|
+
if (!name)
|
|
302
|
+
return undefined;
|
|
303
|
+
const root = await directory(['flows']);
|
|
304
|
+
if (!root)
|
|
305
|
+
notFound();
|
|
306
|
+
const flow = await readRegular(root, name, MAX_FLOW);
|
|
307
|
+
if (verify && sha256(flow) !== suite.source.sha256) {
|
|
308
|
+
throw new HTTPError(409, 'The source flow changed. Save the cases again.');
|
|
309
|
+
}
|
|
310
|
+
return flow;
|
|
311
|
+
}
|
|
312
|
+
async function getSuite(file, verify = false) {
|
|
313
|
+
safeBasename(file, '.json');
|
|
314
|
+
const root = await directory(['cases']);
|
|
315
|
+
if (!root)
|
|
316
|
+
notFound();
|
|
317
|
+
const suite = parse(suiteSchema, jsonObject(await readRegular(root, file, MAX_SUITE)));
|
|
318
|
+
const flow = await readSource(suite, verify);
|
|
319
|
+
return { suite, suiteHash: sha256(JSON.stringify(suite)), script: compileSuite(suite), ...(flow === undefined ? {} : { flow }) };
|
|
320
|
+
}
|
|
321
|
+
async function suites() {
|
|
322
|
+
const root = await directory(['cases']);
|
|
323
|
+
if (!root)
|
|
324
|
+
return [];
|
|
325
|
+
const files = (await readdir(root)).filter((name) => FILE_NAME.test(name) && !name.includes('..') && name.endsWith('.json'));
|
|
326
|
+
const summaries = [];
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
try {
|
|
329
|
+
const suite = parse(suiteSchema, jsonObject(await readRegular(root, file, MAX_SUITE)));
|
|
330
|
+
sourceName(suite);
|
|
331
|
+
summaries.push({ file, id: suite.id, title: suite.title, caseCount: suite.cases.length,
|
|
332
|
+
questionCount: suite.cases.reduce((count, testCase) => count + testCase.questions.length, 0) });
|
|
333
|
+
}
|
|
334
|
+
catch { /* An invalid or unsafe local file is not offered through the UI. */ }
|
|
335
|
+
}
|
|
336
|
+
return summaries.sort((a, b) => a.file.localeCompare(b.file));
|
|
337
|
+
}
|
|
338
|
+
async function runDirectory(runId) {
|
|
339
|
+
if (!RUN_ID.test(runId))
|
|
340
|
+
invalid('Invalid run ID.');
|
|
341
|
+
const root = await directory(['.flowqa', 'runs']);
|
|
342
|
+
if (!root)
|
|
343
|
+
notFound();
|
|
344
|
+
const dir = path.join(root, runId);
|
|
345
|
+
let info;
|
|
346
|
+
try {
|
|
347
|
+
info = await lstat(dir);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (error.code === 'ENOENT')
|
|
351
|
+
notFound();
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(dir) !== dir)
|
|
355
|
+
notFound();
|
|
356
|
+
return dir;
|
|
357
|
+
}
|
|
358
|
+
async function report(runId, includeMarkdown, locale = 'en') {
|
|
359
|
+
const dir = await runDirectory(runId);
|
|
360
|
+
const parsed = reportEnvelope.safeParse(jsonObject(await readRegular(dir, 'qa-report.json', MAX_REPORT)));
|
|
361
|
+
if (!parsed.success || parsed.data.runId !== runId)
|
|
362
|
+
notFound();
|
|
363
|
+
const raw = parsed.data;
|
|
364
|
+
let markdown = includeMarkdown ? await readRegular(dir, 'qa-report.md', MAX_REPORT) : undefined;
|
|
365
|
+
if (markdown !== undefined && locale === 'ko') {
|
|
366
|
+
try {
|
|
367
|
+
markdown = renderMarkdown(raw, locale);
|
|
368
|
+
}
|
|
369
|
+
catch { /* Preserve the saved report when an older or malformed JSON report cannot be rendered. */ }
|
|
370
|
+
}
|
|
371
|
+
return { report: raw, ...(markdown === undefined ? {} : { markdown }) };
|
|
372
|
+
}
|
|
373
|
+
async function runs() {
|
|
374
|
+
const root = await directory(['.flowqa', 'runs']);
|
|
375
|
+
if (!root)
|
|
376
|
+
return [];
|
|
377
|
+
const candidates = [];
|
|
378
|
+
for (const id of await readdir(root)) {
|
|
379
|
+
if (!RUN_ID.test(id))
|
|
380
|
+
continue;
|
|
381
|
+
try {
|
|
382
|
+
const info = await lstat(path.join(root, id));
|
|
383
|
+
if (info.isDirectory() && !info.isSymbolicLink())
|
|
384
|
+
candidates.push({ id, mtime: info.mtimeMs });
|
|
385
|
+
}
|
|
386
|
+
catch { /* incomplete run */ }
|
|
387
|
+
}
|
|
388
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
389
|
+
const history = [];
|
|
390
|
+
for (const candidate of candidates) {
|
|
391
|
+
if (history.length === 50)
|
|
392
|
+
break;
|
|
393
|
+
try {
|
|
394
|
+
const { report: item } = await report(candidate.id, false);
|
|
395
|
+
history.push({ runId: item.runId, suiteId: item.suiteId, status: item.status, startedAt: item.startedAt,
|
|
396
|
+
finishedAt: item.finishedAt, caseCount: item.cases.length,
|
|
397
|
+
passedCount: item.cases.filter((entry) => entry.status === 'passed' || entry.status === 'flaky').length,
|
|
398
|
+
target: item.target });
|
|
399
|
+
}
|
|
400
|
+
catch { /* incomplete or unsafe run */ }
|
|
401
|
+
}
|
|
402
|
+
return history;
|
|
403
|
+
}
|
|
404
|
+
function modelState(model) {
|
|
405
|
+
if (!model)
|
|
406
|
+
return { configured: false, kind: 'openai-compatible', keyReady: false };
|
|
407
|
+
return { configured: true, kind: 'openai-compatible', model: model.model, baseURL: model.baseURL,
|
|
408
|
+
...(model.apiKeyEnv ? { apiKeyEnv: model.apiKeyEnv } : {}), keyReady: !model.apiKeyEnv || Boolean(process.env[model.apiKeyEnv]) };
|
|
409
|
+
}
|
|
410
|
+
function enterMutation() {
|
|
411
|
+
if (closing)
|
|
412
|
+
throw new HTTPError(503, 'The UI server is shutting down.');
|
|
413
|
+
if (busy)
|
|
414
|
+
throw new HTTPError(409, 'Another operation is in progress.');
|
|
415
|
+
busy = true;
|
|
416
|
+
}
|
|
417
|
+
async function mutation(work) {
|
|
418
|
+
enterMutation();
|
|
419
|
+
try {
|
|
420
|
+
return await work();
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
busy = false;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async function route(request, response) {
|
|
427
|
+
headers(response);
|
|
428
|
+
const locale = localeFromAcceptLanguage(request.headers['accept-language']);
|
|
429
|
+
if (request.headers.host !== origin.slice('http://'.length) ||
|
|
430
|
+
(request.headers.origin && request.headers.origin !== origin) ||
|
|
431
|
+
(request.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(String(request.headers['sec-fetch-site'])))) {
|
|
432
|
+
throw new HTTPError(403, 'Invalid request origin.');
|
|
433
|
+
}
|
|
434
|
+
const url = new URL(request.url ?? '/', origin);
|
|
435
|
+
if (url.origin !== origin)
|
|
436
|
+
throw new HTTPError(403, 'Invalid request origin.');
|
|
437
|
+
if (request.method === 'GET') {
|
|
438
|
+
if (url.pathname === '/api/state') {
|
|
439
|
+
const settings = await config();
|
|
440
|
+
const history = await runs();
|
|
441
|
+
sendJSON(response, 200, { csrfToken: token,
|
|
442
|
+
targets: Object.entries(settings.targets).map(([name, target]) => ({ name, ...target })),
|
|
443
|
+
models: { planner: modelState(settings.planner), agent: modelState(settings.agent) },
|
|
444
|
+
runtime: { browserInstalled: await browserInstalled(), browserInstallCommand: BROWSER_INSTALL_COMMAND },
|
|
445
|
+
suites: await suites(), runs: history,
|
|
446
|
+
active: active?.kind === 'plan' ? { ...active, title: localizeProductMessage(active.title, locale) } : active,
|
|
447
|
+
...(history[0] ? { lastRunId: history[0].runId } : {}),
|
|
448
|
+
...(lastError ? { lastError: localizeProductMessage(lastError, locale) } : {}) });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (url.pathname === '/api/suite') {
|
|
452
|
+
const file = url.searchParams.get('file');
|
|
453
|
+
if (!file)
|
|
454
|
+
invalid('A case file name is required.');
|
|
455
|
+
sendJSON(response, 200, await getSuite(file, true));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (url.pathname === '/api/report') {
|
|
459
|
+
const runId = url.searchParams.get('runId');
|
|
460
|
+
if (!runId)
|
|
461
|
+
invalid('A run ID is required.');
|
|
462
|
+
sendJSON(response, 200, await report(runId, true, locale));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (url.pathname === '/api/artifact') {
|
|
466
|
+
const runId = url.searchParams.get('runId');
|
|
467
|
+
const caseId = url.searchParams.get('caseId');
|
|
468
|
+
const attemptText = url.searchParams.get('attempt');
|
|
469
|
+
const artifactText = url.searchParams.get('artifact');
|
|
470
|
+
const download = url.searchParams.get('download');
|
|
471
|
+
if (!runId || !caseId || !idSchema.safeParse(caseId).success ||
|
|
472
|
+
!attemptText || !/^(0|[1-9][0-9]{0,3})$/.test(attemptText) || Number(attemptText) > 1000 ||
|
|
473
|
+
!artifactText || !/^(0|[1-9][0-9]{0,3})$/.test(artifactText) || Number(artifactText) > 1000 ||
|
|
474
|
+
(download !== null && download !== '1'))
|
|
475
|
+
invalid('Invalid evidence selection.');
|
|
476
|
+
const selectedReport = (await report(runId, false)).report;
|
|
477
|
+
const runDir = await runDirectory(runId);
|
|
478
|
+
try {
|
|
479
|
+
const evidence = await readEvidence(runDir, selectedReport, { caseId, attempt: Number(attemptText), artifact: Number(artifactText) });
|
|
480
|
+
response.writeHead(200, {
|
|
481
|
+
'Content-Type': evidence.contentType,
|
|
482
|
+
'Content-Disposition': `${download === '1' ? 'attachment' : 'inline'}; filename="${evidence.filename}"`,
|
|
483
|
+
});
|
|
484
|
+
response.end(evidence.body);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
if (error instanceof EvidenceError)
|
|
488
|
+
throw new HTTPError(error.status, error.message);
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (url.pathname === '/design-system/init.js' && !url.search) {
|
|
494
|
+
response.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
|
495
|
+
response.end(INIT_SCRIPT);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const staticFile = STATIC_FILES[url.pathname];
|
|
499
|
+
if (staticFile && !url.search) {
|
|
500
|
+
const assetRoot = path.dirname(staticFile.file);
|
|
501
|
+
let rootInfo;
|
|
502
|
+
try {
|
|
503
|
+
rootInfo = await lstat(assetRoot);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (error.code === 'ENOENT')
|
|
507
|
+
notFound();
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || await realpath(assetRoot) !== assetRoot)
|
|
511
|
+
notFound();
|
|
512
|
+
const content = await readRegularBytes(assetRoot, path.basename(staticFile.file), MAX_STATIC);
|
|
513
|
+
response.writeHead(200, { 'Content-Type': staticFile.type });
|
|
514
|
+
response.end(content);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
notFound();
|
|
518
|
+
}
|
|
519
|
+
if (request.method !== 'POST' || !url.pathname.startsWith('/api/'))
|
|
520
|
+
throw new HTTPError(405, 'The request is not allowed.');
|
|
521
|
+
if (request.headers['x-flowqa-token'] !== token)
|
|
522
|
+
throw new HTTPError(403, 'Invalid request token.');
|
|
523
|
+
const body = await bodyJSON(request);
|
|
524
|
+
if (url.pathname === '/api/prompt') {
|
|
525
|
+
const { flow } = parse(z.object({ flow: z.string().min(1) }).strict(), body);
|
|
526
|
+
if (Buffer.byteLength(flow) > MAX_FLOW)
|
|
527
|
+
throw new HTTPError(413, 'The flow is too large.');
|
|
528
|
+
sendJSON(response, 200, { prompt: buildPlanningPrompt(flow) });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (url.pathname === '/api/stop') {
|
|
532
|
+
parse(z.object({}).strict(), body);
|
|
533
|
+
if (active)
|
|
534
|
+
active.status = 'stopping';
|
|
535
|
+
if (controller)
|
|
536
|
+
controller.abort();
|
|
537
|
+
sendJSON(response, 200, { ok: true });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (url.pathname === '/api/plan') {
|
|
541
|
+
const result = await mutation(async () => {
|
|
542
|
+
const { flow } = parse(z.object({ flow: z.string().min(1) }).strict(), body);
|
|
543
|
+
if (Buffer.byteLength(flow) > MAX_FLOW)
|
|
544
|
+
throw new HTTPError(413, 'The flow is too large.');
|
|
545
|
+
const settings = await config();
|
|
546
|
+
if (!settings.planner)
|
|
547
|
+
throw new HTTPError(409, 'A compatible planner model must be configured.');
|
|
548
|
+
if (settings.planner.apiKeyEnv && !process.env[settings.planner.apiKeyEnv])
|
|
549
|
+
throw new HTTPError(409, 'The model key environment variable is missing.');
|
|
550
|
+
const abort = new AbortController();
|
|
551
|
+
const id = randomUUID();
|
|
552
|
+
controller = abort;
|
|
553
|
+
active = { id, kind: 'plan', status: 'running', title: 'Generating cases', startedAt: new Date().toISOString() };
|
|
554
|
+
let timedOut = false;
|
|
555
|
+
const onAbort = () => rejectAbort(new HTTPError(timedOut ? 504 : 409, timedOut ? 'Planning timed out.' : 'Planning was cancelled.', { category: timedOut ? 'timeout' : 'cancelled' }));
|
|
556
|
+
let rejectAbort = () => undefined;
|
|
557
|
+
const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
|
|
558
|
+
abort.signal.addEventListener('abort', onAbort, { once: true });
|
|
559
|
+
const timeout = setTimeout(() => { timedOut = true; abort.abort(); }, PLAN_TIMEOUT_MS);
|
|
560
|
+
timeout.unref();
|
|
561
|
+
try {
|
|
562
|
+
const result = await Promise.race([
|
|
563
|
+
Promise.resolve().then(() => plan(flow, settings.planner, { signal: abort.signal })),
|
|
564
|
+
aborted,
|
|
565
|
+
]);
|
|
566
|
+
if (abort.signal.aborted)
|
|
567
|
+
throw new HTTPError(timedOut ? 504 : 409, 'Planning was cancelled.', { category: timedOut ? 'timeout' : 'cancelled' });
|
|
568
|
+
const parsed = suiteSchema.safeParse(result);
|
|
569
|
+
if (!parsed.success)
|
|
570
|
+
throw new HTTPError(502, 'The model returned an invalid suite.', { category: 'invalid-suite' });
|
|
571
|
+
const planned = parsed.data;
|
|
572
|
+
const { source: _modelSource, ...withoutSource } = planned;
|
|
573
|
+
const suite = suiteSchema.parse(withoutSource);
|
|
574
|
+
return { suite, script: compileSuite(suite) };
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
if (error instanceof HTTPError)
|
|
578
|
+
throw error;
|
|
579
|
+
if (error instanceof PlannerError)
|
|
580
|
+
throw plannerFailure(error);
|
|
581
|
+
throw new HTTPError(502, 'Could not generate the plan.', { category: 'unknown' });
|
|
582
|
+
}
|
|
583
|
+
finally {
|
|
584
|
+
clearTimeout(timeout);
|
|
585
|
+
abort.signal.removeEventListener('abort', onAbort);
|
|
586
|
+
if (controller === abort)
|
|
587
|
+
controller = undefined;
|
|
588
|
+
if (active?.id === id)
|
|
589
|
+
active = null;
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
sendJSON(response, 200, result);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (url.pathname === '/api/compile') {
|
|
596
|
+
const result = await mutation(async () => {
|
|
597
|
+
const { suite } = parse(z.object({ suite: suiteSchema }).strict(), body);
|
|
598
|
+
await readSource(suite, true);
|
|
599
|
+
return { suite, script: compileSuite(suite) };
|
|
600
|
+
});
|
|
601
|
+
sendJSON(response, 200, result);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (url.pathname === '/api/save') {
|
|
605
|
+
const result = await mutation(async () => {
|
|
606
|
+
const { suite: inputSuite, flow } = parse(z.object({ suite: suiteSchema, flow: z.string().min(1).optional() }).strict(), body);
|
|
607
|
+
if (flow !== undefined && Buffer.byteLength(flow) > MAX_FLOW)
|
|
608
|
+
throw new HTTPError(413, 'The flow is too large.');
|
|
609
|
+
if (flow === undefined)
|
|
610
|
+
await readSource(inputSuite, true);
|
|
611
|
+
const casesRoot = await directory(['cases'], true);
|
|
612
|
+
if (!casesRoot)
|
|
613
|
+
throw new HTTPError(500, 'Could not prepare the cases directory.');
|
|
614
|
+
let suite = inputSuite;
|
|
615
|
+
if (flow !== undefined) {
|
|
616
|
+
const flowsRoot = await directory(['flows'], true);
|
|
617
|
+
if (!flowsRoot)
|
|
618
|
+
throw new HTTPError(500, 'Could not prepare the flows directory.');
|
|
619
|
+
const flowFile = `${suite.id}-${randomUUID()}.md`;
|
|
620
|
+
await writeNew(flowsRoot, flowFile, flow);
|
|
621
|
+
suite = suiteSchema.parse({ ...suite, source: { file: `flows/${flowFile}`, sha256: sha256(flow) } });
|
|
622
|
+
}
|
|
623
|
+
const file = `${suite.id}-${randomUUID()}.json`;
|
|
624
|
+
await writeNew(casesRoot, file, `${JSON.stringify(suite, null, 2)}\n`);
|
|
625
|
+
return { file };
|
|
626
|
+
});
|
|
627
|
+
sendJSON(response, 200, result);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (url.pathname === '/api/targets') {
|
|
631
|
+
await mutation(async () => {
|
|
632
|
+
const { name, url: targetURL, description } = parse(z.object({ name: idSchema, url: z.string(), description: z.string().optional() }).strict(), body);
|
|
633
|
+
const target = parse(targetSchema, { url: targetURL, ...(description === undefined ? {} : { description }) });
|
|
634
|
+
const settings = await config();
|
|
635
|
+
settings.targets[name] = target;
|
|
636
|
+
await updateConfig(settings);
|
|
637
|
+
});
|
|
638
|
+
sendJSON(response, 200, { ok: true });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (url.pathname === '/api/models') {
|
|
642
|
+
await mutation(async () => {
|
|
643
|
+
const change = parse(z.union([
|
|
644
|
+
z.object({ role: z.enum(['planner', 'agent']), clear: z.literal(true) }).strict(),
|
|
645
|
+
z.object({ role: z.enum(['planner', 'agent']), baseURL: z.string(), model: z.string(), apiKeyEnv: z.string().optional() }).strict(),
|
|
646
|
+
]), body);
|
|
647
|
+
const settings = await config();
|
|
648
|
+
if ('clear' in change)
|
|
649
|
+
delete settings[change.role];
|
|
650
|
+
else
|
|
651
|
+
settings[change.role] = parse(modelSchema, { baseURL: change.baseURL, model: change.model,
|
|
652
|
+
...(change.apiKeyEnv === undefined ? {} : { apiKeyEnv: change.apiKeyEnv }) });
|
|
653
|
+
await updateConfig(settings);
|
|
654
|
+
});
|
|
655
|
+
sendJSON(response, 200, { ok: true });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (url.pathname === '/api/run') {
|
|
659
|
+
enterMutation();
|
|
660
|
+
try {
|
|
661
|
+
const { suite: file, suiteHash, targetName, targetURL } = parse(z.object({
|
|
662
|
+
suite: z.string(), suiteHash: z.string().regex(/^[a-f0-9]{64}$/), targetName: idSchema, targetURL: z.string(),
|
|
663
|
+
}).strict(), body);
|
|
664
|
+
const selected = await getSuite(file, true);
|
|
665
|
+
if (selected.suiteHash !== suiteHash)
|
|
666
|
+
throw new HTTPError(409, 'The reviewed case file changed. Open it again to review.');
|
|
667
|
+
const { suite } = selected;
|
|
668
|
+
const settings = await config();
|
|
669
|
+
if (!settings.targets[targetName])
|
|
670
|
+
invalid('The selected target was not found.');
|
|
671
|
+
if (settings.targets[targetName].url !== targetURL) {
|
|
672
|
+
throw new HTTPError(409, 'The selected target URL changed. Review it again.');
|
|
673
|
+
}
|
|
674
|
+
const operationId = randomUUID();
|
|
675
|
+
const abort = new AbortController();
|
|
676
|
+
controller = abort;
|
|
677
|
+
if (closing)
|
|
678
|
+
throw new HTTPError(503, 'The UI server is shutting down.');
|
|
679
|
+
active = { id: operationId, kind: 'run', status: 'running', title: suite.title, startedAt: new Date().toISOString() };
|
|
680
|
+
lastError = undefined;
|
|
681
|
+
const suiteSnapshot = structuredClone(suite);
|
|
682
|
+
const configSnapshot = structuredClone(settings);
|
|
683
|
+
runTask = Promise.resolve().then(() => run({ workspace, suite: suiteSnapshot, config: configSnapshot, targetName, signal: abort.signal }))
|
|
684
|
+
.then(() => undefined)
|
|
685
|
+
.catch(() => { lastError = 'Could not complete the run. Check the local records.'; })
|
|
686
|
+
.finally(() => { active = null; controller = undefined; busy = false; runTask = undefined; });
|
|
687
|
+
sendJSON(response, 202, { operationId });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
busy = false;
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
notFound();
|
|
696
|
+
}
|
|
697
|
+
// Refuse an unsafe config before exposing a listener. Only private run storage is initialized here.
|
|
698
|
+
await config();
|
|
699
|
+
const privateRoot = await directory(['.flowqa'], true);
|
|
700
|
+
const runsRoot = await directory(['.flowqa', 'runs'], true);
|
|
701
|
+
if (!privateRoot || !runsRoot)
|
|
702
|
+
throw new Error('Could not prepare the run records directory.');
|
|
703
|
+
await chmod(privateRoot, 0o700);
|
|
704
|
+
await chmod(runsRoot, 0o700);
|
|
705
|
+
try {
|
|
706
|
+
await writeNew(privateRoot, '.gitignore', '*\n');
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
if (error.code !== 'EEXIST')
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
if (!(await readRegular(privateRoot, '.gitignore', 4096)).split(/\r?\n/).includes('*')) {
|
|
713
|
+
throw new Error('The run records .gitignore must contain the private storage rule.');
|
|
714
|
+
}
|
|
715
|
+
const server = createServer((request, response) => {
|
|
716
|
+
const task = route(request, response).catch((error) => {
|
|
717
|
+
if (response.headersSent) {
|
|
718
|
+
response.destroy();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
headers(response);
|
|
722
|
+
const locale = localeFromAcceptLanguage(request.headers['accept-language']);
|
|
723
|
+
const status = error instanceof HTTPError ? error.status : 500;
|
|
724
|
+
const message = error instanceof HTTPError ? error.message : 'Could not handle the request.';
|
|
725
|
+
sendJSON(response, status, { error: localizeProductMessage(message, locale),
|
|
726
|
+
...(error instanceof HTTPError && error.issues ? { issues: error.issues.map((issue) => ({ ...issue, message: localizeProductMessage(issue.message, locale) })) } : {}),
|
|
727
|
+
...(error instanceof HTTPError && error.category ? { category: error.category } : {}) });
|
|
728
|
+
});
|
|
729
|
+
requestTasks.add(task);
|
|
730
|
+
void task.finally(() => requestTasks.delete(task));
|
|
731
|
+
});
|
|
732
|
+
try {
|
|
733
|
+
await new Promise((resolve, reject) => {
|
|
734
|
+
server.once('error', reject);
|
|
735
|
+
server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
server.close();
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
742
|
+
const address = server.address();
|
|
743
|
+
if (!address || typeof address === 'string') {
|
|
744
|
+
server.close();
|
|
745
|
+
throw new Error('Could not resolve the UI address.');
|
|
746
|
+
}
|
|
747
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
748
|
+
return {
|
|
749
|
+
server, url: origin,
|
|
750
|
+
close() {
|
|
751
|
+
if (closeTask)
|
|
752
|
+
return closeTask;
|
|
753
|
+
closing = true;
|
|
754
|
+
controller?.abort();
|
|
755
|
+
closeTask = (async () => {
|
|
756
|
+
if (requestTasks.size) {
|
|
757
|
+
let requestTimeout;
|
|
758
|
+
try {
|
|
759
|
+
await Promise.race([
|
|
760
|
+
Promise.allSettled([...requestTasks]),
|
|
761
|
+
new Promise((resolve) => { requestTimeout = setTimeout(resolve, 1_000); }),
|
|
762
|
+
]);
|
|
763
|
+
}
|
|
764
|
+
finally {
|
|
765
|
+
clearTimeout(requestTimeout);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const stopped = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
769
|
+
server.closeIdleConnections();
|
|
770
|
+
await stopped;
|
|
771
|
+
const pending = runTask;
|
|
772
|
+
if (pending) {
|
|
773
|
+
let timeout;
|
|
774
|
+
try {
|
|
775
|
+
await Promise.race([pending, new Promise((resolve) => { timeout = setTimeout(resolve, CLOSE_WAIT_MS); })]);
|
|
776
|
+
}
|
|
777
|
+
finally {
|
|
778
|
+
clearTimeout(timeout);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
})();
|
|
782
|
+
return closeTask;
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
}
|