@schwabyio/gta 0.13.0 → 0.14.1
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 +178 -1
- package/dist/FUNCTIONS.md +6 -3
- package/dist/SPEC.md +289 -111
- package/dist/THIRD_PARTY_NOTICES.txt +26 -0
- package/dist/chunks/chunk-4JUUUUWB.js +234 -0
- package/dist/chunks/chunk-4W4TFJ53.js +577 -0
- package/dist/chunks/{chunk-ABBBM5UZ.js → chunk-S563G22M.js} +8812 -1953
- package/dist/gta.js +1036 -998
- package/dist/index.d.ts +3 -0
- package/dist/index.js +14 -0
- package/dist/playwright.d.ts +37 -0
- package/dist/playwright.js +178 -0
- package/dist/types.d.ts +246 -0
- package/dist/worker.js +1 -1
- package/package.json +25 -2
package/dist/gta.js
CHANGED
|
@@ -1,318 +1,68 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire as __gtaCreateRequire } from 'node:module';
|
|
3
3
|
const require = __gtaCreateRequire(import.meta.url);
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SETTINGS,
|
|
6
|
+
DEFAULT_SOURCE,
|
|
7
|
+
ProjectError,
|
|
8
|
+
SETTING_KEYS,
|
|
9
|
+
SettingsError,
|
|
10
|
+
UsageError,
|
|
11
|
+
colorWanted,
|
|
12
|
+
envNameOf,
|
|
13
|
+
failureDetails,
|
|
14
|
+
field,
|
|
15
|
+
idOf,
|
|
16
|
+
isDirectory,
|
|
17
|
+
loadProject,
|
|
18
|
+
nameCell,
|
|
19
|
+
nameWidthFor,
|
|
20
|
+
paint,
|
|
21
|
+
parseArgs,
|
|
22
|
+
rule,
|
|
23
|
+
selectCollections,
|
|
24
|
+
stepCountOf,
|
|
25
|
+
summary,
|
|
26
|
+
tableHeader,
|
|
27
|
+
tableRow,
|
|
28
|
+
tableWidth,
|
|
29
|
+
tally,
|
|
30
|
+
totalsOf
|
|
31
|
+
} from "./chunks/chunk-4W4TFJ53.js";
|
|
4
32
|
import {
|
|
5
33
|
COLLECTIONS_DIR,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
34
|
+
MARK_GLYPH,
|
|
35
|
+
PROJECT_FILE,
|
|
36
|
+
RULES_FILE,
|
|
37
|
+
RulesError,
|
|
38
|
+
bodyAsObject,
|
|
39
|
+
buildChecks,
|
|
11
40
|
canonical,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
flagOverrides,
|
|
41
|
+
checkedBody,
|
|
42
|
+
checkedDiffersFromRaw,
|
|
43
|
+
formatPath,
|
|
16
44
|
isInside,
|
|
17
45
|
iterationName,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
lintProject,
|
|
47
|
+
loadRules,
|
|
48
|
+
markHeaders,
|
|
49
|
+
markLines,
|
|
50
|
+
markStatus,
|
|
21
51
|
parseMarkdown,
|
|
22
|
-
|
|
52
|
+
parsePath,
|
|
23
53
|
readProject,
|
|
24
54
|
referenceProblems,
|
|
25
|
-
|
|
26
|
-
require_dist,
|
|
27
|
-
resolveFlags,
|
|
55
|
+
resolvePath,
|
|
28
56
|
resultName,
|
|
57
|
+
ruleDoc,
|
|
29
58
|
samePath,
|
|
30
|
-
|
|
59
|
+
sortArraysBy,
|
|
60
|
+
toJsonLines,
|
|
31
61
|
unattempted
|
|
32
|
-
} from "./chunks/chunk-
|
|
33
|
-
|
|
34
|
-
// src/args.ts
|
|
35
|
-
var UsageError = class extends Error {
|
|
36
|
-
name = "UsageError";
|
|
37
|
-
};
|
|
38
|
-
function parseArgs(argv) {
|
|
39
|
-
const parsed = {
|
|
40
|
-
command: null,
|
|
41
|
-
overrides: {},
|
|
42
|
-
help: false,
|
|
43
|
-
version: false,
|
|
44
|
-
json: false,
|
|
45
|
-
flags: []
|
|
46
|
-
};
|
|
47
|
-
for (let i = 0; i < argv.length; i++) {
|
|
48
|
-
const word = argv[i];
|
|
49
|
-
if (word === "-h" || word === "--help") {
|
|
50
|
-
parsed.help = true;
|
|
51
|
-
} else if (word === "-v" || word === "--version") {
|
|
52
|
-
parsed.version = true;
|
|
53
|
-
} else if (word === "--json") {
|
|
54
|
-
parsed.json = true;
|
|
55
|
-
} else if (word === "--flag" || word.startsWith("--flag=")) {
|
|
56
|
-
const value = word === "--flag" ? argv[++i] : word.slice("--flag=".length);
|
|
57
|
-
if (value === void 0 || value.startsWith("--")) {
|
|
58
|
-
throw new UsageError("--flag needs name=value, such as --flag newCheckout=true");
|
|
59
|
-
}
|
|
60
|
-
parsed.flags.push(value);
|
|
61
|
-
} else if (word.startsWith("--")) {
|
|
62
|
-
const body = word.slice(2);
|
|
63
|
-
const equals = body.indexOf("=");
|
|
64
|
-
if (equals > 0) {
|
|
65
|
-
parsed.overrides[body.slice(0, equals)] = body.slice(equals + 1);
|
|
66
|
-
} else {
|
|
67
|
-
const next = argv[i + 1];
|
|
68
|
-
if (next !== void 0 && !next.startsWith("--")) {
|
|
69
|
-
parsed.overrides[body] = next;
|
|
70
|
-
i++;
|
|
71
|
-
} else {
|
|
72
|
-
parsed.overrides[body] = true;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
} else if (word.startsWith("-") && word.length > 1) {
|
|
76
|
-
throw new UsageError(`Unknown option ${word}. Settings are given as --settingsKey value.`);
|
|
77
|
-
} else if (parsed.command === null) {
|
|
78
|
-
parsed.command = word;
|
|
79
|
-
} else {
|
|
80
|
-
throw new UsageError(
|
|
81
|
-
`Unexpected "${word}". To run several collections, separate them with commas: gta ${parsed.command},${word}`
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return parsed;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// src/select.ts
|
|
89
|
-
var idOf = (collection) => collection.name;
|
|
90
|
-
var placeOf = (collection) => collection.relativePath.slice(0, -DOC_EXTENSION.length);
|
|
91
|
-
var normalize = (selector) => {
|
|
92
|
-
const cleaned = selector.normalize("NFC").trim().replace(/\\/g, "/").replace(/^(\.\/)+/, "").replace(/^collections\//, "");
|
|
93
|
-
return {
|
|
94
|
-
name: cleaned.replace(/\/+$/, "").replace(/\.yml$/, ""),
|
|
95
|
-
directoryOnly: cleaned.endsWith("/")
|
|
96
|
-
};
|
|
97
|
-
};
|
|
98
|
-
function selectCollections(collections, request, options) {
|
|
99
|
-
const excluded = /* @__PURE__ */ new Set();
|
|
100
|
-
const inGroup = (group) => group.filter((c) => {
|
|
101
|
-
if (c.doc.exclude === true) excluded.add(c);
|
|
102
|
-
return c.doc.exclude !== true;
|
|
103
|
-
});
|
|
104
|
-
let chosen;
|
|
105
|
-
if (request.kind === "list") {
|
|
106
|
-
const unknown = [];
|
|
107
|
-
chosen = [];
|
|
108
|
-
for (const selector of request.selectors) {
|
|
109
|
-
const { name, directoryOnly } = normalize(selector);
|
|
110
|
-
const exact = directoryOnly ? void 0 : collections.find((c) => idOf(c) === name) ?? collections.find((c) => placeOf(c).normalize("NFC") === name);
|
|
111
|
-
const inDirectory = collections.filter((c) => c.directory?.normalize("NFC") === name);
|
|
112
|
-
const found = exact ? [exact] : inDirectory.length > 0 ? inGroup(inDirectory) : null;
|
|
113
|
-
if (found) chosen.push(...found.filter((c) => !chosen.includes(c)));
|
|
114
|
-
else unknown.push(selector);
|
|
115
|
-
}
|
|
116
|
-
if (unknown.length > 0) {
|
|
117
|
-
throw new UsageError(
|
|
118
|
-
`No collection or directory is called ${unknown.map((u) => `"${u}"`).join(", ")}. List them with: gta get`
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
} else {
|
|
122
|
-
chosen = inGroup(collections);
|
|
123
|
-
}
|
|
124
|
-
const targets = [];
|
|
125
|
-
let untagged = 0;
|
|
126
|
-
for (const collection of chosen) {
|
|
127
|
-
const id = idOf(collection);
|
|
128
|
-
if (collection.problems.length > 0) {
|
|
129
|
-
const broken = collection.problems.map((p) => p.message).join("; ");
|
|
130
|
-
targets.push({ id, collection, steps: null, broken });
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
const picked = stepsForTags(collection.doc, options.tags, options.notTags ?? []);
|
|
134
|
-
if (picked.length === 0 && (options.tags.length > 0 || (options.notTags ?? []).length > 0)) {
|
|
135
|
-
untagged++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
const all = picked.length === collection.doc.steps.length;
|
|
139
|
-
targets.push({ id, collection, steps: all ? null : picked, broken: null });
|
|
140
|
-
}
|
|
141
|
-
return {
|
|
142
|
-
targets,
|
|
143
|
-
// Named on its own, an excluded collection ran: it was not left out.
|
|
144
|
-
excluded: [...excluded].filter((c) => !chosen.includes(c)).map(idOf),
|
|
145
|
-
untagged
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
var stepCountOf = (target) => target.steps?.length ?? target.collection.doc.steps.length;
|
|
149
|
-
|
|
150
|
-
// src/report.ts
|
|
151
|
-
function paint(enabled) {
|
|
152
|
-
const wrap = (open, close) => (text) => enabled ? `\x1B[${open}m${text}\x1B[${close}m` : text;
|
|
153
|
-
return {
|
|
154
|
-
red: wrap(31, 39),
|
|
155
|
-
green: wrap(32, 39),
|
|
156
|
-
cyan: wrap(36, 39),
|
|
157
|
-
yellow: wrap(33, 39),
|
|
158
|
-
dim: wrap(2, 22),
|
|
159
|
-
bold: wrap(1, 22)
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
function colorWanted(stream, env) {
|
|
163
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
164
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
165
|
-
return stream.isTTY === true;
|
|
166
|
-
}
|
|
167
|
-
function tally(target, outcome, durationMs) {
|
|
168
|
-
if (!outcome.ok) {
|
|
169
|
-
const total = stepCountOf(target);
|
|
170
|
-
return {
|
|
171
|
-
id: target.id,
|
|
172
|
-
passed: false,
|
|
173
|
-
durationMs,
|
|
174
|
-
steps: { total, passed: 0, failed: 0, errored: 0, skipped: total },
|
|
175
|
-
assertions: { total: 0, passed: 0, failed: 0 },
|
|
176
|
-
skipped: false,
|
|
177
|
-
error: outcome.message,
|
|
178
|
-
problems: []
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
const { summary: summary2 } = outcome;
|
|
182
|
-
const assertions = summary2.results.flatMap((r) => r.assertions);
|
|
183
|
-
const failed = assertions.filter((a) => a.status === "fail").length;
|
|
184
|
-
return {
|
|
185
|
-
id: target.id,
|
|
186
|
-
passed: summary2.failed === 0 && summary2.errored === 0,
|
|
187
|
-
durationMs,
|
|
188
|
-
steps: {
|
|
189
|
-
total: summary2.total,
|
|
190
|
-
passed: summary2.passed,
|
|
191
|
-
failed: summary2.failed,
|
|
192
|
-
errored: summary2.errored,
|
|
193
|
-
skipped: summary2.skipped
|
|
194
|
-
},
|
|
195
|
-
assertions: { total: assertions.length, passed: assertions.length - failed, failed },
|
|
196
|
-
skipped: allSkipped(summary2),
|
|
197
|
-
error: null,
|
|
198
|
-
// Named for the row they ran with, when a data file drove the collection.
|
|
199
|
-
problems: summary2.results.flatMap(
|
|
200
|
-
(r, i) => r.status === "fail" || r.status === "error" ? [{ ...r, item: { ...r.item, name: iterationName(resultName(r), outcome.steps[i]) } }] : []
|
|
201
|
-
)
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
var seconds = (ms) => `${(ms / 1e3).toFixed(3)}s`;
|
|
205
|
-
var rule = (p, width = 100) => p.dim("\u2500".repeat(width));
|
|
206
|
-
var field = (label, value, width = 14) => `${`${label}:`.padEnd(width)}${value}`;
|
|
207
|
-
var NUMBER_COLUMNS = [
|
|
208
|
-
["Time", 9],
|
|
209
|
-
["Steps", 6],
|
|
210
|
-
["Pass", 5],
|
|
211
|
-
["Fail", 5],
|
|
212
|
-
["Error", 6],
|
|
213
|
-
["Skip", 5],
|
|
214
|
-
["Asserts", 8],
|
|
215
|
-
["Pass", 5],
|
|
216
|
-
["Fail", 5]
|
|
217
|
-
];
|
|
218
|
-
var nameWidthFor = (ids) => Math.min(80, Math.max("Collection".length, ...ids.map((id) => id.length)));
|
|
219
|
-
var tableWidth = (nameWidth) => 4 + 2 + nameWidth + 1 + NUMBER_COLUMNS.reduce((n, [, w]) => n + w + 1, 0) - 1 + 2 + "passed".length;
|
|
220
|
-
function tableHeader(nameWidth, p) {
|
|
221
|
-
const cells = NUMBER_COLUMNS.map(([label, width]) => label.padStart(width));
|
|
222
|
-
return p.bold(`${"#".padStart(4)} ${"Collection".padEnd(nameWidth)} ${cells.join(" ")} Result`);
|
|
223
|
-
}
|
|
224
|
-
var nameCell = (id, width) => id.length > width ? `${id.slice(0, width - 1)}\u2026` : id.padEnd(width);
|
|
225
|
-
function tableRow(n, t, nameWidth, p) {
|
|
226
|
-
const name = nameCell(t.id, nameWidth);
|
|
227
|
-
const values = [
|
|
228
|
-
seconds(t.durationMs),
|
|
229
|
-
t.steps.total,
|
|
230
|
-
t.steps.passed,
|
|
231
|
-
t.steps.failed,
|
|
232
|
-
t.steps.errored,
|
|
233
|
-
t.steps.skipped,
|
|
234
|
-
t.assertions.total,
|
|
235
|
-
t.assertions.passed,
|
|
236
|
-
t.assertions.failed
|
|
237
|
-
];
|
|
238
|
-
const cells = values.map((value, i) => String(value).padStart(NUMBER_COLUMNS[i][1]));
|
|
239
|
-
const result = t.skipped ? p.cyan("skipped") : t.passed ? p.green("passed") : p.red("failed");
|
|
240
|
-
return `${String(n).padStart(4)} ${name} ${cells.join(" ")} ${result}`;
|
|
241
|
-
}
|
|
242
|
-
var clip = (text, max = 300) => text.length > max ? `${text.slice(0, max)}\u2026 (${text.length - max} more characters)` : text;
|
|
243
|
-
function failureDetails(tallies, p) {
|
|
244
|
-
const lines = [];
|
|
245
|
-
for (const t of tallies) {
|
|
246
|
-
if (t.passed) continue;
|
|
247
|
-
lines.push(p.bold(t.id));
|
|
248
|
-
if (t.error) lines.push(` ${p.red("\u2717")} ${t.error}`);
|
|
249
|
-
for (const result of t.problems) {
|
|
250
|
-
const where = result.use ? ` ${p.dim(`(${result.use.set} ${result.use.child + 1}/${result.use.of})`)}` : "";
|
|
251
|
-
const status = result.response ? ` ${p.dim(String(result.response.status))}` : "";
|
|
252
|
-
lines.push(` ${p.red("\u2717")} ${result.item.name}${where}${status}`);
|
|
253
|
-
if (result.error) {
|
|
254
|
-
const { phase, script, line, message } = result.error;
|
|
255
|
-
const at = [script && `${script} script`, line && `line ${line}`].filter(Boolean).join(", ");
|
|
256
|
-
lines.push(` ${p.red(`${phase} error${at ? ` (${at})` : ""}:`)} ${clip(message)}`);
|
|
257
|
-
}
|
|
258
|
-
for (const assertion of result.assertions) {
|
|
259
|
-
if (assertion.status !== "fail") continue;
|
|
260
|
-
const message = assertion.message ? `: ${assertion.message}` : "";
|
|
261
|
-
lines.push(` ${assertion.name}${message}`);
|
|
262
|
-
if (assertion.expected !== void 0) {
|
|
263
|
-
lines.push(` ${p.dim("expected")} ${clip(assertion.expected)}`);
|
|
264
|
-
}
|
|
265
|
-
if (assertion.actual !== void 0) {
|
|
266
|
-
lines.push(` ${p.dim("actual ")} ${clip(assertion.actual)}`);
|
|
267
|
-
}
|
|
268
|
-
if (assertion.unasserted?.length) {
|
|
269
|
-
lines.push(` ${p.dim("not asserted")} ${clip(assertion.unasserted.join(", "))}`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
lines.push("");
|
|
274
|
-
}
|
|
275
|
-
return lines;
|
|
276
|
-
}
|
|
277
|
-
function totalsOf(tallies) {
|
|
278
|
-
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
279
|
-
return {
|
|
280
|
-
collections: {
|
|
281
|
-
total: tallies.length,
|
|
282
|
-
passed: tallies.filter((t) => t.passed).length,
|
|
283
|
-
failed: tallies.filter((t) => !t.passed).length
|
|
284
|
-
},
|
|
285
|
-
steps: {
|
|
286
|
-
total: sum((t) => t.steps.total),
|
|
287
|
-
passed: sum((t) => t.steps.passed),
|
|
288
|
-
failed: sum((t) => t.steps.failed),
|
|
289
|
-
errored: sum((t) => t.steps.errored),
|
|
290
|
-
skipped: sum((t) => t.steps.skipped)
|
|
291
|
-
},
|
|
292
|
-
assertions: {
|
|
293
|
-
total: sum((t) => t.assertions.total),
|
|
294
|
-
passed: sum((t) => t.assertions.passed),
|
|
295
|
-
failed: sum((t) => t.assertions.failed)
|
|
296
|
-
}
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
function summary(totals, durationMs, p) {
|
|
300
|
-
const { collections: c, steps: s, assertions: a } = totals;
|
|
301
|
-
const passed = c.failed === 0;
|
|
302
|
-
return [
|
|
303
|
-
field("Collections", `${c.total} total, ${c.passed} passed, ${c.failed} failed`),
|
|
304
|
-
field(
|
|
305
|
-
"Steps",
|
|
306
|
-
`${s.total} total, ${s.passed} passed, ${s.failed} failed, ${s.errored} errored, ${s.skipped} skipped`
|
|
307
|
-
),
|
|
308
|
-
field("Assertions", `${a.total} total, ${a.passed} passed, ${a.failed} failed`),
|
|
309
|
-
field("Run time", seconds(durationMs)),
|
|
310
|
-
field("Result", passed ? p.green(p.bold("PASSED")) : p.red(p.bold("FAILED")))
|
|
311
|
-
];
|
|
312
|
-
}
|
|
62
|
+
} from "./chunks/chunk-S563G22M.js";
|
|
313
63
|
|
|
314
64
|
// src/main.ts
|
|
315
|
-
import
|
|
65
|
+
import fs from "node:fs/promises";
|
|
316
66
|
import path2 from "node:path";
|
|
317
67
|
|
|
318
68
|
// src/pool.ts
|
|
@@ -348,8 +98,239 @@ async function runPool(items, limit, run2) {
|
|
|
348
98
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
349
99
|
}
|
|
350
100
|
|
|
351
|
-
// src/
|
|
101
|
+
// src/rules.ts
|
|
102
|
+
import path from "node:path";
|
|
103
|
+
|
|
104
|
+
// src/json.ts
|
|
105
|
+
var JSON_FORMAT_VERSION = 1;
|
|
352
106
|
var BODY_LIMIT = 256 * 1024;
|
|
107
|
+
function jsonReport(run2, reports) {
|
|
108
|
+
const totals = totalsOf(run2.collections.map((c) => c.tally));
|
|
109
|
+
const failed = totals.collections.failed;
|
|
110
|
+
return {
|
|
111
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
112
|
+
tool: { name: "gta", version: run2.version },
|
|
113
|
+
project: { name: run2.project, root: run2.root },
|
|
114
|
+
run: {
|
|
115
|
+
result: failed === 0 ? "passed" : "failed",
|
|
116
|
+
startedAt: new Date(run2.startedAt).toISOString(),
|
|
117
|
+
durationMs: Math.round(run2.durationMs),
|
|
118
|
+
environment: run2.environment,
|
|
119
|
+
/** The feature flag values the run used, where each came from, and the command run (SPEC.md §2.9). */
|
|
120
|
+
flags: {
|
|
121
|
+
values: run2.flags ?? {},
|
|
122
|
+
sources: run2.flagSources ?? {},
|
|
123
|
+
command: run2.flagCommand ?? null
|
|
124
|
+
},
|
|
125
|
+
settings: {
|
|
126
|
+
limitConcurrency: run2.concurrency,
|
|
127
|
+
timeoutCollection: run2.timeoutCollection,
|
|
128
|
+
bail: run2.bail,
|
|
129
|
+
tags: run2.tags,
|
|
130
|
+
notTags: run2.notTags ?? []
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
totals,
|
|
134
|
+
leftOut: { excluded: run2.excluded, untagged: run2.untagged },
|
|
135
|
+
reports,
|
|
136
|
+
collections: run2.collections.map(collectionOf)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function collectionOf(c) {
|
|
140
|
+
const { outcome, tally: tally2 } = c;
|
|
141
|
+
return {
|
|
142
|
+
id: c.id,
|
|
143
|
+
file: c.file,
|
|
144
|
+
status: tally2.skipped ? "skipped" : tally2.passed ? "passed" : "failed",
|
|
145
|
+
startedAt: new Date(c.startedAt).toISOString(),
|
|
146
|
+
durationMs: Math.round(c.durationMs),
|
|
147
|
+
counts: { steps: tally2.steps, assertions: tally2.assertions },
|
|
148
|
+
/** Why it did not run at all, or null when it ran. */
|
|
149
|
+
error: outcome.ok ? null : outcome.message,
|
|
150
|
+
/** The data file that ran it once per row (SPEC.md §2.8), or null. */
|
|
151
|
+
dataFile: outcome.ok ? outcome.data : null,
|
|
152
|
+
/** Steps a bail left unrun. */
|
|
153
|
+
notRun: outcome.ok ? unattempted(outcome.summary) : tally2.steps.total,
|
|
154
|
+
steps: outcome.ok ? outcome.summary.results.map(
|
|
155
|
+
(result, i) => stepOf(result, outcome.steps[i] ?? { index: i, line: null })
|
|
156
|
+
) : []
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function stepOf(result, ref) {
|
|
160
|
+
const truncated = {};
|
|
161
|
+
const clip2 = (field2, text) => {
|
|
162
|
+
if (text === null || text.length <= BODY_LIMIT) return text;
|
|
163
|
+
truncated[field2] = text.length;
|
|
164
|
+
return text.slice(0, BODY_LIMIT);
|
|
165
|
+
};
|
|
166
|
+
const request = { ...result.request, body: clip2("request.body", result.request.body) };
|
|
167
|
+
const response = result.response ? { ...result.response, body: clip2("response.body", result.response.body) ?? "" } : null;
|
|
168
|
+
return {
|
|
169
|
+
/** The step's place in the file's `steps`, from 0, and its line, from 1. */
|
|
170
|
+
step: { index: ref.index, line: ref.line },
|
|
171
|
+
/** The data file row it ran with, from 1, or null without a data file. */
|
|
172
|
+
iteration: ref.iteration ?? null,
|
|
173
|
+
...result,
|
|
174
|
+
request,
|
|
175
|
+
response,
|
|
176
|
+
/** Fields cut short, with their full length in characters. */
|
|
177
|
+
...Object.keys(truncated).length > 0 ? { truncated } : {}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function jsonListing(options) {
|
|
181
|
+
return {
|
|
182
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
183
|
+
project: options.project,
|
|
184
|
+
environments: options.environments,
|
|
185
|
+
environmentType: options.environmentType,
|
|
186
|
+
settings: { values: options.settings.values, sources: options.settings.sources },
|
|
187
|
+
flags: { values: options.flags.values, sources: options.flags.sources },
|
|
188
|
+
tags: options.tags,
|
|
189
|
+
notTags: options.notTags ?? [],
|
|
190
|
+
collections: options.targets.map((target) => ({
|
|
191
|
+
id: target.id,
|
|
192
|
+
file: fileOf(target.collection),
|
|
193
|
+
directory: target.collection.directory,
|
|
194
|
+
steps: target.steps ?? target.collection.doc.steps.map((_, i) => i),
|
|
195
|
+
stepCount: target.collection.doc.steps.length,
|
|
196
|
+
dataFile: target.collection.dataFile ? {
|
|
197
|
+
file: `collections/${target.collection.dataFile.relativePath}`,
|
|
198
|
+
rows: target.collection.dataFile.rows
|
|
199
|
+
} : null,
|
|
200
|
+
tags: target.collection.doc.tags ?? [],
|
|
201
|
+
broken: target.broken
|
|
202
|
+
})),
|
|
203
|
+
leftOut: { excluded: options.excluded, untagged: options.untagged },
|
|
204
|
+
problems: options.problems
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
var fileOf = (collection) => `collections/${collection.relativePath}`;
|
|
208
|
+
var jsonError = (message, exitCode) => ({
|
|
209
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
210
|
+
error: { message, exitCode }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// src/rules.ts
|
|
214
|
+
async function openRules(root) {
|
|
215
|
+
const info = await readProject(root);
|
|
216
|
+
if (info.source === null && !await isDirectory(path.join(root, COLLECTIONS_DIR))) {
|
|
217
|
+
throw new ProjectError(
|
|
218
|
+
`There is no ${COLLECTIONS_DIR}/ or ${PROJECT_FILE} in ${root}.
|
|
219
|
+
gta lint and gta rules run from a project folder, or a global project's.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (info.problems.length > 0) {
|
|
223
|
+
throw new ProjectError(
|
|
224
|
+
`${PROJECT_FILE} must be fixed first, to know which rules apply:
|
|
225
|
+
` + info.problems.map((problem) => ` ${problem.message}`).join("\n")
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
root,
|
|
230
|
+
name: info.doc?.name ?? path.basename(root),
|
|
231
|
+
uses: info.global?.uses ?? null,
|
|
232
|
+
global: info.global ? { root: info.global.root } : null,
|
|
233
|
+
loaded: await loadRules(root, info.global)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function rulesLine(project, p) {
|
|
237
|
+
if (project.loaded.files.length > 0) return field("Rules from", project.loaded.files.join(", "));
|
|
238
|
+
const where = project.uses ? `no ${RULES_FILE} here or in ${project.uses}` : `no ${RULES_FILE} here`;
|
|
239
|
+
return field("Rules", p.dim(`none: ${where}`));
|
|
240
|
+
}
|
|
241
|
+
var shownValue = (value) => value === null ? "off" : Array.isArray(value) ? `[${value.join(", ")}]` : String(value);
|
|
242
|
+
async function rulesCommand(root, { out, emit, p }) {
|
|
243
|
+
const project = await openRules(root);
|
|
244
|
+
const { settings } = project.loaded;
|
|
245
|
+
if (emit) {
|
|
246
|
+
emit({
|
|
247
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
248
|
+
project: { name: project.name, root: project.root },
|
|
249
|
+
files: project.loaded.files,
|
|
250
|
+
rules: settings.map((setting) => ({ ...setting, doc: ruleDoc(setting.rule) })),
|
|
251
|
+
guides: project.loaded.guides
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
out(field("Project", `${project.name} ${p.dim(project.root)}`));
|
|
256
|
+
out(rulesLine(project, p));
|
|
257
|
+
if (settings.length > 0) {
|
|
258
|
+
out("");
|
|
259
|
+
const ruleWidth = Math.max(...settings.map((s) => s.rule.length));
|
|
260
|
+
const valueWidth = Math.max(...settings.map((s) => shownValue(s.value).length));
|
|
261
|
+
for (const setting of settings) {
|
|
262
|
+
const value = shownValue(setting.value).padEnd(valueWidth);
|
|
263
|
+
out(
|
|
264
|
+
`${p.bold(setting.rule.padEnd(ruleWidth))} ${setting.on ? value : p.dim(value)} ${p.dim(setting.source)}`
|
|
265
|
+
);
|
|
266
|
+
out(` ${ruleDoc(setting.rule)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const guide of project.loaded.guides) {
|
|
270
|
+
out("");
|
|
271
|
+
out(`${p.bold("Guide")} ${p.dim(`(${guide.source})`)}`);
|
|
272
|
+
for (const line of guide.text.trimEnd().split("\n")) out(line === "" ? "" : ` ${line}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
276
|
+
function checkedLine(checked) {
|
|
277
|
+
return [
|
|
278
|
+
plural(checked.collections, "collection", "collections"),
|
|
279
|
+
plural(checked.requests, "reusable requests file", "reusable requests files"),
|
|
280
|
+
plural(checked.bases, "base collection", "base collections"),
|
|
281
|
+
plural(checked.endpoints, "endpoints file", "endpoints files")
|
|
282
|
+
].join(", ");
|
|
283
|
+
}
|
|
284
|
+
function findingLine(finding, p) {
|
|
285
|
+
const place = finding.line === null ? finding.file : `${finding.file}:${finding.line}`;
|
|
286
|
+
if (finding.rule === null) return `${place} ${p.red(finding.message)}`;
|
|
287
|
+
return `${place} ${finding.message} ${p.dim(`[${finding.rule} from ${finding.source}]`)}`;
|
|
288
|
+
}
|
|
289
|
+
async function lintCommand(root, { out, emit, p }) {
|
|
290
|
+
const project = await openRules(root);
|
|
291
|
+
const { loaded } = project;
|
|
292
|
+
const result = loaded.settings.some((setting) => setting.on) ? await lintProject(root, loaded, project.global) : { findings: [], checked: { collections: 0, requests: 0, bases: 0, endpoints: 0 } };
|
|
293
|
+
const found = result.findings.length > 0;
|
|
294
|
+
if (emit) {
|
|
295
|
+
emit({
|
|
296
|
+
formatVersion: JSON_FORMAT_VERSION,
|
|
297
|
+
project: { name: project.name, root: project.root },
|
|
298
|
+
files: loaded.files,
|
|
299
|
+
rules: loaded.rules,
|
|
300
|
+
checked: result.checked,
|
|
301
|
+
findings: result.findings
|
|
302
|
+
});
|
|
303
|
+
return found;
|
|
304
|
+
}
|
|
305
|
+
out(field("Project", `${project.name} ${p.dim(project.root)}`));
|
|
306
|
+
out(rulesLine(project, p));
|
|
307
|
+
const inEffect = loaded.settings.filter((setting) => setting.on).length;
|
|
308
|
+
if (inEffect === 0) {
|
|
309
|
+
out(p.dim("Nothing to check."));
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
out(field("In effect", `${plural(inEffect, "rule", "rules")} ${p.dim("(gta rules lists them)")}`));
|
|
313
|
+
out(field("Checked", checkedLine(result.checked)));
|
|
314
|
+
out("");
|
|
315
|
+
for (const finding of result.findings) out(findingLine(finding, p));
|
|
316
|
+
if (found) {
|
|
317
|
+
const files = new Set(result.findings.map((finding) => finding.file)).size;
|
|
318
|
+
out("");
|
|
319
|
+
out(
|
|
320
|
+
p.red(
|
|
321
|
+
`${plural(result.findings.length, "finding", "findings")} in ${plural(files, "file", "files")}`
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
} else {
|
|
325
|
+
out(p.green("No findings: every file follows the rules."));
|
|
326
|
+
}
|
|
327
|
+
return found;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/html.ts
|
|
331
|
+
var BODY_LIMIT2 = 256 * 1024;
|
|
332
|
+
var BINARY_LIMIT = 1024 * 1024;
|
|
333
|
+
var REPO_URL = "https://github.com/schwabyio/gravity";
|
|
353
334
|
var SUMMARY_PAGE = "summary.html";
|
|
354
335
|
function htmlReport(run2) {
|
|
355
336
|
return [
|
|
@@ -362,197 +343,235 @@ var flagList = (flags) => {
|
|
|
362
343
|
const entries = Object.entries(flags);
|
|
363
344
|
return entries.length > 0 ? entries.map(([name, value]) => `${name}=${value}`).join(", ") : "none";
|
|
364
345
|
};
|
|
346
|
+
var collectionStatus = (t) => t.skipped ? "skipped" : t.passed ? "passed" : "failed";
|
|
365
347
|
function summaryPage(run2) {
|
|
366
348
|
const tallies = run2.collections.map((c) => c.tally);
|
|
367
349
|
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
368
|
-
const
|
|
369
|
-
const
|
|
350
|
+
const statuses = tallies.map(collectionStatus);
|
|
351
|
+
const count = (status) => statuses.filter((s) => s === status).length;
|
|
352
|
+
const passed = count("failed") === 0;
|
|
353
|
+
const none = '<span class="dim">none</span>';
|
|
354
|
+
const leftOut = (n) => `<a href="#not-run">${plural2(n, "collection")} \u2193</a>`;
|
|
370
355
|
const settings = [
|
|
371
|
-
["
|
|
372
|
-
["Collection Timeout", `${run2.timeoutCollection} ms`],
|
|
373
|
-
["Environment", run2.environment ?? "none"],
|
|
356
|
+
["Environment", run2.environment ? escape(run2.environment) : none],
|
|
374
357
|
["Concurrency Limit", String(run2.concurrency)],
|
|
375
|
-
["
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
["Feature Flags",
|
|
379
|
-
...run2.flagCommand ? [["Flag Command", run2.flagCommand]] : [],
|
|
358
|
+
["Collection Timeout", timeoutText(run2.timeoutCollection)],
|
|
359
|
+
["Tags", run2.tags.length > 0 ? escape(run2.tags.join(", ")) : none],
|
|
360
|
+
...run2.notTags?.length ? [["Not Tags", escape(run2.notTags.join(", "))]] : [],
|
|
361
|
+
["Feature Flags", flagChips(run2.flags ?? {}) || none],
|
|
362
|
+
...run2.flagCommand ? [["Flag Command", `<code>${escape(run2.flagCommand)}</code>`]] : [],
|
|
380
363
|
...run2.bail ? [["Bail", "on"]] : [],
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
] : []
|
|
364
|
+
["Excluded", run2.excluded.length > 0 ? leftOut(run2.excluded.length) : none],
|
|
365
|
+
...run2.untagged > 0 ? [["Left Out By Tags", leftOut(run2.untagged)]] : []
|
|
384
366
|
];
|
|
385
|
-
const
|
|
367
|
+
const rows = run2.collections.map((c, i) => {
|
|
386
368
|
const t = c.tally;
|
|
387
|
-
const status =
|
|
388
|
-
|
|
389
|
-
seconds2(c.durationMs),
|
|
390
|
-
t.steps.total,
|
|
391
|
-
t.steps.passed,
|
|
392
|
-
t.steps.failed,
|
|
393
|
-
t.steps.errored,
|
|
394
|
-
t.steps.skipped,
|
|
395
|
-
t.assertions.total,
|
|
396
|
-
t.assertions.passed,
|
|
397
|
-
t.assertions.failed
|
|
398
|
-
];
|
|
399
|
-
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>`;
|
|
369
|
+
const status = statuses[i];
|
|
370
|
+
return `<tr class="s-${status}" data-status="${status}" data-search="${escape(c.id.toLowerCase())}"><td class="r n">${i + 1}</td><td class="name"><a href="${escape(encodeURI(pageOf(c.id)))}">${escape(c.id)}</a></td><td class="r">${duration(c.durationMs)}</td><td class="r gs">${t.steps.total}</td><td class="r">${num(t.steps.passed)}</td><td class="r">${num(t.steps.failed, true)}</td><td class="r">${num(t.steps.errored, true)}</td><td class="r">${num(t.steps.skipped)}</td><td class="r gs">${t.assertions.total}</td><td class="r">${num(t.assertions.passed)}</td><td class="r">${num(t.assertions.failed, true)}</td><td class="gs">${pill(status)}</td></tr>`;
|
|
400
371
|
}).join("\n");
|
|
372
|
+
const notRun = run2.excluded.length > 0 || run2.untagged > 0 ? card(
|
|
373
|
+
"Not Run",
|
|
374
|
+
[
|
|
375
|
+
run2.excluded.length > 0 ? `<h3 class="group-h">Excluded <span class="dim">\xB7 ${run2.excluded.length} \xB7 <code>exclude: true</code> in the collection file</span></h3>
|
|
376
|
+
<ul class="excluded">${run2.excluded.map((id) => `<li>${escape(id)}</li>`).join("")}</ul>` : "",
|
|
377
|
+
run2.untagged > 0 ? `<h3 class="group-h">Left out by tags <span class="dim">\xB7 ${plural2(run2.untagged, "collection")}</span></h3>` : ""
|
|
378
|
+
].join("\n"),
|
|
379
|
+
{ id: "not-run" }
|
|
380
|
+
) : "";
|
|
401
381
|
return page({
|
|
402
382
|
title: `${passed ? "PASSED" : "FAILED"} \xB7 ${run2.project} \xB7 gta Summary Results`,
|
|
403
383
|
run: run2,
|
|
404
|
-
body:
|
|
384
|
+
body: `${pageHead(passed ? "passed" : "failed", `${escape(run2.project)} <span class="kind">\xB7 Summary Results</span>`, run2.startedAt, run2.durationMs)}
|
|
405
385
|
|
|
406
|
-
|
|
407
|
-
${settings.map(([label, value]) =>
|
|
408
|
-
</
|
|
386
|
+
${card("Settings", `<dl class="pairs">
|
|
387
|
+
${settings.map(([label, value]) => `<div><dt>${label}</dt><dd>${value}</dd></div>`).join("\n")}
|
|
388
|
+
</dl>`)}
|
|
409
389
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
390
|
+
${card(
|
|
391
|
+
"Summary Stats",
|
|
392
|
+
`<div class="stats">
|
|
393
|
+
${ringStat("Collections", [
|
|
394
|
+
["passed", count("passed")],
|
|
395
|
+
["failed", count("failed")],
|
|
396
|
+
["skipped", count("skipped")]
|
|
397
|
+
])}
|
|
398
|
+
${ringStat("Tests", [
|
|
399
|
+
["passed", sum((t) => t.steps.passed)],
|
|
400
|
+
["failed", sum((t) => t.steps.failed)],
|
|
401
|
+
["errored", sum((t) => t.steps.errored)],
|
|
402
|
+
["skipped", sum((t) => t.steps.skipped)]
|
|
403
|
+
])}
|
|
404
|
+
${ringStat("Assertions", [
|
|
405
|
+
["passed", sum((t) => t.assertions.passed)],
|
|
406
|
+
["failed", sum((t) => t.assertions.failed)]
|
|
407
|
+
])}
|
|
408
|
+
</div>`
|
|
409
|
+
)}
|
|
428
410
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
<
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
411
|
+
${card(
|
|
412
|
+
"Results Overview",
|
|
413
|
+
`<div class="scroll-x"><table class="overview">
|
|
414
|
+
<thead><tr class="groups"><th colspan="3"></th><th colspan="5" class="gs">Tests</th><th colspan="3" class="gs">Assertions</th><th class="gs"></th></tr>
|
|
415
|
+
<tr><th class="r">#</th><th>Collection ID</th><th class="r">Run Time</th><th class="r gs">Total</th><th class="r">Passed</th><th class="r">Failed</th><th class="r">Errored</th><th class="r">Skipped</th><th class="r gs">Total</th><th class="r">Passed</th><th class="r">Failed</th><th class="gs">Result</th></tr></thead>
|
|
416
|
+
<tbody>
|
|
417
|
+
${rows}
|
|
418
|
+
</tbody></table></div>`,
|
|
419
|
+
{
|
|
420
|
+
flush: true,
|
|
421
|
+
tools: `${chips({ passed: count("passed"), failed: count("failed"), skipped: count("skipped") })}${searchBox("Search collections")}`
|
|
422
|
+
}
|
|
423
|
+
)}
|
|
424
|
+
${notRun}`
|
|
440
425
|
});
|
|
441
426
|
}
|
|
442
427
|
function collectionPage(run2, c) {
|
|
443
428
|
const t = c.tally;
|
|
429
|
+
const status = collectionStatus(t);
|
|
430
|
+
const results = c.outcome.ok ? c.outcome.summary.results : [];
|
|
444
431
|
const tests = !c.outcome.ok ? "" : [
|
|
445
|
-
...c.outcome.
|
|
446
|
-
(r, i) => testSection(r, i, c.outcome.ok ? c.outcome.steps[i] : void 0)
|
|
447
|
-
),
|
|
432
|
+
...results.map((r, i) => testSection(r, i, c.outcome.ok ? c.outcome.steps[i] : void 0)),
|
|
448
433
|
unattempted(c.outcome.summary) > 0 ? notRunSection(unattempted(c.outcome.summary)) : ""
|
|
449
434
|
].join("\n");
|
|
450
|
-
const
|
|
435
|
+
const groups = results.map((r) => group(statusOf(r)));
|
|
436
|
+
const data = c.outcome.ok ? c.outcome.data : null;
|
|
451
437
|
return page({
|
|
452
|
-
title: `${
|
|
438
|
+
title: `${status.toUpperCase()} \xB7 ${c.id} \xB7 gta Collection Results`,
|
|
453
439
|
run: run2,
|
|
454
|
-
body: `<
|
|
455
|
-
|
|
456
|
-
<div class="card"><div class="card-header">Results Overview</div><div class="card-body">
|
|
457
|
-
${field2("Collection ID", escape(c.id), "wide")}
|
|
458
|
-
${c.docs?.trim() ? field2("Collection Docs", markdown(c.docs), "wide") : ""}
|
|
459
|
-
${c.outcome.ok && c.outcome.data ? field2("Data File", escape(c.outcome.data.file), "wide") : ""}
|
|
460
|
-
${c.outcome.ok && c.outcome.data ? field2("Iterations", String(c.outcome.data.rows)) : ""}
|
|
461
|
-
${!c.outcome.ok ? field2("Error", `<pre class="c-failed">${escape(c.outcome.message)}</pre>`, "wide") : ""}
|
|
462
|
-
${field2("Time of Run", escape(timeOfRun(c.startedAt)))}
|
|
463
|
-
${statsTable([
|
|
464
|
-
["Collection Run Time", seconds2(c.durationMs)],
|
|
465
|
-
["Total Tests", t.steps.total],
|
|
466
|
-
["Tests Passed", t.steps.passed],
|
|
467
|
-
["Tests Failed", t.steps.failed],
|
|
468
|
-
["Tests Errored", t.steps.errored],
|
|
469
|
-
["Tests Skipped", t.steps.skipped],
|
|
470
|
-
["Total Assertions", t.assertions.total],
|
|
471
|
-
["Assertions Passed", t.assertions.passed],
|
|
472
|
-
["Assertions Failed", t.assertions.failed],
|
|
473
|
-
["Overall Result", t.skipped ? '<span class="c-skipped">SKIPPED</span>' : finalResult(t.passed)]
|
|
474
|
-
])}
|
|
475
|
-
</div></div>
|
|
440
|
+
body: `<nav class="crumbs"><a href="${SUMMARY_PAGE}">Summary</a><span aria-hidden="true">/</span><span>${escape(c.id)}</span></nav>
|
|
441
|
+
${pageHead(status, escape(c.id), c.startedAt, c.durationMs)}
|
|
476
442
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
443
|
+
${card(
|
|
444
|
+
"Results Overview",
|
|
445
|
+
[
|
|
446
|
+
c.docs?.trim() ? markdown(c.docs) : "",
|
|
447
|
+
data ? `<dl class="pairs"><div><dt>Data File</dt><dd><code>${escape(data.file)}</code></dd></div><div><dt>Iterations</dt><dd>${data.rows}</dd></div></dl>` : "",
|
|
448
|
+
!c.outcome.ok ? `<section class="sub problem"><h3>Error</h3><pre>${escape(c.outcome.message)}</pre></section>` : "",
|
|
449
|
+
`<div class="stats">
|
|
450
|
+
${ringStat("Tests", [
|
|
451
|
+
["passed", t.steps.passed],
|
|
452
|
+
["failed", t.steps.failed],
|
|
453
|
+
["errored", t.steps.errored],
|
|
454
|
+
["skipped", t.steps.skipped]
|
|
455
|
+
])}
|
|
456
|
+
${ringStat("Assertions", [
|
|
457
|
+
["passed", t.assertions.passed],
|
|
458
|
+
["failed", t.assertions.failed]
|
|
459
|
+
])}
|
|
460
|
+
</div>`
|
|
461
|
+
].filter(Boolean).join("\n"),
|
|
462
|
+
{ tools: `<code class="file">${escape(c.file)}</code>` }
|
|
486
463
|
)}
|
|
487
|
-
|
|
488
|
-
</div
|
|
464
|
+
|
|
465
|
+
<div class="toolbar"><h2>Tests</h2>${groups.length > 0 ? searchBox("Search tests") : ""}<div class="tools-end">${chips(
|
|
466
|
+
{
|
|
467
|
+
passed: groups.filter((g) => g === "passed").length,
|
|
468
|
+
failed: groups.filter((g) => g === "failed").length,
|
|
469
|
+
skipped: groups.filter((g) => g === "skipped").length
|
|
470
|
+
}
|
|
471
|
+
)}${groups.length === 0 ? "" : (
|
|
472
|
+
// Says what a click does: expand while any test is closed, as passed and skipped ones start.
|
|
473
|
+
`<button type="button" class="btn" id="expand-all">${groups.some((g) => g !== "failed") ? "Expand All" : "Collapse All"}</button>`
|
|
474
|
+
)}</div></div>
|
|
489
475
|
<div class="tests">
|
|
490
|
-
${tests || '<p class="dim">No tests were run.</p>'}
|
|
476
|
+
${tests || '<p class="dim empty">No tests were run.</p>'}
|
|
491
477
|
</div>`
|
|
492
478
|
});
|
|
493
479
|
}
|
|
494
480
|
var statusOf = (r) => r.status === "pass" ? "passed" : r.status === "fail" ? "failed" : r.status === "error" ? "errored" : "skipped";
|
|
481
|
+
var group = (status) => status === "errored" ? "failed" : status;
|
|
495
482
|
function testSection(r, index, ref) {
|
|
496
483
|
const status = statusOf(r);
|
|
497
|
-
const named =
|
|
498
|
-
const name = r.use ? `${named} <span class="dim">(${escape(r.use.set)} ${r.use.child + 1}/${r.use.of})</span>` : named;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
<
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
r.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const group = status === "passed" ? "passed" : status === "skipped" ? "skipped" : "failed";
|
|
515
|
-
const search = iterationName(resultName(r), ref).toLowerCase();
|
|
516
|
-
return `<details class="acc ${group} ${status}" data-search="${escape(search)}"${status === "passed" ? "" : " open"}>
|
|
517
|
-
<summary><span>Test ${index + 1}: ${name}</span>${icon(status)}</summary>
|
|
518
|
-
<div class="acc-body">
|
|
519
|
-
${cards.join("\n")}
|
|
484
|
+
const named = iterationName(resultName(r), ref);
|
|
485
|
+
const name = r.use ? `${escape(named)} <span class="dim">(${escape(r.use.set)} ${r.use.child + 1}/${r.use.of})</span>` : escape(named);
|
|
486
|
+
const method = r.request.method.toUpperCase();
|
|
487
|
+
const res = r.response;
|
|
488
|
+
const outcome = r.skipped ? '<span class="dim">skipped</span>' : res ? `${statusCode(res)}<span class="dim">${duration(res.timings.totalMs)}</span>` : '<span class="c-failed">no response</span>';
|
|
489
|
+
const id = `t${index + 1}`;
|
|
490
|
+
const body = r.skipped ? `<p class="skip-reason">${escape(r.skipped.reason)} \u2014 this test was not run</p>` : [
|
|
491
|
+
r.error ? errorSection(r) : "",
|
|
492
|
+
requestSection(r.request),
|
|
493
|
+
responseSection(r, id),
|
|
494
|
+
assertionsSection(r.assertions),
|
|
495
|
+
r.logs?.length ? consoleSection(r) : ""
|
|
496
|
+
].filter(Boolean).join("\n");
|
|
497
|
+
return `<details class="test s-${status}" id="${id}" data-status="${group(status)}" data-search="${escape(named.toLowerCase())}"${status === "passed" || status === "skipped" ? "" : " open"}>
|
|
498
|
+
<summary><span class="n">${index + 1}</span><span class="method ${methodClass(method)}">${escape(method)}</span><span class="name">${name}</span><span class="res">${outcome}${mark(status)}<span class="chev" aria-hidden="true">\u25B6</span></span></summary>
|
|
499
|
+
<div class="test-body">
|
|
500
|
+
${body}
|
|
520
501
|
</div>
|
|
521
502
|
</details>`;
|
|
522
503
|
}
|
|
523
504
|
function notRunSection(count) {
|
|
524
|
-
return `<details class="
|
|
525
|
-
<summary><span
|
|
526
|
-
<div class="
|
|
505
|
+
return `<details class="test s-skipped" data-status="skipped" data-search="">
|
|
506
|
+
<summary><span class="n"></span><span class="name">${plural2(count, "test")} not run</span><span class="res">${mark("skipped")}<span class="chev" aria-hidden="true">\u25B6</span></span></summary>
|
|
507
|
+
<div class="test-body"><p class="skip-reason">The collection stopped at its first failure (bail), so ${count === 1 ? "this test was" : "these tests were"} not run.</p></div>
|
|
527
508
|
</details>`;
|
|
528
509
|
}
|
|
529
|
-
function
|
|
510
|
+
function errorSection(r) {
|
|
530
511
|
const e = r.error;
|
|
531
512
|
const where = [e.script && `${e.script} script`, e.line && `line ${e.line}`].filter(Boolean).join(", ");
|
|
532
513
|
const detail = e.stack ? `
|
|
533
514
|
|
|
534
515
|
${userFrames(e.stack)}` : "";
|
|
535
|
-
return
|
|
536
|
-
"Error",
|
|
537
|
-
field2(
|
|
538
|
-
`${capitalise(e.phase)} Error`,
|
|
539
|
-
`<pre class="c-failed">${escape(`${where ? `(${where}) ` : ""}${e.message}${detail}`)}</pre>`,
|
|
540
|
-
"wide"
|
|
541
|
-
)
|
|
542
|
-
);
|
|
516
|
+
return `<section class="sub problem"><h3>${escape(capitalise(e.phase))} Error</h3><pre>${escape(`${where ? `(${where}) ` : ""}${e.message}${detail}`)}</pre></section>`;
|
|
543
517
|
}
|
|
544
|
-
function
|
|
545
|
-
const { request
|
|
546
|
-
return
|
|
518
|
+
function rawRequest(request) {
|
|
519
|
+
const head = [`${request.method} ${request.url}`, ...request.headers.map(headerLine)].join("\n");
|
|
520
|
+
return request.body ? `${head}
|
|
521
|
+
|
|
522
|
+
${request.body}` : head;
|
|
523
|
+
}
|
|
524
|
+
function rawResponse(response) {
|
|
525
|
+
const head = [
|
|
526
|
+
`${response.status} ${response.statusText}`.trim(),
|
|
527
|
+
...response.headers.map(headerLine)
|
|
528
|
+
].join("\n");
|
|
529
|
+
const body = response.bodyEncoding === "base64" ? `[binary body, ${bytes(response.sizeBytes)}]` : response.body;
|
|
530
|
+
return body ? `${head}
|
|
531
|
+
|
|
532
|
+
${body}` : head;
|
|
533
|
+
}
|
|
534
|
+
var headerLine = (header) => `${header.name}: ${header.value}`;
|
|
535
|
+
var exchange = (title, formatted, raw, rawNote, rawId = "") => `<section class="sub" data-switch data-exchange>
|
|
536
|
+
<div class="sub-h"><h3>${title}</h3><span class="seg" role="group" aria-label="${title} view"><button type="button" data-show="formatted" aria-pressed="true">Formatted</button><button type="button" data-show="raw" aria-pressed="false">Raw</button></span></div>
|
|
537
|
+
<div data-part="formatted">
|
|
538
|
+
${formatted}
|
|
539
|
+
</div>
|
|
540
|
+
<div data-part="raw" hidden>${part(title === "Request" ? "As sent" : "As received", rawNote, copyButton("raw"), `<pre class="raw" data-raw${rawId ? ` id="${rawId}"` : ""}>${escape(clip(raw))}</pre>`)}</div>
|
|
541
|
+
</section>`;
|
|
542
|
+
function requestSection(request) {
|
|
543
|
+
const { headers, body } = request;
|
|
544
|
+
const missing = [headers.length === 0 && "no headers", !body && "no body"].filter(Boolean);
|
|
545
|
+
const formatted = [
|
|
546
|
+
`<div class="line"><span class="method ${methodClass(request.method)}">${escape(request.method.toUpperCase())}</span><code class="url">${escape(request.url || "\u2014")}</code>${request.url ? copyButton("url", "Copy URL") : ""}</div>`,
|
|
547
|
+
headers.length > 0 ? part("Headers", String(headers.length), copyButton("headers"), headerTable(headers)) : "",
|
|
548
|
+
body ? part(
|
|
549
|
+
"Body",
|
|
550
|
+
[headerValue(headers, "content-type"), bytes(Buffer.byteLength(body))].filter(Boolean).map((s) => escape(s)).join(" \xB7 "),
|
|
551
|
+
copyButton("body"),
|
|
552
|
+
codeView(requestBodyLines(body), false)
|
|
553
|
+
) : "",
|
|
554
|
+
missing.length > 0 ? `<p class="dim small none">${capitalise(missing.join(" \xB7 "))}</p>` : ""
|
|
555
|
+
].filter(Boolean).join("\n");
|
|
556
|
+
return exchange(
|
|
547
557
|
"Request",
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
request.headers.length > 0 ? field2("Request Headers", scrollPre(headerText(request.headers)), "wide") : "",
|
|
552
|
-
request.body ? field2("Request Body", scrollPre(clip2(request.body)), "wide") : ""
|
|
553
|
-
].filter(Boolean).join("\n")
|
|
558
|
+
formatted,
|
|
559
|
+
rawRequest(request),
|
|
560
|
+
"method and URL, headers, a blank line, then the body"
|
|
554
561
|
);
|
|
555
562
|
}
|
|
563
|
+
function requestBodyLines(body) {
|
|
564
|
+
if (/^\s*[{[]/.test(body)) {
|
|
565
|
+
try {
|
|
566
|
+
return toJsonLines(JSON.parse(body)).map((line) => ({
|
|
567
|
+
text: line.text,
|
|
568
|
+
html: highlightJson(line.text)
|
|
569
|
+
}));
|
|
570
|
+
} catch {
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return body.split("\n").map((text) => ({ text, html: escape(text) }));
|
|
574
|
+
}
|
|
556
575
|
var ENDED_BY = {
|
|
557
576
|
close: "closed by the server",
|
|
558
577
|
maxEvents: "stopped at maxEvents",
|
|
@@ -563,70 +582,226 @@ var ENDED_BY = {
|
|
|
563
582
|
held: "took the events held"
|
|
564
583
|
};
|
|
565
584
|
var connectionNote = (stream) => stream.connection ? `, connection ${stream.connection.name} ${stream.connection.open ? "open" : "closed"}` : "";
|
|
566
|
-
function
|
|
585
|
+
function responseSection(r, id) {
|
|
567
586
|
const res = r.response;
|
|
568
587
|
if (!res) {
|
|
569
|
-
return
|
|
570
|
-
"Response",
|
|
571
|
-
field2("Failure", '<span class="c-failed">No response was received.</span>', "wide")
|
|
572
|
-
);
|
|
588
|
+
return `<section class="sub"><h3>Response</h3><p class="c-failed none">No response was received.</p></section>`;
|
|
573
589
|
}
|
|
574
|
-
const
|
|
575
|
-
const
|
|
590
|
+
const checks = buildChecks(r.assertions);
|
|
591
|
+
const status = markStatus(checks).mark;
|
|
576
592
|
const extra = [
|
|
577
|
-
`first byte ${
|
|
593
|
+
`first byte ${duration(res.timings.ttfbMs)}`,
|
|
578
594
|
bytes(res.sizeBytes),
|
|
579
|
-
res.redirectCount > 0 ?
|
|
580
|
-
res.stream ? `${res.stream.at.length
|
|
595
|
+
res.redirectCount > 0 ? plural2(res.redirectCount, "redirect") : "",
|
|
596
|
+
res.stream ? `${plural2(res.stream.at.length, "event")}, ${ENDED_BY[res.stream.endedBy]}${connectionNote(res.stream)}` : ""
|
|
581
597
|
].filter(Boolean);
|
|
582
|
-
|
|
598
|
+
const formatted = [
|
|
599
|
+
`<div class="line">${statusCode(res)}${status ? `<span class="mk-inline mk-${status}" title="${status === "fail" ? "A status check failed" : "Status checked"}">${MARK_GLYPH[status]}</span>` : ""}<span class="dim small">${duration(res.timings.totalMs)} \xB7 ${escape(extra.join(" \xB7 "))}</span></div>`,
|
|
600
|
+
res.headers.length > 0 ? part(
|
|
601
|
+
"Headers",
|
|
602
|
+
String(res.headers.length),
|
|
603
|
+
copyButton("headers"),
|
|
604
|
+
headerTable(res.headers, markHeaders(res.headers, checks))
|
|
605
|
+
) : "",
|
|
606
|
+
responseBody(r, res, checks, id)
|
|
607
|
+
].filter(Boolean).join("\n");
|
|
608
|
+
return exchange(
|
|
583
609
|
"Response",
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
field2(
|
|
589
|
-
"Response Time",
|
|
590
|
-
`${Math.round(res.timings.totalMs)}ms <span class="dim">\xB7 ${extra.join(" \xB7 ")}</span>`
|
|
591
|
-
)
|
|
592
|
-
].filter(Boolean).join("\n")
|
|
610
|
+
formatted,
|
|
611
|
+
rawResponse(res),
|
|
612
|
+
"status, headers, a blank line, then the body",
|
|
613
|
+
`${id}-raw`
|
|
593
614
|
);
|
|
594
615
|
}
|
|
595
|
-
function
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
616
|
+
function responseBody(r, res, checks, id) {
|
|
617
|
+
if (res.bodyKind === "empty") return '<p class="dim small none">Empty body</p>';
|
|
618
|
+
const rawId = `${id}-raw`;
|
|
619
|
+
const type = headerValue(res.headers, "content-type") ?? res.bodyKind;
|
|
620
|
+
const how = checkedDiffersFromRaw(res, r.sortedBy) ? [
|
|
621
|
+
res.bodyKind === "xml" ? "converted from XML" : "",
|
|
622
|
+
res.bodyKind === "events" ? "read as events" : "",
|
|
623
|
+
r.sortedBy?.length ? `sorted by ${r.sortedBy.join(", ")}` : ""
|
|
624
|
+
].filter(Boolean) : [];
|
|
625
|
+
const meta = escape([type, bytes(res.sizeBytes), ...how].join(" \xB7 "));
|
|
626
|
+
if (res.bodyEncoding === "base64") return binaryBody(res, meta, id);
|
|
627
|
+
const copy = copyButton("body");
|
|
628
|
+
const checked = checkedBody(res, r.sortedBy);
|
|
629
|
+
if (!checked.ok) {
|
|
630
|
+
return part(
|
|
631
|
+
"Body",
|
|
632
|
+
meta,
|
|
633
|
+
copy,
|
|
634
|
+
`<p class="${res.bodyKind === "binary" ? "dim" : "c-failed"} small pad">${escape(checked.message)}</p><pre class="raw">${escape(clip(res.body))}</pre>`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const ignored = (r.ignored ?? []).map((entry) => parsePath(entry.path));
|
|
638
|
+
const marks = markLines(checked.lines, checks, ignored);
|
|
639
|
+
const structured = res.bodyKind !== "text" && res.bodyKind !== "html";
|
|
640
|
+
const value = structured ? checkedValue(res, r.sortedBy) : void 0;
|
|
641
|
+
const noted = /* @__PURE__ */ new Set();
|
|
642
|
+
const lines = checked.lines.map((line, i) => {
|
|
643
|
+
const marked = marks[i];
|
|
644
|
+
const failing = marked.mark === "fail" ? marked.about.filter((k) => checks[k].assertion.status === "fail" && !noted.has(k)) : [];
|
|
645
|
+
for (const k of failing) noted.add(k);
|
|
646
|
+
const note = failing.length ? `<span class="note">${MARK_GLYPH.fail} ${escape(failing.map((k) => checks[k].assertion.message ?? checks[k].assertion.name).join("; "))}</span>` : "";
|
|
647
|
+
const viewer = value === void 0 ? null : stringViewer(value, line, `${rawId}-v${i}`);
|
|
648
|
+
return {
|
|
649
|
+
text: line.text,
|
|
650
|
+
html: viewer?.html ?? (structured ? highlightJson(line.text) : escape(line.text)),
|
|
651
|
+
mark: marked.mark,
|
|
652
|
+
note: `${viewer?.button ?? ""}${note}`,
|
|
653
|
+
after: viewer?.panel
|
|
654
|
+
};
|
|
655
|
+
});
|
|
656
|
+
const view = codeView(lines, true);
|
|
657
|
+
if (res.bodyKind === "html") {
|
|
658
|
+
return part(
|
|
659
|
+
"Body",
|
|
660
|
+
meta,
|
|
661
|
+
`${renderSwitch()}${copy}`,
|
|
662
|
+
`<div data-part="rendered">${htmlFrame(rawId, true, "The response body, rendered")}</div>
|
|
663
|
+
<div data-part="source" hidden>${view}</div>`,
|
|
664
|
+
true
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
return part("Body", meta, copy, view);
|
|
668
|
+
}
|
|
669
|
+
function binaryBody(res, meta, id) {
|
|
670
|
+
const type = (headerValue(res.headers, "content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
671
|
+
if (res.sizeBytes > BINARY_LIMIT) {
|
|
672
|
+
return part(
|
|
673
|
+
"Body",
|
|
674
|
+
meta,
|
|
675
|
+
"",
|
|
676
|
+
`<p class="dim small pad">Over ${bytes(BINARY_LIMIT)}, too big to put in the report.</p>`
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const uri = escape(`data:${type || "application/octet-stream"};base64,${res.body}`);
|
|
680
|
+
if (type.startsWith("image/")) {
|
|
681
|
+
return part(
|
|
682
|
+
"Body",
|
|
683
|
+
meta,
|
|
684
|
+
copyButton("image", "Copy data URI"),
|
|
685
|
+
`<div class="img-view"><span class="checker"><img loading="lazy" src="${uri}" alt="The response body"></span><span class="dim small"><span data-dims></span>${escape(type)}</span></div>`
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
const subtype = type.split("/")[1] ?? "";
|
|
689
|
+
const extension = /^[a-z0-9]{1,8}$/.test(subtype) ? subtype : "bin";
|
|
690
|
+
return part(
|
|
691
|
+
"Body",
|
|
692
|
+
meta,
|
|
693
|
+
`<a class="btn sm" href="${uri}" download="${id}-body.${extension}">Download</a>`,
|
|
694
|
+
'<p class="dim small pad">Binary, so not shown as text. Download saves it as it came.</p>'
|
|
604
695
|
);
|
|
605
|
-
|
|
606
|
-
|
|
696
|
+
}
|
|
697
|
+
function checkedValue(res, sortedBy) {
|
|
698
|
+
const parsed = bodyAsObject(res);
|
|
699
|
+
if (!parsed.ok) return void 0;
|
|
700
|
+
return sortedBy ? sortArraysBy(parsed.body, sortedBy) : parsed.body;
|
|
701
|
+
}
|
|
702
|
+
function stringViewer(value, line, id) {
|
|
703
|
+
if (line.path.length === 0 || !/"[,]?$/.test(line.text)) return null;
|
|
704
|
+
const found = resolvePath(value, line.path)[0]?.value;
|
|
705
|
+
if (typeof found !== "string") return null;
|
|
706
|
+
const path3 = formatPath(line.path);
|
|
707
|
+
const close = `<button type="button" class="btn sm" data-close="${id}">Close</button>`;
|
|
708
|
+
if (DATA_IMAGE.test(found)) {
|
|
709
|
+
const literal = JSON.stringify(found);
|
|
710
|
+
const at = line.text.lastIndexOf(literal);
|
|
711
|
+
const shown = `${line.text.slice(0, at)}${JSON.stringify(`${found.slice(0, 40)}\u2026`)}${line.text.slice(at + literal.length)}`;
|
|
712
|
+
const comma = found.indexOf(",");
|
|
713
|
+
const size = Math.floor((found.length - comma - 1) * 3 / 4) - (found.match(/=+$/)?.[0].length ?? 0);
|
|
714
|
+
return {
|
|
715
|
+
html: `${highlightJson(shown)}<span class="dim"> (${bytes(found.length)})</span>`,
|
|
716
|
+
button: `<button type="button" class="peek" data-peek="${id}" aria-expanded="false">View image</button>`,
|
|
717
|
+
panel: `<div class="peek-row" id="${id}" hidden><div class="peek-panel">
|
|
718
|
+
<div class="peek-h"><code>${escape(path3)}</code><span class="dim">${escape(found.slice(5, found.indexOf(";")))} \xB7 <span data-dims></span>${bytes(size)}</span><span class="end"><button type="button" class="btn sm" data-copy="image">Copy data URI</button>${close}</span></div>
|
|
719
|
+
<div class="img-view"><span class="checker"><img loading="lazy" src="${escape(found)}" alt="The image at ${escape(path3)}"></span></div>
|
|
720
|
+
</div></div>`
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (looksLikeHtml(found)) {
|
|
724
|
+
return {
|
|
725
|
+
button: `<button type="button" class="peek" data-peek="${id}" aria-expanded="false">View HTML</button>`,
|
|
726
|
+
panel: `<div class="peek-row" id="${id}" hidden><div class="peek-panel" data-switch>
|
|
727
|
+
<div class="peek-h"><code>${escape(path3)}</code><span class="dim">HTML \xB7 ${bytes(Buffer.byteLength(found))}</span><span class="end">${renderSwitch()}<button type="button" class="btn sm" data-copy="source">Copy</button>${close}</span></div>
|
|
728
|
+
<div data-part="rendered">${htmlFrame(`${id}-source`, false, `The HTML at ${path3}, rendered`)}</div>
|
|
729
|
+
<pre class="raw" data-part="source" id="${id}-source" hidden>${escape(found)}</pre>
|
|
730
|
+
</div></div>`
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
var DATA_IMAGE = /^data:image\/[\w.+-]+;base64,[A-Za-z0-9+/]+=*$/;
|
|
736
|
+
var looksLikeHtml = (text) => /^\s*<(!doctype\s+html|[a-z][a-z0-9-]*)[\s>/]/i.test(text) && /<\/[a-z][a-z0-9-]*\s*>/i.test(text);
|
|
737
|
+
var htmlFrame = (from, body, title) => `<div class="html-frame"><iframe sandbox="" title="${escape(title)}" data-from="${from}"${body ? " data-body" : ""}></iframe></div><p class="frame-note">Sandboxed: scripts don't run, and nothing loads from the network.</p>`;
|
|
738
|
+
var renderSwitch = () => '<span class="seg" role="group" aria-label="HTML view"><button type="button" data-show="rendered" aria-pressed="true">Rendered</button><button type="button" data-show="source" aria-pressed="false">Source</button></span>';
|
|
739
|
+
function headerTable(headers, marks) {
|
|
740
|
+
const anyMarked = marks?.some((m) => m.mark !== null) ?? false;
|
|
741
|
+
return `<table class="kv">${headers.map((h, i) => {
|
|
742
|
+
const mark2 = marks?.[i]?.mark ?? null;
|
|
743
|
+
return `<tr${mark2 ? ` class="mk-${mark2}"` : ""}>${anyMarked ? `<td class="mk">${mark2 ? MARK_GLYPH[mark2] : ""}</td>` : ""}<td class="k">${escape(h.name)}</td><td>${escape(h.value)}</td></tr>`;
|
|
744
|
+
}).join("")}</table>`;
|
|
745
|
+
}
|
|
746
|
+
function assertionsSection(assertions) {
|
|
747
|
+
const failed = assertions.filter((a) => a.status === "fail").length;
|
|
748
|
+
const head = `<h3>Assertions <span class="dim">${assertions.length} \xB7 ${assertions.length - failed} passed \xB7 ${failed} failed</span></h3>`;
|
|
749
|
+
if (assertions.length === 0)
|
|
750
|
+
return `<section class="sub">${head}<p class="dim small none">No assertions.</p></section>`;
|
|
751
|
+
const items = assertions.map((a) => {
|
|
607
752
|
const failing = a.status === "fail";
|
|
608
|
-
const
|
|
609
|
-
a.message ? `<div class="
|
|
610
|
-
failing && a.expected !== void 0 ? `<
|
|
611
|
-
|
|
612
|
-
a.unasserted?.length ? `<div class="ea"><span class="dim">not asserted</span> <code>${escape(a.unasserted.join(", "))}</code></div>` : ""
|
|
753
|
+
const detail = [
|
|
754
|
+
a.message ? `<div class="check-msg">${escape(a.message)}</div>` : "",
|
|
755
|
+
failing && (a.expected !== void 0 || a.actual !== void 0) ? `<dl class="ea">${a.expected !== void 0 ? `<dt>expected</dt><dd><code>${escape(a.expected)}</code></dd>` : ""}${a.actual !== void 0 ? `<dt>actual</dt><dd><code>${escape(a.actual)}</code></dd>` : ""}</dl>` : "",
|
|
756
|
+
a.unasserted?.length ? `<dl class="ea"><dt>not asserted</dt><dd><code>${escape(a.unasserted.join(", "))}</code></dd></dl>` : ""
|
|
613
757
|
].join("");
|
|
614
|
-
const
|
|
615
|
-
return `<div class="
|
|
616
|
-
})
|
|
617
|
-
return
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
<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">
|
|
621
|
-
${list2}
|
|
622
|
-
</div></div>`
|
|
623
|
-
);
|
|
758
|
+
const status = failing ? "failed" : "passed";
|
|
759
|
+
return `<li class="s-${status}">${mark(status)}<div class="check"><div class="check-name">${escape(a.name)}</div>${detail}</div>${a.source ? `<span class="src">${escape(sourceText(a.source))}</span>` : ""}</li>`;
|
|
760
|
+
});
|
|
761
|
+
return `<section class="sub">${head}<ul class="checks">
|
|
762
|
+
${items.join("\n")}
|
|
763
|
+
</ul></section>`;
|
|
624
764
|
}
|
|
625
|
-
|
|
765
|
+
var sourceText = (source) => `${source.script === "step" ? "tests" : `${source.script} tests`}:${source.line}${source.check ? ` \xB7 ${source.check.file}:${source.check.line}` : ""}`;
|
|
766
|
+
function consoleSection(r) {
|
|
626
767
|
const lines = r.logs.map(
|
|
627
768
|
(l) => `<span class="log-${l.level}">[${escape(l.phase)} ${l.level}] ${escape(l.message)}</span>`
|
|
628
769
|
);
|
|
629
|
-
return
|
|
770
|
+
return `<section class="sub"><h3>Console</h3><pre class="console">${lines.join("\n")}</pre></section>`;
|
|
771
|
+
}
|
|
772
|
+
function codeView(all, marked) {
|
|
773
|
+
const { lines, more } = cutLines(all);
|
|
774
|
+
const rows = lines.map(
|
|
775
|
+
(line, i) => `<div class="ln${line.mark ? ` mk-${line.mark}` : ""}"><span class="no">${i + 1}</span>${marked ? `<span class="mk" aria-hidden="true">${line.mark ? MARK_GLYPH[line.mark] : ""}</span>` : ""}<span class="tx">${line.html}${line.note ?? ""}</span></div>${line.after ?? ""}`
|
|
776
|
+
);
|
|
777
|
+
if (more > 0) {
|
|
778
|
+
rows.push(
|
|
779
|
+
`<div class="ln cut"><span class="no"></span><span class="tx">\u2026 ${bytes(more)} more not shown</span></div>`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
return `<div class="codeview">${rows.join("\n")}</div>`;
|
|
783
|
+
}
|
|
784
|
+
function cutLines(lines) {
|
|
785
|
+
let left = BODY_LIMIT2;
|
|
786
|
+
for (let i = 0; i < lines.length; i++) {
|
|
787
|
+
const length = lines[i].text.length + 1;
|
|
788
|
+
if (length > left) {
|
|
789
|
+
const total = lines.reduce((n, line) => n + line.text.length + 1, 0) - 1;
|
|
790
|
+
const kept = lines.slice(0, i);
|
|
791
|
+
const cut = lines[i].text.slice(0, left);
|
|
792
|
+
if (cut) kept.push({ text: cut, html: escape(cut) });
|
|
793
|
+
return { lines: kept, more: total - BODY_LIMIT2 };
|
|
794
|
+
}
|
|
795
|
+
left -= length;
|
|
796
|
+
}
|
|
797
|
+
return { lines, more: 0 };
|
|
798
|
+
}
|
|
799
|
+
function highlightJson(text) {
|
|
800
|
+
const match = /^(\s*)("(?:[^"\\]|\\.)*": )?(.*?)(,?)$/.exec(text);
|
|
801
|
+
if (!match) return escape(text);
|
|
802
|
+
const [, indent = "", key, value = "", comma = ""] = match;
|
|
803
|
+
const kind = value.startsWith('"') ? "j-s" : /^-?\d/.test(value) ? "j-n" : /^(true|false|null)$/.test(value) ? "j-l" : "";
|
|
804
|
+
return `${indent}${key ? `<span class="j-k">${escape(key.slice(0, -2))}</span>: ` : ""}${kind ? `<span class="${kind}">${escape(value)}</span>` : escape(value)}${comma}`;
|
|
630
805
|
}
|
|
631
806
|
function page(options) {
|
|
632
807
|
return `<!doctype html>
|
|
@@ -639,60 +814,79 @@ function page(options) {
|
|
|
639
814
|
<script>${SCRIPT}</script>
|
|
640
815
|
</head>
|
|
641
816
|
<body>
|
|
642
|
-
<
|
|
817
|
+
<main class="page">
|
|
643
818
|
${options.body}
|
|
644
|
-
</
|
|
645
|
-
|
|
646
|
-
<span class="btn static">gta Version ${escape(options.run.version)}</span>
|
|
647
|
-
<div class="dim small-print">Run in ${escape(options.run.root)}</div>
|
|
648
|
-
</footer>
|
|
819
|
+
<footer class="foot">Generated by <a href="${REPO_URL}" target="_blank" rel="noopener noreferrer" title="Gravity Test Automation on GitHub">gta ${escape(options.run.version)} <span aria-hidden="true">\u2197</span></a></footer>
|
|
820
|
+
</main>
|
|
649
821
|
</body>
|
|
650
822
|
</html>
|
|
651
823
|
`;
|
|
652
824
|
}
|
|
653
|
-
var
|
|
825
|
+
var pageHead = (status, title, startedAt, ms) => `<header class="head"><div class="title">${pill(status, true)}<h1>${title}</h1></div><div class="meta"><span>${escape(timeOfRun(startedAt))}</span><span>${duration(ms)}</span></div></header>`;
|
|
826
|
+
var card = (title, body, options = {}) => `<section class="card"${options.id ? ` id="${options.id}"` : ""}><div class="card-h"><h2>${title}</h2>${options.tools ?? ""}</div><div class="card-body${options.flush ? " flush" : ""}">
|
|
654
827
|
${body}
|
|
655
|
-
</div></
|
|
656
|
-
var
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
var
|
|
661
|
-
var
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
|
|
828
|
+
</div></section>`;
|
|
829
|
+
var part = (title, meta, tools, body, switches = false) => `<div class="part"${switches ? " data-switch" : ""}><div class="part-h"><span>${title}</span><span class="dim">${meta}</span><span class="end">${tools}</span></div>${body}</div>`;
|
|
830
|
+
var copyButton = (what, label = "Copy") => `<button type="button" class="btn sm" data-copy="${what}">${label}</button>`;
|
|
831
|
+
var pill = (status, solid = false) => `<span class="pill${solid ? " solid" : ""} s-${status}">${status}</span>`;
|
|
832
|
+
var MARKS = { passed: "\u2713", failed: "\u2715", errored: "!", skipped: "\u2298" };
|
|
833
|
+
var mark = (status) => `<span class="mark s-${status}" role="img" aria-label="${status}">${MARKS[status]}</span>`;
|
|
834
|
+
var num = (n, bad = false) => n === 0 ? '<span class="zero">0</span>' : bad ? `<span class="hot">${n}</span>` : String(n);
|
|
835
|
+
function statusCode(res) {
|
|
836
|
+
const kind = res.status >= 500 ? "server" : res.status >= 400 ? "client" : res.status >= 300 ? "redirect" : "ok";
|
|
837
|
+
return `<span class="status-code k-${kind}">${escape(`${res.status} ${res.statusText}`.trim())}</span>`;
|
|
838
|
+
}
|
|
839
|
+
var methodClass = (method) => `m-${method.toLowerCase().replace(/[^a-z]/g, "")}`;
|
|
840
|
+
function ringStat(label, parts) {
|
|
841
|
+
const total = parts.reduce((n, [, v]) => n + v, 0);
|
|
842
|
+
const passed = parts.find(([s]) => s === "passed")?.[1] ?? 0;
|
|
843
|
+
const ran = total - (parts.find(([s]) => s === "skipped")?.[1] ?? 0);
|
|
844
|
+
const rate = ran === 0 ? "\u2014" : `${Math.floor(passed / ran * 100)}%`;
|
|
845
|
+
return `<div class="stat"><div class="ring" title="${passed} of ${ran} that ran passed">${ring(parts)}<b>${rate}</b></div><div><div class="stat-label">${label}</div><div class="stat-total">${total}</div><div class="legend">${parts.map(([s, v]) => `<span class="${v ? `s-${s}` : "zero"}"><i></i>${v} ${s}</span>`).join("")}</div></div></div>`;
|
|
846
|
+
}
|
|
847
|
+
function ring(parts) {
|
|
848
|
+
const R = 25;
|
|
849
|
+
const C = 2 * Math.PI * R;
|
|
850
|
+
const live = parts.filter(([, v]) => v > 0);
|
|
851
|
+
const total = live.reduce((n, [, v]) => n + v, 0);
|
|
852
|
+
const track = `<circle cx="32" cy="32" r="${R}" fill="none" stroke-width="7" style="stroke:var(--surface-alt)"/>`;
|
|
853
|
+
if (total === 0)
|
|
854
|
+
return `<svg viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">${track}</svg>`;
|
|
855
|
+
const gap = live.length > 1 ? 2 : 0;
|
|
856
|
+
const lengths = live.map(([, v]) => Math.max(v / total * C, 5));
|
|
857
|
+
const biggest = lengths.indexOf(Math.max(...lengths));
|
|
858
|
+
lengths[biggest] -= lengths.reduce((a, b) => a + b, 0) - C;
|
|
859
|
+
let offset = 0;
|
|
860
|
+
const arcs = live.map(([s], i) => {
|
|
861
|
+
const length = lengths[i];
|
|
862
|
+
const arc = `<circle class="s-${s}" cx="32" cy="32" r="${R}" fill="none" stroke-width="7" style="stroke:var(--s)" stroke-dasharray="${(length - gap).toFixed(2)} ${(C - length + gap).toFixed(2)}" stroke-dashoffset="${(-offset).toFixed(2)}" transform="rotate(-90 32 32)"/>`;
|
|
863
|
+
offset += length;
|
|
864
|
+
return arc;
|
|
865
|
+
});
|
|
866
|
+
return `<svg viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">${track}${arcs.join("")}</svg>`;
|
|
867
|
+
}
|
|
868
|
+
var flagChips = (flags) => Object.entries(flags).map(([name, value]) => `<code class="flag">${escape(`${name}=${value}`)}</code>`).join("");
|
|
869
|
+
var chips = (counts) => ["passed", "failed", "skipped"].filter((s) => counts[s] > 0).map(
|
|
870
|
+
(s) => `<button type="button" class="chip s-${s}" data-hide="${s}" aria-pressed="true" title="Show or hide ${s}">${mark(s)}${capitalise(s)} <b>${counts[s]}</b></button>`
|
|
871
|
+
).join("");
|
|
872
|
+
var searchBox = (label) => `<input type="search" class="search" placeholder="${label}" aria-label="${label}">`;
|
|
873
|
+
var headerValue = (headers, name) => headers.find((h) => h.name.toLowerCase() === name)?.value;
|
|
874
|
+
function clip(text) {
|
|
875
|
+
return text.length > BODY_LIMIT2 ? `${text.slice(0, BODY_LIMIT2)}
|
|
876
|
+
\u2026 ${bytes(text.length - BODY_LIMIT2)} more not shown` : text;
|
|
682
877
|
}
|
|
683
878
|
var userFrames = (stack) => stack.split("\n").filter((line) => !/^\s+at .*(\bnode:|file:\/\/)/.test(line)).join("\n");
|
|
684
879
|
var capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
685
|
-
var
|
|
686
|
-
var
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
second: "2-digit",
|
|
694
|
-
|
|
695
|
-
});
|
|
880
|
+
var plural2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
881
|
+
var duration = (ms) => ms < 1e3 ? `${Math.round(ms)} ms` : `${(ms / 1e3).toFixed(2)} s`;
|
|
882
|
+
function timeoutText(ms) {
|
|
883
|
+
const round = ms >= 36e5 && ms % 36e5 === 0 ? `${ms / 36e5} h` : ms >= 6e4 && ms % 6e4 === 0 ? `${ms / 6e4} min` : ms >= 1e3 && ms % 1e3 === 0 ? `${ms / 1e3} s` : "";
|
|
884
|
+
return `${ms.toLocaleString("en-US")} ms${round ? ` <span class="dim">\xB7 ${round}</span>` : ""}`;
|
|
885
|
+
}
|
|
886
|
+
var timeOfRun = (epochMs) => {
|
|
887
|
+
const at = new Date(epochMs);
|
|
888
|
+
return `${at.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" })} \xB7 ${at.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit", timeZoneName: "short" })}`;
|
|
889
|
+
};
|
|
696
890
|
function bytes(n) {
|
|
697
891
|
if (n < 1024) return `${n} B`;
|
|
698
892
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
@@ -745,112 +939,294 @@ function mdInline(nodes) {
|
|
|
745
939
|
}).join("");
|
|
746
940
|
}
|
|
747
941
|
var STYLE = `
|
|
748
|
-
:root{color-scheme:light dark;--bg:#
|
|
749
|
-
@media (prefers-color-scheme:dark){:root{--bg:#
|
|
942
|
+
:root{color-scheme:light dark;--bg:#f6f7f9;--surface:#fff;--surface-alt:#eef0f4;--border:#d6dae2;--border-soft:#e6e9ee;--text:#16181d;--dim:#6a7280;--faint:#9aa1ad;--accent:#2f6df6;--link:#1f5fd6;--passed:#107a48;--failed:#cc1f43;--errored:#a65f00;--skipped:#0b7fb8;--on-status:#fff;--ok:#107a48;--redirect:#8a6100;--client:#b23c17;--server:#a01a2b;--m-get:#1f5fd6;--m-post:#0b7a43;--m-put:#946200;--m-patch:#0b7f84;--m-delete:#c42b2b;--m-head:#7c45d0;--m-options:#3f5f80;--j-key:#7c45d0;--j-str:#8a5a2b;--j-num:#0b7f84;--j-lit:#946200;--canvas:#fff;--check-a:#fff;--check-b:#eceef2;--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
|
|
943
|
+
@media (prefers-color-scheme:dark){:root{--bg:#11131a;--surface:#171a22;--surface-alt:#1e222c;--border:#2b303c;--border-soft:#232733;--text:#e6e8ee;--dim:#949bab;--faint:#666e7e;--accent:#5b8cff;--link:#6b9bff;--passed:#46c98a;--failed:#ff5b78;--errored:#f2a93b;--skipped:#56c2ee;--on-status:#11131a;--ok:#46c98a;--redirect:#e0b24d;--client:#f08a5d;--server:#f0607a;--m-get:#6b9bff;--m-post:#4fd08f;--m-put:#eab54e;--m-patch:#4fd1d1;--m-delete:#ff6f6f;--m-head:#b991ff;--m-options:#8fb4dc;--j-key:#b991ff;--j-str:#d9a066;--j-num:#4fd1d1;--j-lit:#eab54e;--check-a:#262b36;--check-b:#1d212a}}
|
|
750
944
|
*{box-sizing:border-box}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
.page
|
|
754
|
-
|
|
755
|
-
|
|
945
|
+
[hidden]{display:none!important}
|
|
946
|
+
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 var(--sans);-webkit-font-smoothing:antialiased}
|
|
947
|
+
.page{max-width:1320px;margin:0 auto;padding:28px clamp(16px,3vw,32px) 24px}
|
|
948
|
+
a{color:var(--link);text-decoration:none}
|
|
949
|
+
a:hover{text-decoration:underline}
|
|
950
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
951
|
+
code,pre{font-family:var(--mono);font-size:12.5px}
|
|
952
|
+
pre{margin:0;white-space:pre-wrap;word-break:break-word}
|
|
953
|
+
.dim{color:var(--dim)}
|
|
954
|
+
.small{font-size:12.5px}
|
|
955
|
+
.hidden,.unmatched{display:none!important}
|
|
956
|
+
.s-passed{--s:var(--passed)}.s-failed{--s:var(--failed)}.s-errored{--s:var(--errored)}.s-skipped{--s:var(--skipped)}
|
|
957
|
+
.c-failed{color:var(--failed)}
|
|
958
|
+
.crumbs{display:flex;flex-wrap:wrap;gap:4px 8px;font-size:13px;color:var(--dim);margin-bottom:10px;overflow-wrap:anywhere}
|
|
959
|
+
.head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 24px;margin-bottom:18px}
|
|
960
|
+
.title{display:flex;align-items:center;gap:12px;min-width:0}
|
|
961
|
+
.title h1{margin:0;font-size:20px;font-weight:650;letter-spacing:-.01em;overflow-wrap:anywhere}
|
|
962
|
+
.kind{color:var(--dim);font-weight:400}
|
|
963
|
+
.meta{display:flex;flex-wrap:wrap;gap:4px 14px;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
|
|
964
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin-bottom:16px;min-width:0}
|
|
965
|
+
.card-h{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;padding:10px 16px;border-bottom:1px solid var(--border-soft)}
|
|
966
|
+
.card-h h2{margin:0 6px 0 0;font-size:13.5px;font-weight:650}
|
|
967
|
+
.card-h .search,.card-h .file{margin-left:auto}
|
|
968
|
+
.card-h .file{color:var(--dim);overflow-wrap:anywhere}
|
|
756
969
|
.card-body{padding:16px}
|
|
757
|
-
.card
|
|
758
|
-
.
|
|
759
|
-
.pairs{display:grid;grid-template-columns:1fr
|
|
760
|
-
|
|
761
|
-
.
|
|
762
|
-
.
|
|
763
|
-
.
|
|
764
|
-
.
|
|
765
|
-
.
|
|
766
|
-
.
|
|
970
|
+
.card-body>*+*{margin-top:16px}
|
|
971
|
+
.card-body.flush{padding:0}
|
|
972
|
+
.pairs{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px 28px;margin:0}
|
|
973
|
+
.pairs dt{font-size:12px;color:var(--dim);margin-bottom:1px}
|
|
974
|
+
.pairs dd{margin:0;overflow-wrap:anywhere}
|
|
975
|
+
.flag{font-size:12px;padding:1px 6px;border-radius:4px;background:var(--surface-alt);display:inline-block;margin:0 4px 2px 0}
|
|
976
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr));gap:20px 36px}
|
|
977
|
+
.stat{display:flex;gap:16px;align-items:center;min-width:0}
|
|
978
|
+
.ring{position:relative;width:64px;height:64px;flex:none}
|
|
979
|
+
.ring svg{display:block}
|
|
980
|
+
.ring b{position:absolute;inset:0;display:grid;place-items:center;font-size:13.5px;font-variant-numeric:tabular-nums}
|
|
981
|
+
.stat-label{font-size:12px;color:var(--dim)}
|
|
982
|
+
.stat-total{font-size:24px;font-weight:650;font-variant-numeric:tabular-nums;line-height:1;margin-top:3px}
|
|
983
|
+
.legend{display:flex;flex-wrap:wrap;gap:4px 12px;margin-top:6px;font-size:12.5px;font-variant-numeric:tabular-nums}
|
|
984
|
+
.legend span{display:inline-flex;align-items:center;gap:5px}
|
|
985
|
+
.legend i{width:8px;height:8px;border-radius:2px;background:var(--s)}
|
|
986
|
+
.legend .zero i{background:var(--border)}
|
|
987
|
+
.zero{color:var(--faint)}
|
|
988
|
+
.hot{color:var(--failed);font-weight:700}
|
|
989
|
+
.pill{display:inline-flex;align-items:center;padding:3px 10px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--s);background:color-mix(in srgb,var(--s) 14%,transparent);white-space:nowrap}
|
|
990
|
+
.pill.solid{background:var(--s);color:var(--on-status)}
|
|
991
|
+
.mark{flex:none;display:inline-grid;place-items:center;width:18px;height:18px;border-radius:50%;color:var(--s);background:color-mix(in srgb,var(--s) 16%,transparent);font-size:10.5px;font-weight:800;line-height:1}
|
|
992
|
+
.status-code{font:600 12.5px var(--mono);white-space:nowrap}
|
|
993
|
+
.k-ok{color:var(--ok)}.k-redirect{color:var(--redirect)}.k-client{color:var(--client)}.k-server{color:var(--server)}
|
|
994
|
+
.method{font:700 11.5px var(--mono);letter-spacing:.02em}
|
|
995
|
+
.m-get{color:var(--m-get)}.m-post{color:var(--m-post)}.m-put{color:var(--m-put)}.m-patch{color:var(--m-patch)}.m-delete{color:var(--m-delete)}.m-head{color:var(--m-head)}.m-options{color:var(--m-options)}
|
|
996
|
+
.search{font:inherit;font-size:13px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);min-width:0;flex:1 1 180px;max-width:300px}
|
|
997
|
+
.chip{font:inherit;font-size:12.5px;display:inline-flex;align-items:center;gap:6px;padding:3px 10px 3px 4px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;white-space:nowrap}
|
|
998
|
+
.chip b{font-variant-numeric:tabular-nums}
|
|
999
|
+
.chip[aria-pressed="false"]{opacity:.45}
|
|
1000
|
+
.btn{font:inherit;font-size:12.5px;padding:4px 12px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);cursor:pointer;white-space:nowrap}
|
|
1001
|
+
.btn.sm{font-size:11.5px;padding:2px 9px;border-radius:5px}
|
|
1002
|
+
.btn:hover,.chip:hover{border-color:var(--faint)}
|
|
1003
|
+
.seg{display:inline-flex;border:1px solid var(--border);border-radius:7px;padding:1px;background:var(--surface-alt)}
|
|
1004
|
+
.seg button{font:inherit;font-size:11.5px;border:none;background:none;color:var(--dim);padding:2px 9px;border-radius:5px;cursor:pointer}
|
|
1005
|
+
.seg button[aria-pressed="true"]{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.1)}
|
|
1006
|
+
.scroll-x{overflow-x:auto}
|
|
1007
|
+
.overview{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums;font-size:13px}
|
|
1008
|
+
.overview th{font-size:11.5px;font-weight:600;color:var(--dim);text-align:left;padding:6px 10px;white-space:nowrap}
|
|
1009
|
+
.overview thead tr:last-child th{border-bottom:1px solid var(--border)}
|
|
1010
|
+
.overview .groups th{padding:10px 10px 0;text-transform:uppercase;letter-spacing:.08em;font-size:10.5px;color:var(--faint)}
|
|
1011
|
+
.overview td{padding:7px 10px;border-bottom:1px solid var(--border-soft);vertical-align:top}
|
|
1012
|
+
.overview tbody tr:last-child td{border-bottom:none}
|
|
1013
|
+
.overview .r{text-align:right}
|
|
1014
|
+
.overview .gs{border-left:1px solid var(--border-soft)}
|
|
1015
|
+
.overview td.name{min-width:17rem}
|
|
1016
|
+
.overview td.n{color:var(--faint)}
|
|
1017
|
+
.overview tbody tr:hover td{background:var(--surface-alt)}
|
|
1018
|
+
.overview tr.s-failed td:first-child{box-shadow:inset 3px 0 0 var(--failed)}
|
|
1019
|
+
.group-h{margin:0 0 8px;font-size:13px;font-weight:600}
|
|
1020
|
+
.group-h .dim{font-weight:400}
|
|
1021
|
+
.excluded{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px 24px}
|
|
1022
|
+
.excluded li{font:12.5px var(--mono);display:flex;gap:8px;align-items:center;overflow-wrap:anywhere}
|
|
1023
|
+
.excluded li::before{content:"\u2298";color:var(--faint);font-family:var(--sans)}
|
|
1024
|
+
.toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 12px;margin:28px 0 10px}
|
|
1025
|
+
.toolbar h2{margin:0 8px 0 0;font-size:17px;font-weight:650}
|
|
1026
|
+
.tools-end{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
1027
|
+
.tests{border:1px solid var(--border);border-radius:8px;background:var(--surface);overflow:hidden}
|
|
1028
|
+
.test+.test{border-top:1px solid var(--border)}
|
|
1029
|
+
.test>summary{display:flex;align-items:center;gap:10px;padding:11px 14px;cursor:pointer;list-style:none}
|
|
1030
|
+
.test>summary::-webkit-details-marker{display:none}
|
|
1031
|
+
.test>summary .n{width:1.6em;color:var(--faint);font-variant-numeric:tabular-nums;text-align:right;flex:none}
|
|
1032
|
+
.test>summary .method{width:52px;flex:none}
|
|
1033
|
+
.test>summary .name{flex:1;min-width:0;font-weight:500;overflow-wrap:anywhere}
|
|
1034
|
+
.test>summary .res{display:flex;align-items:center;gap:10px;font-size:12.5px;flex:none;font-variant-numeric:tabular-nums}
|
|
1035
|
+
.chev{color:var(--faint);font-size:12px;transition:transform .15s}
|
|
1036
|
+
.test[open]>summary .chev{transform:rotate(90deg)}
|
|
1037
|
+
.test[open]>summary{background:var(--surface-alt)}
|
|
1038
|
+
.test.s-failed>summary,.test.s-errored>summary{box-shadow:inset 3px 0 0 var(--s)}
|
|
1039
|
+
.test-body{padding:16px;display:grid;gap:20px;border-top:1px solid var(--border-soft);min-width:0}
|
|
1040
|
+
.empty{margin:0;padding:16px}
|
|
1041
|
+
.skip-reason{margin:0;color:var(--skipped)}
|
|
1042
|
+
.sub{min-width:0}
|
|
1043
|
+
.sub h3{margin:0 0 8px;font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.08em;color:var(--dim)}
|
|
1044
|
+
.sub h3 .dim{text-transform:none;letter-spacing:0;font-weight:400;margin-left:6px}
|
|
1045
|
+
.sub-h{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin-bottom:8px}
|
|
1046
|
+
.sub-h h3{margin:0}
|
|
1047
|
+
.line{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;min-width:0}
|
|
1048
|
+
.line .url{overflow-wrap:anywhere;min-width:0}
|
|
1049
|
+
.line .btn{margin-left:auto;align-self:center}
|
|
1050
|
+
.mk-inline{font-weight:700}
|
|
1051
|
+
.none{margin:8px 0 0}
|
|
1052
|
+
.part{margin-top:10px;border:1px solid var(--border-soft);border-radius:6px;overflow:hidden;background:var(--surface);min-width:0}
|
|
1053
|
+
.part-h{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;padding:5px 6px 5px 10px;background:var(--surface-alt);font-size:12px;font-weight:600;border-bottom:1px solid var(--border-soft)}
|
|
1054
|
+
.part-h .dim{font-weight:400;overflow-wrap:anywhere}
|
|
1055
|
+
.end{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
1056
|
+
.pad{margin:0;padding:8px 10px}
|
|
1057
|
+
.kv{width:100%;border-collapse:collapse;font:12px/1.45 var(--mono)}
|
|
1058
|
+
.kv td{padding:3px 10px;border-top:1px solid var(--border-soft);vertical-align:top;overflow-wrap:anywhere}
|
|
1059
|
+
.kv tr:first-child td{border-top:none}
|
|
1060
|
+
.kv .k{color:var(--dim);white-space:nowrap;width:1%}
|
|
1061
|
+
.kv .mk{width:1%;padding-right:0;text-align:center}
|
|
1062
|
+
.raw{max-height:440px;overflow:auto;padding:8px 10px;font:12px/1.55 var(--mono);white-space:pre-wrap;word-break:break-all}
|
|
1063
|
+
.codeview{font:12px/1.6 var(--mono);max-height:min(70vh,640px);overflow:auto;padding:6px 0}
|
|
1064
|
+
.codeview:has(.peek-row:not([hidden])){max-height:none}
|
|
1065
|
+
.ln{display:flex;padding-right:12px}
|
|
1066
|
+
.ln .no{flex:none;width:3.2em;text-align:right;padding-right:10px;color:var(--faint);user-select:none}
|
|
1067
|
+
.ln .mk{flex:none;width:1.4em;text-align:center;user-select:none}
|
|
1068
|
+
.ln .tx{flex:1;min-width:0;white-space:pre-wrap;word-break:break-all}
|
|
1069
|
+
.ln.cut .tx{color:var(--dim);font-style:italic}
|
|
1070
|
+
.ln.mk-fail,.kv .mk-fail{background:color-mix(in srgb,var(--failed) 12%,transparent)}
|
|
1071
|
+
.mk-pass .mk,.mk-inline.mk-pass{color:var(--passed)}
|
|
1072
|
+
.mk-fail .mk,.mk-inline.mk-fail{color:var(--failed);font-weight:700}
|
|
1073
|
+
.mk-unasserted .mk,.mk-inline.mk-unasserted{color:var(--errored);font-weight:700}
|
|
1074
|
+
.mk-ignored .mk{color:var(--faint)}
|
|
1075
|
+
.ln .note{color:var(--failed);font:600 11.5px var(--sans);margin-left:12px;white-space:normal}
|
|
1076
|
+
.j-k{color:var(--j-key)}.j-s{color:var(--j-str)}.j-n{color:var(--j-num)}.j-l{color:var(--j-lit)}
|
|
1077
|
+
.peek{font:600 11px var(--sans);margin-left:10px;padding:1px 9px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--link);cursor:pointer;white-space:nowrap}
|
|
1078
|
+
.peek[aria-expanded="true"]{background:var(--surface-alt);border-color:var(--faint)}
|
|
1079
|
+
.peek-row{padding:4px 12px 10px 4.6em}
|
|
1080
|
+
.peek-panel{border:1px solid var(--border);border-radius:8px;background:var(--surface);overflow:hidden;font:13px/1.5 var(--sans)}
|
|
1081
|
+
.peek-h{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;padding:5px 6px 5px 10px;background:var(--surface-alt);border-bottom:1px solid var(--border-soft);font-size:12px}
|
|
1082
|
+
.peek-h code{font-weight:600;overflow-wrap:anywhere}
|
|
1083
|
+
.html-frame{height:300px;min-height:120px;resize:vertical;overflow:hidden;background:var(--canvas)}
|
|
1084
|
+
.html-frame iframe{display:block;width:100%;height:100%;border:0}
|
|
1085
|
+
.frame-note{margin:0;padding:5px 10px;font-size:11.5px;color:var(--dim);border-top:1px solid var(--border-soft)}
|
|
1086
|
+
.img-view{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px 12px;padding:12px}
|
|
1087
|
+
.checker{display:inline-block;max-width:100%;padding:10px;border-radius:6px;background-color:var(--check-a);background-image:conic-gradient(var(--check-b) 25%,transparent 0 50%,var(--check-b) 0 75%,transparent 0);background-size:16px 16px}
|
|
1088
|
+
.checker img{display:block;max-width:100%;height:auto}
|
|
1089
|
+
.checks{list-style:none;margin:0;padding:0;border:1px solid var(--border-soft);border-radius:6px;background:var(--surface)}
|
|
1090
|
+
.checks li{display:flex;gap:10px;padding:8px 10px;align-items:flex-start}
|
|
1091
|
+
.checks li+li{border-top:1px solid var(--border-soft)}
|
|
1092
|
+
.check{flex:1;min-width:0}
|
|
1093
|
+
.check-name{font:12.5px var(--mono);overflow-wrap:anywhere}
|
|
1094
|
+
.check-msg{font-size:13px;margin-top:2px;color:var(--dim)}
|
|
1095
|
+
.s-failed .check-msg{color:var(--failed)}
|
|
1096
|
+
.ea{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:6px 0 0;font-size:12.5px}
|
|
1097
|
+
.ea dt{color:var(--dim)}
|
|
1098
|
+
.ea dd{margin:0;overflow-wrap:anywhere}
|
|
1099
|
+
.src{font:11.5px var(--mono);color:var(--faint);flex:none;white-space:nowrap}
|
|
1100
|
+
.problem pre{color:var(--failed);background:color-mix(in srgb,var(--failed) 7%,var(--surface));border:1px solid color-mix(in srgb,var(--failed) 30%,var(--border));border-radius:6px;padding:8px 10px}
|
|
1101
|
+
.console{background:var(--surface-alt);border-radius:6px;padding:8px 10px}
|
|
1102
|
+
.console .log-warn{color:var(--errored)}.console .log-error{color:var(--failed)}
|
|
767
1103
|
.md>*:first-child{margin-top:0}.md>*:last-child{margin-bottom:0}
|
|
768
1104
|
.md-p{margin:0 0 8px}
|
|
769
1105
|
.md-h{margin:12px 0 6px;font-size:1rem;font-weight:600}
|
|
770
1106
|
.md-list{margin:0 0 8px;padding-left:24px}
|
|
771
1107
|
.md-list .md-list{margin:2px 0 0}
|
|
772
|
-
.md-code{margin:0 0 8px;padding:8px 10px;background:var(--
|
|
773
|
-
.md-inline-code{background:var(--
|
|
1108
|
+
.md-code{margin:0 0 8px;padding:8px 10px;background:var(--surface-alt);border-radius:6px}
|
|
1109
|
+
.md-inline-code{background:var(--surface-alt);padding:1px 4px;border-radius:3px}
|
|
774
1110
|
.md-quote{margin:0 0 8px;padding-left:12px;border-left:3px solid var(--border);color:var(--dim)}
|
|
775
1111
|
.md-hr{border:none;border-top:1px solid var(--border);margin:10px 0}
|
|
776
1112
|
.md-table{border-collapse:collapse;margin:0 0 8px}
|
|
777
1113
|
.md-table th,.md-table td{border:1px solid var(--border);padding:4px 8px;text-align:left}
|
|
778
|
-
.md-table th{background:var(--
|
|
779
|
-
.
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
.
|
|
783
|
-
.
|
|
784
|
-
|
|
785
|
-
.stats tr+tr td{border-top:1px solid var(--border)}
|
|
786
|
-
.stats.small{width:50%;margin:0 auto}
|
|
787
|
-
@media (max-width:800px){.stats.small{width:100%}}
|
|
788
|
-
.overview{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
|
|
789
|
-
.overview th,.overview td{border:1px solid var(--border);padding:8px;text-align:left;vertical-align:top}
|
|
790
|
-
.overview th{background:var(--head);font-weight:600}
|
|
791
|
-
.overview td.name{word-break:break-word}
|
|
792
|
-
a{color:var(--link)}
|
|
793
|
-
.dim{color:var(--dim)}
|
|
794
|
-
.c-passed{color:var(--passed)}.c-failed{color:var(--failed)}.c-errored{color:var(--errored)}.c-skipped{color:var(--skipped)}
|
|
795
|
-
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin:40px 0 8px}
|
|
796
|
-
.toolbar h3{margin:0;font-weight:500;font-size:1.75rem}
|
|
797
|
-
.buttons{display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap}
|
|
798
|
-
.toolbar.above{margin:40px 0 8px}
|
|
799
|
-
.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)}
|
|
800
|
-
.toolbar h3+.search{margin-left:16px}
|
|
801
|
-
.toolbar .buttons{margin-left:auto}
|
|
802
|
-
.unmatched{display:none}
|
|
803
|
-
.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}
|
|
804
|
-
.btn:hover{background:var(--btn);color:var(--bg)}
|
|
805
|
-
.btn.static{cursor:default}
|
|
806
|
-
.btn.static:hover{background:none;color:var(--btn)}
|
|
807
|
-
.hidden{display:none}
|
|
808
|
-
.tests details.acc{border:1px solid var(--border);border-top:none;background:var(--card)}
|
|
809
|
-
.tests details.acc:first-child{border-top:1px solid var(--border);border-radius:6px 6px 0 0}
|
|
810
|
-
.tests details.acc:last-child{border-radius:0 0 6px 6px}
|
|
811
|
-
.tests details.acc:only-child{border-radius:6px}
|
|
812
|
-
details.acc>summary{list-style:none;cursor:pointer;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
813
|
-
details.acc>summary::-webkit-details-marker{display:none}
|
|
814
|
-
details.acc[open]>summary{background:var(--open)}
|
|
815
|
-
.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)}
|
|
816
|
-
.icon.passed{background:var(--passed)}.icon.failed{background:var(--failed)}.icon.errored{background:var(--errored)}.icon.skipped{background:var(--skipped)}
|
|
817
|
-
.acc-body{padding:16px 20px}
|
|
818
|
-
.detail-row{display:flex;justify-content:space-between;gap:16px;padding:6px 0}
|
|
819
|
-
.detail-row+.detail-row{border-top:1px solid var(--border)}
|
|
820
|
-
.detail-row.head{padding:0}
|
|
821
|
-
.ea{font-size:14px;margin-top:2px}
|
|
822
|
-
.console .log-warn{color:var(--errored)}.console .log-error{color:var(--failed)}
|
|
823
|
-
footer.container{text-align:center;margin:48px auto 0}
|
|
824
|
-
.small-print{margin-top:8px;font-size:12px;overflow-wrap:anywhere}
|
|
1114
|
+
.md-table th{background:var(--surface-alt)}
|
|
1115
|
+
.foot{margin-top:28px;padding-top:16px;border-top:1px solid var(--border-soft);display:flex;justify-content:center;align-items:center;gap:8px;color:var(--dim);font-size:12.5px}
|
|
1116
|
+
.foot a{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border:1px solid var(--border);border-radius:999px;background:var(--surface);color:var(--text);font-weight:600}
|
|
1117
|
+
.foot a:hover{border-color:var(--faint);text-decoration:none}
|
|
1118
|
+
.foot a span{color:var(--dim);font-weight:400}
|
|
1119
|
+
@media (max-width:560px){.test>summary .res .status-code,.test>summary .method{display:none}}
|
|
1120
|
+
@media (prefers-reduced-motion:reduce){.chev{transition:none}}
|
|
825
1121
|
`;
|
|
826
|
-
var SCRIPT = `
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1122
|
+
var SCRIPT = String.raw`
|
|
1123
|
+
// What an HTML frame may do beyond showing itself: nothing it could load from.
|
|
1124
|
+
const SANDBOX = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; img-src data:; style-src \'unsafe-inline\'; font-src data:; form-action \'none\'">'
|
|
1125
|
+
function filter() {
|
|
1126
|
+
const hidden = new Set([...document.querySelectorAll('[data-hide][aria-pressed="false"]')].map((b) => b.dataset.hide))
|
|
1127
|
+
const input = document.querySelector('.search')
|
|
1128
|
+
const query = input ? input.value.trim().toLowerCase() : ''
|
|
1129
|
+
for (const el of document.querySelectorAll('[data-status][data-search]')) {
|
|
1130
|
+
el.classList.toggle('hidden', hidden.has(el.dataset.status))
|
|
1131
|
+
el.classList.toggle('unmatched', query !== '' && !el.dataset.search.includes(query))
|
|
1132
|
+
}
|
|
832
1133
|
}
|
|
833
1134
|
// The button says what a click will do: Expand All while any test is closed,
|
|
834
|
-
// Collapse All once every one is open
|
|
835
|
-
const tests = () => [...document.querySelectorAll('details.
|
|
1135
|
+
// Collapse All once every one is open — however they got that way.
|
|
1136
|
+
const tests = () => [...document.querySelectorAll('details.test')]
|
|
836
1137
|
function syncExpandAll() {
|
|
837
1138
|
const button = document.getElementById('expand-all')
|
|
838
1139
|
if (button) button.textContent = tests().some((d) => !d.open) ? 'Expand All' : 'Collapse All'
|
|
839
1140
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1141
|
+
// A frame is filled when first shown, from the text it names: a response's raw
|
|
1142
|
+
// text, whose body follows the first blank line, or a viewer's source.
|
|
1143
|
+
function loadFrames(scope) {
|
|
1144
|
+
for (const frame of scope.querySelectorAll('iframe[data-from]:not([srcdoc])')) {
|
|
1145
|
+
if (frame.offsetParent === null) continue
|
|
1146
|
+
const text = document.getElementById(frame.dataset.from).textContent
|
|
1147
|
+
const html = frame.hasAttribute('data-body') ? text.slice(text.indexOf('\n\n') + 2) : text
|
|
1148
|
+
frame.srcdoc = SANDBOX + html.replace(/<meta[^>]*http-equiv\s*=\s*["']?refresh[^>]*>/gi, '')
|
|
844
1149
|
}
|
|
845
1150
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1151
|
+
// Every part of a request or response is read from its raw text, as sent or received.
|
|
1152
|
+
function copied(button) {
|
|
1153
|
+
const what = button.dataset.copy
|
|
1154
|
+
if (what === 'image') return button.closest('.peek-panel, .part').querySelector('img').getAttribute('src')
|
|
1155
|
+
if (what === 'source') return button.closest('.peek-panel').querySelector('[data-part="source"]').textContent
|
|
1156
|
+
const raw = button.closest('[data-exchange]').querySelector('[data-raw]').textContent
|
|
1157
|
+
const blank = raw.indexOf('\n\n')
|
|
1158
|
+
const head = (blank < 0 ? raw : raw.slice(0, blank)).split('\n')
|
|
1159
|
+
if (what === 'raw') return raw
|
|
1160
|
+
if (what === 'body') return blank < 0 ? '' : raw.slice(blank + 2)
|
|
1161
|
+
if (what === 'headers') return head.slice(1).join('\n')
|
|
1162
|
+
return head[0].replace(/^\S+ /, '')
|
|
1163
|
+
}
|
|
1164
|
+
function copy(text, button) {
|
|
1165
|
+
button.dataset.label ??= button.textContent
|
|
1166
|
+
const done = (label) => {
|
|
1167
|
+
button.textContent = label
|
|
1168
|
+
setTimeout(() => { button.textContent = button.dataset.label }, 1500)
|
|
1169
|
+
}
|
|
1170
|
+
const byHand = () => {
|
|
1171
|
+
const area = document.createElement('textarea')
|
|
1172
|
+
area.value = text
|
|
1173
|
+
area.style.position = 'fixed'
|
|
1174
|
+
area.style.opacity = '0'
|
|
1175
|
+
document.body.append(area)
|
|
1176
|
+
area.select()
|
|
1177
|
+
const ok = document.execCommand('copy')
|
|
1178
|
+
area.remove()
|
|
1179
|
+
done(ok ? 'Copied' : 'Copy failed')
|
|
1180
|
+
}
|
|
1181
|
+
if (navigator.clipboard) navigator.clipboard.writeText(text).then(() => done('Copied'), byHand)
|
|
1182
|
+
else byHand()
|
|
850
1183
|
}
|
|
1184
|
+
document.addEventListener('click', (event) => {
|
|
1185
|
+
const button = event.target.closest('button')
|
|
1186
|
+
if (!button) return
|
|
1187
|
+
if (button.dataset.hide) {
|
|
1188
|
+
button.setAttribute('aria-pressed', String(button.getAttribute('aria-pressed') === 'false'))
|
|
1189
|
+
filter()
|
|
1190
|
+
} else if (button.id === 'expand-all') {
|
|
1191
|
+
const open = tests().some((d) => !d.open)
|
|
1192
|
+
for (const d of tests()) d.open = open
|
|
1193
|
+
syncExpandAll()
|
|
1194
|
+
} else if (button.dataset.show) {
|
|
1195
|
+
const box = button.closest('[data-switch]')
|
|
1196
|
+
for (const b of box.querySelectorAll('[data-show]')) {
|
|
1197
|
+
if (b.closest('[data-switch]') === box) b.setAttribute('aria-pressed', String(b === button))
|
|
1198
|
+
}
|
|
1199
|
+
for (const part of box.querySelectorAll(':scope > [data-part]')) part.hidden = part.dataset.part !== button.dataset.show
|
|
1200
|
+
loadFrames(box)
|
|
1201
|
+
} else if (button.dataset.peek) {
|
|
1202
|
+
const open = button.getAttribute('aria-expanded') !== 'true'
|
|
1203
|
+
button.setAttribute('aria-expanded', String(open))
|
|
1204
|
+
const panel = document.getElementById(button.dataset.peek)
|
|
1205
|
+
panel.hidden = !open
|
|
1206
|
+
if (open) loadFrames(panel)
|
|
1207
|
+
} else if (button.dataset.close) {
|
|
1208
|
+
document.getElementById(button.dataset.close).hidden = true
|
|
1209
|
+
document.querySelector('[data-peek="' + button.dataset.close + '"]')?.setAttribute('aria-expanded', 'false')
|
|
1210
|
+
} else if (button.dataset.copy) {
|
|
1211
|
+
copy(copied(button), button)
|
|
1212
|
+
}
|
|
1213
|
+
})
|
|
1214
|
+
document.addEventListener('input', (event) => {
|
|
1215
|
+
if (event.target.matches('.search')) filter()
|
|
1216
|
+
})
|
|
1217
|
+
document.addEventListener('toggle', (event) => {
|
|
1218
|
+
if (!event.target.matches('details.test')) return
|
|
1219
|
+
syncExpandAll()
|
|
1220
|
+
if (event.target.open) loadFrames(event.target)
|
|
1221
|
+
}, true)
|
|
1222
|
+
// An image's size, once it has loaded.
|
|
1223
|
+
document.addEventListener('load', (event) => {
|
|
1224
|
+
const dims = event.target.tagName === 'IMG' && event.target.closest('.peek-panel, .part')?.querySelector('[data-dims]')
|
|
1225
|
+
if (dims) dims.textContent = event.target.naturalWidth + ' × ' + event.target.naturalHeight + ' · '
|
|
1226
|
+
}, true)
|
|
851
1227
|
document.addEventListener('DOMContentLoaded', () => {
|
|
852
|
-
for (const d of tests()) d.addEventListener('toggle', syncExpandAll)
|
|
853
1228
|
syncExpandAll()
|
|
1229
|
+
loadFrames(document)
|
|
854
1230
|
})
|
|
855
1231
|
`;
|
|
856
1232
|
|
|
@@ -874,7 +1250,7 @@ function junitReport(run2) {
|
|
|
874
1250
|
`<testsuites${attributes({
|
|
875
1251
|
name: `gta: ${run2.project}`,
|
|
876
1252
|
...total,
|
|
877
|
-
time:
|
|
1253
|
+
time: seconds(run2.durationMs),
|
|
878
1254
|
timestamp: timestamp(run2.startedAt)
|
|
879
1255
|
})}>`,
|
|
880
1256
|
...suites.flatMap((s) => s.lines),
|
|
@@ -945,7 +1321,7 @@ function suite(collection, environment, flags) {
|
|
|
945
1321
|
` <testsuite${attributes({
|
|
946
1322
|
name: id,
|
|
947
1323
|
...counts,
|
|
948
|
-
time:
|
|
1324
|
+
time: seconds(collection.durationMs),
|
|
949
1325
|
timestamp: timestamp(collection.startedAt),
|
|
950
1326
|
hostname: os.hostname()
|
|
951
1327
|
})}>`,
|
|
@@ -961,11 +1337,11 @@ function suite(collection, environment, flags) {
|
|
|
961
1337
|
};
|
|
962
1338
|
}
|
|
963
1339
|
function testcase(classname, name, durationMs, body) {
|
|
964
|
-
const open = ` <testcase${attributes({ name, classname, time:
|
|
1340
|
+
const open = ` <testcase${attributes({ name, classname, time: seconds(durationMs) })}`;
|
|
965
1341
|
if (body.length === 0) return [`${open}/>`];
|
|
966
1342
|
return [`${open}>`, ...body.map((line) => ` ${line}`), " </testcase>"];
|
|
967
1343
|
}
|
|
968
|
-
var caseName = (result) => result.use ? `${resultName(result)} [${result.use.set} ${result.use.child + 1}/${result.use.of}]` : result
|
|
1344
|
+
var caseName = (result) => result.use ? `${resultName(result)} [${result.use.set} ${result.use.child + 1}/${result.use.of}]` : resultName(result);
|
|
969
1345
|
var summarize = (a) => a.message ? `${a.name}: ${a.message}` : a.name;
|
|
970
1346
|
function describe(a) {
|
|
971
1347
|
const lines = [summarize(a)];
|
|
@@ -975,7 +1351,7 @@ function describe(a) {
|
|
|
975
1351
|
return lines.join("\n");
|
|
976
1352
|
}
|
|
977
1353
|
var userFrames2 = (stack) => stack?.split("\n").filter((line) => !/^\s+at .*(\bnode:|file:\/\/)/.test(line)).join("\n");
|
|
978
|
-
var
|
|
1354
|
+
var seconds = (ms) => (ms / 1e3).toFixed(3);
|
|
979
1355
|
var timestamp = (epochMs) => new Date(epochMs).toISOString().slice(0, 19);
|
|
980
1356
|
function attributes(values) {
|
|
981
1357
|
return Object.entries(values).map(([key, value]) => ` ${key}="${escapeAttribute(String(value))}"`).join("");
|
|
@@ -988,310 +1364,14 @@ function escape2(value) {
|
|
|
988
1364
|
}
|
|
989
1365
|
var allowedInXml = (code) => code === 9 || code === 10 || code === 13 || code >= 32 && code <= 55295 || code >= 57344 && code <= 65533 || code >= 65536;
|
|
990
1366
|
|
|
991
|
-
// src/json.ts
|
|
992
|
-
var JSON_FORMAT_VERSION = 1;
|
|
993
|
-
var BODY_LIMIT2 = 256 * 1024;
|
|
994
|
-
function jsonReport(run2, reports) {
|
|
995
|
-
const tallies = run2.collections.map((c) => c.tally);
|
|
996
|
-
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
997
|
-
const failed = tallies.filter((t) => !t.passed).length;
|
|
998
|
-
return {
|
|
999
|
-
formatVersion: JSON_FORMAT_VERSION,
|
|
1000
|
-
tool: { name: "gta", version: run2.version },
|
|
1001
|
-
project: { name: run2.project, root: run2.root },
|
|
1002
|
-
run: {
|
|
1003
|
-
result: failed === 0 ? "passed" : "failed",
|
|
1004
|
-
startedAt: new Date(run2.startedAt).toISOString(),
|
|
1005
|
-
durationMs: Math.round(run2.durationMs),
|
|
1006
|
-
environment: run2.environment,
|
|
1007
|
-
/** The feature flag values the run used, where each came from, and the command run (SPEC.md §2.9). */
|
|
1008
|
-
flags: {
|
|
1009
|
-
values: run2.flags ?? {},
|
|
1010
|
-
sources: run2.flagSources ?? {},
|
|
1011
|
-
command: run2.flagCommand ?? null
|
|
1012
|
-
},
|
|
1013
|
-
settings: {
|
|
1014
|
-
limitConcurrency: run2.concurrency,
|
|
1015
|
-
timeoutCollection: run2.timeoutCollection,
|
|
1016
|
-
bail: run2.bail,
|
|
1017
|
-
tags: run2.tags,
|
|
1018
|
-
notTags: run2.notTags ?? []
|
|
1019
|
-
}
|
|
1020
|
-
},
|
|
1021
|
-
totals: {
|
|
1022
|
-
collections: { total: tallies.length, passed: tallies.length - failed, failed },
|
|
1023
|
-
steps: {
|
|
1024
|
-
total: sum((t) => t.steps.total),
|
|
1025
|
-
passed: sum((t) => t.steps.passed),
|
|
1026
|
-
failed: sum((t) => t.steps.failed),
|
|
1027
|
-
errored: sum((t) => t.steps.errored),
|
|
1028
|
-
skipped: sum((t) => t.steps.skipped)
|
|
1029
|
-
},
|
|
1030
|
-
assertions: {
|
|
1031
|
-
total: sum((t) => t.assertions.total),
|
|
1032
|
-
passed: sum((t) => t.assertions.passed),
|
|
1033
|
-
failed: sum((t) => t.assertions.failed)
|
|
1034
|
-
}
|
|
1035
|
-
},
|
|
1036
|
-
leftOut: { excluded: run2.excluded, untagged: run2.untagged },
|
|
1037
|
-
reports,
|
|
1038
|
-
collections: run2.collections.map(collectionOf)
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
function collectionOf(c) {
|
|
1042
|
-
const { outcome, tally: tally2 } = c;
|
|
1043
|
-
return {
|
|
1044
|
-
id: c.id,
|
|
1045
|
-
file: c.file,
|
|
1046
|
-
status: tally2.skipped ? "skipped" : tally2.passed ? "passed" : "failed",
|
|
1047
|
-
startedAt: new Date(c.startedAt).toISOString(),
|
|
1048
|
-
durationMs: Math.round(c.durationMs),
|
|
1049
|
-
counts: { steps: tally2.steps, assertions: tally2.assertions },
|
|
1050
|
-
/** Why it did not run at all, or null when it ran. */
|
|
1051
|
-
error: outcome.ok ? null : outcome.message,
|
|
1052
|
-
/** The data file that ran it once per row (SPEC.md §2.8), or null. */
|
|
1053
|
-
dataFile: outcome.ok ? outcome.data : null,
|
|
1054
|
-
/** Steps a bail left unrun. */
|
|
1055
|
-
notRun: outcome.ok ? unattempted(outcome.summary) : tally2.steps.total,
|
|
1056
|
-
steps: outcome.ok ? outcome.summary.results.map(
|
|
1057
|
-
(result, i) => stepOf(result, outcome.steps[i] ?? { index: i, line: null })
|
|
1058
|
-
) : []
|
|
1059
|
-
};
|
|
1060
|
-
}
|
|
1061
|
-
function stepOf(result, ref) {
|
|
1062
|
-
const truncated = {};
|
|
1063
|
-
const clip3 = (field3, text) => {
|
|
1064
|
-
if (text === null || text.length <= BODY_LIMIT2) return text;
|
|
1065
|
-
truncated[field3] = text.length;
|
|
1066
|
-
return text.slice(0, BODY_LIMIT2);
|
|
1067
|
-
};
|
|
1068
|
-
const request = { ...result.request, body: clip3("request.body", result.request.body) };
|
|
1069
|
-
const response = result.response ? { ...result.response, body: clip3("response.body", result.response.body) ?? "" } : null;
|
|
1070
|
-
return {
|
|
1071
|
-
/** The step's place in the file's `steps`, from 0, and its line, from 1. */
|
|
1072
|
-
step: { index: ref.index, line: ref.line },
|
|
1073
|
-
/** The data file row it ran with, from 1, or null without a data file. */
|
|
1074
|
-
iteration: ref.iteration ?? null,
|
|
1075
|
-
...result,
|
|
1076
|
-
request,
|
|
1077
|
-
response,
|
|
1078
|
-
/** Fields cut short, with their full length in characters. */
|
|
1079
|
-
...Object.keys(truncated).length > 0 ? { truncated } : {}
|
|
1080
|
-
};
|
|
1081
|
-
}
|
|
1082
|
-
function jsonListing(options) {
|
|
1083
|
-
return {
|
|
1084
|
-
formatVersion: JSON_FORMAT_VERSION,
|
|
1085
|
-
project: options.project,
|
|
1086
|
-
environments: options.environments,
|
|
1087
|
-
environmentType: options.environmentType,
|
|
1088
|
-
settings: { values: options.settings.values, sources: options.settings.sources },
|
|
1089
|
-
flags: { values: options.flags.values, sources: options.flags.sources },
|
|
1090
|
-
tags: options.tags,
|
|
1091
|
-
notTags: options.notTags ?? [],
|
|
1092
|
-
collections: options.targets.map((target) => ({
|
|
1093
|
-
id: target.id,
|
|
1094
|
-
file: fileOf(target.collection),
|
|
1095
|
-
directory: target.collection.directory,
|
|
1096
|
-
steps: target.steps ?? target.collection.doc.steps.map((_, i) => i),
|
|
1097
|
-
stepCount: target.collection.doc.steps.length,
|
|
1098
|
-
dataFile: target.collection.dataFile ? {
|
|
1099
|
-
file: `collections/${target.collection.dataFile.relativePath}`,
|
|
1100
|
-
rows: target.collection.dataFile.rows
|
|
1101
|
-
} : null,
|
|
1102
|
-
tags: target.collection.doc.tags ?? [],
|
|
1103
|
-
broken: target.broken
|
|
1104
|
-
})),
|
|
1105
|
-
leftOut: { excluded: options.excluded, untagged: options.untagged },
|
|
1106
|
-
problems: options.problems
|
|
1107
|
-
};
|
|
1108
|
-
}
|
|
1109
|
-
var fileOf = (collection) => `collections/${collection.relativePath}`;
|
|
1110
|
-
var jsonError = (message, exitCode) => ({
|
|
1111
|
-
formatVersion: JSON_FORMAT_VERSION,
|
|
1112
|
-
error: { message, exitCode }
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
// src/settings.ts
|
|
1116
|
-
import fs from "node:fs/promises";
|
|
1117
|
-
import path from "node:path";
|
|
1118
|
-
var import_yaml = __toESM(require_dist(), 1);
|
|
1119
|
-
var SETTING_KEYS = {
|
|
1120
|
-
environmentType: {
|
|
1121
|
-
kind: "string",
|
|
1122
|
-
doc: "The environment to run against, by name: environments/<name>.yml (the global project\u2019s too). Unset: no environment."
|
|
1123
|
-
},
|
|
1124
|
-
limitConcurrency: {
|
|
1125
|
-
kind: "integer",
|
|
1126
|
-
doc: "How many collections run at once. Steps within a collection always run in order."
|
|
1127
|
-
},
|
|
1128
|
-
timeoutCollection: {
|
|
1129
|
-
kind: "integer",
|
|
1130
|
-
doc: "Milliseconds a collection may take before it is stopped and reported failed."
|
|
1131
|
-
},
|
|
1132
|
-
bail: {
|
|
1133
|
-
kind: "boolean",
|
|
1134
|
-
doc: "Stop a collection at its first failing step; the rest are reported skipped."
|
|
1135
|
-
},
|
|
1136
|
-
tags: {
|
|
1137
|
-
kind: "list",
|
|
1138
|
-
doc: "Run only what has one of these tags (SPEC.md \xA72.4). Empty: everything."
|
|
1139
|
-
},
|
|
1140
|
-
notTags: {
|
|
1141
|
-
kind: "list",
|
|
1142
|
-
doc: "Leave out what has one of these tags: a collection tagged so, or a step where steps carry tags."
|
|
1143
|
-
},
|
|
1144
|
-
generateJUnitResults: {
|
|
1145
|
-
kind: "boolean",
|
|
1146
|
-
doc: "Write a JUnit XML report of the run to <testResultsBasePath>/junit/junit.xml, for CI."
|
|
1147
|
-
},
|
|
1148
|
-
generateJsonResults: {
|
|
1149
|
-
kind: "boolean",
|
|
1150
|
-
doc: "Write the run as JSON to <testResultsBasePath>/json/results.json, for tools and agents."
|
|
1151
|
-
},
|
|
1152
|
-
generateHtmlResults: {
|
|
1153
|
-
kind: "boolean",
|
|
1154
|
-
doc: "Write an HTML report of the run: <testResultsBasePath>/html/summary.html and a page per collection."
|
|
1155
|
-
},
|
|
1156
|
-
autoOpenTestResultHtml: {
|
|
1157
|
-
kind: "boolean",
|
|
1158
|
-
doc: "Write the HTML report and open its summary in the browser when the run ends."
|
|
1159
|
-
},
|
|
1160
|
-
testResultsBasePath: {
|
|
1161
|
-
kind: "string",
|
|
1162
|
-
doc: "Where reports are written: relative to the project folder, or absolute."
|
|
1163
|
-
}
|
|
1164
|
-
};
|
|
1165
|
-
var KEYS = Object.keys(SETTING_KEYS);
|
|
1166
|
-
var positiveInteger = external_exports.number().int().min(1);
|
|
1167
|
-
var SettingsSchema = external_exports.strictObject({
|
|
1168
|
-
environmentType: external_exports.string().trim().min(1).nullable().default(null),
|
|
1169
|
-
limitConcurrency: positiveInteger.default(1),
|
|
1170
|
-
timeoutCollection: positiveInteger.default(36e5),
|
|
1171
|
-
bail: external_exports.boolean().default(false),
|
|
1172
|
-
tags: external_exports.array(TagSchema).default([]),
|
|
1173
|
-
notTags: external_exports.array(TagSchema).default([]),
|
|
1174
|
-
generateJUnitResults: external_exports.boolean().default(false),
|
|
1175
|
-
generateJsonResults: external_exports.boolean().default(false),
|
|
1176
|
-
generateHtmlResults: external_exports.boolean().default(false),
|
|
1177
|
-
autoOpenTestResultHtml: external_exports.boolean().default(false),
|
|
1178
|
-
testResultsBasePath: external_exports.string().trim().min(1).default("test-results")
|
|
1179
|
-
});
|
|
1180
|
-
var DEFAULT_SETTINGS = SettingsSchema.parse({});
|
|
1181
|
-
var SettingsError = class extends Error {
|
|
1182
|
-
name = "SettingsError";
|
|
1183
|
-
};
|
|
1184
|
-
var envNameOf = (key) => `GTA_${key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
|
|
1185
|
-
var DEFAULT_SOURCE = "default";
|
|
1186
|
-
async function loadSettings(options) {
|
|
1187
|
-
const { root, global = null, overrides = {}, env = process.env } = options;
|
|
1188
|
-
const own = await readSettingsFile(path.join(root, SETTINGS_FILE), SETTINGS_FILE);
|
|
1189
|
-
if (own === null) {
|
|
1190
|
-
throw new SettingsError(
|
|
1191
|
-
`There is no ${SETTINGS_FILE} in ${root}.
|
|
1192
|
-
gta runs from a project folder: the one holding collections/ and ${SETTINGS_FILE}.
|
|
1193
|
-
A minimal ${SETTINGS_FILE}:
|
|
1194
|
-
|
|
1195
|
-
environmentType: demo
|
|
1196
|
-
limitConcurrency: 4
|
|
1197
|
-
`
|
|
1198
|
-
);
|
|
1199
|
-
}
|
|
1200
|
-
const sharedLabel = global ? path.posix.join(global.uses, SETTINGS_FILE) : "";
|
|
1201
|
-
const shared = global ? await readSettingsFile(path.join(global.root, SETTINGS_FILE), sharedLabel) : null;
|
|
1202
|
-
const origin = /* @__PURE__ */ new Map();
|
|
1203
|
-
const merged = {};
|
|
1204
|
-
const files = [
|
|
1205
|
-
[sharedLabel, shared],
|
|
1206
|
-
[SETTINGS_FILE, own]
|
|
1207
|
-
];
|
|
1208
|
-
for (const [label, fromFile] of files) {
|
|
1209
|
-
for (const [key, value] of Object.entries(fromFile ?? {})) {
|
|
1210
|
-
checkKey(key, label);
|
|
1211
|
-
merged[key] = value;
|
|
1212
|
-
origin.set(key, label);
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
for (const key of KEYS) {
|
|
1216
|
-
const value = env[envNameOf(key)];
|
|
1217
|
-
if (value === void 0 || value === "") continue;
|
|
1218
|
-
merged[key] = coerce(key, value, envNameOf(key));
|
|
1219
|
-
origin.set(key, envNameOf(key));
|
|
1220
|
-
}
|
|
1221
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
1222
|
-
checkKey(key, `--${key}`);
|
|
1223
|
-
merged[key] = coerce(key, value, `--${key}`);
|
|
1224
|
-
origin.set(key, `--${key}`);
|
|
1225
|
-
}
|
|
1226
|
-
const result = SettingsSchema.safeParse(merged);
|
|
1227
|
-
if (!result.success) {
|
|
1228
|
-
const lines = result.error.issues.map((issue) => {
|
|
1229
|
-
const key = String(issue.path[0] ?? "");
|
|
1230
|
-
return ` ${key} (from ${origin.get(key) ?? SETTINGS_FILE}): ${issue.message}`;
|
|
1231
|
-
});
|
|
1232
|
-
throw new SettingsError(`Settings are not valid:
|
|
1233
|
-
${lines.join("\n")}`);
|
|
1234
|
-
}
|
|
1235
|
-
const sources = Object.fromEntries(
|
|
1236
|
-
KEYS.map((key) => [key, origin.get(key) ?? DEFAULT_SOURCE])
|
|
1237
|
-
);
|
|
1238
|
-
return { settings: result.data, sources };
|
|
1239
|
-
}
|
|
1240
|
-
async function readSettingsFile(file, label) {
|
|
1241
|
-
let source;
|
|
1242
|
-
try {
|
|
1243
|
-
source = await fs.readFile(file, "utf8");
|
|
1244
|
-
} catch (cause) {
|
|
1245
|
-
if (cause.code === "ENOENT") return null;
|
|
1246
|
-
throw new SettingsError(`${label} cannot be read: ${cause.message}`);
|
|
1247
|
-
}
|
|
1248
|
-
let fromFile;
|
|
1249
|
-
try {
|
|
1250
|
-
fromFile = (0, import_yaml.parse)(source) ?? {};
|
|
1251
|
-
} catch (cause) {
|
|
1252
|
-
throw new SettingsError(`${label} will not parse: ${cause.message}`);
|
|
1253
|
-
}
|
|
1254
|
-
if (typeof fromFile !== "object" || fromFile === null || Array.isArray(fromFile)) {
|
|
1255
|
-
throw new SettingsError(`${label} must be a map of settings, such as limitConcurrency: 4`);
|
|
1256
|
-
}
|
|
1257
|
-
return fromFile;
|
|
1258
|
-
}
|
|
1259
|
-
function checkKey(key, where) {
|
|
1260
|
-
if (!Object.hasOwn(SETTING_KEYS, key)) {
|
|
1261
|
-
throw new SettingsError(
|
|
1262
|
-
`Unknown setting "${key}" (from ${where}). Settings are: ${KEYS.join(", ")}`
|
|
1263
|
-
);
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
function coerce(key, value, where) {
|
|
1267
|
-
const { kind } = SETTING_KEYS[key];
|
|
1268
|
-
if (value === true) {
|
|
1269
|
-
if (kind === "boolean") return true;
|
|
1270
|
-
throw new SettingsError(`${where} needs a value`);
|
|
1271
|
-
}
|
|
1272
|
-
switch (kind) {
|
|
1273
|
-
case "integer":
|
|
1274
|
-
return /^\s*\d+\s*$/.test(value) ? Number(value) : value;
|
|
1275
|
-
case "boolean":
|
|
1276
|
-
return value === "true" ? true : value === "false" ? false : value;
|
|
1277
|
-
case "list":
|
|
1278
|
-
return value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
1279
|
-
case "string":
|
|
1280
|
-
return value;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
1367
|
// src/main.ts
|
|
1285
|
-
var VERSION = true ? "0.
|
|
1368
|
+
var VERSION = true ? "0.14.1" : "0.0.0-dev";
|
|
1286
1369
|
var JUNIT_DIR = "junit";
|
|
1287
1370
|
var JUNIT_FILE = "junit.xml";
|
|
1288
1371
|
var HTML_DIR = "html";
|
|
1289
1372
|
var JSON_DIR = "json";
|
|
1290
1373
|
var JSON_FILE = "results.json";
|
|
1291
1374
|
var EXIT = { passed: 0, failed: 1, unusable: 2 };
|
|
1292
|
-
var ProjectError = class extends Error {
|
|
1293
|
-
name = "ProjectError";
|
|
1294
|
-
};
|
|
1295
1375
|
async function main(argv, cli) {
|
|
1296
1376
|
let ctx = cli;
|
|
1297
1377
|
let p = paint(ctx.color);
|
|
@@ -1312,9 +1392,25 @@ async function main(argv, cli) {
|
|
|
1312
1392
|
ctx.out(usage(p));
|
|
1313
1393
|
return EXIT.passed;
|
|
1314
1394
|
}
|
|
1395
|
+
const command = args.command;
|
|
1396
|
+
if (command === "lint" || command === "rules") {
|
|
1397
|
+
const given = [
|
|
1398
|
+
...Object.keys(args.overrides).map((key) => `--${key}`),
|
|
1399
|
+
...args.flags.map(() => "--flag")
|
|
1400
|
+
];
|
|
1401
|
+
if (given.length > 0) {
|
|
1402
|
+
throw new UsageError(`gta ${command} takes no settings or flags: ${given.join(", ")}`);
|
|
1403
|
+
}
|
|
1404
|
+
const root = await canonical(ctx.cwd);
|
|
1405
|
+
const output = { out: ctx.out, emit, p };
|
|
1406
|
+
if (command === "rules") {
|
|
1407
|
+
await rulesCommand(root, output);
|
|
1408
|
+
return EXIT.passed;
|
|
1409
|
+
}
|
|
1410
|
+
return await lintCommand(root, output) ? EXIT.failed : EXIT.passed;
|
|
1411
|
+
}
|
|
1315
1412
|
const project = await openProject(ctx, args.overrides, args.flags);
|
|
1316
1413
|
for (const warning of project.warnings) ctx.err(p.yellow(`warning: ${warning}`));
|
|
1317
|
-
const command = args.command;
|
|
1318
1414
|
if (command === "g" || command === "get") {
|
|
1319
1415
|
const selection = selectFor(project, { kind: "all" });
|
|
1320
1416
|
const problems = await listingProblems(project, selection);
|
|
@@ -1343,7 +1439,7 @@ async function main(argv, cli) {
|
|
|
1343
1439
|
const request = command === "a" || command === "all" ? { kind: "all" } : { kind: "list", selectors: command.split(",").filter((s) => s.trim() !== "") };
|
|
1344
1440
|
return await run(project, selectFor(project, request), ctx, p, emit);
|
|
1345
1441
|
} catch (cause) {
|
|
1346
|
-
if (cause instanceof UsageError || cause instanceof SettingsError || cause instanceof ProjectError) {
|
|
1442
|
+
if (cause instanceof UsageError || cause instanceof SettingsError || cause instanceof ProjectError || cause instanceof RulesError) {
|
|
1347
1443
|
ctx.err(p.red(cause.message));
|
|
1348
1444
|
const json = emit ?? (argv.includes("--json") ? (v) => cli.out(JSON.stringify(v, null, 2)) : null);
|
|
1349
1445
|
json?.(jsonError(cause.message, EXIT.unusable));
|
|
@@ -1352,63 +1448,7 @@ async function main(argv, cli) {
|
|
|
1352
1448
|
throw cause;
|
|
1353
1449
|
}
|
|
1354
1450
|
}
|
|
1355
|
-
async
|
|
1356
|
-
const root = await canonical(ctx.cwd);
|
|
1357
|
-
if (!await isDirectory(path2.join(root, COLLECTIONS_DIR))) {
|
|
1358
|
-
throw new ProjectError(
|
|
1359
|
-
`There is no ${COLLECTIONS_DIR}/ in ${root}.
|
|
1360
|
-
gta runs from a project folder: the one holding ${COLLECTIONS_DIR}/ and ${SETTINGS_FILE}.`
|
|
1361
|
-
);
|
|
1362
|
-
}
|
|
1363
|
-
const info = await readProject(root);
|
|
1364
|
-
const { settings, sources: settingSources } = await loadSettings({
|
|
1365
|
-
root,
|
|
1366
|
-
global: info.global,
|
|
1367
|
-
overrides,
|
|
1368
|
-
env: ctx.env
|
|
1369
|
-
});
|
|
1370
|
-
const layout = await discoverProject(root);
|
|
1371
|
-
const collections = await loadProjectCollections(root, layout.files);
|
|
1372
|
-
const environments = environmentNames(await projectEnvironments(root, info.global));
|
|
1373
|
-
if (settings.environmentType && !environments.includes(settings.environmentType)) {
|
|
1374
|
-
throw new ProjectError(
|
|
1375
|
-
`environmentType (from ${settingSources.environmentType}): there is no environment called "${settings.environmentType}". ` + (environments.length > 0 ? `This project has: ${environments.join(", ")}` : `This project has no environments/.`)
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
let flags;
|
|
1379
|
-
try {
|
|
1380
|
-
flags = await resolveFlags({
|
|
1381
|
-
root,
|
|
1382
|
-
environmentName: settings.environmentType,
|
|
1383
|
-
overrides: flagOverrides(flagArgs, ctx.env),
|
|
1384
|
-
env: { ...process.env, ...ctx.env }
|
|
1385
|
-
});
|
|
1386
|
-
} catch (cause) {
|
|
1387
|
-
throw new ProjectError(cause.message);
|
|
1388
|
-
}
|
|
1389
|
-
const tls = await loadProjectTls(root, info);
|
|
1390
|
-
if (tls.problems.length > 0) {
|
|
1391
|
-
throw new ProjectError(
|
|
1392
|
-
`A certificate in tls.ca cannot be used:
|
|
1393
|
-
` + tls.problems.map((problem) => ` ${problem.path}: ${problem.message}`).join("\n")
|
|
1394
|
-
);
|
|
1395
|
-
}
|
|
1396
|
-
return {
|
|
1397
|
-
root,
|
|
1398
|
-
name: info.doc?.name ?? path2.basename(root),
|
|
1399
|
-
settings,
|
|
1400
|
-
settingSources,
|
|
1401
|
-
flags,
|
|
1402
|
-
collections,
|
|
1403
|
-
environments,
|
|
1404
|
-
caFiles: tls.files.map(
|
|
1405
|
-
(file) => `${relativePosix(root, file.file)} (${file.certificates.map((c) => c.subject).join(", ")})`
|
|
1406
|
-
),
|
|
1407
|
-
warnings: [...info.problems, ...layout.problems].map(
|
|
1408
|
-
(problem) => problem.path ? `${problem.path}: ${problem.message}` : problem.message
|
|
1409
|
-
)
|
|
1410
|
-
};
|
|
1411
|
-
}
|
|
1451
|
+
var openProject = async (ctx, overrides, flagArgs = []) => loadProject({ root: await canonical(ctx.cwd), env: ctx.env, overrides, flagArgs });
|
|
1412
1452
|
var selectFor = (project, request) => selectCollections(project.collections, request, {
|
|
1413
1453
|
tags: project.settings.tags,
|
|
1414
1454
|
notTags: project.settings.notTags
|
|
@@ -1445,12 +1485,12 @@ function list(project, selection, problems, ctx, p) {
|
|
|
1445
1485
|
ctx.out("");
|
|
1446
1486
|
const width = nameWidthFor(selection.targets.map((t) => t.id));
|
|
1447
1487
|
const dirWidth = Math.max(
|
|
1448
|
-
"
|
|
1488
|
+
"Folder".length,
|
|
1449
1489
|
...selection.targets.map((t) => (t.collection.directory ?? "").length)
|
|
1450
1490
|
);
|
|
1451
1491
|
ctx.out(
|
|
1452
1492
|
p.bold(
|
|
1453
|
-
`${"#".padStart(4)} ${"Collection".padEnd(width)} ${"Steps".padStart(7)} ${"Rows".padStart(5)} ${"
|
|
1493
|
+
`${"#".padStart(4)} ${"Collection".padEnd(width)} ${"Steps".padStart(7)} ${"Rows".padStart(5)} ${"Folder".padEnd(dirWidth)} Tags`
|
|
1454
1494
|
)
|
|
1455
1495
|
);
|
|
1456
1496
|
selection.targets.forEach((target, i) => {
|
|
@@ -1647,8 +1687,8 @@ async function run(project, selection, ctx, p, emit = null) {
|
|
|
1647
1687
|
try {
|
|
1648
1688
|
for (const file of report.files()) {
|
|
1649
1689
|
const target = path2.join(report.dir, ...file.path.split("/"));
|
|
1650
|
-
await
|
|
1651
|
-
await
|
|
1690
|
+
await fs.mkdir(path2.dirname(target), { recursive: true });
|
|
1691
|
+
await fs.writeFile(target, file.text);
|
|
1652
1692
|
}
|
|
1653
1693
|
ctx.out(field(report.label, shown));
|
|
1654
1694
|
if (report.label === "HTML" && settings.autoOpenTestResultHtml) ctx.open(shown);
|
|
@@ -1685,15 +1725,20 @@ function usage(p) {
|
|
|
1685
1725
|
" Exits 1 on a broken collection, or a use:, extends: or body",
|
|
1686
1726
|
" file that would stop a run, in any collection.",
|
|
1687
1727
|
" a, all Run every collection, less those with exclude: true.",
|
|
1688
|
-
" <list> Run the collections and
|
|
1728
|
+
" <list> Run the collections and folders named, comma separated,",
|
|
1689
1729
|
" in that order: smoke,checkout/sessions,payments",
|
|
1690
|
-
" A
|
|
1730
|
+
" A folder leaves out its excluded collections;",
|
|
1691
1731
|
" one named on its own runs.",
|
|
1692
|
-
" A collection called all or
|
|
1732
|
+
" A collection called all, get, lint or rules: give it as all.yml.",
|
|
1733
|
+
" lint Check the project\u2019s files against its rules: its rules.yml,",
|
|
1734
|
+
" over its global project\u2019s. Exits 1 on a finding.",
|
|
1735
|
+
" rules List the rules in effect, the file each came from, what each",
|
|
1736
|
+
" means, and the project\u2019s guide. Neither needs settings.yml.",
|
|
1693
1737
|
"",
|
|
1694
1738
|
` ${p.bold("Options:")}`,
|
|
1695
1739
|
" --json Print the results as JSON on stdout instead of the table; with",
|
|
1696
|
-
" get, the collections it would run
|
|
1740
|
+
" get, the collections it would run; with lint and rules, the",
|
|
1741
|
+
" findings and the rules. Errors are JSON too.",
|
|
1697
1742
|
" --flag name=value",
|
|
1698
1743
|
" Override a feature flag for this run; any number of them.",
|
|
1699
1744
|
" GTA_FLAG_<name>=value does the same from the environment.",
|
|
@@ -1705,13 +1750,6 @@ function usage(p) {
|
|
|
1705
1750
|
` ${p.bold("Exit code:")} 0 passed, 1 failed, 2 could not run (or write a report).`
|
|
1706
1751
|
].join("\n");
|
|
1707
1752
|
}
|
|
1708
|
-
async function isDirectory(target) {
|
|
1709
|
-
try {
|
|
1710
|
-
return (await fs2.stat(target)).isDirectory();
|
|
1711
|
-
} catch {
|
|
1712
|
-
return false;
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
1753
|
var wantsHtml = (settings) => settings.generateHtmlResults || settings.autoOpenTestResultHtml;
|
|
1716
1754
|
var RESULT_DIRS = {
|
|
1717
1755
|
[JUNIT_DIR]: (name) => name === JUNIT_FILE,
|
|
@@ -1725,14 +1763,14 @@ async function clearResults(dir, projectRoot) {
|
|
|
1725
1763
|
}
|
|
1726
1764
|
let entries;
|
|
1727
1765
|
try {
|
|
1728
|
-
entries = await
|
|
1766
|
+
entries = await fs.readdir(dir);
|
|
1729
1767
|
} catch {
|
|
1730
1768
|
return null;
|
|
1731
1769
|
}
|
|
1732
1770
|
const others = entries.filter((entry) => !(entry in RESULT_DIRS) && !SYSTEM_FILES.has(entry));
|
|
1733
1771
|
for (const [folder, ours] of Object.entries(RESULT_DIRS)) {
|
|
1734
1772
|
if (!entries.includes(folder)) continue;
|
|
1735
|
-
const inside = await
|
|
1773
|
+
const inside = await fs.readdir(path2.join(dir, folder)).catch(() => []);
|
|
1736
1774
|
others.push(
|
|
1737
1775
|
...inside.filter((name) => !ours(name) && !SYSTEM_FILES.has(name)).map((name) => `${folder}/${name}`)
|
|
1738
1776
|
);
|
|
@@ -1741,7 +1779,7 @@ async function clearResults(dir, projectRoot) {
|
|
|
1741
1779
|
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.`;
|
|
1742
1780
|
}
|
|
1743
1781
|
try {
|
|
1744
|
-
await
|
|
1782
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
1745
1783
|
return null;
|
|
1746
1784
|
} catch (cause) {
|
|
1747
1785
|
return `Could not delete ${dir} before the run: ${cause.message}`;
|