@schwabyio/gta 0.12.0 → 0.14.0

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