verify-grid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +641 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +536 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# verify-grid
|
|
2
|
+
|
|
3
|
+
Run matrix of tasks across services with parallel execution and status reporting.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install verify-grid
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
Create `qa.config.js` or `qa.config.mjs`:
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
export default {
|
|
17
|
+
tasks: {
|
|
18
|
+
unit: { command: 'npm', args: ['test'] },
|
|
19
|
+
lint: { command: 'npm', args: ['run', 'lint'] },
|
|
20
|
+
},
|
|
21
|
+
services: [
|
|
22
|
+
{ name: 'service1', cwd: './services/service1' },
|
|
23
|
+
{ name: 'service2', cwd: './services/service2' },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run tasks:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
qa run --tasks unit,lint --services all
|
|
32
|
+
qa run --tasks all --services all --fail-fast
|
|
33
|
+
qa run --tasks unit --services all --watch
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- Parallel execution with configurable concurrency
|
|
39
|
+
- Fail-fast mode to stop on first error
|
|
40
|
+
- Watch mode with live updates
|
|
41
|
+
- Multiple output formats (table, minimal, json)
|
|
42
|
+
- Proper exit codes for CI/CD (0=success, 1=errors, 2=config error)
|
|
43
|
+
- Task overrides per service
|
|
44
|
+
- Timeout support
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
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
|
+
|
|
400
|
+
// src/render/table.ts
|
|
401
|
+
function renderTable(state) {
|
|
402
|
+
const table = new Table({
|
|
403
|
+
head: ["Task", "Service", "Status"],
|
|
404
|
+
style: {
|
|
405
|
+
head: [],
|
|
406
|
+
border: []
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
const taskGroups = groupJobsByTask(state);
|
|
410
|
+
for (const [taskName, jobs] of taskGroups) {
|
|
411
|
+
let isFirstRow = true;
|
|
412
|
+
for (const job of jobs) {
|
|
413
|
+
const statusColor = getStatusColor(job.status);
|
|
414
|
+
const statusText = statusColor(job.status);
|
|
415
|
+
table.push([
|
|
416
|
+
isFirstRow ? taskName : "",
|
|
417
|
+
job.serviceName,
|
|
418
|
+
statusText
|
|
419
|
+
]);
|
|
420
|
+
isFirstRow = false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return table.toString();
|
|
424
|
+
}
|
|
425
|
+
function renderMinimal(state) {
|
|
426
|
+
const lines = [];
|
|
427
|
+
for (const [, job] of state.jobs) {
|
|
428
|
+
const statusColor = getStatusColor(job.status);
|
|
429
|
+
const statusText = statusColor(job.status);
|
|
430
|
+
lines.push(`${job.taskName} ${job.serviceName} ${statusText}`);
|
|
431
|
+
}
|
|
432
|
+
return lines.join("\n");
|
|
433
|
+
}
|
|
434
|
+
function renderJson(state) {
|
|
435
|
+
const matrix = {};
|
|
436
|
+
for (const [, job] of state.jobs) {
|
|
437
|
+
if (!matrix[job.taskName]) {
|
|
438
|
+
matrix[job.taskName] = {};
|
|
439
|
+
}
|
|
440
|
+
const result = state.results.get(job.id);
|
|
441
|
+
const taskMatrix = matrix[job.taskName];
|
|
442
|
+
if (taskMatrix) {
|
|
443
|
+
taskMatrix[job.serviceName] = {
|
|
444
|
+
status: job.status,
|
|
445
|
+
exitCode: result?.exitCode,
|
|
446
|
+
durationMs: result?.durationMs
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const errors = Array.from(state.results.values()).filter((r) => r.job.status === "error").map((r) => ({
|
|
451
|
+
taskName: r.job.taskName,
|
|
452
|
+
serviceName: r.job.serviceName,
|
|
453
|
+
exitCode: r.exitCode,
|
|
454
|
+
durationMs: r.durationMs,
|
|
455
|
+
errorType: r.errorType
|
|
456
|
+
}));
|
|
457
|
+
const output = {
|
|
458
|
+
startedAt: state.startedAt,
|
|
459
|
+
endedAt: state.endedAt,
|
|
460
|
+
durationMs: state.endedAt ? state.endedAt - state.startedAt : void 0,
|
|
461
|
+
matrix,
|
|
462
|
+
errors
|
|
463
|
+
};
|
|
464
|
+
return JSON.stringify(output, null, 2);
|
|
465
|
+
}
|
|
466
|
+
function groupJobsByTask(state) {
|
|
467
|
+
const groups = /* @__PURE__ */ new Map();
|
|
468
|
+
for (const [, job] of state.jobs) {
|
|
469
|
+
if (!groups.has(job.taskName)) {
|
|
470
|
+
groups.set(job.taskName, []);
|
|
471
|
+
}
|
|
472
|
+
groups.get(job.taskName).push(job);
|
|
473
|
+
}
|
|
474
|
+
return groups;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/render/watch.ts
|
|
478
|
+
import logUpdate from "log-update";
|
|
479
|
+
var WatchRenderer = class {
|
|
480
|
+
updateInterval = null;
|
|
481
|
+
state = null;
|
|
482
|
+
start(state) {
|
|
483
|
+
this.state = state;
|
|
484
|
+
this.render();
|
|
485
|
+
this.updateInterval = setInterval(() => {
|
|
486
|
+
this.render();
|
|
487
|
+
}, 100);
|
|
488
|
+
}
|
|
489
|
+
update(state) {
|
|
490
|
+
this.state = state;
|
|
491
|
+
}
|
|
492
|
+
stop() {
|
|
493
|
+
if (this.updateInterval) {
|
|
494
|
+
clearInterval(this.updateInterval);
|
|
495
|
+
this.updateInterval = null;
|
|
496
|
+
}
|
|
497
|
+
logUpdate.done();
|
|
498
|
+
}
|
|
499
|
+
render() {
|
|
500
|
+
if (!this.state) return;
|
|
501
|
+
const output = renderTable(this.state);
|
|
502
|
+
logUpdate(output);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// src/report/aggregator.ts
|
|
507
|
+
function aggregateErrors(state) {
|
|
508
|
+
const errors = [];
|
|
509
|
+
for (const [, result] of state.results) {
|
|
510
|
+
if (result.job.status === "error") {
|
|
511
|
+
const errorMessage = getErrorMessage(result);
|
|
512
|
+
const logSnippet = extractErrorSnippet(result.stderr, result.stdout);
|
|
513
|
+
errors.push({
|
|
514
|
+
taskName: result.job.taskName,
|
|
515
|
+
serviceName: result.job.serviceName,
|
|
516
|
+
exitCode: result.exitCode,
|
|
517
|
+
durationMs: result.durationMs,
|
|
518
|
+
message: errorMessage,
|
|
519
|
+
logSnippet,
|
|
520
|
+
errorType: result.errorType
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
errors.sort((a, b) => {
|
|
525
|
+
if (a.taskName !== b.taskName) {
|
|
526
|
+
return a.taskName.localeCompare(b.taskName);
|
|
527
|
+
}
|
|
528
|
+
return a.serviceName.localeCompare(b.serviceName);
|
|
529
|
+
});
|
|
530
|
+
return errors;
|
|
531
|
+
}
|
|
532
|
+
function getErrorMessage(result) {
|
|
533
|
+
if (result.errorType === "timeout") {
|
|
534
|
+
return `Timeout after ${result.durationMs}ms`;
|
|
535
|
+
}
|
|
536
|
+
if (result.errorType === "spawn") {
|
|
537
|
+
return `Command not found: ${result.job.command}`;
|
|
538
|
+
}
|
|
539
|
+
if (result.errorType === "signal") {
|
|
540
|
+
return `Killed by signal: ${result.signal}`;
|
|
541
|
+
}
|
|
542
|
+
return `Exit code ${result.exitCode}`;
|
|
543
|
+
}
|
|
544
|
+
function formatErrors(errors) {
|
|
545
|
+
if (errors.length === 0) {
|
|
546
|
+
return "";
|
|
547
|
+
}
|
|
548
|
+
const lines = ["", "Errors:"];
|
|
549
|
+
for (const error of errors) {
|
|
550
|
+
lines.push(`- ${error.serviceName} has error in ${error.taskName} test:`);
|
|
551
|
+
const snippet = error.logSnippet.trim();
|
|
552
|
+
if (snippet) {
|
|
553
|
+
const snippetLines = snippet.split("\n").slice(0, 10);
|
|
554
|
+
for (const line of snippetLines) {
|
|
555
|
+
lines.push(` ${line}`);
|
|
556
|
+
}
|
|
557
|
+
} else {
|
|
558
|
+
lines.push(` ${error.message}`);
|
|
559
|
+
}
|
|
560
|
+
lines.push("");
|
|
561
|
+
}
|
|
562
|
+
return lines.join("\n");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/cli.ts
|
|
566
|
+
var cli = cac("qa");
|
|
567
|
+
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) => {
|
|
568
|
+
try {
|
|
569
|
+
const isTTY = process.stdout.isTTY;
|
|
570
|
+
const colorsEnabled2 = options.color !== false && isTTY;
|
|
571
|
+
setColorsEnabled(colorsEnabled2);
|
|
572
|
+
const config = await loadConfig(options.config);
|
|
573
|
+
const tasks = options.tasks === "all" ? ["all"] : options.tasks.split(",").map((t) => t.trim());
|
|
574
|
+
const services = options.services === "all" ? ["all"] : options.services.split(",").map((s) => s.trim());
|
|
575
|
+
const runOptions = {
|
|
576
|
+
tasks,
|
|
577
|
+
services,
|
|
578
|
+
concurrency: Number(options.concurrency),
|
|
579
|
+
failFast: options.failFast,
|
|
580
|
+
watch: options.watch && isTTY,
|
|
581
|
+
format: options.format,
|
|
582
|
+
output: options.output,
|
|
583
|
+
verbose: options.verbose,
|
|
584
|
+
quiet: options.quiet,
|
|
585
|
+
timeout: options.timeout ? Number(options.timeout) : void 0,
|
|
586
|
+
noColor: !colorsEnabled2
|
|
587
|
+
};
|
|
588
|
+
const jobs = generateJobs(config, tasks, services);
|
|
589
|
+
if (jobs.length === 0) {
|
|
590
|
+
console.error("No jobs to run");
|
|
591
|
+
process.exit(2);
|
|
592
|
+
}
|
|
593
|
+
const runner = new Runner(runOptions);
|
|
594
|
+
let watchRenderer = null;
|
|
595
|
+
if (runOptions.watch) {
|
|
596
|
+
watchRenderer = new WatchRenderer();
|
|
597
|
+
watchRenderer.start(runner.getState());
|
|
598
|
+
runner.on("jobStart", () => {
|
|
599
|
+
watchRenderer?.update(runner.getState());
|
|
600
|
+
});
|
|
601
|
+
runner.on("jobComplete", () => {
|
|
602
|
+
watchRenderer?.update(runner.getState());
|
|
603
|
+
});
|
|
604
|
+
runner.on("jobError", () => {
|
|
605
|
+
watchRenderer?.update(runner.getState());
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
const state = await runner.run(jobs);
|
|
609
|
+
if (watchRenderer) {
|
|
610
|
+
watchRenderer.stop();
|
|
611
|
+
}
|
|
612
|
+
if (!runOptions.quiet) {
|
|
613
|
+
let output;
|
|
614
|
+
if (runOptions.format === "json") {
|
|
615
|
+
output = renderJson(state);
|
|
616
|
+
} else if (runOptions.format === "minimal") {
|
|
617
|
+
output = renderMinimal(state);
|
|
618
|
+
} else {
|
|
619
|
+
output = renderTable(state);
|
|
620
|
+
}
|
|
621
|
+
console.log(output);
|
|
622
|
+
const errors = aggregateErrors(state);
|
|
623
|
+
if (errors.length > 0 && runOptions.format !== "json") {
|
|
624
|
+
console.log(formatErrors(errors));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (options.output) {
|
|
628
|
+
const { writeFileSync } = await import("fs");
|
|
629
|
+
writeFileSync(options.output, renderJson(state), "utf-8");
|
|
630
|
+
}
|
|
631
|
+
const hasErrors = Array.from(state.jobs.values()).some((j) => j.status === "error");
|
|
632
|
+
process.exit(hasErrors ? 1 : 0);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
console.error("Error:", error instanceof Error ? error.message : String(error));
|
|
635
|
+
process.exit(2);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
cli.help();
|
|
639
|
+
cli.version("0.1.0");
|
|
640
|
+
cli.parse();
|
|
641
|
+
//# sourceMappingURL=cli.js.map
|