verify-grid 0.2.0 → 0.2.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/dist/cli.d.ts +1 -0
- package/dist/cli.js +704 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +562 -0
- package/dist/index.js.map +1 -0
- package/package.json +13 -2
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { cac } from "cac";
|
|
5
|
+
import { cpus } from "os";
|
|
6
|
+
|
|
7
|
+
// src/config/loader.ts
|
|
8
|
+
import { pathToFileURL } from "url";
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import { resolve, dirname } from "path";
|
|
11
|
+
|
|
12
|
+
// src/config/schema.ts
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var TaskDefSchema = z.object({
|
|
15
|
+
name: z.string(),
|
|
16
|
+
command: z.string(),
|
|
17
|
+
args: z.array(z.string()).optional(),
|
|
18
|
+
shell: z.boolean().optional(),
|
|
19
|
+
timeout: z.number().positive().optional(),
|
|
20
|
+
env: z.record(z.string()).optional()
|
|
21
|
+
});
|
|
22
|
+
var ServiceDefSchema = z.object({
|
|
23
|
+
name: z.string(),
|
|
24
|
+
cwd: z.string(),
|
|
25
|
+
tasks: z.record(z.object({
|
|
26
|
+
command: z.string().optional(),
|
|
27
|
+
args: z.array(z.string()).optional(),
|
|
28
|
+
shell: z.boolean().optional(),
|
|
29
|
+
timeout: z.number().positive().optional(),
|
|
30
|
+
env: z.record(z.string()).optional()
|
|
31
|
+
})).optional(),
|
|
32
|
+
env: z.record(z.string()).optional()
|
|
33
|
+
});
|
|
34
|
+
var ConfigSchema = z.object({
|
|
35
|
+
services: z.array(ServiceDefSchema),
|
|
36
|
+
tasks: z.record(TaskDefSchema.omit({ name: true }))
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// src/config/loader.ts
|
|
40
|
+
var CONFIG_FILES = ["qa.config.ts", "qa.config.js", "qa.config.mjs"];
|
|
41
|
+
async function findConfig(startDir = process.cwd()) {
|
|
42
|
+
let currentDir = resolve(startDir);
|
|
43
|
+
const root = resolve("/");
|
|
44
|
+
while (currentDir !== root) {
|
|
45
|
+
for (const configFile of CONFIG_FILES) {
|
|
46
|
+
const configPath = resolve(currentDir, configFile);
|
|
47
|
+
if (existsSync(configPath)) {
|
|
48
|
+
return configPath;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
currentDir = dirname(currentDir);
|
|
52
|
+
}
|
|
53
|
+
for (const configFile of CONFIG_FILES) {
|
|
54
|
+
const configPath = resolve(root, configFile);
|
|
55
|
+
if (existsSync(configPath)) {
|
|
56
|
+
return configPath;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
async function loadConfig(configPath) {
|
|
62
|
+
const path = configPath || await findConfig();
|
|
63
|
+
if (!path) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"No config file found. Create qa.config.ts or qa.config.js in your project root."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
throw new Error(`Config file not found: ${path}`);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const fileUrl = pathToFileURL(path).href;
|
|
73
|
+
const module = await import(fileUrl);
|
|
74
|
+
const configData = module.default || module;
|
|
75
|
+
const result = ConfigSchema.safeParse(configData);
|
|
76
|
+
if (!result.success) {
|
|
77
|
+
const errors = result.error.errors.map(
|
|
78
|
+
(err) => ` - ${err.path.join(".")}: ${err.message}`
|
|
79
|
+
).join("\n");
|
|
80
|
+
throw new Error(`Invalid config:
|
|
81
|
+
${errors}`);
|
|
82
|
+
}
|
|
83
|
+
const serviceNames = /* @__PURE__ */ new Set();
|
|
84
|
+
for (const service of result.data.services) {
|
|
85
|
+
if (serviceNames.has(service.name)) {
|
|
86
|
+
throw new Error(`Duplicate service name: ${service.name}`);
|
|
87
|
+
}
|
|
88
|
+
serviceNames.add(service.name);
|
|
89
|
+
}
|
|
90
|
+
const config = {
|
|
91
|
+
services: result.data.services,
|
|
92
|
+
tasks: Object.entries(result.data.tasks).reduce((acc, [name, def]) => {
|
|
93
|
+
acc[name] = { name, ...def };
|
|
94
|
+
return acc;
|
|
95
|
+
}, {})
|
|
96
|
+
};
|
|
97
|
+
return config;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error instanceof Error) {
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Failed to load config: ${String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/runner/generator.ts
|
|
107
|
+
import { existsSync as existsSync2 } from "fs";
|
|
108
|
+
function generateJobs(config, taskNames, serviceNames) {
|
|
109
|
+
const jobs = [];
|
|
110
|
+
const resolvedServiceNames = serviceNames.includes("all") ? config.services.map((s) => s.name) : serviceNames;
|
|
111
|
+
const resolvedTaskNames = taskNames.includes("all") ? Object.keys(config.tasks) : taskNames;
|
|
112
|
+
for (const taskName of resolvedTaskNames) {
|
|
113
|
+
const globalTask = config.tasks[taskName];
|
|
114
|
+
if (!globalTask) {
|
|
115
|
+
throw new Error(`Unknown task: ${taskName}`);
|
|
116
|
+
}
|
|
117
|
+
for (const serviceName of resolvedServiceNames) {
|
|
118
|
+
const service = config.services.find((s) => s.name === serviceName);
|
|
119
|
+
if (!service) {
|
|
120
|
+
throw new Error(`Unknown service: ${serviceName}`);
|
|
121
|
+
}
|
|
122
|
+
if (!existsSync2(service.cwd)) {
|
|
123
|
+
jobs.push({
|
|
124
|
+
id: `${taskName}:${serviceName}`,
|
|
125
|
+
taskName,
|
|
126
|
+
serviceName,
|
|
127
|
+
cwd: service.cwd,
|
|
128
|
+
command: "",
|
|
129
|
+
status: "skipped"
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const taskOverride = service.tasks?.[taskName];
|
|
134
|
+
if (taskOverride && taskOverride.command === void 0 && !globalTask.command) {
|
|
135
|
+
jobs.push({
|
|
136
|
+
id: `${taskName}:${serviceName}`,
|
|
137
|
+
taskName,
|
|
138
|
+
serviceName,
|
|
139
|
+
cwd: service.cwd,
|
|
140
|
+
command: "",
|
|
141
|
+
status: "skipped"
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const mergedTask = {
|
|
146
|
+
name: taskName,
|
|
147
|
+
command: taskOverride?.command ?? globalTask.command,
|
|
148
|
+
args: taskOverride?.args ?? globalTask.args,
|
|
149
|
+
shell: taskOverride?.shell ?? globalTask.shell ?? true,
|
|
150
|
+
timeout: taskOverride?.timeout ?? globalTask.timeout,
|
|
151
|
+
env: {
|
|
152
|
+
...globalTask.env,
|
|
153
|
+
...service.env,
|
|
154
|
+
...taskOverride?.env
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
jobs.push({
|
|
158
|
+
id: `${taskName}:${serviceName}`,
|
|
159
|
+
taskName,
|
|
160
|
+
serviceName,
|
|
161
|
+
cwd: service.cwd,
|
|
162
|
+
command: mergedTask.command,
|
|
163
|
+
args: mergedTask.args,
|
|
164
|
+
shell: mergedTask.shell,
|
|
165
|
+
timeout: mergedTask.timeout,
|
|
166
|
+
env: mergedTask.env,
|
|
167
|
+
status: "queued"
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return jobs;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/runner/runner.ts
|
|
175
|
+
import PQueue from "p-queue";
|
|
176
|
+
import { EventEmitter } from "events";
|
|
177
|
+
|
|
178
|
+
// src/runner/executor.ts
|
|
179
|
+
import { execa } from "execa";
|
|
180
|
+
|
|
181
|
+
// src/utils/log.ts
|
|
182
|
+
var MAX_LOG_LINES = 200;
|
|
183
|
+
function extractErrorSnippet(stderr, stdout, maxLines = 10) {
|
|
184
|
+
const errorLog = stderr || stdout;
|
|
185
|
+
const lines = errorLog.split("\n").filter((line) => line.trim());
|
|
186
|
+
if (lines.length === 0) {
|
|
187
|
+
return "No error output";
|
|
188
|
+
}
|
|
189
|
+
const relevantLines = lines.slice(-maxLines);
|
|
190
|
+
return relevantLines.join("\n");
|
|
191
|
+
}
|
|
192
|
+
var LogBuffer = class {
|
|
193
|
+
lines = [];
|
|
194
|
+
maxLines;
|
|
195
|
+
constructor(maxLines = MAX_LOG_LINES) {
|
|
196
|
+
this.maxLines = maxLines;
|
|
197
|
+
}
|
|
198
|
+
append(data) {
|
|
199
|
+
const newLines = data.split("\n");
|
|
200
|
+
this.lines.push(...newLines);
|
|
201
|
+
if (this.lines.length > this.maxLines) {
|
|
202
|
+
this.lines = this.lines.slice(-this.maxLines);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
getContent() {
|
|
206
|
+
return this.lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
clear() {
|
|
209
|
+
this.lines = [];
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// src/runner/executor.ts
|
|
214
|
+
async function executeJob(job) {
|
|
215
|
+
const startTime = Date.now();
|
|
216
|
+
const stdoutBuffer = new LogBuffer();
|
|
217
|
+
const stderrBuffer = new LogBuffer();
|
|
218
|
+
try {
|
|
219
|
+
const args = job.args || [];
|
|
220
|
+
const options = {
|
|
221
|
+
cwd: job.cwd,
|
|
222
|
+
env: { ...process.env, ...job.env },
|
|
223
|
+
shell: job.shell,
|
|
224
|
+
timeout: job.timeout,
|
|
225
|
+
reject: false,
|
|
226
|
+
all: true
|
|
227
|
+
};
|
|
228
|
+
const result = await execa(job.command, args, options);
|
|
229
|
+
stdoutBuffer.append(result.stdout || "");
|
|
230
|
+
stderrBuffer.append(result.stderr || "");
|
|
231
|
+
const durationMs = Date.now() - startTime;
|
|
232
|
+
if (result.exitCode === 0) {
|
|
233
|
+
return {
|
|
234
|
+
job,
|
|
235
|
+
exitCode: 0,
|
|
236
|
+
stdout: stdoutBuffer.getContent(),
|
|
237
|
+
stderr: stderrBuffer.getContent(),
|
|
238
|
+
durationMs
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
job,
|
|
243
|
+
exitCode: result.exitCode,
|
|
244
|
+
stdout: stdoutBuffer.getContent(),
|
|
245
|
+
stderr: stderrBuffer.getContent(),
|
|
246
|
+
durationMs,
|
|
247
|
+
errorType: result.signal ? "signal" : "exit",
|
|
248
|
+
signal: result.signal || void 0
|
|
249
|
+
};
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const durationMs = Date.now() - startTime;
|
|
252
|
+
const execaError = error;
|
|
253
|
+
if (execaError.stdout) stdoutBuffer.append(execaError.stdout);
|
|
254
|
+
if (execaError.stderr) stderrBuffer.append(execaError.stderr);
|
|
255
|
+
let errorType = "exit";
|
|
256
|
+
if (execaError.timedOut) {
|
|
257
|
+
errorType = "timeout";
|
|
258
|
+
} else if (execaError.signal) {
|
|
259
|
+
errorType = "signal";
|
|
260
|
+
} else if (execaError.exitCode === 127 || error.code === "ENOENT") {
|
|
261
|
+
errorType = "spawn";
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
job,
|
|
265
|
+
exitCode: execaError.exitCode,
|
|
266
|
+
stdout: stdoutBuffer.getContent(),
|
|
267
|
+
stderr: stderrBuffer.getContent(),
|
|
268
|
+
durationMs,
|
|
269
|
+
errorType,
|
|
270
|
+
signal: execaError.signal || void 0
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/runner/runner.ts
|
|
276
|
+
var Runner = class extends EventEmitter {
|
|
277
|
+
queue;
|
|
278
|
+
state;
|
|
279
|
+
shouldStop = false;
|
|
280
|
+
options;
|
|
281
|
+
constructor(options) {
|
|
282
|
+
super();
|
|
283
|
+
this.options = options;
|
|
284
|
+
this.queue = new PQueue({ concurrency: options.concurrency });
|
|
285
|
+
this.state = {
|
|
286
|
+
jobs: /* @__PURE__ */ new Map(),
|
|
287
|
+
results: /* @__PURE__ */ new Map(),
|
|
288
|
+
errors: [],
|
|
289
|
+
startedAt: Date.now()
|
|
290
|
+
};
|
|
291
|
+
process.on("SIGINT", () => this.handleInterrupt());
|
|
292
|
+
process.on("SIGTERM", () => this.handleInterrupt());
|
|
293
|
+
}
|
|
294
|
+
handleInterrupt() {
|
|
295
|
+
if (this.shouldStop) {
|
|
296
|
+
process.exit(130);
|
|
297
|
+
}
|
|
298
|
+
this.shouldStop = true;
|
|
299
|
+
this.queue.clear();
|
|
300
|
+
for (const [id, job] of this.state.jobs) {
|
|
301
|
+
if (job.status === "queued") {
|
|
302
|
+
job.status = "canceled";
|
|
303
|
+
this.state.jobs.set(id, job);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.emit("runCanceled");
|
|
307
|
+
}
|
|
308
|
+
async run(jobs) {
|
|
309
|
+
for (const job of jobs) {
|
|
310
|
+
this.state.jobs.set(job.id, job);
|
|
311
|
+
}
|
|
312
|
+
const executableJobs = jobs.filter((j) => j.status === "queued");
|
|
313
|
+
for (const job of executableJobs) {
|
|
314
|
+
if (this.shouldStop) {
|
|
315
|
+
job.status = "canceled";
|
|
316
|
+
this.state.jobs.set(job.id, job);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
this.queue.add(async () => {
|
|
320
|
+
if (this.shouldStop) {
|
|
321
|
+
job.status = "canceled";
|
|
322
|
+
this.state.jobs.set(job.id, job);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
job.status = "running";
|
|
326
|
+
job.startedAt = Date.now();
|
|
327
|
+
this.state.jobs.set(job.id, job);
|
|
328
|
+
this.emit("jobStart", job);
|
|
329
|
+
const result = await executeJob(job);
|
|
330
|
+
job.status = result.exitCode === 0 ? "done" : "error";
|
|
331
|
+
job.endedAt = Date.now();
|
|
332
|
+
this.state.jobs.set(job.id, job);
|
|
333
|
+
this.state.results.set(job.id, result);
|
|
334
|
+
if (result.exitCode !== 0) {
|
|
335
|
+
this.emit("jobError", result);
|
|
336
|
+
if (this.options.failFast) {
|
|
337
|
+
this.shouldStop = true;
|
|
338
|
+
this.queue.clear();
|
|
339
|
+
for (const [id, j] of this.state.jobs) {
|
|
340
|
+
if (j.status === "queued") {
|
|
341
|
+
j.status = "canceled";
|
|
342
|
+
this.state.jobs.set(id, j);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
this.emit("jobComplete", result);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
await this.queue.onIdle();
|
|
352
|
+
this.state.endedAt = Date.now();
|
|
353
|
+
this.emit("runComplete", this.state);
|
|
354
|
+
return this.state;
|
|
355
|
+
}
|
|
356
|
+
getState() {
|
|
357
|
+
return this.state;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// src/render/table.ts
|
|
362
|
+
import Table from "cli-table3";
|
|
363
|
+
|
|
364
|
+
// src/utils/colors.ts
|
|
365
|
+
import kleur from "kleur";
|
|
366
|
+
var colorsEnabled = true;
|
|
367
|
+
function setColorsEnabled(enabled) {
|
|
368
|
+
colorsEnabled = enabled;
|
|
369
|
+
kleur.enabled = enabled;
|
|
370
|
+
}
|
|
371
|
+
function getStatusColor(status) {
|
|
372
|
+
if (!colorsEnabled) {
|
|
373
|
+
return (text) => text;
|
|
374
|
+
}
|
|
375
|
+
switch (status) {
|
|
376
|
+
case "done":
|
|
377
|
+
return kleur.green;
|
|
378
|
+
case "error":
|
|
379
|
+
return kleur.red;
|
|
380
|
+
case "running":
|
|
381
|
+
return kleur.blue;
|
|
382
|
+
case "queued":
|
|
383
|
+
return kleur.yellow;
|
|
384
|
+
case "skipped":
|
|
385
|
+
case "canceled":
|
|
386
|
+
return kleur.gray;
|
|
387
|
+
default:
|
|
388
|
+
return (text) => text;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
var colors = {
|
|
392
|
+
error: kleur.red,
|
|
393
|
+
success: kleur.green,
|
|
394
|
+
warning: kleur.yellow,
|
|
395
|
+
info: kleur.blue,
|
|
396
|
+
dim: kleur.gray,
|
|
397
|
+
bold: kleur.bold
|
|
398
|
+
};
|
|
399
|
+
var green = kleur.green;
|
|
400
|
+
var yellow = kleur.yellow;
|
|
401
|
+
var red = kleur.red;
|
|
402
|
+
var cyan = kleur.cyan;
|
|
403
|
+
var dim = kleur.gray;
|
|
404
|
+
|
|
405
|
+
// src/utils/spinner.ts
|
|
406
|
+
var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
407
|
+
var Spinner = class {
|
|
408
|
+
frame = 0;
|
|
409
|
+
next() {
|
|
410
|
+
const char = SPINNER_FRAMES[this.frame];
|
|
411
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
412
|
+
return char || "\u280B";
|
|
413
|
+
}
|
|
414
|
+
reset() {
|
|
415
|
+
this.frame = 0;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// src/render/table.ts
|
|
420
|
+
var spinner = new Spinner();
|
|
421
|
+
function renderTable(state) {
|
|
422
|
+
const table = new Table({
|
|
423
|
+
head: ["Task", "Service", "Status"],
|
|
424
|
+
style: {
|
|
425
|
+
head: [],
|
|
426
|
+
border: []
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
const taskGroups = groupJobsByTask(state);
|
|
430
|
+
for (const [taskName, jobs] of taskGroups) {
|
|
431
|
+
let isFirstRow = true;
|
|
432
|
+
for (const job of jobs) {
|
|
433
|
+
const statusColor = getStatusColor(job.status);
|
|
434
|
+
let statusText = job.status;
|
|
435
|
+
if (job.status === "running") {
|
|
436
|
+
statusText = `${spinner.next()} ${job.status}`;
|
|
437
|
+
} else if (job.status === "queued") {
|
|
438
|
+
statusText = `\u23F3 ${job.status}`;
|
|
439
|
+
}
|
|
440
|
+
const coloredStatus = statusColor(statusText);
|
|
441
|
+
table.push([
|
|
442
|
+
isFirstRow ? taskName : "",
|
|
443
|
+
job.serviceName,
|
|
444
|
+
coloredStatus
|
|
445
|
+
]);
|
|
446
|
+
isFirstRow = false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return table.toString();
|
|
450
|
+
}
|
|
451
|
+
function renderMinimal(state) {
|
|
452
|
+
const lines = [];
|
|
453
|
+
for (const [, job] of state.jobs) {
|
|
454
|
+
const statusColor = getStatusColor(job.status);
|
|
455
|
+
const statusText = statusColor(job.status);
|
|
456
|
+
lines.push(`${job.taskName} ${job.serviceName} ${statusText}`);
|
|
457
|
+
}
|
|
458
|
+
return lines.join("\n");
|
|
459
|
+
}
|
|
460
|
+
function renderJson(state) {
|
|
461
|
+
const matrix = {};
|
|
462
|
+
for (const [, job] of state.jobs) {
|
|
463
|
+
if (!matrix[job.taskName]) {
|
|
464
|
+
matrix[job.taskName] = {};
|
|
465
|
+
}
|
|
466
|
+
const result = state.results.get(job.id);
|
|
467
|
+
const taskMatrix = matrix[job.taskName];
|
|
468
|
+
if (taskMatrix) {
|
|
469
|
+
taskMatrix[job.serviceName] = {
|
|
470
|
+
status: job.status,
|
|
471
|
+
exitCode: result?.exitCode,
|
|
472
|
+
durationMs: result?.durationMs
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const errors = Array.from(state.results.values()).filter((r) => r.job.status === "error").map((r) => ({
|
|
477
|
+
taskName: r.job.taskName,
|
|
478
|
+
serviceName: r.job.serviceName,
|
|
479
|
+
exitCode: r.exitCode,
|
|
480
|
+
durationMs: r.durationMs,
|
|
481
|
+
errorType: r.errorType
|
|
482
|
+
}));
|
|
483
|
+
const output = {
|
|
484
|
+
startedAt: state.startedAt,
|
|
485
|
+
endedAt: state.endedAt,
|
|
486
|
+
durationMs: state.endedAt ? state.endedAt - state.startedAt : void 0,
|
|
487
|
+
matrix,
|
|
488
|
+
errors
|
|
489
|
+
};
|
|
490
|
+
return JSON.stringify(output, null, 2);
|
|
491
|
+
}
|
|
492
|
+
function groupJobsByTask(state) {
|
|
493
|
+
const groups = /* @__PURE__ */ new Map();
|
|
494
|
+
for (const [, job] of state.jobs) {
|
|
495
|
+
if (!groups.has(job.taskName)) {
|
|
496
|
+
groups.set(job.taskName, []);
|
|
497
|
+
}
|
|
498
|
+
groups.get(job.taskName).push(job);
|
|
499
|
+
}
|
|
500
|
+
return groups;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/render/watch.ts
|
|
504
|
+
import logUpdate from "log-update";
|
|
505
|
+
var WatchRenderer = class {
|
|
506
|
+
updateInterval = null;
|
|
507
|
+
state = null;
|
|
508
|
+
start(state) {
|
|
509
|
+
this.state = state;
|
|
510
|
+
this.render();
|
|
511
|
+
this.updateInterval = setInterval(() => {
|
|
512
|
+
this.render();
|
|
513
|
+
}, 80);
|
|
514
|
+
}
|
|
515
|
+
update(state) {
|
|
516
|
+
this.state = state;
|
|
517
|
+
}
|
|
518
|
+
stop() {
|
|
519
|
+
if (this.updateInterval) {
|
|
520
|
+
clearInterval(this.updateInterval);
|
|
521
|
+
this.updateInterval = null;
|
|
522
|
+
}
|
|
523
|
+
logUpdate.done();
|
|
524
|
+
}
|
|
525
|
+
render() {
|
|
526
|
+
if (!this.state) return;
|
|
527
|
+
const output = renderTable(this.state);
|
|
528
|
+
logUpdate(output);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// src/report/aggregator.ts
|
|
533
|
+
function aggregateErrors(state) {
|
|
534
|
+
const errors = [];
|
|
535
|
+
for (const [, result] of state.results) {
|
|
536
|
+
if (result.job.status === "error") {
|
|
537
|
+
const errorMessage = getErrorMessage(result);
|
|
538
|
+
const logSnippet = extractErrorSnippet(result.stderr, result.stdout);
|
|
539
|
+
errors.push({
|
|
540
|
+
taskName: result.job.taskName,
|
|
541
|
+
serviceName: result.job.serviceName,
|
|
542
|
+
exitCode: result.exitCode,
|
|
543
|
+
durationMs: result.durationMs,
|
|
544
|
+
message: errorMessage,
|
|
545
|
+
logSnippet,
|
|
546
|
+
errorType: result.errorType
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
errors.sort((a, b) => {
|
|
551
|
+
if (a.taskName !== b.taskName) {
|
|
552
|
+
return a.taskName.localeCompare(b.taskName);
|
|
553
|
+
}
|
|
554
|
+
return a.serviceName.localeCompare(b.serviceName);
|
|
555
|
+
});
|
|
556
|
+
return errors;
|
|
557
|
+
}
|
|
558
|
+
function getErrorMessage(result) {
|
|
559
|
+
if (result.errorType === "timeout") {
|
|
560
|
+
return `Timeout after ${result.durationMs}ms`;
|
|
561
|
+
}
|
|
562
|
+
if (result.errorType === "spawn") {
|
|
563
|
+
return `Command not found: ${result.job.command}`;
|
|
564
|
+
}
|
|
565
|
+
if (result.errorType === "signal") {
|
|
566
|
+
return `Killed by signal: ${result.signal}`;
|
|
567
|
+
}
|
|
568
|
+
return `Exit code ${result.exitCode}`;
|
|
569
|
+
}
|
|
570
|
+
function formatErrors(errors) {
|
|
571
|
+
if (errors.length === 0) {
|
|
572
|
+
return "";
|
|
573
|
+
}
|
|
574
|
+
const lines = ["", "Errors:"];
|
|
575
|
+
for (const error of errors) {
|
|
576
|
+
lines.push(`- ${error.serviceName} has error in ${error.taskName} test:`);
|
|
577
|
+
const snippet = error.logSnippet.trim();
|
|
578
|
+
if (snippet) {
|
|
579
|
+
const snippetLines = snippet.split("\n").slice(0, 10);
|
|
580
|
+
for (const line of snippetLines) {
|
|
581
|
+
lines.push(` ${line}`);
|
|
582
|
+
}
|
|
583
|
+
} else {
|
|
584
|
+
lines.push(` ${error.message}`);
|
|
585
|
+
}
|
|
586
|
+
lines.push("");
|
|
587
|
+
}
|
|
588
|
+
return lines.join("\n");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/cli.ts
|
|
592
|
+
var cli = cac("qa");
|
|
593
|
+
cli.usage(`${green("qa")} ${cyan("<command>")} ${dim("[options]")}`);
|
|
594
|
+
cli.command("run", "Run tasks across services").option("--tasks <tasks>", 'Comma-separated list of tasks or "all"', { default: "all" }).option("--services <services>", 'Comma-separated list of services or "all"', { default: "all" }).option("--concurrency <n>", "Maximum parallel jobs", { default: cpus().length }).option("--parallel", "Alias for concurrency").option("--fail-fast", "Stop on first error", { default: false }).option("--watch", "Watch mode with live updates", { default: false }).option("--format <format>", "Output format: table, minimal, json", { default: "table" }).option("--output <path>", "Save JSON output to file").option("--verbose", "Verbose output", { default: false }).option("--quiet", "Minimal output", { default: false }).option("--timeout <ms>", "Timeout per task in milliseconds").option("--no-color", "Disable colors").option("--config <path>", "Path to config file").action(async (options) => {
|
|
595
|
+
try {
|
|
596
|
+
const isTTY = process.stdout.isTTY;
|
|
597
|
+
const colorsEnabled2 = options.color !== false && isTTY;
|
|
598
|
+
setColorsEnabled(colorsEnabled2);
|
|
599
|
+
let config;
|
|
600
|
+
try {
|
|
601
|
+
config = await loadConfig(options.config);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
console.error(red("\u2717 Config Error:"), error instanceof Error ? error.message : String(error));
|
|
604
|
+
console.log(dim("\nMake sure you have a qa.config.js file in your project root."));
|
|
605
|
+
console.log(dim("Example: https://github.com/your-repo/verify-grid#configuration"));
|
|
606
|
+
process.exit(2);
|
|
607
|
+
}
|
|
608
|
+
const tasks = options.tasks === "all" ? ["all"] : options.tasks.split(",").map((t) => t.trim());
|
|
609
|
+
const services = options.services === "all" ? ["all"] : options.services.split(",").map((s) => s.trim());
|
|
610
|
+
const runOptions = {
|
|
611
|
+
tasks,
|
|
612
|
+
services,
|
|
613
|
+
concurrency: Number(options.concurrency),
|
|
614
|
+
failFast: options.failFast,
|
|
615
|
+
watch: options.watch !== false && isTTY && options.format === "table",
|
|
616
|
+
format: options.format,
|
|
617
|
+
output: options.output,
|
|
618
|
+
verbose: options.verbose,
|
|
619
|
+
quiet: options.quiet,
|
|
620
|
+
timeout: options.timeout ? Number(options.timeout) : void 0,
|
|
621
|
+
noColor: !colorsEnabled2
|
|
622
|
+
};
|
|
623
|
+
let jobs;
|
|
624
|
+
try {
|
|
625
|
+
jobs = generateJobs(config, tasks, services);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
console.error(red("\u2717 Job Generation Error:"), error instanceof Error ? error.message : String(error));
|
|
628
|
+
process.exit(2);
|
|
629
|
+
}
|
|
630
|
+
if (jobs.length === 0) {
|
|
631
|
+
console.error(yellow("\u26A0 No jobs to run"));
|
|
632
|
+
console.log(dim("\nCheck your --tasks and --services filters."));
|
|
633
|
+
console.log(dim(`Available tasks: ${Object.keys(config.tasks).join(", ")}`));
|
|
634
|
+
console.log(dim(`Available services: ${config.services.map((s) => s.name).join(", ")}`));
|
|
635
|
+
process.exit(2);
|
|
636
|
+
}
|
|
637
|
+
const runner = new Runner(runOptions);
|
|
638
|
+
let watchRenderer = null;
|
|
639
|
+
if (runOptions.watch) {
|
|
640
|
+
watchRenderer = new WatchRenderer();
|
|
641
|
+
watchRenderer.start(runner.getState());
|
|
642
|
+
runner.on("jobStart", () => {
|
|
643
|
+
watchRenderer?.update(runner.getState());
|
|
644
|
+
});
|
|
645
|
+
runner.on("jobComplete", () => {
|
|
646
|
+
watchRenderer?.update(runner.getState());
|
|
647
|
+
});
|
|
648
|
+
runner.on("jobError", () => {
|
|
649
|
+
watchRenderer?.update(runner.getState());
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
const state = await runner.run(jobs);
|
|
653
|
+
if (watchRenderer) {
|
|
654
|
+
watchRenderer.stop();
|
|
655
|
+
}
|
|
656
|
+
if (!runOptions.quiet) {
|
|
657
|
+
let output;
|
|
658
|
+
if (runOptions.format === "json") {
|
|
659
|
+
output = renderJson(state);
|
|
660
|
+
} else if (runOptions.format === "minimal") {
|
|
661
|
+
output = renderMinimal(state);
|
|
662
|
+
} else {
|
|
663
|
+
output = renderTable(state);
|
|
664
|
+
}
|
|
665
|
+
console.log(output);
|
|
666
|
+
const errors = aggregateErrors(state);
|
|
667
|
+
if (errors.length > 0 && runOptions.format !== "json") {
|
|
668
|
+
console.log(formatErrors(errors));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (options.output) {
|
|
672
|
+
const { writeFileSync } = await import("fs");
|
|
673
|
+
writeFileSync(options.output, renderJson(state), "utf-8");
|
|
674
|
+
}
|
|
675
|
+
const hasErrors = Array.from(state.jobs.values()).some((j) => j.status === "error");
|
|
676
|
+
process.exit(hasErrors ? 1 : 0);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
console.error(red("\u2717 Unexpected Error:"), error instanceof Error ? error.message : String(error));
|
|
679
|
+
if (error instanceof Error && error.stack) {
|
|
680
|
+
console.error(dim(error.stack));
|
|
681
|
+
}
|
|
682
|
+
process.exit(2);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
cli.help();
|
|
686
|
+
cli.version("0.1.0");
|
|
687
|
+
if (process.argv.length === 2) {
|
|
688
|
+
console.log(green("\u{1F680} QA Task Runner\n"));
|
|
689
|
+
console.log("Quick start:");
|
|
690
|
+
console.log(` ${cyan("qa run")} ${dim("# Run all tasks on all services")}`);
|
|
691
|
+
console.log(` ${cyan("qa run --tasks lint")} ${dim("# Run specific task")}`);
|
|
692
|
+
console.log(` ${cyan("qa run --services api,web")} ${dim("# Run on specific services")}`);
|
|
693
|
+
console.log(` ${cyan("qa run --fail-fast")} ${dim("# Stop on first error")}`);
|
|
694
|
+
console.log(` ${cyan("qa run --concurrency 4")} ${dim("# Limit parallel jobs")}`);
|
|
695
|
+
console.log("\nOptions:");
|
|
696
|
+
console.log(` ${cyan("--help")} ${dim("# Show all available options")}`);
|
|
697
|
+
console.log(` ${cyan("--version")} ${dim("# Show version")}`);
|
|
698
|
+
console.log("\nConfiguration:");
|
|
699
|
+
console.log(` Create ${yellow("qa.config.js")} in your project root.`);
|
|
700
|
+
console.log(` See: ${dim("https://github.com/your-repo/verify-grid#configuration")}`);
|
|
701
|
+
process.exit(0);
|
|
702
|
+
}
|
|
703
|
+
cli.parse();
|
|
704
|
+
//# sourceMappingURL=cli.js.map
|