@schwabyio/gta 0.9.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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/SPEC.md +1354 -0
- package/dist/THIRD_PARTY_NOTICES.txt +71 -0
- package/dist/chunks/chunk-VRAFYQNE.js +58985 -0
- package/dist/gta.js +1703 -0
- package/dist/worker.js +10 -0
- package/package.json +51 -0
package/dist/gta.js
ADDED
|
@@ -0,0 +1,1703 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __gtaCreateRequire } from 'node:module';
|
|
3
|
+
const require = __gtaCreateRequire(import.meta.url);
|
|
4
|
+
import {
|
|
5
|
+
COLLECTIONS_DIR,
|
|
6
|
+
DOC_EXTENSION,
|
|
7
|
+
SETTINGS_FILE,
|
|
8
|
+
TagSchema,
|
|
9
|
+
__toESM,
|
|
10
|
+
allSkipped,
|
|
11
|
+
canonical,
|
|
12
|
+
discoverProject,
|
|
13
|
+
environmentNames,
|
|
14
|
+
external_exports,
|
|
15
|
+
flagOverrides,
|
|
16
|
+
isInside,
|
|
17
|
+
iterationName,
|
|
18
|
+
loadProjectCollections,
|
|
19
|
+
loadProjectTls,
|
|
20
|
+
parseMarkdown,
|
|
21
|
+
projectEnvironments,
|
|
22
|
+
readProject,
|
|
23
|
+
relativePosix,
|
|
24
|
+
require_dist,
|
|
25
|
+
resolveFlags,
|
|
26
|
+
samePath,
|
|
27
|
+
stepsForTags,
|
|
28
|
+
unattempted
|
|
29
|
+
} from "./chunks/chunk-VRAFYQNE.js";
|
|
30
|
+
|
|
31
|
+
// src/args.ts
|
|
32
|
+
var UsageError = class extends Error {
|
|
33
|
+
name = "UsageError";
|
|
34
|
+
};
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const parsed = {
|
|
37
|
+
command: null,
|
|
38
|
+
overrides: {},
|
|
39
|
+
help: false,
|
|
40
|
+
version: false,
|
|
41
|
+
json: false,
|
|
42
|
+
flags: []
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const word = argv[i];
|
|
46
|
+
if (word === "-h" || word === "--help") {
|
|
47
|
+
parsed.help = true;
|
|
48
|
+
} else if (word === "-v" || word === "--version") {
|
|
49
|
+
parsed.version = true;
|
|
50
|
+
} else if (word === "--json") {
|
|
51
|
+
parsed.json = true;
|
|
52
|
+
} else if (word === "--flag" || word.startsWith("--flag=")) {
|
|
53
|
+
const value = word === "--flag" ? argv[++i] : word.slice("--flag=".length);
|
|
54
|
+
if (value === void 0 || value.startsWith("--")) {
|
|
55
|
+
throw new UsageError("--flag needs name=value, such as --flag newCheckout=true");
|
|
56
|
+
}
|
|
57
|
+
parsed.flags.push(value);
|
|
58
|
+
} else if (word.startsWith("--")) {
|
|
59
|
+
const body = word.slice(2);
|
|
60
|
+
const equals = body.indexOf("=");
|
|
61
|
+
if (equals > 0) {
|
|
62
|
+
parsed.overrides[body.slice(0, equals)] = body.slice(equals + 1);
|
|
63
|
+
} else {
|
|
64
|
+
const next = argv[i + 1];
|
|
65
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
66
|
+
parsed.overrides[body] = next;
|
|
67
|
+
i++;
|
|
68
|
+
} else {
|
|
69
|
+
parsed.overrides[body] = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (word.startsWith("-") && word.length > 1) {
|
|
73
|
+
throw new UsageError(`Unknown option ${word}. Settings are given as --settingsKey value.`);
|
|
74
|
+
} else if (parsed.command === null) {
|
|
75
|
+
parsed.command = word;
|
|
76
|
+
} else {
|
|
77
|
+
throw new UsageError(
|
|
78
|
+
`Unexpected "${word}". To run several collections, separate them with commas: gta ${parsed.command},${word}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/select.ts
|
|
86
|
+
var idOf = (collection) => collection.name;
|
|
87
|
+
var placeOf = (collection) => collection.relativePath.slice(0, -DOC_EXTENSION.length);
|
|
88
|
+
var normalize = (selector) => {
|
|
89
|
+
const cleaned = selector.normalize("NFC").trim().replace(/\\/g, "/").replace(/^(\.\/)+/, "").replace(/^collections\//, "");
|
|
90
|
+
return {
|
|
91
|
+
name: cleaned.replace(/\/+$/, "").replace(/\.yml$/, ""),
|
|
92
|
+
directoryOnly: cleaned.endsWith("/")
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
function selectCollections(collections, request, options) {
|
|
96
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
97
|
+
const inGroup = (group) => group.filter((c) => {
|
|
98
|
+
if (c.doc.exclude === true) excluded.add(c);
|
|
99
|
+
return c.doc.exclude !== true;
|
|
100
|
+
});
|
|
101
|
+
let chosen;
|
|
102
|
+
if (request.kind === "list") {
|
|
103
|
+
const unknown = [];
|
|
104
|
+
chosen = [];
|
|
105
|
+
for (const selector of request.selectors) {
|
|
106
|
+
const { name, directoryOnly } = normalize(selector);
|
|
107
|
+
const exact = directoryOnly ? void 0 : collections.find((c) => idOf(c) === name) ?? collections.find((c) => placeOf(c).normalize("NFC") === name);
|
|
108
|
+
const inDirectory = collections.filter((c) => c.directory?.normalize("NFC") === name);
|
|
109
|
+
const found = exact ? [exact] : inDirectory.length > 0 ? inGroup(inDirectory) : null;
|
|
110
|
+
if (found) chosen.push(...found.filter((c) => !chosen.includes(c)));
|
|
111
|
+
else unknown.push(selector);
|
|
112
|
+
}
|
|
113
|
+
if (unknown.length > 0) {
|
|
114
|
+
throw new UsageError(
|
|
115
|
+
`No collection or directory is called ${unknown.map((u) => `"${u}"`).join(", ")}. List them with: gta get`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
chosen = inGroup(collections);
|
|
120
|
+
}
|
|
121
|
+
const targets = [];
|
|
122
|
+
let untagged = 0;
|
|
123
|
+
for (const collection of chosen) {
|
|
124
|
+
const id = idOf(collection);
|
|
125
|
+
if (collection.problems.length > 0) {
|
|
126
|
+
const broken = collection.problems.map((p) => p.message).join("; ");
|
|
127
|
+
targets.push({ id, collection, steps: null, broken });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const picked = stepsForTags(collection.doc, options.tags, options.notTags ?? []);
|
|
131
|
+
if (picked.length === 0 && (options.tags.length > 0 || (options.notTags ?? []).length > 0)) {
|
|
132
|
+
untagged++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const all = picked.length === collection.doc.steps.length;
|
|
136
|
+
targets.push({ id, collection, steps: all ? null : picked, broken: null });
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
targets,
|
|
140
|
+
// Named on its own, an excluded collection ran: it was not left out.
|
|
141
|
+
excluded: [...excluded].filter((c) => !chosen.includes(c)).map(idOf),
|
|
142
|
+
untagged
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
var stepCountOf = (target) => target.steps?.length ?? target.collection.doc.steps.length;
|
|
146
|
+
|
|
147
|
+
// src/report.ts
|
|
148
|
+
function paint(enabled) {
|
|
149
|
+
const wrap = (open, close) => (text) => enabled ? `\x1B[${open}m${text}\x1B[${close}m` : text;
|
|
150
|
+
return {
|
|
151
|
+
red: wrap(31, 39),
|
|
152
|
+
green: wrap(32, 39),
|
|
153
|
+
cyan: wrap(36, 39),
|
|
154
|
+
yellow: wrap(33, 39),
|
|
155
|
+
dim: wrap(2, 22),
|
|
156
|
+
bold: wrap(1, 22)
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function colorWanted(stream, env) {
|
|
160
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
161
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
162
|
+
return stream.isTTY === true;
|
|
163
|
+
}
|
|
164
|
+
function tally(target, outcome, durationMs) {
|
|
165
|
+
if (!outcome.ok) {
|
|
166
|
+
const total = stepCountOf(target);
|
|
167
|
+
return {
|
|
168
|
+
id: target.id,
|
|
169
|
+
passed: false,
|
|
170
|
+
durationMs,
|
|
171
|
+
steps: { total, passed: 0, failed: 0, errored: 0, skipped: total },
|
|
172
|
+
assertions: { total: 0, passed: 0, failed: 0 },
|
|
173
|
+
skipped: false,
|
|
174
|
+
error: outcome.message,
|
|
175
|
+
problems: []
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const { summary: summary2 } = outcome;
|
|
179
|
+
const assertions = summary2.results.flatMap((r) => r.assertions);
|
|
180
|
+
const failed = assertions.filter((a) => a.status === "fail").length;
|
|
181
|
+
return {
|
|
182
|
+
id: target.id,
|
|
183
|
+
passed: summary2.failed === 0 && summary2.errored === 0,
|
|
184
|
+
durationMs,
|
|
185
|
+
steps: {
|
|
186
|
+
total: summary2.total,
|
|
187
|
+
passed: summary2.passed,
|
|
188
|
+
failed: summary2.failed,
|
|
189
|
+
errored: summary2.errored,
|
|
190
|
+
skipped: summary2.skipped
|
|
191
|
+
},
|
|
192
|
+
assertions: { total: assertions.length, passed: assertions.length - failed, failed },
|
|
193
|
+
skipped: allSkipped(summary2),
|
|
194
|
+
error: null,
|
|
195
|
+
// Named for the row they ran with, when a data file drove the collection.
|
|
196
|
+
problems: summary2.results.flatMap(
|
|
197
|
+
(r, i) => r.status === "fail" || r.status === "error" ? [{ ...r, item: { ...r.item, name: iterationName(r.item.name, outcome.steps[i]) } }] : []
|
|
198
|
+
)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
var seconds = (ms) => `${(ms / 1e3).toFixed(3)}s`;
|
|
202
|
+
var rule = (p, width = 100) => p.dim("\u2500".repeat(width));
|
|
203
|
+
var field = (label, value, width = 14) => `${`${label}:`.padEnd(width)}${value}`;
|
|
204
|
+
var NUMBER_COLUMNS = [
|
|
205
|
+
["Time", 9],
|
|
206
|
+
["Steps", 6],
|
|
207
|
+
["Pass", 5],
|
|
208
|
+
["Fail", 5],
|
|
209
|
+
["Error", 6],
|
|
210
|
+
["Skip", 5],
|
|
211
|
+
["Asserts", 8],
|
|
212
|
+
["Pass", 5],
|
|
213
|
+
["Fail", 5]
|
|
214
|
+
];
|
|
215
|
+
var nameWidthFor = (ids) => Math.min(80, Math.max("Collection".length, ...ids.map((id) => id.length)));
|
|
216
|
+
var tableWidth = (nameWidth) => 4 + 2 + nameWidth + 1 + NUMBER_COLUMNS.reduce((n, [, w]) => n + w + 1, 0) - 1 + 2 + "passed".length;
|
|
217
|
+
function tableHeader(nameWidth, p) {
|
|
218
|
+
const cells = NUMBER_COLUMNS.map(([label, width]) => label.padStart(width));
|
|
219
|
+
return p.bold(`${"#".padStart(4)} ${"Collection".padEnd(nameWidth)} ${cells.join(" ")} Result`);
|
|
220
|
+
}
|
|
221
|
+
var nameCell = (id, width) => id.length > width ? `${id.slice(0, width - 1)}\u2026` : id.padEnd(width);
|
|
222
|
+
function tableRow(n, t, nameWidth, p) {
|
|
223
|
+
const name = nameCell(t.id, nameWidth);
|
|
224
|
+
const values = [
|
|
225
|
+
seconds(t.durationMs),
|
|
226
|
+
t.steps.total,
|
|
227
|
+
t.steps.passed,
|
|
228
|
+
t.steps.failed,
|
|
229
|
+
t.steps.errored,
|
|
230
|
+
t.steps.skipped,
|
|
231
|
+
t.assertions.total,
|
|
232
|
+
t.assertions.passed,
|
|
233
|
+
t.assertions.failed
|
|
234
|
+
];
|
|
235
|
+
const cells = values.map((value, i) => String(value).padStart(NUMBER_COLUMNS[i][1]));
|
|
236
|
+
const result = t.skipped ? p.cyan("skipped") : t.passed ? p.green("passed") : p.red("failed");
|
|
237
|
+
return `${String(n).padStart(4)} ${name} ${cells.join(" ")} ${result}`;
|
|
238
|
+
}
|
|
239
|
+
var clip = (text, max = 300) => text.length > max ? `${text.slice(0, max)}\u2026 (${text.length - max} more characters)` : text;
|
|
240
|
+
function failureDetails(tallies, p) {
|
|
241
|
+
const lines = [];
|
|
242
|
+
for (const t of tallies) {
|
|
243
|
+
if (t.passed) continue;
|
|
244
|
+
lines.push(p.bold(t.id));
|
|
245
|
+
if (t.error) lines.push(` ${p.red("\u2717")} ${t.error}`);
|
|
246
|
+
for (const result of t.problems) {
|
|
247
|
+
const where = result.use ? ` ${p.dim(`(${result.use.set} ${result.use.child + 1}/${result.use.of})`)}` : "";
|
|
248
|
+
const status = result.response ? ` ${p.dim(String(result.response.status))}` : "";
|
|
249
|
+
lines.push(` ${p.red("\u2717")} ${result.item.name}${where}${status}`);
|
|
250
|
+
if (result.error) {
|
|
251
|
+
const { phase, script, line, message } = result.error;
|
|
252
|
+
const at = [script && `${script} script`, line && `line ${line}`].filter(Boolean).join(", ");
|
|
253
|
+
lines.push(` ${p.red(`${phase} error${at ? ` (${at})` : ""}:`)} ${clip(message)}`);
|
|
254
|
+
}
|
|
255
|
+
for (const assertion of result.assertions) {
|
|
256
|
+
if (assertion.status !== "fail") continue;
|
|
257
|
+
const message = assertion.message ? `: ${assertion.message}` : "";
|
|
258
|
+
lines.push(` ${assertion.name}${message}`);
|
|
259
|
+
if (assertion.expected !== void 0) {
|
|
260
|
+
lines.push(` ${p.dim("expected")} ${clip(assertion.expected)}`);
|
|
261
|
+
}
|
|
262
|
+
if (assertion.actual !== void 0) {
|
|
263
|
+
lines.push(` ${p.dim("actual ")} ${clip(assertion.actual)}`);
|
|
264
|
+
}
|
|
265
|
+
if (assertion.unasserted?.length) {
|
|
266
|
+
lines.push(` ${p.dim("not asserted")} ${clip(assertion.unasserted.join(", "))}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
lines.push("");
|
|
271
|
+
}
|
|
272
|
+
return lines;
|
|
273
|
+
}
|
|
274
|
+
function totalsOf(tallies) {
|
|
275
|
+
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
276
|
+
return {
|
|
277
|
+
collections: {
|
|
278
|
+
total: tallies.length,
|
|
279
|
+
passed: tallies.filter((t) => t.passed).length,
|
|
280
|
+
failed: tallies.filter((t) => !t.passed).length
|
|
281
|
+
},
|
|
282
|
+
steps: {
|
|
283
|
+
total: sum((t) => t.steps.total),
|
|
284
|
+
passed: sum((t) => t.steps.passed),
|
|
285
|
+
failed: sum((t) => t.steps.failed),
|
|
286
|
+
errored: sum((t) => t.steps.errored),
|
|
287
|
+
skipped: sum((t) => t.steps.skipped)
|
|
288
|
+
},
|
|
289
|
+
assertions: {
|
|
290
|
+
total: sum((t) => t.assertions.total),
|
|
291
|
+
passed: sum((t) => t.assertions.passed),
|
|
292
|
+
failed: sum((t) => t.assertions.failed)
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function summary(totals, durationMs, p) {
|
|
297
|
+
const { collections: c, steps: s, assertions: a } = totals;
|
|
298
|
+
const passed = c.failed === 0;
|
|
299
|
+
return [
|
|
300
|
+
field("Collections", `${c.total} total, ${c.passed} passed, ${c.failed} failed`),
|
|
301
|
+
field(
|
|
302
|
+
"Steps",
|
|
303
|
+
`${s.total} total, ${s.passed} passed, ${s.failed} failed, ${s.errored} errored, ${s.skipped} skipped`
|
|
304
|
+
),
|
|
305
|
+
field("Assertions", `${a.total} total, ${a.passed} passed, ${a.failed} failed`),
|
|
306
|
+
field("Run time", seconds(durationMs)),
|
|
307
|
+
field("Result", passed ? p.green(p.bold("PASSED")) : p.red(p.bold("FAILED")))
|
|
308
|
+
];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/main.ts
|
|
312
|
+
import fs2 from "node:fs/promises";
|
|
313
|
+
import path2 from "node:path";
|
|
314
|
+
|
|
315
|
+
// src/pool.ts
|
|
316
|
+
import { Worker } from "node:worker_threads";
|
|
317
|
+
function workerRunner(workerUrl2, timeoutMs) {
|
|
318
|
+
return (job) => new Promise((resolve) => {
|
|
319
|
+
const worker = new Worker(workerUrl2, { workerData: job });
|
|
320
|
+
let settled = false;
|
|
321
|
+
const settle = (outcome) => {
|
|
322
|
+
if (settled) return;
|
|
323
|
+
settled = true;
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
resolve(outcome);
|
|
326
|
+
void worker.terminate();
|
|
327
|
+
};
|
|
328
|
+
const timer = setTimeout(
|
|
329
|
+
() => settle({ ok: false, message: `Timed out after ${timeoutMs} ms (timeoutCollection)` }),
|
|
330
|
+
timeoutMs
|
|
331
|
+
);
|
|
332
|
+
worker.once("message", (outcome) => settle(outcome));
|
|
333
|
+
worker.once("error", (error) => settle({ ok: false, message: error.message }));
|
|
334
|
+
worker.once(
|
|
335
|
+
"exit",
|
|
336
|
+
(code) => settle({ ok: false, message: `The run stopped before it finished (exit code ${code})` })
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async function runPool(items, limit, run2) {
|
|
341
|
+
let next = 0;
|
|
342
|
+
const lane = async () => {
|
|
343
|
+
while (next < items.length) await run2(items[next++]);
|
|
344
|
+
};
|
|
345
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/html.ts
|
|
349
|
+
var BODY_LIMIT = 256 * 1024;
|
|
350
|
+
var SUMMARY_PAGE = "summary.html";
|
|
351
|
+
function htmlReport(run2) {
|
|
352
|
+
return [
|
|
353
|
+
{ path: SUMMARY_PAGE, html: summaryPage(run2) },
|
|
354
|
+
...run2.collections.map((c) => ({ path: pageOf(c.id), html: collectionPage(run2, c) }))
|
|
355
|
+
];
|
|
356
|
+
}
|
|
357
|
+
var pageOf = (id) => `${id}.html`;
|
|
358
|
+
var flagList = (flags) => {
|
|
359
|
+
const entries = Object.entries(flags);
|
|
360
|
+
return entries.length > 0 ? entries.map(([name, value]) => `${name}=${value}`).join(", ") : "none";
|
|
361
|
+
};
|
|
362
|
+
function summaryPage(run2) {
|
|
363
|
+
const tallies = run2.collections.map((c) => c.tally);
|
|
364
|
+
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
365
|
+
const failedCollections = tallies.filter((t) => !t.passed).length;
|
|
366
|
+
const passed = failedCollections === 0;
|
|
367
|
+
const settings = [
|
|
368
|
+
["Project Name", run2.project],
|
|
369
|
+
["Collection Timeout", `${run2.timeoutCollection} ms`],
|
|
370
|
+
["Environment", run2.environment ?? "none"],
|
|
371
|
+
["Concurrency Limit", String(run2.concurrency)],
|
|
372
|
+
["Tags", run2.tags.length > 0 ? run2.tags.join(", ") : "none"],
|
|
373
|
+
...run2.notTags?.length ? [["Not Tags", run2.notTags.join(", ")]] : [],
|
|
374
|
+
["Excluded", run2.excluded.length > 0 ? run2.excluded.join(", ") : "none"],
|
|
375
|
+
["Feature Flags", flagList(run2.flags ?? {})],
|
|
376
|
+
...run2.flagCommand ? [["Flag Command", run2.flagCommand]] : [],
|
|
377
|
+
...run2.bail ? [["Bail", "on"]] : [],
|
|
378
|
+
...run2.untagged > 0 ? [
|
|
379
|
+
["Left Out By Tags", `${run2.untagged} collection${run2.untagged === 1 ? "" : "s"}`]
|
|
380
|
+
] : []
|
|
381
|
+
];
|
|
382
|
+
const overview = run2.collections.map((c, i) => {
|
|
383
|
+
const t = c.tally;
|
|
384
|
+
const status = t.skipped ? "skipped" : t.passed ? "passed" : "failed";
|
|
385
|
+
const cells = [
|
|
386
|
+
seconds2(c.durationMs),
|
|
387
|
+
t.steps.total,
|
|
388
|
+
t.steps.passed,
|
|
389
|
+
t.steps.failed,
|
|
390
|
+
t.steps.errored,
|
|
391
|
+
t.steps.skipped,
|
|
392
|
+
t.assertions.total,
|
|
393
|
+
t.assertions.passed,
|
|
394
|
+
t.assertions.failed
|
|
395
|
+
];
|
|
396
|
+
return `<tr class="${status}" data-search="${escape(c.id.toLowerCase())}"><td>${i + 1}</td><td class="name"><a href="${escape(encodeURI(pageOf(c.id)))}">${escape(c.id)}</a></td>${cells.map((v) => `<td>${v}</td>`).join("")}<td>${statusText(status)}</td></tr>`;
|
|
397
|
+
}).join("\n");
|
|
398
|
+
return page({
|
|
399
|
+
title: `${passed ? "PASSED" : "FAILED"} \xB7 ${run2.project} \xB7 gta Summary Results`,
|
|
400
|
+
run: run2,
|
|
401
|
+
body: `<h2 class="page-title">gta Summary Results</h2>
|
|
402
|
+
|
|
403
|
+
<div class="card"><div class="card-header">Settings</div><div class="card-body"><div class="pairs">
|
|
404
|
+
${settings.map(([label, value]) => field2(label, escape(value))).join("\n")}
|
|
405
|
+
</div></div></div>
|
|
406
|
+
|
|
407
|
+
<div class="card"><div class="card-header">Summary Stats</div><div class="card-body">
|
|
408
|
+
${field2("Time of Run", escape(timeOfRun(run2.startedAt)))}
|
|
409
|
+
${statsTable([
|
|
410
|
+
["Total Collections", tallies.length],
|
|
411
|
+
["Collections Passed", tallies.length - failedCollections],
|
|
412
|
+
["Collections Failed", failedCollections],
|
|
413
|
+
["Total Tests", sum((t) => t.steps.total)],
|
|
414
|
+
["Tests Passed", sum((t) => t.steps.passed)],
|
|
415
|
+
["Tests Failed", sum((t) => t.steps.failed)],
|
|
416
|
+
["Tests Errored", sum((t) => t.steps.errored)],
|
|
417
|
+
["Tests Skipped", sum((t) => t.steps.skipped)],
|
|
418
|
+
["Total Assertions", sum((t) => t.assertions.total)],
|
|
419
|
+
["Assertions Passed", sum((t) => t.assertions.passed)],
|
|
420
|
+
["Assertions Failed", sum((t) => t.assertions.failed)],
|
|
421
|
+
["Total Run Time", seconds2(run2.durationMs)],
|
|
422
|
+
["Final Result", finalResult(passed)]
|
|
423
|
+
])}
|
|
424
|
+
</div></div>
|
|
425
|
+
|
|
426
|
+
<div class="toolbar above">${searchBox("Search collections", "tr[data-search]")}<div class="buttons">${hideButtons(
|
|
427
|
+
tallies.some((t) => t.passed && !t.skipped),
|
|
428
|
+
failedCollections > 0,
|
|
429
|
+
tallies.some((t) => t.skipped)
|
|
430
|
+
)}</div></div>
|
|
431
|
+
<div class="card"><div class="card-header">Results Overview</div><div class="card-body">
|
|
432
|
+
<table class="overview">
|
|
433
|
+
<tr><th>Collection #</th><th>Collection ID</th><th>Collection Run Time</th><th>Total Tests</th><th>Tests Passed</th><th>Tests Failed</th><th>Tests Errored</th><th>Tests Skipped</th><th>Total Assertions</th><th>Assertions Passed</th><th>Assertions Failed</th><th>Collection Result</th></tr>
|
|
434
|
+
${overview}
|
|
435
|
+
</table>
|
|
436
|
+
</div></div>`
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function collectionPage(run2, c) {
|
|
440
|
+
const t = c.tally;
|
|
441
|
+
const tests = !c.outcome.ok ? "" : [
|
|
442
|
+
...c.outcome.summary.results.map(
|
|
443
|
+
(r, i) => testSection(r, i, c.outcome.ok ? c.outcome.steps[i] : void 0)
|
|
444
|
+
),
|
|
445
|
+
unattempted(c.outcome.summary) > 0 ? notRunSection(unattempted(c.outcome.summary)) : ""
|
|
446
|
+
].join("\n");
|
|
447
|
+
const statuses = c.outcome.ok ? c.outcome.summary.results.map(statusOf) : [];
|
|
448
|
+
return page({
|
|
449
|
+
title: `${t.skipped ? "SKIPPED" : t.passed ? "PASSED" : "FAILED"} \xB7 ${c.id} \xB7 gta Collection Results`,
|
|
450
|
+
run: run2,
|
|
451
|
+
body: `<h2 class="page-title">gta Collection Results</h2>
|
|
452
|
+
|
|
453
|
+
<div class="card"><div class="card-header">Results Overview</div><div class="card-body">
|
|
454
|
+
${field2("Collection ID", escape(c.id), "wide")}
|
|
455
|
+
${c.docs?.trim() ? field2("Collection Docs", markdown(c.docs), "wide") : ""}
|
|
456
|
+
${c.outcome.ok && c.outcome.data ? field2("Data File", escape(c.outcome.data.file), "wide") : ""}
|
|
457
|
+
${c.outcome.ok && c.outcome.data ? field2("Iterations", String(c.outcome.data.rows)) : ""}
|
|
458
|
+
${!c.outcome.ok ? field2("Error", `<pre class="c-failed">${escape(c.outcome.message)}</pre>`, "wide") : ""}
|
|
459
|
+
${field2("Time of Run", escape(timeOfRun(c.startedAt)))}
|
|
460
|
+
${statsTable([
|
|
461
|
+
["Collection Run Time", seconds2(c.durationMs)],
|
|
462
|
+
["Total Tests", t.steps.total],
|
|
463
|
+
["Tests Passed", t.steps.passed],
|
|
464
|
+
["Tests Failed", t.steps.failed],
|
|
465
|
+
["Tests Errored", t.steps.errored],
|
|
466
|
+
["Tests Skipped", t.steps.skipped],
|
|
467
|
+
["Total Assertions", t.assertions.total],
|
|
468
|
+
["Assertions Passed", t.assertions.passed],
|
|
469
|
+
["Assertions Failed", t.assertions.failed],
|
|
470
|
+
["Overall Result", t.skipped ? '<span class="c-skipped">SKIPPED</span>' : finalResult(t.passed)]
|
|
471
|
+
])}
|
|
472
|
+
</div></div>
|
|
473
|
+
|
|
474
|
+
<div class="toolbar"><h3>Tests</h3>${statuses.length > 0 ? searchBox("Search steps", "details[data-search]") : ""}<div class="buttons">
|
|
475
|
+
${hideButtons(
|
|
476
|
+
statuses.includes("passed"),
|
|
477
|
+
statuses.some((s) => s === "failed" || s === "errored"),
|
|
478
|
+
statuses.includes("skipped")
|
|
479
|
+
)}
|
|
480
|
+
${statuses.length === 0 ? "" : (
|
|
481
|
+
// Says what a click does: expand while any test is closed, as passed ones start.
|
|
482
|
+
`<button type="button" class="btn" id="expand-all" onclick="expandAll()">${statuses.some((s) => s === "passed" || s === "skipped") ? "Expand All" : "Collapse All"}</button>`
|
|
483
|
+
)}
|
|
484
|
+
<a class="btn" href="${SUMMARY_PAGE}">Summary</a>
|
|
485
|
+
</div></div>
|
|
486
|
+
<div class="tests">
|
|
487
|
+
${tests || '<p class="dim">No tests were run.</p>'}
|
|
488
|
+
</div>`
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
var statusOf = (r) => r.status === "pass" ? "passed" : r.status === "fail" ? "failed" : r.status === "error" ? "errored" : "skipped";
|
|
492
|
+
function testSection(r, index, ref) {
|
|
493
|
+
const status = statusOf(r);
|
|
494
|
+
const named = escape(iterationName(r.item.name, ref));
|
|
495
|
+
const name = r.use ? `${named} <span class="dim">(${escape(r.use.set)} ${r.use.child + 1}/${r.use.of})</span>` : named;
|
|
496
|
+
if (r.skipped) {
|
|
497
|
+
return `<details class="acc skipped skipped" data-search="${escape(iterationName(r.item.name, ref).toLowerCase())}">
|
|
498
|
+
<summary><span>Test ${index + 1}: ${name}</span>${icon("skipped")}</summary>
|
|
499
|
+
<div class="acc-body">
|
|
500
|
+
${card("Skipped", field2("Feature Flag", `<span class="c-skipped">${escape(r.skipped.reason)} \u2014 this test was not run</span>`, "wide"))}
|
|
501
|
+
</div>
|
|
502
|
+
</details>`;
|
|
503
|
+
}
|
|
504
|
+
const cards = [
|
|
505
|
+
r.error ? errorCard(r) : "",
|
|
506
|
+
requestCard(r),
|
|
507
|
+
responseCard(r),
|
|
508
|
+
assertionsCard(r.assertions),
|
|
509
|
+
r.logs?.length ? consoleCard(r) : ""
|
|
510
|
+
].filter(Boolean);
|
|
511
|
+
const group = status === "passed" ? "passed" : status === "skipped" ? "skipped" : "failed";
|
|
512
|
+
const search = iterationName(r.item.name, ref).toLowerCase();
|
|
513
|
+
return `<details class="acc ${group} ${status}" data-search="${escape(search)}"${status === "passed" ? "" : " open"}>
|
|
514
|
+
<summary><span>Test ${index + 1}: ${name}</span>${icon(status)}</summary>
|
|
515
|
+
<div class="acc-body">
|
|
516
|
+
${cards.join("\n")}
|
|
517
|
+
</div>
|
|
518
|
+
</details>`;
|
|
519
|
+
}
|
|
520
|
+
function notRunSection(count) {
|
|
521
|
+
return `<details class="acc skipped">
|
|
522
|
+
<summary><span>${count} test${count === 1 ? "" : "s"} not run</span>${icon("skipped")}</summary>
|
|
523
|
+
<div class="acc-body"><p class="c-skipped">The collection stopped at its first failure (bail), so ${count === 1 ? "this test was" : "these tests were"} not run.</p></div>
|
|
524
|
+
</details>`;
|
|
525
|
+
}
|
|
526
|
+
function errorCard(r) {
|
|
527
|
+
const e = r.error;
|
|
528
|
+
const where = [e.script && `${e.script} script`, e.line && `line ${e.line}`].filter(Boolean).join(", ");
|
|
529
|
+
const detail = e.stack ? `
|
|
530
|
+
|
|
531
|
+
${userFrames(e.stack)}` : "";
|
|
532
|
+
return card(
|
|
533
|
+
"Error",
|
|
534
|
+
field2(
|
|
535
|
+
`${capitalise(e.phase)} Error`,
|
|
536
|
+
`<pre class="c-failed">${escape(`${where ? `(${where}) ` : ""}${e.message}${detail}`)}</pre>`,
|
|
537
|
+
"wide"
|
|
538
|
+
)
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
function requestCard(r) {
|
|
542
|
+
const { request } = r;
|
|
543
|
+
return card(
|
|
544
|
+
"Request",
|
|
545
|
+
[
|
|
546
|
+
field2("Request Method", escape(request.method)),
|
|
547
|
+
field2("Request URL", escape(request.url || "\u2014"), "wide"),
|
|
548
|
+
request.headers.length > 0 ? field2("Request Headers", scrollPre(headerText(request.headers)), "wide") : "",
|
|
549
|
+
request.body ? field2("Request Body", scrollPre(clip2(request.body)), "wide") : ""
|
|
550
|
+
].filter(Boolean).join("\n")
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
function responseCard(r) {
|
|
554
|
+
const res = r.response;
|
|
555
|
+
if (!res) {
|
|
556
|
+
return card(
|
|
557
|
+
"Response",
|
|
558
|
+
field2("Failure", '<span class="c-failed">No response was received.</span>', "wide")
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
const body = res.bodyKind === "json" ? prettyJson(res.body) : res.body;
|
|
562
|
+
const extra = [
|
|
563
|
+
`first byte ${Math.round(res.timings.ttfbMs)}ms`,
|
|
564
|
+
bytes(res.sizeBytes),
|
|
565
|
+
res.redirectCount > 0 ? `${res.redirectCount} redirect${res.redirectCount === 1 ? "" : "s"}` : ""
|
|
566
|
+
].filter(Boolean);
|
|
567
|
+
return card(
|
|
568
|
+
"Response",
|
|
569
|
+
[
|
|
570
|
+
field2("Response Code", escape(`${res.status} ${res.statusText}`.trim())),
|
|
571
|
+
res.headers.length > 0 ? field2("Response Headers", scrollPre(headerText(res.headers)), "wide") : "",
|
|
572
|
+
body ? field2("Response Body", scrollPre(clip2(body)), "wide") : "",
|
|
573
|
+
field2(
|
|
574
|
+
"Response Time",
|
|
575
|
+
`${Math.round(res.timings.totalMs)}ms <span class="dim">\xB7 ${extra.join(" \xB7 ")}</span>`
|
|
576
|
+
)
|
|
577
|
+
].filter(Boolean).join("\n")
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
function assertionsCard(assertions) {
|
|
581
|
+
const failed = assertions.filter((a) => a.status === "fail").length;
|
|
582
|
+
const counts = statsTable(
|
|
583
|
+
[
|
|
584
|
+
["Total Assertions", assertions.length],
|
|
585
|
+
["Assertions Passed", assertions.length - failed],
|
|
586
|
+
["Assertions Failed", failed]
|
|
587
|
+
],
|
|
588
|
+
"small"
|
|
589
|
+
);
|
|
590
|
+
if (assertions.length === 0) return card("Assertions", counts);
|
|
591
|
+
const list2 = assertions.map((a) => {
|
|
592
|
+
const failing = a.status === "fail";
|
|
593
|
+
const extra = [
|
|
594
|
+
a.message ? `<div class="c-failed">${escape(a.message)}</div>` : "",
|
|
595
|
+
failing && a.expected !== void 0 ? `<div class="ea"><span class="dim">expected</span> <code>${escape(a.expected)}</code></div>` : "",
|
|
596
|
+
failing && a.actual !== void 0 ? `<div class="ea"><span class="dim">actual</span> <code>${escape(a.actual)}</code></div>` : "",
|
|
597
|
+
a.unasserted?.length ? `<div class="ea"><span class="dim">not asserted</span> <code>${escape(a.unasserted.join(", "))}</code></div>` : ""
|
|
598
|
+
].join("");
|
|
599
|
+
const result = failing ? '<span class="c-failed">FAILED</span>' : '<span class="c-passed">PASSED</span>';
|
|
600
|
+
return `<div class="detail-row"><div>${escape(a.name)}${extra}</div>${result}</div>`;
|
|
601
|
+
}).join("\n");
|
|
602
|
+
return card(
|
|
603
|
+
"Assertions",
|
|
604
|
+
`${counts}
|
|
605
|
+
<div class="card inner"><div class="card-header"><div class="detail-row head"><span>Detail</span><span>Result</span></div></div><div class="card-body">
|
|
606
|
+
${list2}
|
|
607
|
+
</div></div>`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
function consoleCard(r) {
|
|
611
|
+
const lines = r.logs.map(
|
|
612
|
+
(l) => `<span class="log-${l.level}">[${escape(l.phase)} ${l.level}] ${escape(l.message)}</span>`
|
|
613
|
+
);
|
|
614
|
+
return card("Console", `<pre class="console">${lines.join("\n")}</pre>`);
|
|
615
|
+
}
|
|
616
|
+
function page(options) {
|
|
617
|
+
return `<!doctype html>
|
|
618
|
+
<html lang="en">
|
|
619
|
+
<head>
|
|
620
|
+
<meta charset="utf-8">
|
|
621
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
622
|
+
<title>${escape(options.title)}</title>
|
|
623
|
+
<style>${STYLE}</style>
|
|
624
|
+
<script>${SCRIPT}</script>
|
|
625
|
+
</head>
|
|
626
|
+
<body>
|
|
627
|
+
<div class="container">
|
|
628
|
+
${options.body}
|
|
629
|
+
</div>
|
|
630
|
+
<footer class="container">
|
|
631
|
+
<span class="btn static">gta Version ${escape(options.run.version)}</span>
|
|
632
|
+
<div class="dim small-print">Run in ${escape(options.run.root)}</div>
|
|
633
|
+
</footer>
|
|
634
|
+
</body>
|
|
635
|
+
</html>
|
|
636
|
+
`;
|
|
637
|
+
}
|
|
638
|
+
var card = (title, body) => `<div class="card"><div class="card-header">${title}</div><div class="card-body">
|
|
639
|
+
${body}
|
|
640
|
+
</div></div>`;
|
|
641
|
+
var field2 = (label, valueHtml, width = "fit") => `<div class="field"><div class="label">${escape(label)}</div><div class="value ${width}">${valueHtml}</div></div>`;
|
|
642
|
+
function statsTable(cells, size = "") {
|
|
643
|
+
return `<table class="stats${size ? ` ${size}` : ""}"><tr>${cells.map(([label]) => `<td>${escape(label)}</td>`).join("")}</tr><tr>${cells.map(([, value]) => `<td>${value}</td>`).join("")}</tr></table>`;
|
|
644
|
+
}
|
|
645
|
+
var finalResult = (passed) => passed ? '<span class="c-passed">PASSED</span>' : '<span class="c-failed">FAILED</span>';
|
|
646
|
+
var statusText = (status) => `<span class="c-${status}">${status}</span>`;
|
|
647
|
+
var ICONS = { passed: "\u2713", failed: "!", errored: "!", skipped: "\u2013" };
|
|
648
|
+
var icon = (status) => `<span class="icon ${status}" role="img" aria-label="${status}">${ICONS[status]}</span>`;
|
|
649
|
+
var searchBox = (label, target) => `<input type="search" class="search" placeholder="${label}" aria-label="${label}" data-target="${escape(target)}" oninput="search(this)">`;
|
|
650
|
+
var hideButtons = (anyPassed, anyFailed, anySkipped = false) => [
|
|
651
|
+
anyPassed ? `<button type="button" class="btn" onclick="toggle(this,'passed','Passed')">Hide Passed</button>` : "",
|
|
652
|
+
anyFailed ? `<button type="button" class="btn" onclick="toggle(this,'failed','Failed')">Hide Failed</button>` : "",
|
|
653
|
+
anySkipped ? `<button type="button" class="btn" onclick="toggle(this,'skipped','Skipped')">Hide Skipped</button>` : ""
|
|
654
|
+
].join("");
|
|
655
|
+
var headerText = (headers) => headers.map((h) => `${h.name}: ${h.value}`).join("\n");
|
|
656
|
+
var scrollPre = (text) => `<div class="scroll"><pre>${escape(text)}</pre></div>`;
|
|
657
|
+
function clip2(text) {
|
|
658
|
+
return text.length > BODY_LIMIT ? `${text.slice(0, BODY_LIMIT)}
|
|
659
|
+
\u2026 ${bytes(text.length - BODY_LIMIT)} more not shown` : text;
|
|
660
|
+
}
|
|
661
|
+
function prettyJson(text) {
|
|
662
|
+
try {
|
|
663
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
664
|
+
} catch {
|
|
665
|
+
return text;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
var userFrames = (stack) => stack.split("\n").filter((line) => !/^\s+at .*(\bnode:|file:\/\/)/.test(line)).join("\n");
|
|
669
|
+
var capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
670
|
+
var seconds2 = (ms) => `${(ms / 1e3).toFixed(3)}s`;
|
|
671
|
+
var timeOfRun = (epochMs) => new Date(epochMs).toLocaleString("en-US", {
|
|
672
|
+
weekday: "short",
|
|
673
|
+
month: "long",
|
|
674
|
+
day: "2-digit",
|
|
675
|
+
year: "numeric",
|
|
676
|
+
hour: "numeric",
|
|
677
|
+
minute: "2-digit",
|
|
678
|
+
second: "2-digit",
|
|
679
|
+
timeZoneName: "long"
|
|
680
|
+
});
|
|
681
|
+
function bytes(n) {
|
|
682
|
+
if (n < 1024) return `${n} B`;
|
|
683
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
684
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
685
|
+
}
|
|
686
|
+
function escape(value) {
|
|
687
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
688
|
+
}
|
|
689
|
+
function markdown(source) {
|
|
690
|
+
return `<div class="md">${parseMarkdown(source).map(mdBlock).join("")}</div>`;
|
|
691
|
+
}
|
|
692
|
+
function mdBlock(block) {
|
|
693
|
+
switch (block.type) {
|
|
694
|
+
case "code":
|
|
695
|
+
return `<pre class="md-code">${escape(block.text)}</pre>`;
|
|
696
|
+
case "heading": {
|
|
697
|
+
const level = Math.min(block.level + 3, 6);
|
|
698
|
+
return `<h${level} class="md-h">${mdInline(block.content)}</h${level}>`;
|
|
699
|
+
}
|
|
700
|
+
case "rule":
|
|
701
|
+
return '<hr class="md-hr">';
|
|
702
|
+
case "table":
|
|
703
|
+
return `<table class="md-table"><thead><tr>${block.header.map((cell) => `<th>${mdInline(cell)}</th>`).join("")}</tr></thead><tbody>${block.rows.map((row) => `<tr>${row.map((cell) => `<td>${mdInline(cell)}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
|
|
704
|
+
case "quote":
|
|
705
|
+
return `<blockquote class="md-quote">${block.blocks.map(mdBlock).join("")}</blockquote>`;
|
|
706
|
+
case "list": {
|
|
707
|
+
const tag = block.ordered ? "ol" : "ul";
|
|
708
|
+
return `<${tag} class="md-list">${block.items.map(
|
|
709
|
+
(item) => `<li>${mdInline(item.content)}${item.children ? mdBlock(item.children) : ""}</li>`
|
|
710
|
+
).join("")}</${tag}>`;
|
|
711
|
+
}
|
|
712
|
+
case "paragraph":
|
|
713
|
+
return `<p class="md-p">${mdInline(block.content)}</p>`;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function mdInline(nodes) {
|
|
717
|
+
return nodes.map((node) => {
|
|
718
|
+
switch (node.type) {
|
|
719
|
+
case "text":
|
|
720
|
+
return escape(node.text);
|
|
721
|
+
case "code":
|
|
722
|
+
return `<code class="md-inline-code">${escape(node.text)}</code>`;
|
|
723
|
+
case "strong":
|
|
724
|
+
return `<strong>${mdInline(node.children)}</strong>`;
|
|
725
|
+
case "em":
|
|
726
|
+
return `<em>${mdInline(node.children)}</em>`;
|
|
727
|
+
case "link":
|
|
728
|
+
return `<a href="${escape(node.href)}" target="_blank" rel="noreferrer noopener">${escape(node.label)}</a>`;
|
|
729
|
+
}
|
|
730
|
+
}).join("");
|
|
731
|
+
}
|
|
732
|
+
var STYLE = `
|
|
733
|
+
:root{color-scheme:light dark;--bg:#f8f9fa;--card:#fff;--head:#eef0f3;--border:#dee2e6;--text:#212529;--dim:#6c757d;--passed:#198754;--failed:#d0104c;--errored:#b86e00;--skipped:#0f86b6;--open:#e3e8f0;--link:#0d6efd;--btn:#6c757d;--icon-text:#fff;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
734
|
+
@media (prefers-color-scheme:dark){:root{--bg:#212529;--card:#212529;--head:#2b3035;--border:#495057;--text:#dee2e6;--dim:#adb5bd;--passed:#87EA67;--failed:#F50F55;--errored:#F5A623;--skipped:#4FC3E8;--open:#393D47;--link:#6ea8fe;--btn:#adb5bd;--icon-text:#212529}}
|
|
735
|
+
*{box-sizing:border-box}
|
|
736
|
+
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;padding:48px 0 32px}
|
|
737
|
+
.container{max-width:1320px;margin:0 auto;padding:0 12px}
|
|
738
|
+
.page-title{text-align:center;font-weight:500;font-size:2rem;margin:0 0 32px}
|
|
739
|
+
.card{border:1px solid var(--border);border-radius:6px;margin-bottom:24px;background:var(--card)}
|
|
740
|
+
.card-header{padding:8px 16px;background:var(--head);border-bottom:1px solid var(--border);border-radius:6px 6px 0 0}
|
|
741
|
+
.card-body{padding:16px}
|
|
742
|
+
.card.inner{margin:16px 0 0}
|
|
743
|
+
.acc-body .card{margin-bottom:16px}
|
|
744
|
+
.pairs{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}
|
|
745
|
+
@media (max-width:800px){.pairs{grid-template-columns:1fr}}
|
|
746
|
+
.field{display:flex;gap:12px;margin-bottom:16px;align-items:flex-start;min-width:0}
|
|
747
|
+
.label{flex:0 0 190px}
|
|
748
|
+
.value{border:1px solid var(--border);border-radius:6px;padding:0 8px;min-width:0;overflow-wrap:anywhere}
|
|
749
|
+
.value.wide{flex:1}
|
|
750
|
+
.value pre{margin:0;padding:4px 0}
|
|
751
|
+
.md{padding:6px 0}
|
|
752
|
+
.md>*:first-child{margin-top:0}.md>*:last-child{margin-bottom:0}
|
|
753
|
+
.md-p{margin:0 0 8px}
|
|
754
|
+
.md-h{margin:12px 0 6px;font-size:1rem;font-weight:600}
|
|
755
|
+
.md-list{margin:0 0 8px;padding-left:24px}
|
|
756
|
+
.md-list .md-list{margin:2px 0 0}
|
|
757
|
+
.md-code{margin:0 0 8px;padding:8px 10px;background:var(--head);border-radius:4px}
|
|
758
|
+
.md-inline-code{background:var(--head);padding:1px 4px;border-radius:3px;font-size:13px}
|
|
759
|
+
.md-quote{margin:0 0 8px;padding-left:12px;border-left:3px solid var(--border);color:var(--dim)}
|
|
760
|
+
.md-hr{border:none;border-top:1px solid var(--border);margin:10px 0}
|
|
761
|
+
.md-table{border-collapse:collapse;margin:0 0 8px}
|
|
762
|
+
.md-table th,.md-table td{border:1px solid var(--border);padding:4px 8px;text-align:left}
|
|
763
|
+
.md-table th{background:var(--head)}
|
|
764
|
+
.scroll{max-height:200px;overflow:auto}
|
|
765
|
+
pre{margin:0;font-family:var(--mono);font-size:14px;white-space:pre-wrap;word-break:break-word}
|
|
766
|
+
code{font-family:var(--mono);font-size:14px}
|
|
767
|
+
.stats{width:100%;border-collapse:collapse;text-align:center;border:1px solid var(--border);font-variant-numeric:tabular-nums}
|
|
768
|
+
.stats td{padding:8px}
|
|
769
|
+
.stats tr:first-child td{background:var(--head)}
|
|
770
|
+
.stats tr+tr td{border-top:1px solid var(--border)}
|
|
771
|
+
.stats.small{width:50%;margin:0 auto}
|
|
772
|
+
@media (max-width:800px){.stats.small{width:100%}}
|
|
773
|
+
.overview{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
|
|
774
|
+
.overview th,.overview td{border:1px solid var(--border);padding:8px;text-align:left;vertical-align:top}
|
|
775
|
+
.overview th{background:var(--head);font-weight:600}
|
|
776
|
+
.overview td.name{word-break:break-word}
|
|
777
|
+
a{color:var(--link)}
|
|
778
|
+
.dim{color:var(--dim)}
|
|
779
|
+
.c-passed{color:var(--passed)}.c-failed{color:var(--failed)}.c-errored{color:var(--errored)}.c-skipped{color:var(--skipped)}
|
|
780
|
+
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin:40px 0 8px}
|
|
781
|
+
.toolbar h3{margin:0;font-weight:500;font-size:1.75rem}
|
|
782
|
+
.buttons{display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap}
|
|
783
|
+
.toolbar.above{margin:40px 0 8px}
|
|
784
|
+
.search{flex:1;min-width:200px;max-width:480px;font:inherit;padding:6px 12px;border:1px solid var(--btn);border-radius:6px;background:var(--card);color:var(--text)}
|
|
785
|
+
.toolbar h3+.search{margin-left:16px}
|
|
786
|
+
.toolbar .buttons{margin-left:auto}
|
|
787
|
+
.unmatched{display:none}
|
|
788
|
+
.btn{font:inherit;color:var(--btn);background:none;border:1px solid var(--btn);border-radius:6px;padding:6px 12px;cursor:pointer;text-decoration:none;display:inline-block}
|
|
789
|
+
.btn:hover{background:var(--btn);color:var(--bg)}
|
|
790
|
+
.btn.static{cursor:default}
|
|
791
|
+
.btn.static:hover{background:none;color:var(--btn)}
|
|
792
|
+
.hidden{display:none}
|
|
793
|
+
.tests details.acc{border:1px solid var(--border);border-top:none;background:var(--card)}
|
|
794
|
+
.tests details.acc:first-child{border-top:1px solid var(--border);border-radius:6px 6px 0 0}
|
|
795
|
+
.tests details.acc:last-child{border-radius:0 0 6px 6px}
|
|
796
|
+
.tests details.acc:only-child{border-radius:6px}
|
|
797
|
+
details.acc>summary{list-style:none;cursor:pointer;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
798
|
+
details.acc>summary::-webkit-details-marker{display:none}
|
|
799
|
+
details.acc[open]>summary{background:var(--open)}
|
|
800
|
+
.icon{flex:none;width:18px;height:18px;border-radius:3px;display:inline-grid;place-items:center;font-size:12px;font-weight:800;color:var(--icon-text)}
|
|
801
|
+
.icon.passed{background:var(--passed)}.icon.failed{background:var(--failed)}.icon.errored{background:var(--errored)}.icon.skipped{background:var(--skipped)}
|
|
802
|
+
.acc-body{padding:16px 20px}
|
|
803
|
+
.detail-row{display:flex;justify-content:space-between;gap:16px;padding:6px 0}
|
|
804
|
+
.detail-row+.detail-row{border-top:1px solid var(--border)}
|
|
805
|
+
.detail-row.head{padding:0}
|
|
806
|
+
.ea{font-size:14px;margin-top:2px}
|
|
807
|
+
.console .log-warn{color:var(--errored)}.console .log-error{color:var(--failed)}
|
|
808
|
+
footer.container{text-align:center;margin:48px auto 0}
|
|
809
|
+
.small-print{margin-top:8px;font-size:12px;overflow-wrap:anywhere}
|
|
810
|
+
`;
|
|
811
|
+
var SCRIPT = `
|
|
812
|
+
function toggle(button, status, noun) {
|
|
813
|
+
const hide = button.dataset.hidden !== 'true'
|
|
814
|
+
button.dataset.hidden = String(hide)
|
|
815
|
+
button.textContent = (hide ? 'Show ' : 'Hide ') + noun
|
|
816
|
+
for (const el of document.querySelectorAll('tr.' + status + ', details.acc.' + status)) el.classList.toggle('hidden', hide)
|
|
817
|
+
}
|
|
818
|
+
// The button says what a click will do: Expand All while any test is closed,
|
|
819
|
+
// Collapse All once every one is open \u2014 however they got that way.
|
|
820
|
+
const tests = () => [...document.querySelectorAll('details.acc')]
|
|
821
|
+
function syncExpandAll() {
|
|
822
|
+
const button = document.getElementById('expand-all')
|
|
823
|
+
if (button) button.textContent = tests().some((d) => !d.open) ? 'Expand All' : 'Collapse All'
|
|
824
|
+
}
|
|
825
|
+
function search(input) {
|
|
826
|
+
const query = input.value.trim().toLowerCase()
|
|
827
|
+
for (const el of document.querySelectorAll(input.dataset.target)) {
|
|
828
|
+
el.classList.toggle('unmatched', query !== '' && !el.dataset.search.includes(query))
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function expandAll() {
|
|
832
|
+
const open = tests().some((d) => !d.open)
|
|
833
|
+
for (const d of tests()) d.open = open
|
|
834
|
+
syncExpandAll()
|
|
835
|
+
}
|
|
836
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
837
|
+
for (const d of tests()) d.addEventListener('toggle', syncExpandAll)
|
|
838
|
+
syncExpandAll()
|
|
839
|
+
})
|
|
840
|
+
`;
|
|
841
|
+
|
|
842
|
+
// src/junit.ts
|
|
843
|
+
import os from "node:os";
|
|
844
|
+
function junitReport(run2) {
|
|
845
|
+
const suites = run2.collections.map(
|
|
846
|
+
(collection) => suite(collection, run2.environment, run2.flags ?? {})
|
|
847
|
+
);
|
|
848
|
+
const total = suites.reduce(
|
|
849
|
+
(sum, s) => ({
|
|
850
|
+
tests: sum.tests + s.counts.tests,
|
|
851
|
+
failures: sum.failures + s.counts.failures,
|
|
852
|
+
errors: sum.errors + s.counts.errors,
|
|
853
|
+
skipped: sum.skipped + s.counts.skipped
|
|
854
|
+
}),
|
|
855
|
+
{ tests: 0, failures: 0, errors: 0, skipped: 0 }
|
|
856
|
+
);
|
|
857
|
+
return [
|
|
858
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
859
|
+
`<testsuites${attributes({
|
|
860
|
+
name: `gta: ${run2.project}`,
|
|
861
|
+
...total,
|
|
862
|
+
time: seconds3(run2.durationMs),
|
|
863
|
+
timestamp: timestamp(run2.startedAt)
|
|
864
|
+
})}>`,
|
|
865
|
+
...suites.flatMap((s) => s.lines),
|
|
866
|
+
"</testsuites>",
|
|
867
|
+
""
|
|
868
|
+
].join("\n");
|
|
869
|
+
}
|
|
870
|
+
function suite(collection, environment, flags) {
|
|
871
|
+
const cases = [];
|
|
872
|
+
const counts = { tests: 0, failures: 0, errors: 0, skipped: 0 };
|
|
873
|
+
const { outcome, id } = collection;
|
|
874
|
+
if (!outcome.ok) {
|
|
875
|
+
counts.tests = 1;
|
|
876
|
+
counts.errors = 1;
|
|
877
|
+
cases.push(
|
|
878
|
+
testcase(id, "(collection)", 0, [
|
|
879
|
+
`<error${attributes({ message: outcome.message, type: "collection" })}>${escape2(outcome.message)}</error>`
|
|
880
|
+
])
|
|
881
|
+
);
|
|
882
|
+
} else {
|
|
883
|
+
for (const [i, result] of outcome.summary.results.entries()) {
|
|
884
|
+
counts.tests++;
|
|
885
|
+
const body = [];
|
|
886
|
+
if (result.status === "error") {
|
|
887
|
+
counts.errors++;
|
|
888
|
+
const { phase, message, stack } = result.error ?? {
|
|
889
|
+
phase: "http",
|
|
890
|
+
message: "no response",
|
|
891
|
+
stack: void 0
|
|
892
|
+
};
|
|
893
|
+
body.push(
|
|
894
|
+
`<error${attributes({ message: `${phase} error: ${message}`, type: phase })}>${escape2(userFrames2(stack) ?? message)}</error>`
|
|
895
|
+
);
|
|
896
|
+
} else if (result.status === "fail") {
|
|
897
|
+
counts.failures++;
|
|
898
|
+
const failed = result.assertions.filter((a) => a.status === "fail");
|
|
899
|
+
body.push(
|
|
900
|
+
`<failure${attributes({
|
|
901
|
+
message: failed.map(summarize).join("; "),
|
|
902
|
+
type: "assertion"
|
|
903
|
+
})}>${escape2(failed.map(describe).join("\n\n"))}</failure>`
|
|
904
|
+
);
|
|
905
|
+
} else if (result.status === "skipped") {
|
|
906
|
+
counts.skipped++;
|
|
907
|
+
body.push(
|
|
908
|
+
result.skipped ? `<skipped${attributes({ message: result.skipped.reason })}/>` : "<skipped/>"
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (result.logs?.length) {
|
|
912
|
+
const logged = result.logs.map((log) => `[${log.phase} ${log.level}] ${log.message}`);
|
|
913
|
+
body.push(`<system-out>${escape2(logged.join("\n"))}</system-out>`);
|
|
914
|
+
}
|
|
915
|
+
cases.push(
|
|
916
|
+
testcase(id, iterationName(caseName(result), outcome.steps[i]), result.durationMs, body)
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const unrun = unattempted(outcome.summary);
|
|
920
|
+
if (unrun > 0) {
|
|
921
|
+
counts.tests++;
|
|
922
|
+
counts.skipped++;
|
|
923
|
+
const message = `${unrun} step${unrun === 1 ? "" : "s"} not run: the collection stopped at its first failure (bail)`;
|
|
924
|
+
cases.push(testcase(id, "(not run)", 0, [`<skipped${attributes({ message })}/>`]));
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
counts,
|
|
929
|
+
lines: [
|
|
930
|
+
` <testsuite${attributes({
|
|
931
|
+
name: id,
|
|
932
|
+
...counts,
|
|
933
|
+
time: seconds3(collection.durationMs),
|
|
934
|
+
timestamp: timestamp(collection.startedAt),
|
|
935
|
+
hostname: os.hostname()
|
|
936
|
+
})}>`,
|
|
937
|
+
" <properties>",
|
|
938
|
+
` <property${attributes({ name: "environment", value: environment ?? "" })}/>`,
|
|
939
|
+
...Object.entries(flags).map(
|
|
940
|
+
([name, value]) => ` <property${attributes({ name: `flag.${name}`, value: String(value) })}/>`
|
|
941
|
+
),
|
|
942
|
+
" </properties>",
|
|
943
|
+
...cases.flat(),
|
|
944
|
+
" </testsuite>"
|
|
945
|
+
]
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
function testcase(classname, name, durationMs, body) {
|
|
949
|
+
const open = ` <testcase${attributes({ name, classname, time: seconds3(durationMs) })}`;
|
|
950
|
+
if (body.length === 0) return [`${open}/>`];
|
|
951
|
+
return [`${open}>`, ...body.map((line) => ` ${line}`), " </testcase>"];
|
|
952
|
+
}
|
|
953
|
+
var caseName = (result) => result.use ? `${result.item.name} [${result.use.set} ${result.use.child + 1}/${result.use.of}]` : result.item.name;
|
|
954
|
+
var summarize = (a) => a.message ? `${a.name}: ${a.message}` : a.name;
|
|
955
|
+
function describe(a) {
|
|
956
|
+
const lines = [summarize(a)];
|
|
957
|
+
if (a.expected !== void 0) lines.push(` expected: ${a.expected}`);
|
|
958
|
+
if (a.actual !== void 0) lines.push(` actual: ${a.actual}`);
|
|
959
|
+
if (a.unasserted?.length) lines.push(` not asserted: ${a.unasserted.join(", ")}`);
|
|
960
|
+
return lines.join("\n");
|
|
961
|
+
}
|
|
962
|
+
var userFrames2 = (stack) => stack?.split("\n").filter((line) => !/^\s+at .*(\bnode:|file:\/\/)/.test(line)).join("\n");
|
|
963
|
+
var seconds3 = (ms) => (ms / 1e3).toFixed(3);
|
|
964
|
+
var timestamp = (epochMs) => new Date(epochMs).toISOString().slice(0, 19);
|
|
965
|
+
function attributes(values) {
|
|
966
|
+
return Object.entries(values).map(([key, value]) => ` ${key}="${escapeAttribute(String(value))}"`).join("");
|
|
967
|
+
}
|
|
968
|
+
var escapeAttribute = (value) => escape2(value).replace(/"/g, """).replace(/\n/g, " ").replace(/\r/g, " ").replace(/\t/g, "	");
|
|
969
|
+
function escape2(value) {
|
|
970
|
+
let kept = "";
|
|
971
|
+
for (const char of value) if (allowedInXml(char.codePointAt(0))) kept += char;
|
|
972
|
+
return kept.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
973
|
+
}
|
|
974
|
+
var allowedInXml = (code) => code === 9 || code === 10 || code === 13 || code >= 32 && code <= 55295 || code >= 57344 && code <= 65533 || code >= 65536;
|
|
975
|
+
|
|
976
|
+
// src/json.ts
|
|
977
|
+
var JSON_FORMAT_VERSION = 1;
|
|
978
|
+
var BODY_LIMIT2 = 256 * 1024;
|
|
979
|
+
function jsonReport(run2, reports) {
|
|
980
|
+
const tallies = run2.collections.map((c) => c.tally);
|
|
981
|
+
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
982
|
+
const failed = tallies.filter((t) => !t.passed).length;
|
|
983
|
+
return {
|
|
984
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
985
|
+
tool: { name: "gta", version: run2.version },
|
|
986
|
+
project: { name: run2.project, root: run2.root },
|
|
987
|
+
run: {
|
|
988
|
+
result: failed === 0 ? "passed" : "failed",
|
|
989
|
+
startedAt: new Date(run2.startedAt).toISOString(),
|
|
990
|
+
durationMs: Math.round(run2.durationMs),
|
|
991
|
+
environment: run2.environment,
|
|
992
|
+
/** The feature flag values the run used, where each came from, and the command run (SPEC.md §2.9). */
|
|
993
|
+
flags: {
|
|
994
|
+
values: run2.flags ?? {},
|
|
995
|
+
sources: run2.flagSources ?? {},
|
|
996
|
+
command: run2.flagCommand ?? null
|
|
997
|
+
},
|
|
998
|
+
settings: {
|
|
999
|
+
limitConcurrency: run2.concurrency,
|
|
1000
|
+
timeoutCollection: run2.timeoutCollection,
|
|
1001
|
+
bail: run2.bail,
|
|
1002
|
+
tags: run2.tags,
|
|
1003
|
+
notTags: run2.notTags ?? []
|
|
1004
|
+
}
|
|
1005
|
+
},
|
|
1006
|
+
totals: {
|
|
1007
|
+
collections: { total: tallies.length, passed: tallies.length - failed, failed },
|
|
1008
|
+
steps: {
|
|
1009
|
+
total: sum((t) => t.steps.total),
|
|
1010
|
+
passed: sum((t) => t.steps.passed),
|
|
1011
|
+
failed: sum((t) => t.steps.failed),
|
|
1012
|
+
errored: sum((t) => t.steps.errored),
|
|
1013
|
+
skipped: sum((t) => t.steps.skipped)
|
|
1014
|
+
},
|
|
1015
|
+
assertions: {
|
|
1016
|
+
total: sum((t) => t.assertions.total),
|
|
1017
|
+
passed: sum((t) => t.assertions.passed),
|
|
1018
|
+
failed: sum((t) => t.assertions.failed)
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
leftOut: { excluded: run2.excluded, untagged: run2.untagged },
|
|
1022
|
+
reports,
|
|
1023
|
+
collections: run2.collections.map(collectionOf)
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
function collectionOf(c) {
|
|
1027
|
+
const { outcome, tally: tally2 } = c;
|
|
1028
|
+
return {
|
|
1029
|
+
id: c.id,
|
|
1030
|
+
file: c.file,
|
|
1031
|
+
status: tally2.skipped ? "skipped" : tally2.passed ? "passed" : "failed",
|
|
1032
|
+
startedAt: new Date(c.startedAt).toISOString(),
|
|
1033
|
+
durationMs: Math.round(c.durationMs),
|
|
1034
|
+
counts: { steps: tally2.steps, assertions: tally2.assertions },
|
|
1035
|
+
/** Why it did not run at all, or null when it ran. */
|
|
1036
|
+
error: outcome.ok ? null : outcome.message,
|
|
1037
|
+
/** The data file that ran it once per row (SPEC.md §2.8), or null. */
|
|
1038
|
+
dataFile: outcome.ok ? outcome.data : null,
|
|
1039
|
+
/** Steps a bail left unrun. */
|
|
1040
|
+
notRun: outcome.ok ? unattempted(outcome.summary) : tally2.steps.total,
|
|
1041
|
+
steps: outcome.ok ? outcome.summary.results.map(
|
|
1042
|
+
(result, i) => stepOf(result, outcome.steps[i] ?? { index: i, line: null })
|
|
1043
|
+
) : []
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
function stepOf(result, ref) {
|
|
1047
|
+
const truncated = {};
|
|
1048
|
+
const clip3 = (field3, text) => {
|
|
1049
|
+
if (text === null || text.length <= BODY_LIMIT2) return text;
|
|
1050
|
+
truncated[field3] = text.length;
|
|
1051
|
+
return text.slice(0, BODY_LIMIT2);
|
|
1052
|
+
};
|
|
1053
|
+
const request = { ...result.request, body: clip3("request.body", result.request.body) };
|
|
1054
|
+
const response = result.response ? { ...result.response, body: clip3("response.body", result.response.body) ?? "" } : null;
|
|
1055
|
+
return {
|
|
1056
|
+
/** The step's place in the file's `steps`, from 0, and its line, from 1. */
|
|
1057
|
+
step: { index: ref.index, line: ref.line },
|
|
1058
|
+
/** The data file row it ran with, from 1, or null without a data file. */
|
|
1059
|
+
iteration: ref.iteration ?? null,
|
|
1060
|
+
...result,
|
|
1061
|
+
request,
|
|
1062
|
+
response,
|
|
1063
|
+
/** Fields cut short, with their full length in characters. */
|
|
1064
|
+
...Object.keys(truncated).length > 0 ? { truncated } : {}
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
function jsonListing(options) {
|
|
1068
|
+
return {
|
|
1069
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
1070
|
+
project: options.project,
|
|
1071
|
+
environments: options.environments,
|
|
1072
|
+
environmentType: options.environmentType,
|
|
1073
|
+
flags: { values: options.flags.values, sources: options.flags.sources },
|
|
1074
|
+
tags: options.tags,
|
|
1075
|
+
notTags: options.notTags ?? [],
|
|
1076
|
+
collections: options.targets.map((target) => ({
|
|
1077
|
+
id: target.id,
|
|
1078
|
+
file: fileOf(target.collection),
|
|
1079
|
+
directory: target.collection.directory,
|
|
1080
|
+
steps: target.steps ?? target.collection.doc.steps.map((_, i) => i),
|
|
1081
|
+
stepCount: target.collection.doc.steps.length,
|
|
1082
|
+
dataFile: target.collection.dataFile ? {
|
|
1083
|
+
file: `collections/${target.collection.dataFile.relativePath}`,
|
|
1084
|
+
rows: target.collection.dataFile.rows
|
|
1085
|
+
} : null,
|
|
1086
|
+
tags: target.collection.doc.tags ?? [],
|
|
1087
|
+
broken: target.broken
|
|
1088
|
+
})),
|
|
1089
|
+
leftOut: { excluded: options.excluded, untagged: options.untagged }
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
var fileOf = (collection) => `collections/${collection.relativePath}`;
|
|
1093
|
+
var jsonError = (message, exitCode) => ({
|
|
1094
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
1095
|
+
error: { message, exitCode }
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
// src/settings.ts
|
|
1099
|
+
import fs from "node:fs/promises";
|
|
1100
|
+
import path from "node:path";
|
|
1101
|
+
var import_yaml = __toESM(require_dist(), 1);
|
|
1102
|
+
var SETTING_KEYS = {
|
|
1103
|
+
environmentType: {
|
|
1104
|
+
kind: "string",
|
|
1105
|
+
doc: "The environment to run against, by name: environments/<name>.yml (the global project\u2019s too). Unset: no environment."
|
|
1106
|
+
},
|
|
1107
|
+
limitConcurrency: {
|
|
1108
|
+
kind: "integer",
|
|
1109
|
+
doc: "How many collections run at once. Steps within a collection always run in order."
|
|
1110
|
+
},
|
|
1111
|
+
timeoutCollection: {
|
|
1112
|
+
kind: "integer",
|
|
1113
|
+
doc: "Milliseconds a collection may take before it is stopped and reported failed."
|
|
1114
|
+
},
|
|
1115
|
+
bail: {
|
|
1116
|
+
kind: "boolean",
|
|
1117
|
+
doc: "Stop a collection at its first failing step; the rest are reported skipped."
|
|
1118
|
+
},
|
|
1119
|
+
tags: {
|
|
1120
|
+
kind: "list",
|
|
1121
|
+
doc: "Run only what has one of these tags (SPEC.md \xA72.4). Empty: everything."
|
|
1122
|
+
},
|
|
1123
|
+
notTags: {
|
|
1124
|
+
kind: "list",
|
|
1125
|
+
doc: "Leave out what has one of these tags: a collection tagged so, or a step where steps carry tags."
|
|
1126
|
+
},
|
|
1127
|
+
generateJUnitResults: {
|
|
1128
|
+
kind: "boolean",
|
|
1129
|
+
doc: "Write a JUnit XML report of the run to <testResultsBasePath>/junit/junit.xml, for CI."
|
|
1130
|
+
},
|
|
1131
|
+
generateJsonResults: {
|
|
1132
|
+
kind: "boolean",
|
|
1133
|
+
doc: "Write the run as JSON to <testResultsBasePath>/json/results.json, for tools and agents."
|
|
1134
|
+
},
|
|
1135
|
+
generateHtmlResults: {
|
|
1136
|
+
kind: "boolean",
|
|
1137
|
+
doc: "Write an HTML report of the run: <testResultsBasePath>/html/summary.html and a page per collection."
|
|
1138
|
+
},
|
|
1139
|
+
autoOpenTestResultHtml: {
|
|
1140
|
+
kind: "boolean",
|
|
1141
|
+
doc: "Write the HTML report and open its summary in the browser when the run ends."
|
|
1142
|
+
},
|
|
1143
|
+
testResultsBasePath: {
|
|
1144
|
+
kind: "string",
|
|
1145
|
+
doc: "Where reports are written: relative to the project folder, or absolute."
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
var KEYS = Object.keys(SETTING_KEYS);
|
|
1149
|
+
var positiveInteger = external_exports.number().int().min(1);
|
|
1150
|
+
var SettingsSchema = external_exports.strictObject({
|
|
1151
|
+
environmentType: external_exports.string().trim().min(1).nullable().default(null),
|
|
1152
|
+
limitConcurrency: positiveInteger.default(1),
|
|
1153
|
+
timeoutCollection: positiveInteger.default(36e5),
|
|
1154
|
+
bail: external_exports.boolean().default(false),
|
|
1155
|
+
tags: external_exports.array(TagSchema).default([]),
|
|
1156
|
+
notTags: external_exports.array(TagSchema).default([]),
|
|
1157
|
+
generateJUnitResults: external_exports.boolean().default(false),
|
|
1158
|
+
generateJsonResults: external_exports.boolean().default(false),
|
|
1159
|
+
generateHtmlResults: external_exports.boolean().default(false),
|
|
1160
|
+
autoOpenTestResultHtml: external_exports.boolean().default(false),
|
|
1161
|
+
testResultsBasePath: external_exports.string().trim().min(1).default("test-results")
|
|
1162
|
+
});
|
|
1163
|
+
var DEFAULT_SETTINGS = SettingsSchema.parse({});
|
|
1164
|
+
var SettingsError = class extends Error {
|
|
1165
|
+
name = "SettingsError";
|
|
1166
|
+
};
|
|
1167
|
+
var envNameOf = (key) => `GTA_${key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
|
|
1168
|
+
async function loadSettings(options) {
|
|
1169
|
+
const { root, overrides = {}, env = process.env } = options;
|
|
1170
|
+
const file = path.join(root, SETTINGS_FILE);
|
|
1171
|
+
let source;
|
|
1172
|
+
try {
|
|
1173
|
+
source = await fs.readFile(file, "utf8");
|
|
1174
|
+
} catch {
|
|
1175
|
+
throw new SettingsError(
|
|
1176
|
+
`There is no ${SETTINGS_FILE} in ${root}.
|
|
1177
|
+
gta runs from a project folder: the one holding collections/ and ${SETTINGS_FILE}.
|
|
1178
|
+
A minimal ${SETTINGS_FILE}:
|
|
1179
|
+
|
|
1180
|
+
environmentType: demo
|
|
1181
|
+
limitConcurrency: 4
|
|
1182
|
+
`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
let fromFile;
|
|
1186
|
+
try {
|
|
1187
|
+
fromFile = (0, import_yaml.parse)(source) ?? {};
|
|
1188
|
+
} catch (cause) {
|
|
1189
|
+
throw new SettingsError(`${SETTINGS_FILE} will not parse: ${cause.message}`);
|
|
1190
|
+
}
|
|
1191
|
+
if (typeof fromFile !== "object" || fromFile === null || Array.isArray(fromFile)) {
|
|
1192
|
+
throw new SettingsError(
|
|
1193
|
+
`${SETTINGS_FILE} must be a map of settings, such as limitConcurrency: 4`
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
const origin = /* @__PURE__ */ new Map();
|
|
1197
|
+
const merged = {};
|
|
1198
|
+
for (const [key, value] of Object.entries(fromFile)) {
|
|
1199
|
+
checkKey(key, SETTINGS_FILE);
|
|
1200
|
+
merged[key] = value;
|
|
1201
|
+
origin.set(key, SETTINGS_FILE);
|
|
1202
|
+
}
|
|
1203
|
+
for (const key of KEYS) {
|
|
1204
|
+
const value = env[envNameOf(key)];
|
|
1205
|
+
if (value === void 0 || value === "") continue;
|
|
1206
|
+
merged[key] = coerce(key, value, envNameOf(key));
|
|
1207
|
+
origin.set(key, envNameOf(key));
|
|
1208
|
+
}
|
|
1209
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
1210
|
+
checkKey(key, `--${key}`);
|
|
1211
|
+
merged[key] = coerce(key, value, `--${key}`);
|
|
1212
|
+
origin.set(key, `--${key}`);
|
|
1213
|
+
}
|
|
1214
|
+
const result = SettingsSchema.safeParse(merged);
|
|
1215
|
+
if (!result.success) {
|
|
1216
|
+
const lines = result.error.issues.map((issue) => {
|
|
1217
|
+
const key = String(issue.path[0] ?? "");
|
|
1218
|
+
return ` ${key} (from ${origin.get(key) ?? SETTINGS_FILE}): ${issue.message}`;
|
|
1219
|
+
});
|
|
1220
|
+
throw new SettingsError(`Settings are not valid:
|
|
1221
|
+
${lines.join("\n")}`);
|
|
1222
|
+
}
|
|
1223
|
+
return result.data;
|
|
1224
|
+
}
|
|
1225
|
+
function checkKey(key, where) {
|
|
1226
|
+
if (!Object.hasOwn(SETTING_KEYS, key)) {
|
|
1227
|
+
throw new SettingsError(
|
|
1228
|
+
`Unknown setting "${key}" (from ${where}). Settings are: ${KEYS.join(", ")}`
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
function coerce(key, value, where) {
|
|
1233
|
+
const { kind } = SETTING_KEYS[key];
|
|
1234
|
+
if (value === true) {
|
|
1235
|
+
if (kind === "boolean") return true;
|
|
1236
|
+
throw new SettingsError(`${where} needs a value`);
|
|
1237
|
+
}
|
|
1238
|
+
switch (kind) {
|
|
1239
|
+
case "integer":
|
|
1240
|
+
return /^\s*\d+\s*$/.test(value) ? Number(value) : value;
|
|
1241
|
+
case "boolean":
|
|
1242
|
+
return value === "true" ? true : value === "false" ? false : value;
|
|
1243
|
+
case "list":
|
|
1244
|
+
return value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
1245
|
+
case "string":
|
|
1246
|
+
return value;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// src/main.ts
|
|
1251
|
+
var VERSION = true ? "0.9.0" : "0.0.0-dev";
|
|
1252
|
+
var JUNIT_DIR = "junit";
|
|
1253
|
+
var JUNIT_FILE = "junit.xml";
|
|
1254
|
+
var HTML_DIR = "html";
|
|
1255
|
+
var JSON_DIR = "json";
|
|
1256
|
+
var JSON_FILE = "results.json";
|
|
1257
|
+
var EXIT = { passed: 0, failed: 1, unusable: 2 };
|
|
1258
|
+
var ProjectError = class extends Error {
|
|
1259
|
+
name = "ProjectError";
|
|
1260
|
+
};
|
|
1261
|
+
async function main(argv, cli) {
|
|
1262
|
+
let ctx = cli;
|
|
1263
|
+
let p = paint(ctx.color);
|
|
1264
|
+
let emit = null;
|
|
1265
|
+
try {
|
|
1266
|
+
const args = parseArgs(argv);
|
|
1267
|
+
if (args.json && !args.help && !args.version && args.command !== null) {
|
|
1268
|
+
emit = (value) => cli.out(JSON.stringify(value, null, 2));
|
|
1269
|
+
ctx = { ...cli, out: () => {
|
|
1270
|
+
}, color: false };
|
|
1271
|
+
p = paint(false);
|
|
1272
|
+
}
|
|
1273
|
+
if (args.version) {
|
|
1274
|
+
ctx.out(VERSION);
|
|
1275
|
+
return EXIT.passed;
|
|
1276
|
+
}
|
|
1277
|
+
if (args.help || args.command === null || args.command === "help") {
|
|
1278
|
+
ctx.out(usage(p));
|
|
1279
|
+
return EXIT.passed;
|
|
1280
|
+
}
|
|
1281
|
+
const project = await openProject(ctx, args.overrides, args.flags);
|
|
1282
|
+
for (const warning of project.warnings) ctx.err(p.yellow(`warning: ${warning}`));
|
|
1283
|
+
const command = args.command;
|
|
1284
|
+
if (command === "g" || command === "get") {
|
|
1285
|
+
const selection = selectFor(project, { kind: "all" });
|
|
1286
|
+
if (emit) {
|
|
1287
|
+
emit(
|
|
1288
|
+
jsonListing({
|
|
1289
|
+
project: { name: project.name, root: project.root },
|
|
1290
|
+
environments: project.environments,
|
|
1291
|
+
environmentType: project.settings.environmentType,
|
|
1292
|
+
flags: project.flags,
|
|
1293
|
+
tags: project.settings.tags,
|
|
1294
|
+
notTags: project.settings.notTags,
|
|
1295
|
+
targets: selection.targets,
|
|
1296
|
+
excluded: selection.excluded,
|
|
1297
|
+
untagged: selection.untagged
|
|
1298
|
+
})
|
|
1299
|
+
);
|
|
1300
|
+
return EXIT.passed;
|
|
1301
|
+
}
|
|
1302
|
+
return list(project, selection, ctx, p);
|
|
1303
|
+
}
|
|
1304
|
+
const request = command === "a" || command === "all" ? { kind: "all" } : { kind: "list", selectors: command.split(",").filter((s) => s.trim() !== "") };
|
|
1305
|
+
return await run(project, selectFor(project, request), ctx, p, emit);
|
|
1306
|
+
} catch (cause) {
|
|
1307
|
+
if (cause instanceof UsageError || cause instanceof SettingsError || cause instanceof ProjectError) {
|
|
1308
|
+
ctx.err(p.red(cause.message));
|
|
1309
|
+
const json = emit ?? (argv.includes("--json") ? (v) => cli.out(JSON.stringify(v, null, 2)) : null);
|
|
1310
|
+
json?.(jsonError(cause.message, EXIT.unusable));
|
|
1311
|
+
return EXIT.unusable;
|
|
1312
|
+
}
|
|
1313
|
+
throw cause;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async function openProject(ctx, overrides, flagArgs = []) {
|
|
1317
|
+
const root = await canonical(ctx.cwd);
|
|
1318
|
+
if (!await isDirectory(path2.join(root, COLLECTIONS_DIR))) {
|
|
1319
|
+
throw new ProjectError(
|
|
1320
|
+
`There is no ${COLLECTIONS_DIR}/ in ${root}.
|
|
1321
|
+
gta runs from a project folder: the one holding ${COLLECTIONS_DIR}/ and ${SETTINGS_FILE}.`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
const settings = await loadSettings({ root, overrides, env: ctx.env });
|
|
1325
|
+
const info = await readProject(root);
|
|
1326
|
+
const layout = await discoverProject(root);
|
|
1327
|
+
const collections = await loadProjectCollections(root, layout.files);
|
|
1328
|
+
const environments = environmentNames(await projectEnvironments(root, info.global));
|
|
1329
|
+
if (settings.environmentType && !environments.includes(settings.environmentType)) {
|
|
1330
|
+
throw new ProjectError(
|
|
1331
|
+
`environmentType: there is no environment called "${settings.environmentType}". ` + (environments.length > 0 ? `This project has: ${environments.join(", ")}` : `This project has no environments/.`)
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
let flags;
|
|
1335
|
+
try {
|
|
1336
|
+
flags = await resolveFlags({
|
|
1337
|
+
root,
|
|
1338
|
+
environmentName: settings.environmentType,
|
|
1339
|
+
overrides: flagOverrides(flagArgs, ctx.env),
|
|
1340
|
+
env: { ...process.env, ...ctx.env }
|
|
1341
|
+
});
|
|
1342
|
+
} catch (cause) {
|
|
1343
|
+
throw new ProjectError(cause.message);
|
|
1344
|
+
}
|
|
1345
|
+
const tls = await loadProjectTls(root, info);
|
|
1346
|
+
if (tls.problems.length > 0) {
|
|
1347
|
+
throw new ProjectError(
|
|
1348
|
+
`A certificate in tls.ca cannot be used:
|
|
1349
|
+
` + tls.problems.map((problem) => ` ${problem.path}: ${problem.message}`).join("\n")
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
root,
|
|
1354
|
+
name: info.doc?.name ?? path2.basename(root),
|
|
1355
|
+
settings,
|
|
1356
|
+
flags,
|
|
1357
|
+
collections,
|
|
1358
|
+
environments,
|
|
1359
|
+
caFiles: tls.files.map(
|
|
1360
|
+
(file) => `${relativePosix(root, file.file)} (${file.certificates.map((c) => c.subject).join(", ")})`
|
|
1361
|
+
),
|
|
1362
|
+
warnings: [...info.problems, ...layout.problems].map(
|
|
1363
|
+
(problem) => problem.path ? `${problem.path}: ${problem.message}` : problem.message
|
|
1364
|
+
)
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
var selectFor = (project, request) => selectCollections(project.collections, request, {
|
|
1368
|
+
tags: project.settings.tags,
|
|
1369
|
+
notTags: project.settings.notTags
|
|
1370
|
+
});
|
|
1371
|
+
function list(project, selection, ctx, p) {
|
|
1372
|
+
const { settings } = project;
|
|
1373
|
+
ctx.out(field("Project", `${project.name} ${p.dim(project.root)}`));
|
|
1374
|
+
ctx.out(
|
|
1375
|
+
field(
|
|
1376
|
+
"Environments",
|
|
1377
|
+
project.environments.length > 0 ? project.environments.map((name) => name === settings.environmentType ? p.bold(`${name} \u25C0`) : name).join(", ") : p.dim("none")
|
|
1378
|
+
)
|
|
1379
|
+
);
|
|
1380
|
+
ctx.out(field("Flags", flagLine(project.flags, p)));
|
|
1381
|
+
if (project.caFiles.length > 0) ctx.out(field("CA files", project.caFiles.join(", ")));
|
|
1382
|
+
if (settings.tags.length > 0) ctx.out(field("Tags", settings.tags.join(", ")));
|
|
1383
|
+
if (settings.notTags.length > 0) ctx.out(field("Not tags", settings.notTags.join(", ")));
|
|
1384
|
+
ctx.out("");
|
|
1385
|
+
const width = nameWidthFor(selection.targets.map((t) => t.id));
|
|
1386
|
+
const dirWidth = Math.max(
|
|
1387
|
+
"Directory".length,
|
|
1388
|
+
...selection.targets.map((t) => (t.collection.directory ?? "").length)
|
|
1389
|
+
);
|
|
1390
|
+
ctx.out(
|
|
1391
|
+
p.bold(
|
|
1392
|
+
`${"#".padStart(4)} ${"Collection".padEnd(width)} ${"Steps".padStart(7)} ${"Rows".padStart(5)} ${"Directory".padEnd(dirWidth)} Tags`
|
|
1393
|
+
)
|
|
1394
|
+
);
|
|
1395
|
+
selection.targets.forEach((target, i) => {
|
|
1396
|
+
const { doc } = target.collection;
|
|
1397
|
+
const count = target.broken ? "broken" : target.steps ? `${target.steps.length}/${doc.steps.length}` : String(doc.steps.length);
|
|
1398
|
+
const steps2 = target.broken ? p.red(count.padStart(7)) : count.padStart(7);
|
|
1399
|
+
const tags = [.../* @__PURE__ */ new Set([...doc.tags ?? [], ...doc.steps.flatMap((s) => s.tags ?? [])])];
|
|
1400
|
+
const rows = target.collection.dataFile ? String(target.collection.dataFile.rows) : "";
|
|
1401
|
+
ctx.out(
|
|
1402
|
+
`${String(i + 1).padStart(4)} ${nameCell(target.id, width)} ${steps2} ${rows.padStart(5)} ${(target.collection.directory ?? "").padEnd(dirWidth)} ${p.dim(tags.join(", "))}`
|
|
1403
|
+
);
|
|
1404
|
+
});
|
|
1405
|
+
ctx.out("");
|
|
1406
|
+
for (const target of selection.targets.filter((t) => t.broken)) {
|
|
1407
|
+
ctx.out(`${p.red("broken")} ${target.id}: ${target.broken}`);
|
|
1408
|
+
}
|
|
1409
|
+
if (selection.excluded.length > 0) {
|
|
1410
|
+
ctx.out(field("Excluded", `${selection.excluded.join(", ")} ${p.dim("(exclude: true)")}`));
|
|
1411
|
+
}
|
|
1412
|
+
if (selection.untagged > 0) {
|
|
1413
|
+
ctx.out(field("By tags", `${selection.untagged} collection(s) left out by tags`));
|
|
1414
|
+
}
|
|
1415
|
+
const steps = selection.targets.reduce((n, t) => n + stepCountOf(t), 0);
|
|
1416
|
+
ctx.out(field("Total", `${selection.targets.length} collections, ${steps} steps`));
|
|
1417
|
+
return EXIT.passed;
|
|
1418
|
+
}
|
|
1419
|
+
async function run(project, selection, ctx, p, emit = null) {
|
|
1420
|
+
const { settings } = project;
|
|
1421
|
+
const width = nameWidthFor(selection.targets.map((t) => t.id));
|
|
1422
|
+
const line = rule(p, tableWidth(width));
|
|
1423
|
+
ctx.out(line);
|
|
1424
|
+
ctx.out(p.bold(`gta ${VERSION}`));
|
|
1425
|
+
ctx.out(line);
|
|
1426
|
+
ctx.out(field("Project", `${project.name} ${p.dim(project.root)}`));
|
|
1427
|
+
ctx.out(field("Environment", settings.environmentType ?? p.dim("none")));
|
|
1428
|
+
ctx.out(field("Concurrency", String(settings.limitConcurrency)));
|
|
1429
|
+
ctx.out(field("Flags", flagLine(project.flags, p)));
|
|
1430
|
+
ctx.out(field("Timeout", `${settings.timeoutCollection} ms per collection`));
|
|
1431
|
+
if (project.caFiles.length > 0) ctx.out(field("CA files", project.caFiles.join(", ")));
|
|
1432
|
+
if (settings.tags.length > 0) ctx.out(field("Tags", settings.tags.join(", ")));
|
|
1433
|
+
if (settings.notTags.length > 0) ctx.out(field("Not tags", settings.notTags.join(", ")));
|
|
1434
|
+
if (settings.bail) ctx.out(field("Bail", "on: a collection stops at its first failing step"));
|
|
1435
|
+
const reportNames = [
|
|
1436
|
+
settings.generateJUnitResults ? `${JUNIT_DIR}/${JUNIT_FILE}` : "",
|
|
1437
|
+
settings.generateJsonResults ? `${JSON_DIR}/${JSON_FILE}` : "",
|
|
1438
|
+
wantsHtml(settings) ? `${HTML_DIR}/${SUMMARY_PAGE}` : ""
|
|
1439
|
+
].filter(Boolean);
|
|
1440
|
+
if (reportNames.length > 0) {
|
|
1441
|
+
ctx.out(
|
|
1442
|
+
field("Reports", reportNames.map((n) => `${settings.testResultsBasePath}/${n}`).join(", "))
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
const leftOut = [
|
|
1446
|
+
selection.excluded.length > 0 ? `${selection.excluded.length} excluded` : "",
|
|
1447
|
+
selection.untagged > 0 ? `${selection.untagged} left out by tags` : ""
|
|
1448
|
+
].filter(Boolean);
|
|
1449
|
+
ctx.out(
|
|
1450
|
+
field(
|
|
1451
|
+
"Collections",
|
|
1452
|
+
`${selection.targets.length} to run${leftOut.length > 0 ? p.dim(` (${leftOut.join(", ")})`) : ""}`
|
|
1453
|
+
)
|
|
1454
|
+
);
|
|
1455
|
+
ctx.out(line);
|
|
1456
|
+
if (selection.targets.length === 0) {
|
|
1457
|
+
ctx.err(p.red("Nothing to run: no collection matched."));
|
|
1458
|
+
emit?.(jsonError("Nothing to run: no collection matched.", EXIT.failed));
|
|
1459
|
+
return EXIT.failed;
|
|
1460
|
+
}
|
|
1461
|
+
ctx.out(tableHeader(width, p));
|
|
1462
|
+
const base = settings.testResultsBasePath;
|
|
1463
|
+
const reportsDir = path2.isAbsolute(base) ? path2.resolve(base) : path2.resolve(project.root, ...base.split(/[\\/]+/));
|
|
1464
|
+
const htmlDir = path2.join(reportsDir, HTML_DIR);
|
|
1465
|
+
const problem = await clearResults(reportsDir, project.root);
|
|
1466
|
+
if (problem) {
|
|
1467
|
+
ctx.err(p.red(problem));
|
|
1468
|
+
emit?.(jsonError(problem, EXIT.unusable));
|
|
1469
|
+
return EXIT.unusable;
|
|
1470
|
+
}
|
|
1471
|
+
const runner = ctx.runner(settings);
|
|
1472
|
+
const tallies = [];
|
|
1473
|
+
const finished = /* @__PURE__ */ new Map();
|
|
1474
|
+
const startedAt = Date.now();
|
|
1475
|
+
const started = performance.now();
|
|
1476
|
+
await runPool(selection.targets, settings.limitConcurrency, async (target) => {
|
|
1477
|
+
const collectionStartedAt = Date.now();
|
|
1478
|
+
const collectionStarted = performance.now();
|
|
1479
|
+
const outcome = target.broken ? { ok: false, message: target.broken } : await runner({
|
|
1480
|
+
file: target.collection.path,
|
|
1481
|
+
projectRoot: project.root,
|
|
1482
|
+
environment: settings.environmentType,
|
|
1483
|
+
steps: target.steps,
|
|
1484
|
+
bail: settings.bail,
|
|
1485
|
+
flags: project.flags.values
|
|
1486
|
+
});
|
|
1487
|
+
const collectionMs = performance.now() - collectionStarted;
|
|
1488
|
+
const result = tally(target, outcome, collectionMs);
|
|
1489
|
+
finished.set(target, {
|
|
1490
|
+
id: target.id,
|
|
1491
|
+
file: `${COLLECTIONS_DIR}/${target.collection.relativePath}`,
|
|
1492
|
+
outcome,
|
|
1493
|
+
startedAt: collectionStartedAt,
|
|
1494
|
+
durationMs: collectionMs,
|
|
1495
|
+
tally: result,
|
|
1496
|
+
docs: target.collection.doc.docs
|
|
1497
|
+
});
|
|
1498
|
+
tallies.push(result);
|
|
1499
|
+
ctx.out(tableRow(tallies.length, result, width, p));
|
|
1500
|
+
});
|
|
1501
|
+
const durationMs = performance.now() - started;
|
|
1502
|
+
ctx.out(line);
|
|
1503
|
+
const details = failureDetails(tallies, p);
|
|
1504
|
+
if (details.length > 0) {
|
|
1505
|
+
ctx.out(p.bold("Failures"));
|
|
1506
|
+
ctx.out("");
|
|
1507
|
+
for (const detail of details) ctx.out(detail);
|
|
1508
|
+
ctx.out(line);
|
|
1509
|
+
}
|
|
1510
|
+
const totals = totalsOf(tallies);
|
|
1511
|
+
for (const total of summary(totals, durationMs, p)) ctx.out(total);
|
|
1512
|
+
const run2 = {
|
|
1513
|
+
flags: project.flags.values,
|
|
1514
|
+
flagSources: project.flags.sources,
|
|
1515
|
+
flagCommand: project.flags.command?.command ?? null,
|
|
1516
|
+
project: project.name,
|
|
1517
|
+
root: project.root,
|
|
1518
|
+
version: VERSION,
|
|
1519
|
+
environment: settings.environmentType,
|
|
1520
|
+
concurrency: settings.limitConcurrency,
|
|
1521
|
+
timeoutCollection: settings.timeoutCollection,
|
|
1522
|
+
tags: settings.tags,
|
|
1523
|
+
notTags: settings.notTags,
|
|
1524
|
+
bail: settings.bail,
|
|
1525
|
+
excluded: selection.excluded,
|
|
1526
|
+
untagged: selection.untagged,
|
|
1527
|
+
startedAt,
|
|
1528
|
+
durationMs,
|
|
1529
|
+
// In the order selected, whichever finished first, so two reports compare.
|
|
1530
|
+
collections: selection.targets.flatMap((target) => finished.get(target) ?? [])
|
|
1531
|
+
};
|
|
1532
|
+
const reports = [];
|
|
1533
|
+
if (settings.generateJUnitResults) {
|
|
1534
|
+
reports.push({
|
|
1535
|
+
label: "JUnit",
|
|
1536
|
+
dir: path2.join(reportsDir, JUNIT_DIR),
|
|
1537
|
+
shown: JUNIT_FILE,
|
|
1538
|
+
files: () => [{ path: JUNIT_FILE, text: junitReport(run2) }]
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
const paths = {
|
|
1542
|
+
...settings.generateJUnitResults ? { junit: path2.join(reportsDir, JUNIT_DIR, JUNIT_FILE) } : {},
|
|
1543
|
+
...wantsHtml(settings) ? { html: path2.join(htmlDir, SUMMARY_PAGE) } : {},
|
|
1544
|
+
...settings.generateJsonResults ? { json: path2.join(reportsDir, JSON_DIR, JSON_FILE) } : {}
|
|
1545
|
+
};
|
|
1546
|
+
if (settings.generateJsonResults) {
|
|
1547
|
+
reports.push({
|
|
1548
|
+
label: "JSON",
|
|
1549
|
+
dir: path2.join(reportsDir, JSON_DIR),
|
|
1550
|
+
shown: JSON_FILE,
|
|
1551
|
+
files: () => [
|
|
1552
|
+
{ path: JSON_FILE, text: `${JSON.stringify(jsonReport(run2, paths), null, 2)}
|
|
1553
|
+
` }
|
|
1554
|
+
]
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
if (wantsHtml(settings)) {
|
|
1558
|
+
reports.push({
|
|
1559
|
+
label: "HTML",
|
|
1560
|
+
dir: htmlDir,
|
|
1561
|
+
shown: SUMMARY_PAGE,
|
|
1562
|
+
files: () => htmlReport(run2).map((page2) => ({ path: page2.path, text: page2.html }))
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
let reportFailed = false;
|
|
1566
|
+
for (const report of reports) {
|
|
1567
|
+
const shown = path2.join(report.dir, report.shown);
|
|
1568
|
+
try {
|
|
1569
|
+
for (const file of report.files()) {
|
|
1570
|
+
const target = path2.join(report.dir, ...file.path.split("/"));
|
|
1571
|
+
await fs2.mkdir(path2.dirname(target), { recursive: true });
|
|
1572
|
+
await fs2.writeFile(target, file.text);
|
|
1573
|
+
}
|
|
1574
|
+
ctx.out(field(report.label, shown));
|
|
1575
|
+
if (report.label === "HTML" && settings.autoOpenTestResultHtml) ctx.open(shown);
|
|
1576
|
+
} catch (cause) {
|
|
1577
|
+
ctx.err(
|
|
1578
|
+
p.red(`Could not write the ${report.label} report to ${shown}: ${cause.message}`)
|
|
1579
|
+
);
|
|
1580
|
+
reportFailed = true;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
ctx.out(line);
|
|
1584
|
+
emit?.(jsonReport(run2, paths));
|
|
1585
|
+
if (reportFailed) return EXIT.unusable;
|
|
1586
|
+
return totals.collections.failed === 0 ? EXIT.passed : EXIT.failed;
|
|
1587
|
+
}
|
|
1588
|
+
function usage(p) {
|
|
1589
|
+
const settings = Object.keys(SETTING_KEYS).flatMap((key) => {
|
|
1590
|
+
const fallback = DEFAULT_SETTINGS[key];
|
|
1591
|
+
const shown = Array.isArray(fallback) ? fallback.length > 0 ? fallback.join(",") : "none" : String(fallback ?? "none");
|
|
1592
|
+
return [
|
|
1593
|
+
` ${p.bold(`--${key}`)} ${p.dim(`(default ${shown}; ${envNameOf(key)})`)}`,
|
|
1594
|
+
` ${SETTING_KEYS[key].doc}`
|
|
1595
|
+
];
|
|
1596
|
+
});
|
|
1597
|
+
return [
|
|
1598
|
+
`${p.bold("gta")} ${VERSION} \u2014 Gravity Test Automation`,
|
|
1599
|
+
"",
|
|
1600
|
+
` ${p.bold("Usage:")} gta <command> [--settingsKey value]...`,
|
|
1601
|
+
"",
|
|
1602
|
+
" Run from a project folder: the one holding collections/ and settings.yml.",
|
|
1603
|
+
"",
|
|
1604
|
+
` ${p.bold("Commands:")}`,
|
|
1605
|
+
" g, get List the collections `gta all` would run, without running them.",
|
|
1606
|
+
" a, all Run every collection, less those with exclude: true.",
|
|
1607
|
+
" <list> Run the collections and directories named, comma separated,",
|
|
1608
|
+
" in that order: smoke,checkout/sessions,payments",
|
|
1609
|
+
" A directory leaves out its excluded collections;",
|
|
1610
|
+
" one named on its own runs.",
|
|
1611
|
+
" A collection called all or get: give it as all.yml.",
|
|
1612
|
+
"",
|
|
1613
|
+
` ${p.bold("Options:")}`,
|
|
1614
|
+
" --json Print the results as JSON on stdout instead of the table; with",
|
|
1615
|
+
" get, the collections it would run. Errors are JSON too.",
|
|
1616
|
+
" --flag name=value",
|
|
1617
|
+
" Override a feature flag for this run; any number of them.",
|
|
1618
|
+
" GTA_FLAG_<name>=value does the same from the environment.",
|
|
1619
|
+
"",
|
|
1620
|
+
` ${p.bold("Settings")} (settings.yml, then GTA_* environment variables, then --flags):`,
|
|
1621
|
+
...settings,
|
|
1622
|
+
"",
|
|
1623
|
+
` ${p.bold("Exit code:")} 0 passed, 1 failed, 2 could not run (or write a report).`
|
|
1624
|
+
].join("\n");
|
|
1625
|
+
}
|
|
1626
|
+
async function isDirectory(target) {
|
|
1627
|
+
try {
|
|
1628
|
+
return (await fs2.stat(target)).isDirectory();
|
|
1629
|
+
} catch {
|
|
1630
|
+
return false;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
var wantsHtml = (settings) => settings.generateHtmlResults || settings.autoOpenTestResultHtml;
|
|
1634
|
+
var RESULT_DIRS = {
|
|
1635
|
+
[JUNIT_DIR]: (name) => name === JUNIT_FILE,
|
|
1636
|
+
[HTML_DIR]: (name) => name.endsWith(".html"),
|
|
1637
|
+
[JSON_DIR]: (name) => name === JSON_FILE
|
|
1638
|
+
};
|
|
1639
|
+
var SYSTEM_FILES = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db"]);
|
|
1640
|
+
async function clearResults(dir, projectRoot) {
|
|
1641
|
+
if (samePath(dir, projectRoot) || isInside(dir, projectRoot)) {
|
|
1642
|
+
return `testResultsBasePath is ${dir}, which holds the project: gta deletes its test results folder before each run, so point it at a folder of its own.`;
|
|
1643
|
+
}
|
|
1644
|
+
let entries;
|
|
1645
|
+
try {
|
|
1646
|
+
entries = await fs2.readdir(dir);
|
|
1647
|
+
} catch {
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1650
|
+
const others = entries.filter((entry) => !(entry in RESULT_DIRS) && !SYSTEM_FILES.has(entry));
|
|
1651
|
+
for (const [folder, ours] of Object.entries(RESULT_DIRS)) {
|
|
1652
|
+
if (!entries.includes(folder)) continue;
|
|
1653
|
+
const inside = await fs2.readdir(path2.join(dir, folder)).catch(() => []);
|
|
1654
|
+
others.push(
|
|
1655
|
+
...inside.filter((name) => !ours(name) && !SYSTEM_FILES.has(name)).map((name) => `${folder}/${name}`)
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
if (others.length > 0) {
|
|
1659
|
+
return `${dir} holds files gta did not write (${others.slice(0, 5).join(", ")}${others.length > 5 ? ", \u2026" : ""}), so it will not delete it before the run. Point testResultsBasePath somewhere else, or remove them.`;
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
await fs2.rm(dir, { recursive: true, force: true });
|
|
1663
|
+
return null;
|
|
1664
|
+
} catch (cause) {
|
|
1665
|
+
return `Could not delete ${dir} before the run: ${cause.message}`;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
function flagLine(flags, p) {
|
|
1669
|
+
const list2 = flagList(flags.values);
|
|
1670
|
+
const overridden = Object.values(flags.sources).filter((s) => s === "override").length;
|
|
1671
|
+
const notes = [
|
|
1672
|
+
flags.command ? `from ${flags.command.command}` : "",
|
|
1673
|
+
overridden > 0 ? `${overridden} overridden` : ""
|
|
1674
|
+
].filter(Boolean);
|
|
1675
|
+
return notes.length > 0 ? `${list2} ${p.dim(`(${notes.join("; ")})`)}` : list2;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// src/open.ts
|
|
1679
|
+
import { spawn } from "node:child_process";
|
|
1680
|
+
function openFile(file) {
|
|
1681
|
+
const [command, args] = process.platform === "darwin" ? ["open", [file]] : process.platform === "win32" ? ["explorer.exe", [file]] : ["xdg-open", [file]];
|
|
1682
|
+
try {
|
|
1683
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
1684
|
+
child.on("error", () => {
|
|
1685
|
+
});
|
|
1686
|
+
child.unref();
|
|
1687
|
+
} catch {
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// src/gta.ts
|
|
1692
|
+
var workerUrl = new URL("./worker.js", import.meta.url);
|
|
1693
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
1694
|
+
cwd: process.cwd(),
|
|
1695
|
+
env: process.env,
|
|
1696
|
+
out: (line) => process.stdout.write(`${line}
|
|
1697
|
+
`),
|
|
1698
|
+
err: (line) => process.stderr.write(`${line}
|
|
1699
|
+
`),
|
|
1700
|
+
color: colorWanted(process.stdout, process.env),
|
|
1701
|
+
runner: (settings) => workerRunner(workerUrl, settings.timeoutCollection),
|
|
1702
|
+
open: openFile
|
|
1703
|
+
});
|