@schwabyio/gta 0.13.0 → 0.14.1

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