@schwabyio/gta 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -1
- package/dist/FUNCTIONS.md +563 -0
- package/dist/SPEC.md +428 -85
- package/dist/THIRD_PARTY_NOTICES.txt +26 -0
- package/dist/chunks/chunk-KVO46B7X.js +234 -0
- package/dist/chunks/chunk-Q3VRXFS5.js +577 -0
- package/dist/chunks/{chunk-3NSHWS24.js → chunk-ZDHGCHIV.js} +9344 -1952
- package/dist/gta.js +1046 -995
- 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 +243 -0
- package/dist/worker.js +1 -1
- package/package.json +25 -2
package/dist/gta.js
CHANGED
|
@@ -1,317 +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-Q3VRXFS5.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
|
-
|
|
46
|
+
lintProject,
|
|
47
|
+
loadRules,
|
|
48
|
+
markHeaders,
|
|
49
|
+
markLines,
|
|
50
|
+
markStatus,
|
|
20
51
|
parseMarkdown,
|
|
21
|
-
|
|
52
|
+
parsePath,
|
|
22
53
|
readProject,
|
|
23
54
|
referenceProblems,
|
|
24
|
-
|
|
25
|
-
require_dist,
|
|
26
|
-
resolveFlags,
|
|
55
|
+
resolvePath,
|
|
27
56
|
resultName,
|
|
57
|
+
ruleDoc,
|
|
28
58
|
samePath,
|
|
29
|
-
|
|
59
|
+
sortArraysBy,
|
|
60
|
+
toJsonLines,
|
|
30
61
|
unattempted
|
|
31
|
-
} from "./chunks/chunk-
|
|
32
|
-
|
|
33
|
-
// src/args.ts
|
|
34
|
-
var UsageError = class extends Error {
|
|
35
|
-
name = "UsageError";
|
|
36
|
-
};
|
|
37
|
-
function parseArgs(argv) {
|
|
38
|
-
const parsed = {
|
|
39
|
-
command: null,
|
|
40
|
-
overrides: {},
|
|
41
|
-
help: false,
|
|
42
|
-
version: false,
|
|
43
|
-
json: false,
|
|
44
|
-
flags: []
|
|
45
|
-
};
|
|
46
|
-
for (let i = 0; i < argv.length; i++) {
|
|
47
|
-
const word = argv[i];
|
|
48
|
-
if (word === "-h" || word === "--help") {
|
|
49
|
-
parsed.help = true;
|
|
50
|
-
} else if (word === "-v" || word === "--version") {
|
|
51
|
-
parsed.version = true;
|
|
52
|
-
} else if (word === "--json") {
|
|
53
|
-
parsed.json = true;
|
|
54
|
-
} else if (word === "--flag" || word.startsWith("--flag=")) {
|
|
55
|
-
const value = word === "--flag" ? argv[++i] : word.slice("--flag=".length);
|
|
56
|
-
if (value === void 0 || value.startsWith("--")) {
|
|
57
|
-
throw new UsageError("--flag needs name=value, such as --flag newCheckout=true");
|
|
58
|
-
}
|
|
59
|
-
parsed.flags.push(value);
|
|
60
|
-
} else if (word.startsWith("--")) {
|
|
61
|
-
const body = word.slice(2);
|
|
62
|
-
const equals = body.indexOf("=");
|
|
63
|
-
if (equals > 0) {
|
|
64
|
-
parsed.overrides[body.slice(0, equals)] = body.slice(equals + 1);
|
|
65
|
-
} else {
|
|
66
|
-
const next = argv[i + 1];
|
|
67
|
-
if (next !== void 0 && !next.startsWith("--")) {
|
|
68
|
-
parsed.overrides[body] = next;
|
|
69
|
-
i++;
|
|
70
|
-
} else {
|
|
71
|
-
parsed.overrides[body] = true;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
} else if (word.startsWith("-") && word.length > 1) {
|
|
75
|
-
throw new UsageError(`Unknown option ${word}. Settings are given as --settingsKey value.`);
|
|
76
|
-
} else if (parsed.command === null) {
|
|
77
|
-
parsed.command = word;
|
|
78
|
-
} else {
|
|
79
|
-
throw new UsageError(
|
|
80
|
-
`Unexpected "${word}". To run several collections, separate them with commas: gta ${parsed.command},${word}`
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return parsed;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// src/select.ts
|
|
88
|
-
var idOf = (collection) => collection.name;
|
|
89
|
-
var placeOf = (collection) => collection.relativePath.slice(0, -DOC_EXTENSION.length);
|
|
90
|
-
var normalize = (selector) => {
|
|
91
|
-
const cleaned = selector.normalize("NFC").trim().replace(/\\/g, "/").replace(/^(\.\/)+/, "").replace(/^collections\//, "");
|
|
92
|
-
return {
|
|
93
|
-
name: cleaned.replace(/\/+$/, "").replace(/\.yml$/, ""),
|
|
94
|
-
directoryOnly: cleaned.endsWith("/")
|
|
95
|
-
};
|
|
96
|
-
};
|
|
97
|
-
function selectCollections(collections, request, options) {
|
|
98
|
-
const excluded = /* @__PURE__ */ new Set();
|
|
99
|
-
const inGroup = (group) => group.filter((c) => {
|
|
100
|
-
if (c.doc.exclude === true) excluded.add(c);
|
|
101
|
-
return c.doc.exclude !== true;
|
|
102
|
-
});
|
|
103
|
-
let chosen;
|
|
104
|
-
if (request.kind === "list") {
|
|
105
|
-
const unknown = [];
|
|
106
|
-
chosen = [];
|
|
107
|
-
for (const selector of request.selectors) {
|
|
108
|
-
const { name, directoryOnly } = normalize(selector);
|
|
109
|
-
const exact = directoryOnly ? void 0 : collections.find((c) => idOf(c) === name) ?? collections.find((c) => placeOf(c).normalize("NFC") === name);
|
|
110
|
-
const inDirectory = collections.filter((c) => c.directory?.normalize("NFC") === name);
|
|
111
|
-
const found = exact ? [exact] : inDirectory.length > 0 ? inGroup(inDirectory) : null;
|
|
112
|
-
if (found) chosen.push(...found.filter((c) => !chosen.includes(c)));
|
|
113
|
-
else unknown.push(selector);
|
|
114
|
-
}
|
|
115
|
-
if (unknown.length > 0) {
|
|
116
|
-
throw new UsageError(
|
|
117
|
-
`No collection or directory is called ${unknown.map((u) => `"${u}"`).join(", ")}. List them with: gta get`
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
} else {
|
|
121
|
-
chosen = inGroup(collections);
|
|
122
|
-
}
|
|
123
|
-
const targets = [];
|
|
124
|
-
let untagged = 0;
|
|
125
|
-
for (const collection of chosen) {
|
|
126
|
-
const id = idOf(collection);
|
|
127
|
-
if (collection.problems.length > 0) {
|
|
128
|
-
const broken = collection.problems.map((p) => p.message).join("; ");
|
|
129
|
-
targets.push({ id, collection, steps: null, broken });
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const picked = stepsForTags(collection.doc, options.tags, options.notTags ?? []);
|
|
133
|
-
if (picked.length === 0 && (options.tags.length > 0 || (options.notTags ?? []).length > 0)) {
|
|
134
|
-
untagged++;
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
const all = picked.length === collection.doc.steps.length;
|
|
138
|
-
targets.push({ id, collection, steps: all ? null : picked, broken: null });
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
targets,
|
|
142
|
-
// Named on its own, an excluded collection ran: it was not left out.
|
|
143
|
-
excluded: [...excluded].filter((c) => !chosen.includes(c)).map(idOf),
|
|
144
|
-
untagged
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
var stepCountOf = (target) => target.steps?.length ?? target.collection.doc.steps.length;
|
|
148
|
-
|
|
149
|
-
// src/report.ts
|
|
150
|
-
function paint(enabled) {
|
|
151
|
-
const wrap = (open, close) => (text) => enabled ? `\x1B[${open}m${text}\x1B[${close}m` : text;
|
|
152
|
-
return {
|
|
153
|
-
red: wrap(31, 39),
|
|
154
|
-
green: wrap(32, 39),
|
|
155
|
-
cyan: wrap(36, 39),
|
|
156
|
-
yellow: wrap(33, 39),
|
|
157
|
-
dim: wrap(2, 22),
|
|
158
|
-
bold: wrap(1, 22)
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
function colorWanted(stream, env) {
|
|
162
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
163
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
164
|
-
return stream.isTTY === true;
|
|
165
|
-
}
|
|
166
|
-
function tally(target, outcome, durationMs) {
|
|
167
|
-
if (!outcome.ok) {
|
|
168
|
-
const total = stepCountOf(target);
|
|
169
|
-
return {
|
|
170
|
-
id: target.id,
|
|
171
|
-
passed: false,
|
|
172
|
-
durationMs,
|
|
173
|
-
steps: { total, passed: 0, failed: 0, errored: 0, skipped: total },
|
|
174
|
-
assertions: { total: 0, passed: 0, failed: 0 },
|
|
175
|
-
skipped: false,
|
|
176
|
-
error: outcome.message,
|
|
177
|
-
problems: []
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
const { summary: summary2 } = outcome;
|
|
181
|
-
const assertions = summary2.results.flatMap((r) => r.assertions);
|
|
182
|
-
const failed = assertions.filter((a) => a.status === "fail").length;
|
|
183
|
-
return {
|
|
184
|
-
id: target.id,
|
|
185
|
-
passed: summary2.failed === 0 && summary2.errored === 0,
|
|
186
|
-
durationMs,
|
|
187
|
-
steps: {
|
|
188
|
-
total: summary2.total,
|
|
189
|
-
passed: summary2.passed,
|
|
190
|
-
failed: summary2.failed,
|
|
191
|
-
errored: summary2.errored,
|
|
192
|
-
skipped: summary2.skipped
|
|
193
|
-
},
|
|
194
|
-
assertions: { total: assertions.length, passed: assertions.length - failed, failed },
|
|
195
|
-
skipped: allSkipped(summary2),
|
|
196
|
-
error: null,
|
|
197
|
-
// Named for the row they ran with, when a data file drove the collection.
|
|
198
|
-
problems: summary2.results.flatMap(
|
|
199
|
-
(r, i) => r.status === "fail" || r.status === "error" ? [{ ...r, item: { ...r.item, name: iterationName(resultName(r), outcome.steps[i]) } }] : []
|
|
200
|
-
)
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
var seconds = (ms) => `${(ms / 1e3).toFixed(3)}s`;
|
|
204
|
-
var rule = (p, width = 100) => p.dim("\u2500".repeat(width));
|
|
205
|
-
var field = (label, value, width = 14) => `${`${label}:`.padEnd(width)}${value}`;
|
|
206
|
-
var NUMBER_COLUMNS = [
|
|
207
|
-
["Time", 9],
|
|
208
|
-
["Steps", 6],
|
|
209
|
-
["Pass", 5],
|
|
210
|
-
["Fail", 5],
|
|
211
|
-
["Error", 6],
|
|
212
|
-
["Skip", 5],
|
|
213
|
-
["Asserts", 8],
|
|
214
|
-
["Pass", 5],
|
|
215
|
-
["Fail", 5]
|
|
216
|
-
];
|
|
217
|
-
var nameWidthFor = (ids) => Math.min(80, Math.max("Collection".length, ...ids.map((id) => id.length)));
|
|
218
|
-
var tableWidth = (nameWidth) => 4 + 2 + nameWidth + 1 + NUMBER_COLUMNS.reduce((n, [, w]) => n + w + 1, 0) - 1 + 2 + "passed".length;
|
|
219
|
-
function tableHeader(nameWidth, p) {
|
|
220
|
-
const cells = NUMBER_COLUMNS.map(([label, width]) => label.padStart(width));
|
|
221
|
-
return p.bold(`${"#".padStart(4)} ${"Collection".padEnd(nameWidth)} ${cells.join(" ")} Result`);
|
|
222
|
-
}
|
|
223
|
-
var nameCell = (id, width) => id.length > width ? `${id.slice(0, width - 1)}\u2026` : id.padEnd(width);
|
|
224
|
-
function tableRow(n, t, nameWidth, p) {
|
|
225
|
-
const name = nameCell(t.id, nameWidth);
|
|
226
|
-
const values = [
|
|
227
|
-
seconds(t.durationMs),
|
|
228
|
-
t.steps.total,
|
|
229
|
-
t.steps.passed,
|
|
230
|
-
t.steps.failed,
|
|
231
|
-
t.steps.errored,
|
|
232
|
-
t.steps.skipped,
|
|
233
|
-
t.assertions.total,
|
|
234
|
-
t.assertions.passed,
|
|
235
|
-
t.assertions.failed
|
|
236
|
-
];
|
|
237
|
-
const cells = values.map((value, i) => String(value).padStart(NUMBER_COLUMNS[i][1]));
|
|
238
|
-
const result = t.skipped ? p.cyan("skipped") : t.passed ? p.green("passed") : p.red("failed");
|
|
239
|
-
return `${String(n).padStart(4)} ${name} ${cells.join(" ")} ${result}`;
|
|
240
|
-
}
|
|
241
|
-
var clip = (text, max = 300) => text.length > max ? `${text.slice(0, max)}\u2026 (${text.length - max} more characters)` : text;
|
|
242
|
-
function failureDetails(tallies, p) {
|
|
243
|
-
const lines = [];
|
|
244
|
-
for (const t of tallies) {
|
|
245
|
-
if (t.passed) continue;
|
|
246
|
-
lines.push(p.bold(t.id));
|
|
247
|
-
if (t.error) lines.push(` ${p.red("\u2717")} ${t.error}`);
|
|
248
|
-
for (const result of t.problems) {
|
|
249
|
-
const where = result.use ? ` ${p.dim(`(${result.use.set} ${result.use.child + 1}/${result.use.of})`)}` : "";
|
|
250
|
-
const status = result.response ? ` ${p.dim(String(result.response.status))}` : "";
|
|
251
|
-
lines.push(` ${p.red("\u2717")} ${result.item.name}${where}${status}`);
|
|
252
|
-
if (result.error) {
|
|
253
|
-
const { phase, script, line, message } = result.error;
|
|
254
|
-
const at = [script && `${script} script`, line && `line ${line}`].filter(Boolean).join(", ");
|
|
255
|
-
lines.push(` ${p.red(`${phase} error${at ? ` (${at})` : ""}:`)} ${clip(message)}`);
|
|
256
|
-
}
|
|
257
|
-
for (const assertion of result.assertions) {
|
|
258
|
-
if (assertion.status !== "fail") continue;
|
|
259
|
-
const message = assertion.message ? `: ${assertion.message}` : "";
|
|
260
|
-
lines.push(` ${assertion.name}${message}`);
|
|
261
|
-
if (assertion.expected !== void 0) {
|
|
262
|
-
lines.push(` ${p.dim("expected")} ${clip(assertion.expected)}`);
|
|
263
|
-
}
|
|
264
|
-
if (assertion.actual !== void 0) {
|
|
265
|
-
lines.push(` ${p.dim("actual ")} ${clip(assertion.actual)}`);
|
|
266
|
-
}
|
|
267
|
-
if (assertion.unasserted?.length) {
|
|
268
|
-
lines.push(` ${p.dim("not asserted")} ${clip(assertion.unasserted.join(", "))}`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
lines.push("");
|
|
273
|
-
}
|
|
274
|
-
return lines;
|
|
275
|
-
}
|
|
276
|
-
function totalsOf(tallies) {
|
|
277
|
-
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
278
|
-
return {
|
|
279
|
-
collections: {
|
|
280
|
-
total: tallies.length,
|
|
281
|
-
passed: tallies.filter((t) => t.passed).length,
|
|
282
|
-
failed: tallies.filter((t) => !t.passed).length
|
|
283
|
-
},
|
|
284
|
-
steps: {
|
|
285
|
-
total: sum((t) => t.steps.total),
|
|
286
|
-
passed: sum((t) => t.steps.passed),
|
|
287
|
-
failed: sum((t) => t.steps.failed),
|
|
288
|
-
errored: sum((t) => t.steps.errored),
|
|
289
|
-
skipped: sum((t) => t.steps.skipped)
|
|
290
|
-
},
|
|
291
|
-
assertions: {
|
|
292
|
-
total: sum((t) => t.assertions.total),
|
|
293
|
-
passed: sum((t) => t.assertions.passed),
|
|
294
|
-
failed: sum((t) => t.assertions.failed)
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
function summary(totals, durationMs, p) {
|
|
299
|
-
const { collections: c, steps: s, assertions: a } = totals;
|
|
300
|
-
const passed = c.failed === 0;
|
|
301
|
-
return [
|
|
302
|
-
field("Collections", `${c.total} total, ${c.passed} passed, ${c.failed} failed`),
|
|
303
|
-
field(
|
|
304
|
-
"Steps",
|
|
305
|
-
`${s.total} total, ${s.passed} passed, ${s.failed} failed, ${s.errored} errored, ${s.skipped} skipped`
|
|
306
|
-
),
|
|
307
|
-
field("Assertions", `${a.total} total, ${a.passed} passed, ${a.failed} failed`),
|
|
308
|
-
field("Run time", seconds(durationMs)),
|
|
309
|
-
field("Result", passed ? p.green(p.bold("PASSED")) : p.red(p.bold("FAILED")))
|
|
310
|
-
];
|
|
311
|
-
}
|
|
62
|
+
} from "./chunks/chunk-ZDHGCHIV.js";
|
|
312
63
|
|
|
313
64
|
// src/main.ts
|
|
314
|
-
import
|
|
65
|
+
import fs from "node:fs/promises";
|
|
315
66
|
import path2 from "node:path";
|
|
316
67
|
|
|
317
68
|
// src/pool.ts
|
|
@@ -347,8 +98,239 @@ async function runPool(items, limit, run2) {
|
|
|
347
98
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
348
99
|
}
|
|
349
100
|
|
|
350
|
-
// src/
|
|
101
|
+
// src/rules.ts
|
|
102
|
+
import path from "node:path";
|
|
103
|
+
|
|
104
|
+
// src/json.ts
|
|
105
|
+
var JSON_FORMAT_VERSION = 1;
|
|
351
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, "request set", "request sets"),
|
|
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";
|
|
352
334
|
var SUMMARY_PAGE = "summary.html";
|
|
353
335
|
function htmlReport(run2) {
|
|
354
336
|
return [
|
|
@@ -361,259 +343,465 @@ var flagList = (flags) => {
|
|
|
361
343
|
const entries = Object.entries(flags);
|
|
362
344
|
return entries.length > 0 ? entries.map(([name, value]) => `${name}=${value}`).join(", ") : "none";
|
|
363
345
|
};
|
|
346
|
+
var collectionStatus = (t) => t.skipped ? "skipped" : t.passed ? "passed" : "failed";
|
|
364
347
|
function summaryPage(run2) {
|
|
365
348
|
const tallies = run2.collections.map((c) => c.tally);
|
|
366
349
|
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
367
|
-
const
|
|
368
|
-
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>`;
|
|
369
355
|
const settings = [
|
|
370
|
-
["
|
|
371
|
-
["Collection Timeout", `${run2.timeoutCollection} ms`],
|
|
372
|
-
["Environment", run2.environment ?? "none"],
|
|
356
|
+
["Environment", run2.environment ? escape(run2.environment) : none],
|
|
373
357
|
["Concurrency Limit", String(run2.concurrency)],
|
|
374
|
-
["
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
["Feature Flags",
|
|
378
|
-
...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>`]] : [],
|
|
379
363
|
...run2.bail ? [["Bail", "on"]] : [],
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
] : []
|
|
364
|
+
["Excluded", run2.excluded.length > 0 ? leftOut(run2.excluded.length) : none],
|
|
365
|
+
...run2.untagged > 0 ? [["Left Out By Tags", leftOut(run2.untagged)]] : []
|
|
383
366
|
];
|
|
384
|
-
const
|
|
367
|
+
const rows = run2.collections.map((c, i) => {
|
|
385
368
|
const t = c.tally;
|
|
386
|
-
const status =
|
|
387
|
-
|
|
388
|
-
seconds2(c.durationMs),
|
|
389
|
-
t.steps.total,
|
|
390
|
-
t.steps.passed,
|
|
391
|
-
t.steps.failed,
|
|
392
|
-
t.steps.errored,
|
|
393
|
-
t.steps.skipped,
|
|
394
|
-
t.assertions.total,
|
|
395
|
-
t.assertions.passed,
|
|
396
|
-
t.assertions.failed
|
|
397
|
-
];
|
|
398
|
-
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>`;
|
|
399
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
|
+
) : "";
|
|
400
381
|
return page({
|
|
401
382
|
title: `${passed ? "PASSED" : "FAILED"} \xB7 ${run2.project} \xB7 gta Summary Results`,
|
|
402
383
|
run: run2,
|
|
403
|
-
body:
|
|
384
|
+
body: `${pageHead(passed ? "passed" : "failed", `${escape(run2.project)} <span class="kind">\xB7 Summary Results</span>`, run2.startedAt, run2.durationMs)}
|
|
404
385
|
|
|
405
|
-
|
|
406
|
-
${settings.map(([label, value]) =>
|
|
407
|
-
</
|
|
386
|
+
${card("Settings", `<dl class="pairs">
|
|
387
|
+
${settings.map(([label, value]) => `<div><dt>${label}</dt><dd>${value}</dd></div>`).join("\n")}
|
|
388
|
+
</dl>`)}
|
|
408
389
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
+
)}
|
|
427
410
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
<
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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}`
|
|
439
425
|
});
|
|
440
426
|
}
|
|
441
427
|
function collectionPage(run2, c) {
|
|
442
428
|
const t = c.tally;
|
|
429
|
+
const status = collectionStatus(t);
|
|
430
|
+
const results = c.outcome.ok ? c.outcome.summary.results : [];
|
|
443
431
|
const tests = !c.outcome.ok ? "" : [
|
|
444
|
-
...c.outcome.
|
|
445
|
-
(r, i) => testSection(r, i, c.outcome.ok ? c.outcome.steps[i] : void 0)
|
|
446
|
-
),
|
|
432
|
+
...results.map((r, i) => testSection(r, i, c.outcome.ok ? c.outcome.steps[i] : void 0)),
|
|
447
433
|
unattempted(c.outcome.summary) > 0 ? notRunSection(unattempted(c.outcome.summary)) : ""
|
|
448
434
|
].join("\n");
|
|
449
|
-
const
|
|
435
|
+
const groups = results.map((r) => group(statusOf(r)));
|
|
436
|
+
const data = c.outcome.ok ? c.outcome.data : null;
|
|
450
437
|
return page({
|
|
451
|
-
title: `${
|
|
438
|
+
title: `${status.toUpperCase()} \xB7 ${c.id} \xB7 gta Collection Results`,
|
|
452
439
|
run: run2,
|
|
453
|
-
body: `<
|
|
454
|
-
|
|
455
|
-
<div class="card"><div class="card-header">Results Overview</div><div class="card-body">
|
|
456
|
-
${field2("Collection ID", escape(c.id), "wide")}
|
|
457
|
-
${c.docs?.trim() ? field2("Collection Docs", markdown(c.docs), "wide") : ""}
|
|
458
|
-
${c.outcome.ok && c.outcome.data ? field2("Data File", escape(c.outcome.data.file), "wide") : ""}
|
|
459
|
-
${c.outcome.ok && c.outcome.data ? field2("Iterations", String(c.outcome.data.rows)) : ""}
|
|
460
|
-
${!c.outcome.ok ? field2("Error", `<pre class="c-failed">${escape(c.outcome.message)}</pre>`, "wide") : ""}
|
|
461
|
-
${field2("Time of Run", escape(timeOfRun(c.startedAt)))}
|
|
462
|
-
${statsTable([
|
|
463
|
-
["Collection Run Time", seconds2(c.durationMs)],
|
|
464
|
-
["Total Tests", t.steps.total],
|
|
465
|
-
["Tests Passed", t.steps.passed],
|
|
466
|
-
["Tests Failed", t.steps.failed],
|
|
467
|
-
["Tests Errored", t.steps.errored],
|
|
468
|
-
["Tests Skipped", t.steps.skipped],
|
|
469
|
-
["Total Assertions", t.assertions.total],
|
|
470
|
-
["Assertions Passed", t.assertions.passed],
|
|
471
|
-
["Assertions Failed", t.assertions.failed],
|
|
472
|
-
["Overall Result", t.skipped ? '<span class="c-skipped">SKIPPED</span>' : finalResult(t.passed)]
|
|
473
|
-
])}
|
|
474
|
-
</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)}
|
|
475
442
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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>` }
|
|
485
463
|
)}
|
|
486
|
-
|
|
487
|
-
</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>
|
|
488
475
|
<div class="tests">
|
|
489
|
-
${tests || '<p class="dim">No tests were run.</p>'}
|
|
476
|
+
${tests || '<p class="dim empty">No tests were run.</p>'}
|
|
490
477
|
</div>`
|
|
491
478
|
});
|
|
492
479
|
}
|
|
493
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;
|
|
494
482
|
function testSection(r, index, ref) {
|
|
495
483
|
const status = statusOf(r);
|
|
496
|
-
const named =
|
|
497
|
-
const name = r.use ? `${named} <span class="dim">(${escape(r.use.set)} ${r.use.child + 1}/${r.use.of})</span>` : named;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
<
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
r.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const group = status === "passed" ? "passed" : status === "skipped" ? "skipped" : "failed";
|
|
514
|
-
const search = iterationName(resultName(r), ref).toLowerCase();
|
|
515
|
-
return `<details class="acc ${group} ${status}" data-search="${escape(search)}"${status === "passed" ? "" : " open"}>
|
|
516
|
-
<summary><span>Test ${index + 1}: ${name}</span>${icon(status)}</summary>
|
|
517
|
-
<div class="acc-body">
|
|
518
|
-
${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}
|
|
519
501
|
</div>
|
|
520
502
|
</details>`;
|
|
521
503
|
}
|
|
522
504
|
function notRunSection(count) {
|
|
523
|
-
return `<details class="
|
|
524
|
-
<summary><span
|
|
525
|
-
<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>
|
|
526
508
|
</details>`;
|
|
527
509
|
}
|
|
528
|
-
function
|
|
510
|
+
function errorSection(r) {
|
|
529
511
|
const e = r.error;
|
|
530
512
|
const where = [e.script && `${e.script} script`, e.line && `line ${e.line}`].filter(Boolean).join(", ");
|
|
531
513
|
const detail = e.stack ? `
|
|
532
514
|
|
|
533
515
|
${userFrames(e.stack)}` : "";
|
|
534
|
-
return
|
|
535
|
-
"Error",
|
|
536
|
-
field2(
|
|
537
|
-
`${capitalise(e.phase)} Error`,
|
|
538
|
-
`<pre class="c-failed">${escape(`${where ? `(${where}) ` : ""}${e.message}${detail}`)}</pre>`,
|
|
539
|
-
"wide"
|
|
540
|
-
)
|
|
541
|
-
);
|
|
516
|
+
return `<section class="sub problem"><h3>${escape(capitalise(e.phase))} Error</h3><pre>${escape(`${where ? `(${where}) ` : ""}${e.message}${detail}`)}</pre></section>`;
|
|
542
517
|
}
|
|
543
|
-
function
|
|
544
|
-
const { request
|
|
545
|
-
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(
|
|
546
557
|
"Request",
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
request.headers.length > 0 ? field2("Request Headers", scrollPre(headerText(request.headers)), "wide") : "",
|
|
551
|
-
request.body ? field2("Request Body", scrollPre(clip2(request.body)), "wide") : ""
|
|
552
|
-
].filter(Boolean).join("\n")
|
|
558
|
+
formatted,
|
|
559
|
+
rawRequest(request),
|
|
560
|
+
"method and URL, headers, a blank line, then the body"
|
|
553
561
|
);
|
|
554
562
|
}
|
|
555
|
-
function
|
|
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
|
+
}
|
|
575
|
+
var ENDED_BY = {
|
|
576
|
+
close: "closed by the server",
|
|
577
|
+
maxEvents: "stopped at maxEvents",
|
|
578
|
+
streamTimeout: "stopped at streamTimeout",
|
|
579
|
+
untilEvent: "stopped at untilEvent",
|
|
580
|
+
limit: "stopped at the safety limit",
|
|
581
|
+
stopped: "stopped by hand",
|
|
582
|
+
held: "took the events held"
|
|
583
|
+
};
|
|
584
|
+
var connectionNote = (stream) => stream.connection ? `, connection ${stream.connection.name} ${stream.connection.open ? "open" : "closed"}` : "";
|
|
585
|
+
function responseSection(r, id) {
|
|
556
586
|
const res = r.response;
|
|
557
587
|
if (!res) {
|
|
558
|
-
return
|
|
559
|
-
"Response",
|
|
560
|
-
field2("Failure", '<span class="c-failed">No response was received.</span>', "wide")
|
|
561
|
-
);
|
|
588
|
+
return `<section class="sub"><h3>Response</h3><p class="c-failed none">No response was received.</p></section>`;
|
|
562
589
|
}
|
|
563
|
-
const
|
|
590
|
+
const checks = buildChecks(r.assertions);
|
|
591
|
+
const status = markStatus(checks).mark;
|
|
564
592
|
const extra = [
|
|
565
|
-
`first byte ${
|
|
593
|
+
`first byte ${duration(res.timings.ttfbMs)}`,
|
|
566
594
|
bytes(res.sizeBytes),
|
|
567
|
-
res.redirectCount > 0 ?
|
|
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)}` : ""
|
|
568
597
|
].filter(Boolean);
|
|
569
|
-
|
|
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(
|
|
570
609
|
"Response",
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
field2(
|
|
576
|
-
"Response Time",
|
|
577
|
-
`${Math.round(res.timings.totalMs)}ms <span class="dim">\xB7 ${extra.join(" \xB7 ")}</span>`
|
|
578
|
-
)
|
|
579
|
-
].filter(Boolean).join("\n")
|
|
610
|
+
formatted,
|
|
611
|
+
rawResponse(res),
|
|
612
|
+
"status, headers, a blank line, then the body",
|
|
613
|
+
`${id}-raw`
|
|
580
614
|
);
|
|
581
615
|
}
|
|
582
|
-
function
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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>'
|
|
591
695
|
);
|
|
592
|
-
|
|
593
|
-
|
|
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) => {
|
|
594
752
|
const failing = a.status === "fail";
|
|
595
|
-
const
|
|
596
|
-
a.message ? `<div class="
|
|
597
|
-
failing && a.expected !== void 0 ? `<
|
|
598
|
-
|
|
599
|
-
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>` : ""
|
|
600
757
|
].join("");
|
|
601
|
-
const
|
|
602
|
-
return `<div class="
|
|
603
|
-
})
|
|
604
|
-
return
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
<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">
|
|
608
|
-
${list2}
|
|
609
|
-
</div></div>`
|
|
610
|
-
);
|
|
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>`;
|
|
611
764
|
}
|
|
612
|
-
|
|
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) {
|
|
613
767
|
const lines = r.logs.map(
|
|
614
768
|
(l) => `<span class="log-${l.level}">[${escape(l.phase)} ${l.level}] ${escape(l.message)}</span>`
|
|
615
769
|
);
|
|
616
|
-
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}`;
|
|
617
805
|
}
|
|
618
806
|
function page(options) {
|
|
619
807
|
return `<!doctype html>
|
|
@@ -626,60 +814,79 @@ function page(options) {
|
|
|
626
814
|
<script>${SCRIPT}</script>
|
|
627
815
|
</head>
|
|
628
816
|
<body>
|
|
629
|
-
<
|
|
817
|
+
<main class="page">
|
|
630
818
|
${options.body}
|
|
631
|
-
</
|
|
632
|
-
|
|
633
|
-
<span class="btn static">gta Version ${escape(options.run.version)}</span>
|
|
634
|
-
<div class="dim small-print">Run in ${escape(options.run.root)}</div>
|
|
635
|
-
</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>
|
|
636
821
|
</body>
|
|
637
822
|
</html>
|
|
638
823
|
`;
|
|
639
824
|
}
|
|
640
|
-
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" : ""}">
|
|
641
827
|
${body}
|
|
642
|
-
</div></
|
|
643
|
-
var
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
var
|
|
648
|
-
var
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
|
|
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;
|
|
669
877
|
}
|
|
670
878
|
var userFrames = (stack) => stack.split("\n").filter((line) => !/^\s+at .*(\bnode:|file:\/\/)/.test(line)).join("\n");
|
|
671
879
|
var capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
672
|
-
var
|
|
673
|
-
var
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
second: "2-digit",
|
|
681
|
-
|
|
682
|
-
});
|
|
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
|
+
};
|
|
683
890
|
function bytes(n) {
|
|
684
891
|
if (n < 1024) return `${n} B`;
|
|
685
892
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
@@ -732,112 +939,294 @@ function mdInline(nodes) {
|
|
|
732
939
|
}).join("");
|
|
733
940
|
}
|
|
734
941
|
var STYLE = `
|
|
735
|
-
:root{color-scheme:light dark;--bg:#
|
|
736
|
-
@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}}
|
|
737
944
|
*{box-sizing:border-box}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
.page
|
|
741
|
-
|
|
742
|
-
|
|
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}
|
|
743
969
|
.card-body{padding:16px}
|
|
744
|
-
.card
|
|
745
|
-
.
|
|
746
|
-
.pairs{display:grid;grid-template-columns:1fr
|
|
747
|
-
|
|
748
|
-
.
|
|
749
|
-
.
|
|
750
|
-
.
|
|
751
|
-
.
|
|
752
|
-
.
|
|
753
|
-
.
|
|
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)}
|
|
754
1103
|
.md>*:first-child{margin-top:0}.md>*:last-child{margin-bottom:0}
|
|
755
1104
|
.md-p{margin:0 0 8px}
|
|
756
1105
|
.md-h{margin:12px 0 6px;font-size:1rem;font-weight:600}
|
|
757
1106
|
.md-list{margin:0 0 8px;padding-left:24px}
|
|
758
1107
|
.md-list .md-list{margin:2px 0 0}
|
|
759
|
-
.md-code{margin:0 0 8px;padding:8px 10px;background:var(--
|
|
760
|
-
.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}
|
|
761
1110
|
.md-quote{margin:0 0 8px;padding-left:12px;border-left:3px solid var(--border);color:var(--dim)}
|
|
762
1111
|
.md-hr{border:none;border-top:1px solid var(--border);margin:10px 0}
|
|
763
1112
|
.md-table{border-collapse:collapse;margin:0 0 8px}
|
|
764
1113
|
.md-table th,.md-table td{border:1px solid var(--border);padding:4px 8px;text-align:left}
|
|
765
|
-
.md-table th{background:var(--
|
|
766
|
-
.
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
.
|
|
770
|
-
.
|
|
771
|
-
|
|
772
|
-
.stats tr+tr td{border-top:1px solid var(--border)}
|
|
773
|
-
.stats.small{width:50%;margin:0 auto}
|
|
774
|
-
@media (max-width:800px){.stats.small{width:100%}}
|
|
775
|
-
.overview{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
|
|
776
|
-
.overview th,.overview td{border:1px solid var(--border);padding:8px;text-align:left;vertical-align:top}
|
|
777
|
-
.overview th{background:var(--head);font-weight:600}
|
|
778
|
-
.overview td.name{word-break:break-word}
|
|
779
|
-
a{color:var(--link)}
|
|
780
|
-
.dim{color:var(--dim)}
|
|
781
|
-
.c-passed{color:var(--passed)}.c-failed{color:var(--failed)}.c-errored{color:var(--errored)}.c-skipped{color:var(--skipped)}
|
|
782
|
-
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin:40px 0 8px}
|
|
783
|
-
.toolbar h3{margin:0;font-weight:500;font-size:1.75rem}
|
|
784
|
-
.buttons{display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap}
|
|
785
|
-
.toolbar.above{margin:40px 0 8px}
|
|
786
|
-
.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)}
|
|
787
|
-
.toolbar h3+.search{margin-left:16px}
|
|
788
|
-
.toolbar .buttons{margin-left:auto}
|
|
789
|
-
.unmatched{display:none}
|
|
790
|
-
.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}
|
|
791
|
-
.btn:hover{background:var(--btn);color:var(--bg)}
|
|
792
|
-
.btn.static{cursor:default}
|
|
793
|
-
.btn.static:hover{background:none;color:var(--btn)}
|
|
794
|
-
.hidden{display:none}
|
|
795
|
-
.tests details.acc{border:1px solid var(--border);border-top:none;background:var(--card)}
|
|
796
|
-
.tests details.acc:first-child{border-top:1px solid var(--border);border-radius:6px 6px 0 0}
|
|
797
|
-
.tests details.acc:last-child{border-radius:0 0 6px 6px}
|
|
798
|
-
.tests details.acc:only-child{border-radius:6px}
|
|
799
|
-
details.acc>summary{list-style:none;cursor:pointer;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
800
|
-
details.acc>summary::-webkit-details-marker{display:none}
|
|
801
|
-
details.acc[open]>summary{background:var(--open)}
|
|
802
|
-
.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)}
|
|
803
|
-
.icon.passed{background:var(--passed)}.icon.failed{background:var(--failed)}.icon.errored{background:var(--errored)}.icon.skipped{background:var(--skipped)}
|
|
804
|
-
.acc-body{padding:16px 20px}
|
|
805
|
-
.detail-row{display:flex;justify-content:space-between;gap:16px;padding:6px 0}
|
|
806
|
-
.detail-row+.detail-row{border-top:1px solid var(--border)}
|
|
807
|
-
.detail-row.head{padding:0}
|
|
808
|
-
.ea{font-size:14px;margin-top:2px}
|
|
809
|
-
.console .log-warn{color:var(--errored)}.console .log-error{color:var(--failed)}
|
|
810
|
-
footer.container{text-align:center;margin:48px auto 0}
|
|
811
|
-
.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}}
|
|
812
1121
|
`;
|
|
813
|
-
var SCRIPT = `
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
+
}
|
|
819
1133
|
}
|
|
820
1134
|
// The button says what a click will do: Expand All while any test is closed,
|
|
821
|
-
// Collapse All once every one is open
|
|
822
|
-
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')]
|
|
823
1137
|
function syncExpandAll() {
|
|
824
1138
|
const button = document.getElementById('expand-all')
|
|
825
1139
|
if (button) button.textContent = tests().some((d) => !d.open) ? 'Expand All' : 'Collapse All'
|
|
826
1140
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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, '')
|
|
831
1149
|
}
|
|
832
1150
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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+ /, '')
|
|
837
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()
|
|
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)
|
|
838
1227
|
document.addEventListener('DOMContentLoaded', () => {
|
|
839
|
-
for (const d of tests()) d.addEventListener('toggle', syncExpandAll)
|
|
840
1228
|
syncExpandAll()
|
|
1229
|
+
loadFrames(document)
|
|
841
1230
|
})
|
|
842
1231
|
`;
|
|
843
1232
|
|
|
@@ -861,7 +1250,7 @@ function junitReport(run2) {
|
|
|
861
1250
|
`<testsuites${attributes({
|
|
862
1251
|
name: `gta: ${run2.project}`,
|
|
863
1252
|
...total,
|
|
864
|
-
time:
|
|
1253
|
+
time: seconds(run2.durationMs),
|
|
865
1254
|
timestamp: timestamp(run2.startedAt)
|
|
866
1255
|
})}>`,
|
|
867
1256
|
...suites.flatMap((s) => s.lines),
|
|
@@ -932,7 +1321,7 @@ function suite(collection, environment, flags) {
|
|
|
932
1321
|
` <testsuite${attributes({
|
|
933
1322
|
name: id,
|
|
934
1323
|
...counts,
|
|
935
|
-
time:
|
|
1324
|
+
time: seconds(collection.durationMs),
|
|
936
1325
|
timestamp: timestamp(collection.startedAt),
|
|
937
1326
|
hostname: os.hostname()
|
|
938
1327
|
})}>`,
|
|
@@ -948,11 +1337,11 @@ function suite(collection, environment, flags) {
|
|
|
948
1337
|
};
|
|
949
1338
|
}
|
|
950
1339
|
function testcase(classname, name, durationMs, body) {
|
|
951
|
-
const open = ` <testcase${attributes({ name, classname, time:
|
|
1340
|
+
const open = ` <testcase${attributes({ name, classname, time: seconds(durationMs) })}`;
|
|
952
1341
|
if (body.length === 0) return [`${open}/>`];
|
|
953
1342
|
return [`${open}>`, ...body.map((line) => ` ${line}`), " </testcase>"];
|
|
954
1343
|
}
|
|
955
|
-
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);
|
|
956
1345
|
var summarize = (a) => a.message ? `${a.name}: ${a.message}` : a.name;
|
|
957
1346
|
function describe(a) {
|
|
958
1347
|
const lines = [summarize(a)];
|
|
@@ -962,7 +1351,7 @@ function describe(a) {
|
|
|
962
1351
|
return lines.join("\n");
|
|
963
1352
|
}
|
|
964
1353
|
var userFrames2 = (stack) => stack?.split("\n").filter((line) => !/^\s+at .*(\bnode:|file:\/\/)/.test(line)).join("\n");
|
|
965
|
-
var
|
|
1354
|
+
var seconds = (ms) => (ms / 1e3).toFixed(3);
|
|
966
1355
|
var timestamp = (epochMs) => new Date(epochMs).toISOString().slice(0, 19);
|
|
967
1356
|
function attributes(values) {
|
|
968
1357
|
return Object.entries(values).map(([key, value]) => ` ${key}="${escapeAttribute(String(value))}"`).join("");
|
|
@@ -975,310 +1364,14 @@ function escape2(value) {
|
|
|
975
1364
|
}
|
|
976
1365
|
var allowedInXml = (code) => code === 9 || code === 10 || code === 13 || code >= 32 && code <= 55295 || code >= 57344 && code <= 65533 || code >= 65536;
|
|
977
1366
|
|
|
978
|
-
// src/json.ts
|
|
979
|
-
var JSON_FORMAT_VERSION = 1;
|
|
980
|
-
var BODY_LIMIT2 = 256 * 1024;
|
|
981
|
-
function jsonReport(run2, reports) {
|
|
982
|
-
const tallies = run2.collections.map((c) => c.tally);
|
|
983
|
-
const sum = (pick) => tallies.reduce((n, t) => n + pick(t), 0);
|
|
984
|
-
const failed = tallies.filter((t) => !t.passed).length;
|
|
985
|
-
return {
|
|
986
|
-
formatVersion: JSON_FORMAT_VERSION,
|
|
987
|
-
tool: { name: "gta", version: run2.version },
|
|
988
|
-
project: { name: run2.project, root: run2.root },
|
|
989
|
-
run: {
|
|
990
|
-
result: failed === 0 ? "passed" : "failed",
|
|
991
|
-
startedAt: new Date(run2.startedAt).toISOString(),
|
|
992
|
-
durationMs: Math.round(run2.durationMs),
|
|
993
|
-
environment: run2.environment,
|
|
994
|
-
/** The feature flag values the run used, where each came from, and the command run (SPEC.md §2.9). */
|
|
995
|
-
flags: {
|
|
996
|
-
values: run2.flags ?? {},
|
|
997
|
-
sources: run2.flagSources ?? {},
|
|
998
|
-
command: run2.flagCommand ?? null
|
|
999
|
-
},
|
|
1000
|
-
settings: {
|
|
1001
|
-
limitConcurrency: run2.concurrency,
|
|
1002
|
-
timeoutCollection: run2.timeoutCollection,
|
|
1003
|
-
bail: run2.bail,
|
|
1004
|
-
tags: run2.tags,
|
|
1005
|
-
notTags: run2.notTags ?? []
|
|
1006
|
-
}
|
|
1007
|
-
},
|
|
1008
|
-
totals: {
|
|
1009
|
-
collections: { total: tallies.length, passed: tallies.length - failed, failed },
|
|
1010
|
-
steps: {
|
|
1011
|
-
total: sum((t) => t.steps.total),
|
|
1012
|
-
passed: sum((t) => t.steps.passed),
|
|
1013
|
-
failed: sum((t) => t.steps.failed),
|
|
1014
|
-
errored: sum((t) => t.steps.errored),
|
|
1015
|
-
skipped: sum((t) => t.steps.skipped)
|
|
1016
|
-
},
|
|
1017
|
-
assertions: {
|
|
1018
|
-
total: sum((t) => t.assertions.total),
|
|
1019
|
-
passed: sum((t) => t.assertions.passed),
|
|
1020
|
-
failed: sum((t) => t.assertions.failed)
|
|
1021
|
-
}
|
|
1022
|
-
},
|
|
1023
|
-
leftOut: { excluded: run2.excluded, untagged: run2.untagged },
|
|
1024
|
-
reports,
|
|
1025
|
-
collections: run2.collections.map(collectionOf)
|
|
1026
|
-
};
|
|
1027
|
-
}
|
|
1028
|
-
function collectionOf(c) {
|
|
1029
|
-
const { outcome, tally: tally2 } = c;
|
|
1030
|
-
return {
|
|
1031
|
-
id: c.id,
|
|
1032
|
-
file: c.file,
|
|
1033
|
-
status: tally2.skipped ? "skipped" : tally2.passed ? "passed" : "failed",
|
|
1034
|
-
startedAt: new Date(c.startedAt).toISOString(),
|
|
1035
|
-
durationMs: Math.round(c.durationMs),
|
|
1036
|
-
counts: { steps: tally2.steps, assertions: tally2.assertions },
|
|
1037
|
-
/** Why it did not run at all, or null when it ran. */
|
|
1038
|
-
error: outcome.ok ? null : outcome.message,
|
|
1039
|
-
/** The data file that ran it once per row (SPEC.md §2.8), or null. */
|
|
1040
|
-
dataFile: outcome.ok ? outcome.data : null,
|
|
1041
|
-
/** Steps a bail left unrun. */
|
|
1042
|
-
notRun: outcome.ok ? unattempted(outcome.summary) : tally2.steps.total,
|
|
1043
|
-
steps: outcome.ok ? outcome.summary.results.map(
|
|
1044
|
-
(result, i) => stepOf(result, outcome.steps[i] ?? { index: i, line: null })
|
|
1045
|
-
) : []
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
function stepOf(result, ref) {
|
|
1049
|
-
const truncated = {};
|
|
1050
|
-
const clip3 = (field3, text) => {
|
|
1051
|
-
if (text === null || text.length <= BODY_LIMIT2) return text;
|
|
1052
|
-
truncated[field3] = text.length;
|
|
1053
|
-
return text.slice(0, BODY_LIMIT2);
|
|
1054
|
-
};
|
|
1055
|
-
const request = { ...result.request, body: clip3("request.body", result.request.body) };
|
|
1056
|
-
const response = result.response ? { ...result.response, body: clip3("response.body", result.response.body) ?? "" } : null;
|
|
1057
|
-
return {
|
|
1058
|
-
/** The step's place in the file's `steps`, from 0, and its line, from 1. */
|
|
1059
|
-
step: { index: ref.index, line: ref.line },
|
|
1060
|
-
/** The data file row it ran with, from 1, or null without a data file. */
|
|
1061
|
-
iteration: ref.iteration ?? null,
|
|
1062
|
-
...result,
|
|
1063
|
-
request,
|
|
1064
|
-
response,
|
|
1065
|
-
/** Fields cut short, with their full length in characters. */
|
|
1066
|
-
...Object.keys(truncated).length > 0 ? { truncated } : {}
|
|
1067
|
-
};
|
|
1068
|
-
}
|
|
1069
|
-
function jsonListing(options) {
|
|
1070
|
-
return {
|
|
1071
|
-
formatVersion: JSON_FORMAT_VERSION,
|
|
1072
|
-
project: options.project,
|
|
1073
|
-
environments: options.environments,
|
|
1074
|
-
environmentType: options.environmentType,
|
|
1075
|
-
settings: { values: options.settings.values, sources: options.settings.sources },
|
|
1076
|
-
flags: { values: options.flags.values, sources: options.flags.sources },
|
|
1077
|
-
tags: options.tags,
|
|
1078
|
-
notTags: options.notTags ?? [],
|
|
1079
|
-
collections: options.targets.map((target) => ({
|
|
1080
|
-
id: target.id,
|
|
1081
|
-
file: fileOf(target.collection),
|
|
1082
|
-
directory: target.collection.directory,
|
|
1083
|
-
steps: target.steps ?? target.collection.doc.steps.map((_, i) => i),
|
|
1084
|
-
stepCount: target.collection.doc.steps.length,
|
|
1085
|
-
dataFile: target.collection.dataFile ? {
|
|
1086
|
-
file: `collections/${target.collection.dataFile.relativePath}`,
|
|
1087
|
-
rows: target.collection.dataFile.rows
|
|
1088
|
-
} : null,
|
|
1089
|
-
tags: target.collection.doc.tags ?? [],
|
|
1090
|
-
broken: target.broken
|
|
1091
|
-
})),
|
|
1092
|
-
leftOut: { excluded: options.excluded, untagged: options.untagged },
|
|
1093
|
-
problems: options.problems
|
|
1094
|
-
};
|
|
1095
|
-
}
|
|
1096
|
-
var fileOf = (collection) => `collections/${collection.relativePath}`;
|
|
1097
|
-
var jsonError = (message, exitCode) => ({
|
|
1098
|
-
formatVersion: JSON_FORMAT_VERSION,
|
|
1099
|
-
error: { message, exitCode }
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
// src/settings.ts
|
|
1103
|
-
import fs from "node:fs/promises";
|
|
1104
|
-
import path from "node:path";
|
|
1105
|
-
var import_yaml = __toESM(require_dist(), 1);
|
|
1106
|
-
var SETTING_KEYS = {
|
|
1107
|
-
environmentType: {
|
|
1108
|
-
kind: "string",
|
|
1109
|
-
doc: "The environment to run against, by name: environments/<name>.yml (the global project\u2019s too). Unset: no environment."
|
|
1110
|
-
},
|
|
1111
|
-
limitConcurrency: {
|
|
1112
|
-
kind: "integer",
|
|
1113
|
-
doc: "How many collections run at once. Steps within a collection always run in order."
|
|
1114
|
-
},
|
|
1115
|
-
timeoutCollection: {
|
|
1116
|
-
kind: "integer",
|
|
1117
|
-
doc: "Milliseconds a collection may take before it is stopped and reported failed."
|
|
1118
|
-
},
|
|
1119
|
-
bail: {
|
|
1120
|
-
kind: "boolean",
|
|
1121
|
-
doc: "Stop a collection at its first failing step; the rest are reported skipped."
|
|
1122
|
-
},
|
|
1123
|
-
tags: {
|
|
1124
|
-
kind: "list",
|
|
1125
|
-
doc: "Run only what has one of these tags (SPEC.md \xA72.4). Empty: everything."
|
|
1126
|
-
},
|
|
1127
|
-
notTags: {
|
|
1128
|
-
kind: "list",
|
|
1129
|
-
doc: "Leave out what has one of these tags: a collection tagged so, or a step where steps carry tags."
|
|
1130
|
-
},
|
|
1131
|
-
generateJUnitResults: {
|
|
1132
|
-
kind: "boolean",
|
|
1133
|
-
doc: "Write a JUnit XML report of the run to <testResultsBasePath>/junit/junit.xml, for CI."
|
|
1134
|
-
},
|
|
1135
|
-
generateJsonResults: {
|
|
1136
|
-
kind: "boolean",
|
|
1137
|
-
doc: "Write the run as JSON to <testResultsBasePath>/json/results.json, for tools and agents."
|
|
1138
|
-
},
|
|
1139
|
-
generateHtmlResults: {
|
|
1140
|
-
kind: "boolean",
|
|
1141
|
-
doc: "Write an HTML report of the run: <testResultsBasePath>/html/summary.html and a page per collection."
|
|
1142
|
-
},
|
|
1143
|
-
autoOpenTestResultHtml: {
|
|
1144
|
-
kind: "boolean",
|
|
1145
|
-
doc: "Write the HTML report and open its summary in the browser when the run ends."
|
|
1146
|
-
},
|
|
1147
|
-
testResultsBasePath: {
|
|
1148
|
-
kind: "string",
|
|
1149
|
-
doc: "Where reports are written: relative to the project folder, or absolute."
|
|
1150
|
-
}
|
|
1151
|
-
};
|
|
1152
|
-
var KEYS = Object.keys(SETTING_KEYS);
|
|
1153
|
-
var positiveInteger = external_exports.number().int().min(1);
|
|
1154
|
-
var SettingsSchema = external_exports.strictObject({
|
|
1155
|
-
environmentType: external_exports.string().trim().min(1).nullable().default(null),
|
|
1156
|
-
limitConcurrency: positiveInteger.default(1),
|
|
1157
|
-
timeoutCollection: positiveInteger.default(36e5),
|
|
1158
|
-
bail: external_exports.boolean().default(false),
|
|
1159
|
-
tags: external_exports.array(TagSchema).default([]),
|
|
1160
|
-
notTags: external_exports.array(TagSchema).default([]),
|
|
1161
|
-
generateJUnitResults: external_exports.boolean().default(false),
|
|
1162
|
-
generateJsonResults: external_exports.boolean().default(false),
|
|
1163
|
-
generateHtmlResults: external_exports.boolean().default(false),
|
|
1164
|
-
autoOpenTestResultHtml: external_exports.boolean().default(false),
|
|
1165
|
-
testResultsBasePath: external_exports.string().trim().min(1).default("test-results")
|
|
1166
|
-
});
|
|
1167
|
-
var DEFAULT_SETTINGS = SettingsSchema.parse({});
|
|
1168
|
-
var SettingsError = class extends Error {
|
|
1169
|
-
name = "SettingsError";
|
|
1170
|
-
};
|
|
1171
|
-
var envNameOf = (key) => `GTA_${key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
|
|
1172
|
-
var DEFAULT_SOURCE = "default";
|
|
1173
|
-
async function loadSettings(options) {
|
|
1174
|
-
const { root, global = null, overrides = {}, env = process.env } = options;
|
|
1175
|
-
const own = await readSettingsFile(path.join(root, SETTINGS_FILE), SETTINGS_FILE);
|
|
1176
|
-
if (own === null) {
|
|
1177
|
-
throw new SettingsError(
|
|
1178
|
-
`There is no ${SETTINGS_FILE} in ${root}.
|
|
1179
|
-
gta runs from a project folder: the one holding collections/ and ${SETTINGS_FILE}.
|
|
1180
|
-
A minimal ${SETTINGS_FILE}:
|
|
1181
|
-
|
|
1182
|
-
environmentType: demo
|
|
1183
|
-
limitConcurrency: 4
|
|
1184
|
-
`
|
|
1185
|
-
);
|
|
1186
|
-
}
|
|
1187
|
-
const sharedLabel = global ? path.posix.join(global.uses, SETTINGS_FILE) : "";
|
|
1188
|
-
const shared = global ? await readSettingsFile(path.join(global.root, SETTINGS_FILE), sharedLabel) : null;
|
|
1189
|
-
const origin = /* @__PURE__ */ new Map();
|
|
1190
|
-
const merged = {};
|
|
1191
|
-
const files = [
|
|
1192
|
-
[sharedLabel, shared],
|
|
1193
|
-
[SETTINGS_FILE, own]
|
|
1194
|
-
];
|
|
1195
|
-
for (const [label, fromFile] of files) {
|
|
1196
|
-
for (const [key, value] of Object.entries(fromFile ?? {})) {
|
|
1197
|
-
checkKey(key, label);
|
|
1198
|
-
merged[key] = value;
|
|
1199
|
-
origin.set(key, label);
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
for (const key of KEYS) {
|
|
1203
|
-
const value = env[envNameOf(key)];
|
|
1204
|
-
if (value === void 0 || value === "") continue;
|
|
1205
|
-
merged[key] = coerce(key, value, envNameOf(key));
|
|
1206
|
-
origin.set(key, envNameOf(key));
|
|
1207
|
-
}
|
|
1208
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
1209
|
-
checkKey(key, `--${key}`);
|
|
1210
|
-
merged[key] = coerce(key, value, `--${key}`);
|
|
1211
|
-
origin.set(key, `--${key}`);
|
|
1212
|
-
}
|
|
1213
|
-
const result = SettingsSchema.safeParse(merged);
|
|
1214
|
-
if (!result.success) {
|
|
1215
|
-
const lines = result.error.issues.map((issue) => {
|
|
1216
|
-
const key = String(issue.path[0] ?? "");
|
|
1217
|
-
return ` ${key} (from ${origin.get(key) ?? SETTINGS_FILE}): ${issue.message}`;
|
|
1218
|
-
});
|
|
1219
|
-
throw new SettingsError(`Settings are not valid:
|
|
1220
|
-
${lines.join("\n")}`);
|
|
1221
|
-
}
|
|
1222
|
-
const sources = Object.fromEntries(
|
|
1223
|
-
KEYS.map((key) => [key, origin.get(key) ?? DEFAULT_SOURCE])
|
|
1224
|
-
);
|
|
1225
|
-
return { settings: result.data, sources };
|
|
1226
|
-
}
|
|
1227
|
-
async function readSettingsFile(file, label) {
|
|
1228
|
-
let source;
|
|
1229
|
-
try {
|
|
1230
|
-
source = await fs.readFile(file, "utf8");
|
|
1231
|
-
} catch (cause) {
|
|
1232
|
-
if (cause.code === "ENOENT") return null;
|
|
1233
|
-
throw new SettingsError(`${label} cannot be read: ${cause.message}`);
|
|
1234
|
-
}
|
|
1235
|
-
let fromFile;
|
|
1236
|
-
try {
|
|
1237
|
-
fromFile = (0, import_yaml.parse)(source) ?? {};
|
|
1238
|
-
} catch (cause) {
|
|
1239
|
-
throw new SettingsError(`${label} will not parse: ${cause.message}`);
|
|
1240
|
-
}
|
|
1241
|
-
if (typeof fromFile !== "object" || fromFile === null || Array.isArray(fromFile)) {
|
|
1242
|
-
throw new SettingsError(`${label} must be a map of settings, such as limitConcurrency: 4`);
|
|
1243
|
-
}
|
|
1244
|
-
return fromFile;
|
|
1245
|
-
}
|
|
1246
|
-
function checkKey(key, where) {
|
|
1247
|
-
if (!Object.hasOwn(SETTING_KEYS, key)) {
|
|
1248
|
-
throw new SettingsError(
|
|
1249
|
-
`Unknown setting "${key}" (from ${where}). Settings are: ${KEYS.join(", ")}`
|
|
1250
|
-
);
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
function coerce(key, value, where) {
|
|
1254
|
-
const { kind } = SETTING_KEYS[key];
|
|
1255
|
-
if (value === true) {
|
|
1256
|
-
if (kind === "boolean") return true;
|
|
1257
|
-
throw new SettingsError(`${where} needs a value`);
|
|
1258
|
-
}
|
|
1259
|
-
switch (kind) {
|
|
1260
|
-
case "integer":
|
|
1261
|
-
return /^\s*\d+\s*$/.test(value) ? Number(value) : value;
|
|
1262
|
-
case "boolean":
|
|
1263
|
-
return value === "true" ? true : value === "false" ? false : value;
|
|
1264
|
-
case "list":
|
|
1265
|
-
return value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
1266
|
-
case "string":
|
|
1267
|
-
return value;
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
1367
|
// src/main.ts
|
|
1272
|
-
var VERSION = true ? "0.
|
|
1368
|
+
var VERSION = true ? "0.14.0" : "0.0.0-dev";
|
|
1273
1369
|
var JUNIT_DIR = "junit";
|
|
1274
1370
|
var JUNIT_FILE = "junit.xml";
|
|
1275
1371
|
var HTML_DIR = "html";
|
|
1276
1372
|
var JSON_DIR = "json";
|
|
1277
1373
|
var JSON_FILE = "results.json";
|
|
1278
1374
|
var EXIT = { passed: 0, failed: 1, unusable: 2 };
|
|
1279
|
-
var ProjectError = class extends Error {
|
|
1280
|
-
name = "ProjectError";
|
|
1281
|
-
};
|
|
1282
1375
|
async function main(argv, cli) {
|
|
1283
1376
|
let ctx = cli;
|
|
1284
1377
|
let p = paint(ctx.color);
|
|
@@ -1299,9 +1392,25 @@ async function main(argv, cli) {
|
|
|
1299
1392
|
ctx.out(usage(p));
|
|
1300
1393
|
return EXIT.passed;
|
|
1301
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
|
+
}
|
|
1302
1412
|
const project = await openProject(ctx, args.overrides, args.flags);
|
|
1303
1413
|
for (const warning of project.warnings) ctx.err(p.yellow(`warning: ${warning}`));
|
|
1304
|
-
const command = args.command;
|
|
1305
1414
|
if (command === "g" || command === "get") {
|
|
1306
1415
|
const selection = selectFor(project, { kind: "all" });
|
|
1307
1416
|
const problems = await listingProblems(project, selection);
|
|
@@ -1330,7 +1439,7 @@ async function main(argv, cli) {
|
|
|
1330
1439
|
const request = command === "a" || command === "all" ? { kind: "all" } : { kind: "list", selectors: command.split(",").filter((s) => s.trim() !== "") };
|
|
1331
1440
|
return await run(project, selectFor(project, request), ctx, p, emit);
|
|
1332
1441
|
} catch (cause) {
|
|
1333
|
-
if (cause instanceof UsageError || cause instanceof SettingsError || cause instanceof ProjectError) {
|
|
1442
|
+
if (cause instanceof UsageError || cause instanceof SettingsError || cause instanceof ProjectError || cause instanceof RulesError) {
|
|
1334
1443
|
ctx.err(p.red(cause.message));
|
|
1335
1444
|
const json = emit ?? (argv.includes("--json") ? (v) => cli.out(JSON.stringify(v, null, 2)) : null);
|
|
1336
1445
|
json?.(jsonError(cause.message, EXIT.unusable));
|
|
@@ -1339,63 +1448,7 @@ async function main(argv, cli) {
|
|
|
1339
1448
|
throw cause;
|
|
1340
1449
|
}
|
|
1341
1450
|
}
|
|
1342
|
-
async
|
|
1343
|
-
const root = await canonical(ctx.cwd);
|
|
1344
|
-
if (!await isDirectory(path2.join(root, COLLECTIONS_DIR))) {
|
|
1345
|
-
throw new ProjectError(
|
|
1346
|
-
`There is no ${COLLECTIONS_DIR}/ in ${root}.
|
|
1347
|
-
gta runs from a project folder: the one holding ${COLLECTIONS_DIR}/ and ${SETTINGS_FILE}.`
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
1350
|
-
const info = await readProject(root);
|
|
1351
|
-
const { settings, sources: settingSources } = await loadSettings({
|
|
1352
|
-
root,
|
|
1353
|
-
global: info.global,
|
|
1354
|
-
overrides,
|
|
1355
|
-
env: ctx.env
|
|
1356
|
-
});
|
|
1357
|
-
const layout = await discoverProject(root);
|
|
1358
|
-
const collections = await loadProjectCollections(root, layout.files);
|
|
1359
|
-
const environments = environmentNames(await projectEnvironments(root, info.global));
|
|
1360
|
-
if (settings.environmentType && !environments.includes(settings.environmentType)) {
|
|
1361
|
-
throw new ProjectError(
|
|
1362
|
-
`environmentType (from ${settingSources.environmentType}): there is no environment called "${settings.environmentType}". ` + (environments.length > 0 ? `This project has: ${environments.join(", ")}` : `This project has no environments/.`)
|
|
1363
|
-
);
|
|
1364
|
-
}
|
|
1365
|
-
let flags;
|
|
1366
|
-
try {
|
|
1367
|
-
flags = await resolveFlags({
|
|
1368
|
-
root,
|
|
1369
|
-
environmentName: settings.environmentType,
|
|
1370
|
-
overrides: flagOverrides(flagArgs, ctx.env),
|
|
1371
|
-
env: { ...process.env, ...ctx.env }
|
|
1372
|
-
});
|
|
1373
|
-
} catch (cause) {
|
|
1374
|
-
throw new ProjectError(cause.message);
|
|
1375
|
-
}
|
|
1376
|
-
const tls = await loadProjectTls(root, info);
|
|
1377
|
-
if (tls.problems.length > 0) {
|
|
1378
|
-
throw new ProjectError(
|
|
1379
|
-
`A certificate in tls.ca cannot be used:
|
|
1380
|
-
` + tls.problems.map((problem) => ` ${problem.path}: ${problem.message}`).join("\n")
|
|
1381
|
-
);
|
|
1382
|
-
}
|
|
1383
|
-
return {
|
|
1384
|
-
root,
|
|
1385
|
-
name: info.doc?.name ?? path2.basename(root),
|
|
1386
|
-
settings,
|
|
1387
|
-
settingSources,
|
|
1388
|
-
flags,
|
|
1389
|
-
collections,
|
|
1390
|
-
environments,
|
|
1391
|
-
caFiles: tls.files.map(
|
|
1392
|
-
(file) => `${relativePosix(root, file.file)} (${file.certificates.map((c) => c.subject).join(", ")})`
|
|
1393
|
-
),
|
|
1394
|
-
warnings: [...info.problems, ...layout.problems].map(
|
|
1395
|
-
(problem) => problem.path ? `${problem.path}: ${problem.message}` : problem.message
|
|
1396
|
-
)
|
|
1397
|
-
};
|
|
1398
|
-
}
|
|
1451
|
+
var openProject = async (ctx, overrides, flagArgs = []) => loadProject({ root: await canonical(ctx.cwd), env: ctx.env, overrides, flagArgs });
|
|
1399
1452
|
var selectFor = (project, request) => selectCollections(project.collections, request, {
|
|
1400
1453
|
tags: project.settings.tags,
|
|
1401
1454
|
notTags: project.settings.notTags
|
|
@@ -1432,12 +1485,12 @@ function list(project, selection, problems, ctx, p) {
|
|
|
1432
1485
|
ctx.out("");
|
|
1433
1486
|
const width = nameWidthFor(selection.targets.map((t) => t.id));
|
|
1434
1487
|
const dirWidth = Math.max(
|
|
1435
|
-
"
|
|
1488
|
+
"Folder".length,
|
|
1436
1489
|
...selection.targets.map((t) => (t.collection.directory ?? "").length)
|
|
1437
1490
|
);
|
|
1438
1491
|
ctx.out(
|
|
1439
1492
|
p.bold(
|
|
1440
|
-
`${"#".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`
|
|
1441
1494
|
)
|
|
1442
1495
|
);
|
|
1443
1496
|
selection.targets.forEach((target, i) => {
|
|
@@ -1634,8 +1687,8 @@ async function run(project, selection, ctx, p, emit = null) {
|
|
|
1634
1687
|
try {
|
|
1635
1688
|
for (const file of report.files()) {
|
|
1636
1689
|
const target = path2.join(report.dir, ...file.path.split("/"));
|
|
1637
|
-
await
|
|
1638
|
-
await
|
|
1690
|
+
await fs.mkdir(path2.dirname(target), { recursive: true });
|
|
1691
|
+
await fs.writeFile(target, file.text);
|
|
1639
1692
|
}
|
|
1640
1693
|
ctx.out(field(report.label, shown));
|
|
1641
1694
|
if (report.label === "HTML" && settings.autoOpenTestResultHtml) ctx.open(shown);
|
|
@@ -1672,15 +1725,20 @@ function usage(p) {
|
|
|
1672
1725
|
" Exits 1 on a broken collection, or a use:, extends: or body",
|
|
1673
1726
|
" file that would stop a run, in any collection.",
|
|
1674
1727
|
" a, all Run every collection, less those with exclude: true.",
|
|
1675
|
-
" <list> Run the collections and
|
|
1728
|
+
" <list> Run the collections and folders named, comma separated,",
|
|
1676
1729
|
" in that order: smoke,checkout/sessions,payments",
|
|
1677
|
-
" A
|
|
1730
|
+
" A folder leaves out its excluded collections;",
|
|
1678
1731
|
" one named on its own runs.",
|
|
1679
|
-
" 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.",
|
|
1680
1737
|
"",
|
|
1681
1738
|
` ${p.bold("Options:")}`,
|
|
1682
1739
|
" --json Print the results as JSON on stdout instead of the table; with",
|
|
1683
|
-
" 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.",
|
|
1684
1742
|
" --flag name=value",
|
|
1685
1743
|
" Override a feature flag for this run; any number of them.",
|
|
1686
1744
|
" GTA_FLAG_<name>=value does the same from the environment.",
|
|
@@ -1692,13 +1750,6 @@ function usage(p) {
|
|
|
1692
1750
|
` ${p.bold("Exit code:")} 0 passed, 1 failed, 2 could not run (or write a report).`
|
|
1693
1751
|
].join("\n");
|
|
1694
1752
|
}
|
|
1695
|
-
async function isDirectory(target) {
|
|
1696
|
-
try {
|
|
1697
|
-
return (await fs2.stat(target)).isDirectory();
|
|
1698
|
-
} catch {
|
|
1699
|
-
return false;
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
1753
|
var wantsHtml = (settings) => settings.generateHtmlResults || settings.autoOpenTestResultHtml;
|
|
1703
1754
|
var RESULT_DIRS = {
|
|
1704
1755
|
[JUNIT_DIR]: (name) => name === JUNIT_FILE,
|
|
@@ -1712,14 +1763,14 @@ async function clearResults(dir, projectRoot) {
|
|
|
1712
1763
|
}
|
|
1713
1764
|
let entries;
|
|
1714
1765
|
try {
|
|
1715
|
-
entries = await
|
|
1766
|
+
entries = await fs.readdir(dir);
|
|
1716
1767
|
} catch {
|
|
1717
1768
|
return null;
|
|
1718
1769
|
}
|
|
1719
1770
|
const others = entries.filter((entry) => !(entry in RESULT_DIRS) && !SYSTEM_FILES.has(entry));
|
|
1720
1771
|
for (const [folder, ours] of Object.entries(RESULT_DIRS)) {
|
|
1721
1772
|
if (!entries.includes(folder)) continue;
|
|
1722
|
-
const inside = await
|
|
1773
|
+
const inside = await fs.readdir(path2.join(dir, folder)).catch(() => []);
|
|
1723
1774
|
others.push(
|
|
1724
1775
|
...inside.filter((name) => !ours(name) && !SYSTEM_FILES.has(name)).map((name) => `${folder}/${name}`)
|
|
1725
1776
|
);
|
|
@@ -1728,7 +1779,7 @@ async function clearResults(dir, projectRoot) {
|
|
|
1728
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.`;
|
|
1729
1780
|
}
|
|
1730
1781
|
try {
|
|
1731
|
-
await
|
|
1782
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
1732
1783
|
return null;
|
|
1733
1784
|
} catch (cause) {
|
|
1734
1785
|
return `Could not delete ${dir} before the run: ${cause.message}`;
|