as-test 1.0.1 → 1.0.3

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.
@@ -1,91 +1,16 @@
1
1
  import chalk from "chalk";
2
2
  import { spawn } from "child_process";
3
3
  import { glob } from "glob";
4
- import { applyMode, getExec, loadConfig, tokenizeCommand } from "../util.js";
4
+ import { Channel, MessageType } from "../wipc.js";
5
+ import { applyMode, formatTime, getExec, loadConfig, tokenizeCommand, } from "../util.js";
5
6
  import * as path from "path";
6
7
  import { pathToFileURL } from "url";
7
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
+ import { buildWebRunnerSource } from "./web-runner-source.js";
8
10
  import { createReporter as createDefaultReporter } from "../reporters/default.js";
9
11
  import { createTapReporter } from "../reporters/tap.js";
12
+ import { persistCrashRecord } from "../crash-store.js";
10
13
  const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
11
- var MessageType;
12
- (function (MessageType) {
13
- MessageType[MessageType["OPEN"] = 0] = "OPEN";
14
- MessageType[MessageType["CLOSE"] = 1] = "CLOSE";
15
- MessageType[MessageType["CALL"] = 2] = "CALL";
16
- MessageType[MessageType["DATA"] = 3] = "DATA";
17
- })(MessageType || (MessageType = {}));
18
- class Channel {
19
- constructor(input, output) {
20
- this.input = input;
21
- this.output = output;
22
- this.buffer = Buffer.alloc(0);
23
- this.input.on("data", (chunk) => this.onData(chunk));
24
- }
25
- send(type, payload) {
26
- const body = payload ?? Buffer.alloc(0);
27
- const header = Buffer.alloc(Channel.HEADER_SIZE);
28
- Channel.MAGIC.copy(header, 0);
29
- header.writeUInt8(type, 4);
30
- header.writeUInt32LE(body.length, 5);
31
- this.output.write(Buffer.concat([header, body]));
32
- }
33
- sendJSON(type, msg) {
34
- this.send(type, Buffer.from(JSON.stringify(msg), "utf8"));
35
- }
36
- onData(chunk) {
37
- this.buffer = Buffer.concat([this.buffer, chunk]);
38
- while (true) {
39
- if (this.buffer.length === 0)
40
- return;
41
- const idx = this.buffer.indexOf(Channel.MAGIC);
42
- if (idx === -1) {
43
- this.onPassthrough(this.buffer);
44
- this.buffer = Buffer.alloc(0);
45
- return;
46
- }
47
- if (idx > 0) {
48
- this.onPassthrough(this.buffer.subarray(0, idx));
49
- this.buffer = this.buffer.subarray(idx);
50
- }
51
- if (this.buffer.length < Channel.HEADER_SIZE)
52
- return;
53
- const type = this.buffer.readUInt8(4);
54
- const length = this.buffer.readUInt32LE(5);
55
- const frameSize = Channel.HEADER_SIZE + length;
56
- if (this.buffer.length < frameSize)
57
- return;
58
- const payload = this.buffer.subarray(Channel.HEADER_SIZE, frameSize);
59
- this.buffer = this.buffer.subarray(frameSize);
60
- this.handleFrame(type, payload);
61
- }
62
- }
63
- handleFrame(type, payload) {
64
- switch (type) {
65
- case MessageType.OPEN:
66
- this.onOpen();
67
- break;
68
- case MessageType.CLOSE:
69
- this.onClose();
70
- break;
71
- case MessageType.CALL:
72
- this.onCall(JSON.parse(payload.toString("utf8")));
73
- break;
74
- case MessageType.DATA:
75
- this.onDataMessage(payload);
76
- break;
77
- default:
78
- this.onPassthrough(payload);
79
- }
80
- }
81
- onPassthrough(_data) { }
82
- onOpen() { }
83
- onClose() { }
84
- onCall(_msg) { }
85
- onDataMessage(_data) { }
86
- }
87
- Channel.MAGIC = Buffer.from("WIPC");
88
- Channel.HEADER_SIZE = 9;
89
14
  class SnapshotStore {
90
15
  constructor(specFile, snapshotDir, duplicateSpecBasenames = new Set()) {
91
16
  this.dirty = false;
@@ -94,30 +19,24 @@ class SnapshotStore {
94
19
  this.matched = 0;
95
20
  this.failed = 0;
96
21
  this.warnedMissing = new Set();
97
- const base = path.basename(specFile, ".ts");
98
- const disambiguator = resolveDisambiguator(specFile, duplicateSpecBasenames);
99
- const snapshotBase = disambiguator.length
100
- ? `${base}.${disambiguator}`
101
- : base;
102
22
  const dir = path.join(process.cwd(), snapshotDir);
103
- if (!existsSync(dir))
104
- mkdirSync(dir, { recursive: true });
105
- this.filePath = path.join(dir, `${snapshotBase}.snap.json`);
106
- const legacyFilePath = path.join(dir, `${base}.snap.json`);
107
- const sourcePath = existsSync(this.filePath)
108
- ? this.filePath
109
- : existsSync(legacyFilePath)
110
- ? legacyFilePath
111
- : null;
112
- this.data = sourcePath
113
- ? JSON.parse(readFileSync(sourcePath, "utf8"))
114
- : {};
115
- }
116
- assert(key, actual, allowSnapshot, updateSnapshots) {
23
+ const relative = resolveArtifactRelativePath(specFile, "__tests__").replace(/\.ts$/, ".snap");
24
+ this.filePath = path.join(dir, relative);
25
+ const sourcePath = resolveSnapshotSourcePath(specFile, dir, duplicateSpecBasenames, this.filePath) ?? null;
26
+ const loaded = sourcePath
27
+ ? readSnapshotFile(sourcePath, specFile)
28
+ : { data: {}, normalized: false, preamble: "" };
29
+ this.data = loaded.data;
30
+ this.preamble = loaded.preamble;
31
+ this.existed = Boolean(sourcePath && existsSync(sourcePath));
32
+ this.dirty = Boolean((sourcePath && sourcePath != this.filePath) || loaded.normalized);
33
+ }
34
+ assert(key, actual, allowSnapshot, createSnapshots, overwriteSnapshots) {
35
+ key = canonicalizeSnapshotKey(key);
117
36
  if (!allowSnapshot)
118
37
  return { ok: true, expected: actual, warnMissing: false };
119
38
  if (!(key in this.data)) {
120
- if (!updateSnapshots) {
39
+ if (!createSnapshots) {
121
40
  this.failed++;
122
41
  const warnMissing = !this.warnedMissing.has(key);
123
42
  if (warnMissing)
@@ -138,7 +57,7 @@ class SnapshotStore {
138
57
  this.matched++;
139
58
  return { ok: true, expected, warnMissing: false };
140
59
  }
141
- if (!updateSnapshots) {
60
+ if (!overwriteSnapshots) {
142
61
  this.failed++;
143
62
  return { ok: false, expected, warnMissing: false };
144
63
  }
@@ -150,8 +69,335 @@ class SnapshotStore {
150
69
  flush() {
151
70
  if (!this.dirty)
152
71
  return;
153
- writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
72
+ const outDir = path.dirname(this.filePath);
73
+ if (!existsSync(outDir))
74
+ mkdirSync(outDir, { recursive: true });
75
+ writeFileSync(this.filePath, formatSnapshotFile(this.data, this.filePath, this.existed ? this.preamble : defaultSnapshotPreamble()));
76
+ }
77
+ }
78
+ function resolveSnapshotSourcePath(specFile, snapshotDir, duplicateSpecBasenames, preferredPath) {
79
+ if (existsSync(preferredPath))
80
+ return preferredPath;
81
+ const base = path.basename(specFile, ".ts");
82
+ const legacyFlat = path.join(snapshotDir, `${base}.snap.json`);
83
+ if (existsSync(legacyFlat))
84
+ return legacyFlat;
85
+ const disambiguator = resolveDisambiguator(specFile, duplicateSpecBasenames);
86
+ if (disambiguator.length) {
87
+ const legacyDisambiguated = path.join(snapshotDir, `${base}.${disambiguator}.snap.json`);
88
+ if (existsSync(legacyDisambiguated))
89
+ return legacyDisambiguated;
90
+ }
91
+ return null;
92
+ }
93
+ function readSnapshotFile(filePath, specFile) {
94
+ const raw = readFileSync(filePath, "utf8");
95
+ if (filePath.endsWith(".json")) {
96
+ const normalized = normalizeSnapshotRecord(JSON.parse(raw));
97
+ return { ...normalized, preamble: "" };
98
+ }
99
+ return parseSnapshotText(raw, specFile);
100
+ }
101
+ function parseSnapshotText(source, specFile) {
102
+ const out = {};
103
+ const lines = source.split(/\r?\n/);
104
+ let i = 0;
105
+ let normalized = false;
106
+ const preambleLines = [];
107
+ while (i < lines.length) {
108
+ const header = lines[i] ?? "";
109
+ if (isSnapshotOuterComment(header) || !header.length) {
110
+ if (!Object.keys(out).length)
111
+ preambleLines.push(header);
112
+ i++;
113
+ continue;
114
+ }
115
+ const match = header.match(/^=== (.+) ===$/);
116
+ if (!match) {
117
+ i++;
118
+ continue;
119
+ }
120
+ const localKey = match[1];
121
+ i++;
122
+ let value = "";
123
+ if ((lines[i] ?? "") == "<<<") {
124
+ i++;
125
+ const block = [];
126
+ while (i < lines.length && (lines[i] ?? "") != ">>>") {
127
+ block.push(lines[i] ?? "");
128
+ i++;
129
+ }
130
+ value = block.join("\n");
131
+ if ((lines[i] ?? "") == ">>>")
132
+ i++;
133
+ }
134
+ else {
135
+ value = lines[i] ?? "";
136
+ i++;
137
+ }
138
+ while (i < lines.length && !(lines[i] ?? "").startsWith("=== ")) {
139
+ if (!lines[i]?.length || isSnapshotOuterComment(lines[i] ?? "")) {
140
+ i++;
141
+ continue;
142
+ }
143
+ break;
144
+ }
145
+ while (i < lines.length && isSnapshotOuterComment(lines[i] ?? "")) {
146
+ i++;
147
+ }
148
+ const qualified = qualifySnapshotKey(specFile, localKey);
149
+ const canonical = canonicalizeSnapshotKey(qualified);
150
+ if (canonical != qualified)
151
+ normalized = true;
152
+ out[canonical] = value;
153
+ }
154
+ return {
155
+ data: out,
156
+ normalized,
157
+ preamble: trimSnapshotPreamble(preambleLines),
158
+ };
159
+ }
160
+ function normalizeSnapshotRecord(data) {
161
+ const out = {};
162
+ let normalized = false;
163
+ for (const [key, value] of Object.entries(data)) {
164
+ const canonical = canonicalizeSnapshotKey(key);
165
+ if (canonical != key)
166
+ normalized = true;
167
+ out[canonical] = value;
168
+ }
169
+ return { data: out, normalized };
170
+ }
171
+ function isSnapshotOuterComment(line) {
172
+ const trimmed = line.trim();
173
+ return trimmed.startsWith("#") || trimmed.startsWith("//");
174
+ }
175
+ function formatSnapshotFile(data, filePath, preamble) {
176
+ const specFile = resolveSnapshotSpecFile(filePath);
177
+ const seen = new Set();
178
+ const sections = [];
179
+ for (const key of Object.keys(data)) {
180
+ const localKey = canonicalizeSnapshotLocalKey(localizeSnapshotKey(specFile, key));
181
+ if (seen.has(localKey))
182
+ continue;
183
+ seen.add(localKey);
184
+ const value = data[key] ?? "";
185
+ if (value.includes("\n")) {
186
+ sections.push(`=== ${localKey} ===\n<<<\n${value}\n>>>`);
187
+ }
188
+ else {
189
+ sections.push(`=== ${localKey} ===\n${value}`);
190
+ }
191
+ }
192
+ if (!sections.length)
193
+ return "";
194
+ const prefix = preamble.length ? preamble + "\n\n" : "";
195
+ return prefix + sections.join("\n\n") + "\n";
196
+ }
197
+ function defaultSnapshotPreamble() {
198
+ return [
199
+ "# as-test snapshot file",
200
+ "#",
201
+ "# IDs use this format:",
202
+ "# Suite > test",
203
+ "# Suite > test [name]",
204
+ "# Suite > test #2",
205
+ "#",
206
+ "# Examples:",
207
+ '# test("renders card", () => {',
208
+ "# expect(view()).toMatchSnapshot();",
209
+ "# })",
210
+ "# -> renders card",
211
+ "#",
212
+ '# test("renders card", () => {',
213
+ '# expect(view()).toMatchSnapshot("mobile");',
214
+ "# })",
215
+ "# -> renders card [mobile]",
216
+ "#",
217
+ '# test("renders card", () => {',
218
+ "# expect(header()).toMatchSnapshot();",
219
+ "# expect(body()).toMatchSnapshot();",
220
+ "# })",
221
+ "# -> renders card",
222
+ "# -> renders card #2",
223
+ "#",
224
+ '# describe("Card", () => {',
225
+ '# test("renders", () => {',
226
+ "# expect(view()).toMatchSnapshot();",
227
+ "# })",
228
+ "# })",
229
+ "# -> Card > renders",
230
+ "#",
231
+ "# Single-line values are written directly below the ID.",
232
+ "# Multi-line values use delimiters:",
233
+ "# <<<",
234
+ "# ...",
235
+ "# >>>",
236
+ ].join("\n");
237
+ }
238
+ function trimSnapshotPreamble(lines) {
239
+ let end = lines.length;
240
+ while (end > 0 && !(lines[end - 1] ?? "").trim().length)
241
+ end--;
242
+ return lines.slice(0, end).join("\n");
243
+ }
244
+ function resolveSnapshotSpecFile(filePath) {
245
+ const normalized = filePath.replace(/\\/g, "/");
246
+ const marker = "/snapshots/";
247
+ const markerIndex = normalized.lastIndexOf(marker);
248
+ const suffix = markerIndex >= 0
249
+ ? normalized.slice(markerIndex + marker.length)
250
+ : path.basename(normalized);
251
+ const withoutMode = suffix.replace(/^default\//, "");
252
+ const relative = withoutMode.replace(/\.snap$/, ".ts");
253
+ return `assembly/__tests__/${relative}`;
254
+ }
255
+ function localizeSnapshotKey(specFile, key) {
256
+ const prefix = `${path.basename(specFile)}::`;
257
+ return key.startsWith(prefix) ? key.slice(prefix.length) : key;
258
+ }
259
+ function qualifySnapshotKey(specFile, key) {
260
+ return `${path.basename(specFile)}::${key}`;
261
+ }
262
+ function canonicalizeSnapshotKey(key) {
263
+ const sep = key.indexOf("::");
264
+ if (sep < 0)
265
+ return canonicalizeSnapshotLocalKey(key);
266
+ const prefix = key.slice(0, sep + 2);
267
+ const local = key.slice(sep + 2);
268
+ return prefix + canonicalizeSnapshotLocalKey(local);
269
+ }
270
+ function canonicalizeSnapshotLocalKey(localKey) {
271
+ const named = localKey.match(/^(.*)::\d+::(.+)$/);
272
+ if (named) {
273
+ return `${named[1]} [${named[2]}]`;
274
+ }
275
+ const simpleNamed = localKey.match(/^(.*)::([^:]+)$/);
276
+ if (simpleNamed && !/^\d+$/.test(simpleNamed[2])) {
277
+ return `${simpleNamed[1]} [${simpleNamed[2]}]`;
278
+ }
279
+ const unnamed = localKey.match(/^(.*)::(\d+)$/);
280
+ if (unnamed) {
281
+ const index = Number(unnamed[2]);
282
+ if (!Number.isFinite(index) || index < 0)
283
+ return localKey;
284
+ return index === 0 ? unnamed[1] : `${unnamed[1]} #${index + 1}`;
285
+ }
286
+ return localKey;
287
+ }
288
+ function resolveArtifactRelativePath(sourceFile, segment) {
289
+ const normalized = sourceFile.replace(/\\/g, "/");
290
+ const marker = `/${segment}/`;
291
+ const index = normalized.lastIndexOf(marker);
292
+ if (index >= 0)
293
+ return normalized.slice(index + marker.length);
294
+ return path.basename(normalized);
295
+ }
296
+ function writeReadableLog(logRoot, file, suites, modeName, buildCommand, runCommand, snapshotSummary) {
297
+ const relative = resolveArtifactRelativePath(file, "__tests__").replace(/\.ts$/, ".log");
298
+ const filePath = path.join(logRoot, relative);
299
+ const dir = path.dirname(filePath);
300
+ if (!existsSync(dir))
301
+ mkdirSync(dir, { recursive: true });
302
+ writeFileSync(filePath, formatReadableLog(file, suites, modeName, buildCommand, runCommand, snapshotSummary));
303
+ }
304
+ function formatReadableLog(file, suites, modeName, buildCommand, runCommand, snapshotSummary) {
305
+ const stats = collectRunStats([suites]);
306
+ const verdict = stats.failedFiles
307
+ ? "FAIL"
308
+ : stats.passedFiles
309
+ ? "PASS"
310
+ : "SKIP";
311
+ const lines = [
312
+ `Mode: ${modeName ?? "default"}`,
313
+ `Build: ${buildCommand || "(unknown)"}`,
314
+ `Run: ${runCommand}`,
315
+ "",
316
+ `${verdict} ${file}`,
317
+ "",
318
+ `Snapshots: ${snapshotSummary.matched} matched, ${snapshotSummary.created} created, ${snapshotSummary.updated} updated, ${snapshotSummary.failed} failed`,
319
+ "",
320
+ `Suites: ${stats.passedSuites} passed, ${stats.failedSuites} failed, ${stats.skippedSuites} skipped`,
321
+ `Tests: ${stats.passedTests} passed, ${stats.failedTests} failed, ${stats.skippedTests} skipped`,
322
+ `Time: ${formatTime(stats.time)}`,
323
+ ];
324
+ const failures = collectReadableFailures(suites, file, []);
325
+ if (failures.length) {
326
+ lines.push("", "Failures:");
327
+ for (const failure of failures) {
328
+ lines.push(`FAIL ${failure.title}${failure.where.length ? ` (${failure.where})` : ""}`);
329
+ if (failure.message.length)
330
+ lines.push(`Message: ${failure.message}`);
331
+ if (failure.left.length)
332
+ lines.push(`Expected: ${failure.right}`);
333
+ if (failure.right.length)
334
+ lines.push(`Received: ${failure.left}`);
335
+ lines.push("");
336
+ }
337
+ if (!lines[lines.length - 1].length)
338
+ lines.pop();
339
+ }
340
+ const logs = collectReadableLogs(suites);
341
+ if (logs.length) {
342
+ lines.push("", "Log:");
343
+ for (const entry of logs) {
344
+ lines.push(entry);
345
+ }
154
346
  }
347
+ return lines.join("\n") + "\n";
348
+ }
349
+ function formatInvocation(invocation) {
350
+ return [invocation.command, ...invocation.args]
351
+ .map((part) => (/[\s"'\\]/.test(part) ? JSON.stringify(part) : part))
352
+ .join(" ");
353
+ }
354
+ function collectReadableFailures(suites, file, pathParts) {
355
+ const out = [];
356
+ for (const suite of suites) {
357
+ const suiteAny = suite;
358
+ const nextPath = [...pathParts, String(suiteAny.description ?? "unknown")];
359
+ const tests = Array.isArray(suiteAny.tests)
360
+ ? suiteAny.tests
361
+ : [];
362
+ for (let i = 0; i < tests.length; i++) {
363
+ const test = tests[i];
364
+ if (String(test.verdict ?? "none") != "fail")
365
+ continue;
366
+ out.push({
367
+ title: `${nextPath.join(" > ")}#${i + 1}`,
368
+ where: String(test.location ?? "").length
369
+ ? `${path.basename(file)}:${String(test.location ?? "")}`
370
+ : path.basename(file),
371
+ message: String(test.message ?? ""),
372
+ left: JSON.stringify(test.left ?? ""),
373
+ right: JSON.stringify(test.right ?? ""),
374
+ });
375
+ }
376
+ const childSuites = Array.isArray(suiteAny.suites)
377
+ ? suiteAny.suites
378
+ : [];
379
+ out.push(...collectReadableFailures(childSuites, file, nextPath));
380
+ }
381
+ return out;
382
+ }
383
+ function collectReadableLogs(suites) {
384
+ const out = [];
385
+ for (const suite of suites) {
386
+ const suiteAny = suite;
387
+ const logs = Array.isArray(suiteAny.logs)
388
+ ? suiteAny.logs
389
+ : [];
390
+ for (const log of logs) {
391
+ const value = String(log.value ?? log.message ?? "");
392
+ if (value.length)
393
+ out.push(value);
394
+ }
395
+ const childSuites = Array.isArray(suiteAny.suites)
396
+ ? suiteAny.suites
397
+ : [];
398
+ out.push(...collectReadableLogs(childSuites));
399
+ }
400
+ return out;
155
401
  }
156
402
  export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selectors = [], shouldExit = true, options = {}) {
157
403
  const resolvedConfigPath = configPath ?? DEFAULT_CONFIG_PATH;
@@ -163,7 +409,8 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
163
409
  const inputFiles = (await glob(inputPatterns)).sort((a, b) => a.localeCompare(b));
164
410
  const duplicateSpecBasenames = await resolveDuplicateSpecBasenames(config.input);
165
411
  const snapshotEnabled = flags.snapshot !== false;
166
- const updateSnapshots = Boolean(flags.updateSnapshots);
412
+ const createSnapshots = Boolean(flags.createSnapshots);
413
+ const overwriteSnapshots = Boolean(flags.overwriteSnapshots);
167
414
  const cleanOutput = Boolean(flags.clean);
168
415
  const showCoverage = Boolean(flags.showCoverage);
169
416
  const coverage = resolveCoverageOptions(config.coverage);
@@ -200,7 +447,7 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
200
447
  clean: cleanOutput,
201
448
  verbose: Boolean(flags.verbose),
202
449
  snapshotEnabled,
203
- updateSnapshots,
450
+ createSnapshots,
204
451
  });
205
452
  }
206
453
  if (showCoverage && !coverageEnabled) {
@@ -229,10 +476,14 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
229
476
  .slice(1)
230
477
  .map((token) => token.replace(/<name>/g, fileBase).replace(/<file>/g, fileToken)),
231
478
  };
479
+ const runCommandForLog = formatInvocation(invocation);
232
480
  const snapshotStore = new SnapshotStore(file, config.snapshotDir, duplicateSpecBasenames);
233
481
  let report;
234
482
  try {
235
- report = await runProcess(invocation, snapshotStore, snapshotEnabled, updateSnapshots, reporter, reporterKind == "tap", mode.env);
483
+ report = await runProcess(invocation, file, config.fuzz.crashDir, options.modeName, snapshotStore, snapshotEnabled, createSnapshots, overwriteSnapshots, reporter, reporterKind == "tap", {
484
+ ...mode.env,
485
+ ...config.runOptions.env,
486
+ });
236
487
  }
237
488
  catch (error) {
238
489
  const modeLabel = options.modeName ?? "default";
@@ -247,21 +498,30 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
247
498
  snapshotSummary.failed += snapshotStore.failed;
248
499
  reports.push({
249
500
  file,
501
+ modeName: options.modeName ?? "default",
250
502
  suites: normalized.suites,
251
503
  coverage: normalized.coverage,
504
+ runCommand: runCommandForLog,
505
+ snapshotSummary: {
506
+ matched: snapshotStore.matched,
507
+ created: snapshotStore.created,
508
+ updated: snapshotStore.updated,
509
+ failed: snapshotStore.failed,
510
+ },
252
511
  });
253
512
  }
254
513
  if (config.logs && config.logs != "none") {
255
- if (!existsSync(path.join(process.cwd(), config.logs))) {
256
- mkdirSync(path.join(process.cwd(), config.logs), { recursive: true });
514
+ const logRoot = path.join(process.cwd(), config.logs);
515
+ if (!existsSync(logRoot)) {
516
+ mkdirSync(logRoot, { recursive: true });
517
+ }
518
+ for (const report of reports) {
519
+ writeReadableLog(logRoot, report.file, report.suites, options.modeName, options.buildCommandsByFile?.[report.file] ??
520
+ options.buildCommand ??
521
+ "", report.runCommand, report.snapshotSummary);
257
522
  }
258
- const logReports = reports.map((report) => ({
259
- file: report.file,
260
- suites: report.suites,
261
- }));
262
- writeFileSync(path.join(process.cwd(), config.logs, options.logFileName ?? "test.log.json"), JSON.stringify(logReports, null, 2));
263
523
  }
264
- const stats = collectRunStats(reports.map((report) => report.suites));
524
+ const stats = collectRunStats(reports);
265
525
  if (options.fileSummaryTotal != undefined) {
266
526
  applyConfiguredFileTotalToStats(stats, options.fileSummaryTotal);
267
527
  }
@@ -285,6 +545,7 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
285
545
  clean: cleanOutput,
286
546
  snapshotEnabled,
287
547
  showCoverage,
548
+ buildTime: 0,
288
549
  snapshotSummary,
289
550
  coverageSummary,
290
551
  stats,
@@ -295,6 +556,7 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
295
556
  total: totalModes,
296
557
  },
297
558
  });
559
+ reporter.flush?.();
298
560
  }
299
561
  const failed = Boolean(stats.failedFiles || snapshotSummary.failed);
300
562
  if (shouldExit) {
@@ -302,6 +564,7 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
302
564
  }
303
565
  return {
304
566
  failed,
567
+ buildTime: 0,
305
568
  stats,
306
569
  snapshotSummary,
307
570
  coverageSummary,
@@ -362,6 +625,12 @@ function resolveLegacyRuntime(runtimeRun, target, emitWarnings) {
362
625
  return runtimeRun.replace(legacyPath, preferredPath);
363
626
  }
364
627
  }
628
+ if (target == "web") {
629
+ const preferredPath = "./.as-test/runners/default.web.js";
630
+ if (runtimeRun.includes(preferredPath)) {
631
+ ensureDefaultRuntimeRunner("web", emitWarnings);
632
+ }
633
+ }
365
634
  return runtimeRun;
366
635
  }
367
636
  function fallbackToDefaultRuntime(runtimeRun, target, emitWarnings) {
@@ -399,6 +668,12 @@ function getDefaultRuntimeFallback(target) {
399
668
  scriptPath: "./.as-test/runners/default.bindings.js",
400
669
  };
401
670
  }
671
+ if (target == "web") {
672
+ return {
673
+ command: "node ./.as-test/runners/default.web.js <file>",
674
+ scriptPath: "./.as-test/runners/default.web.js",
675
+ };
676
+ }
402
677
  return null;
403
678
  }
404
679
  function ensureDefaultRuntimeRunner(target, emitWarnings) {
@@ -456,7 +731,18 @@ try {
456
731
 
457
732
  const binary = readFileSync(wasmPath);
458
733
  const module = new WebAssembly.Module(binary);
734
+ const envImports = {
735
+ __as_test_request_fuzz_config() {
736
+ return 0;
737
+ },
738
+ };
739
+ for (const entry of WebAssembly.Module.imports(module)) {
740
+ if (entry.module == "env" && entry.kind == "function" && !(entry.name in envImports)) {
741
+ envImports[entry.name] = () => 0;
742
+ }
743
+ }
459
744
  const instance = new WebAssembly.Instance(module, {
745
+ env: envImports,
460
746
  wasi_snapshot_preview1: wasi.wasiImport,
461
747
  });
462
748
  wasi.start(instance);
@@ -537,6 +823,9 @@ try {
537
823
  }
538
824
  `;
539
825
  }
826
+ if (target == "web") {
827
+ return buildWebRunnerSource();
828
+ }
540
829
  return null;
541
830
  }
542
831
  function resolveArtifactFileName(file, target, modeName, duplicateSpecBasenames = new Set()) {
@@ -884,8 +1173,57 @@ function isIgnoredCoverageFile(file, coverage) {
884
1173
  return true;
885
1174
  if (!coverage.includeSpecs && normalized.endsWith(".spec.ts"))
886
1175
  return true;
1176
+ if (coverage.include.length &&
1177
+ !coverage.include.some((pattern) => matchesCoverageGlob(normalized, pattern))) {
1178
+ return true;
1179
+ }
1180
+ if (coverage.exclude.some((pattern) => matchesCoverageGlob(normalized, pattern))) {
1181
+ return true;
1182
+ }
887
1183
  return false;
888
1184
  }
1185
+ function matchesCoverageGlob(file, pattern) {
1186
+ const normalizedPattern = pattern.replace(/\\/g, "/").trim();
1187
+ if (!normalizedPattern.length)
1188
+ return false;
1189
+ const regex = globPatternToRegExp(normalizedPattern);
1190
+ return regex.test(file);
1191
+ }
1192
+ function globPatternToRegExp(pattern) {
1193
+ let source = "^";
1194
+ for (let i = 0; i < pattern.length; i++) {
1195
+ const char = pattern[i];
1196
+ if (char == "*") {
1197
+ const next = pattern[i + 1];
1198
+ if (next == "*") {
1199
+ const after = pattern[i + 2];
1200
+ if (after == "/") {
1201
+ source += "(?:.*/)?";
1202
+ i += 2;
1203
+ }
1204
+ else {
1205
+ source += ".*";
1206
+ i += 1;
1207
+ }
1208
+ }
1209
+ else {
1210
+ source += "[^/]*";
1211
+ }
1212
+ continue;
1213
+ }
1214
+ if (char == "?") {
1215
+ source += "[^/]";
1216
+ continue;
1217
+ }
1218
+ if ("\\.[]{}()+-^$|".includes(char)) {
1219
+ source += `\\${char}`;
1220
+ continue;
1221
+ }
1222
+ source += char;
1223
+ }
1224
+ source += "$";
1225
+ return new RegExp(source);
1226
+ }
889
1227
  function isAllowedCoverageSourceFile(file) {
890
1228
  const lower = file.toLowerCase();
891
1229
  return lower.endsWith(".ts") || lower.endsWith(".as");
@@ -906,18 +1244,28 @@ function resolveCoverageOptions(raw) {
906
1244
  return {
907
1245
  enabled: raw,
908
1246
  includeSpecs: false,
1247
+ include: [],
1248
+ exclude: [],
909
1249
  };
910
1250
  }
911
1251
  if (raw && typeof raw == "object") {
912
1252
  const obj = raw;
913
1253
  return {
914
- enabled: obj.enabled == null ? true : Boolean(obj.enabled),
1254
+ enabled: obj.enabled == null ? false : Boolean(obj.enabled),
915
1255
  includeSpecs: Boolean(obj.includeSpecs),
1256
+ include: Array.isArray(obj.include)
1257
+ ? obj.include.filter((item) => typeof item == "string")
1258
+ : [],
1259
+ exclude: Array.isArray(obj.exclude)
1260
+ ? obj.exclude.filter((item) => typeof item == "string")
1261
+ : [],
916
1262
  };
917
1263
  }
918
1264
  return {
919
1265
  enabled: false,
920
1266
  includeSpecs: false,
1267
+ include: [],
1268
+ exclude: [],
921
1269
  };
922
1270
  }
923
1271
  function compareCoveragePoints(a, b) {
@@ -929,7 +1277,7 @@ function compareCoveragePoints(a, b) {
929
1277
  return a.type.localeCompare(b.type);
930
1278
  return a.hash.localeCompare(b.hash);
931
1279
  }
932
- async function runProcess(invocation, snapshots, snapshotEnabled, updateSnapshots, reporter, tapMode = false, env = process.env) {
1280
+ async function runProcess(invocation, specFile, crashDir, modeName, snapshots, snapshotEnabled, createSnapshots, overwriteSnapshots, reporter, tapMode = false, env = process.env) {
933
1281
  const child = spawn(invocation.command, invocation.args, {
934
1282
  stdio: ["pipe", "pipe", "pipe"],
935
1283
  shell: false,
@@ -938,6 +1286,7 @@ async function runProcess(invocation, snapshots, snapshotEnabled, updateSnapshot
938
1286
  let report = null;
939
1287
  let parseError = null;
940
1288
  let stderrBuffer = "";
1289
+ let stdoutBuffer = "";
941
1290
  let suppressTraceWarningLine = false;
942
1291
  let spawnError = null;
943
1292
  child.on("error", (error) => {
@@ -961,6 +1310,7 @@ async function runProcess(invocation, snapshots, snapshotEnabled, updateSnapshot
961
1310
  });
962
1311
  class TestChannel extends Channel {
963
1312
  onPassthrough(data) {
1313
+ stdoutBuffer += data.toString("utf8");
964
1314
  if (tapMode) {
965
1315
  process.stderr.write(data);
966
1316
  }
@@ -1020,10 +1370,24 @@ async function runProcess(invocation, snapshots, snapshotEnabled, updateSnapshot
1020
1370
  });
1021
1371
  return;
1022
1372
  }
1373
+ if (kind === "event:warn") {
1374
+ reporter.onWarning?.({
1375
+ message: String(event.message ?? ""),
1376
+ });
1377
+ return;
1378
+ }
1379
+ if (kind === "event:log") {
1380
+ reporter.onLog?.({
1381
+ file: String(event.file ?? "unknown"),
1382
+ depth: Number(event.depth ?? 0),
1383
+ text: String(event.text ?? ""),
1384
+ });
1385
+ return;
1386
+ }
1023
1387
  if (kind === "snapshot:assert") {
1024
1388
  const key = String(event.key ?? "");
1025
1389
  const actual = String(event.actual ?? "");
1026
- const result = snapshots.assert(key, actual, snapshotEnabled, updateSnapshots);
1390
+ const result = snapshots.assert(key, actual, snapshotEnabled, createSnapshots, overwriteSnapshots);
1027
1391
  if (result.warnMissing) {
1028
1392
  reporter.onSnapshotMissing?.({ key });
1029
1393
  }
@@ -1051,12 +1415,36 @@ async function runProcess(invocation, snapshots, snapshotEnabled, updateSnapshot
1051
1415
  }
1052
1416
  }
1053
1417
  if (spawnError) {
1418
+ persistCrashRecord(crashDir, {
1419
+ kind: "test",
1420
+ file: specFile,
1421
+ mode: modeName ?? "default",
1422
+ error: spawnError.stack ?? spawnError.message,
1423
+ stdout: stdoutBuffer,
1424
+ stderr: stderrBuffer,
1425
+ });
1054
1426
  throw spawnError;
1055
1427
  }
1056
1428
  if (parseError) {
1429
+ persistCrashRecord(crashDir, {
1430
+ kind: "test",
1431
+ file: specFile,
1432
+ mode: modeName ?? "default",
1433
+ error: `could not parse report payload: ${parseError}`,
1434
+ stdout: stdoutBuffer,
1435
+ stderr: stderrBuffer,
1436
+ });
1057
1437
  throw new Error(`could not parse report payload: ${parseError}`);
1058
1438
  }
1059
1439
  if (!report) {
1440
+ persistCrashRecord(crashDir, {
1441
+ kind: "test",
1442
+ file: specFile,
1443
+ mode: modeName ?? "default",
1444
+ error: "missing report payload from test runtime",
1445
+ stdout: stdoutBuffer,
1446
+ stderr: stderrBuffer,
1447
+ });
1060
1448
  throw new Error("missing report payload from test runtime");
1061
1449
  }
1062
1450
  if (code !== 0) {
@@ -1094,10 +1482,17 @@ function collectRunStats(reports) {
1094
1482
  return stats;
1095
1483
  }
1096
1484
  function readFileReport(stats, fileReport) {
1097
- const suites = Array.isArray(fileReport) ? fileReport : [];
1485
+ const fileReportAny = fileReport;
1486
+ const suites = Array.isArray(fileReportAny.suites)
1487
+ ? fileReportAny.suites
1488
+ : Array.isArray(fileReport)
1489
+ ? fileReport
1490
+ : [];
1491
+ const file = String(fileReportAny.file ?? "");
1492
+ const modeName = String(fileReportAny.modeName ?? "");
1098
1493
  let fileVerdict = "none";
1099
1494
  for (const suite of suites) {
1100
- fileVerdict = mergeVerdict(fileVerdict, readSuite(stats, suite));
1495
+ fileVerdict = mergeVerdict(fileVerdict, readSuite(stats, suite, file, modeName));
1101
1496
  }
1102
1497
  if (fileVerdict == "fail") {
1103
1498
  stats.failedFiles++;
@@ -1109,7 +1504,7 @@ function readFileReport(stats, fileReport) {
1109
1504
  stats.skippedFiles++;
1110
1505
  }
1111
1506
  }
1112
- function readSuite(stats, suite) {
1507
+ function readSuite(stats, suite, file, modeName) {
1113
1508
  const suiteAny = suite;
1114
1509
  let verdict = normalizeVerdict(suiteAny.verdict);
1115
1510
  const time = suiteAny.time;
@@ -1120,7 +1515,7 @@ function readSuite(stats, suite) {
1120
1515
  ? suiteAny.suites
1121
1516
  : [];
1122
1517
  for (const subSuite of subSuites) {
1123
- verdict = mergeVerdict(verdict, readSuite(stats, subSuite));
1518
+ verdict = mergeVerdict(verdict, readSuite(stats, subSuite, file, modeName));
1124
1519
  }
1125
1520
  const tests = Array.isArray(suiteAny.tests)
1126
1521
  ? suiteAny.tests
@@ -1140,7 +1535,11 @@ function readSuite(stats, suite) {
1140
1535
  }
1141
1536
  if (verdict == "fail") {
1142
1537
  stats.failedSuites++;
1143
- stats.failedEntries.push(suite);
1538
+ stats.failedEntries.push({
1539
+ ...suiteAny,
1540
+ file,
1541
+ modeName,
1542
+ });
1144
1543
  }
1145
1544
  else if (verdict == "ok") {
1146
1545
  stats.passedSuites++;
@@ -1169,15 +1568,18 @@ function mergeVerdict(current, next) {
1169
1568
  return "skip";
1170
1569
  return "none";
1171
1570
  }
1172
- export async function createRunReporter(configPath = DEFAULT_CONFIG_PATH, reporterPath, modeName) {
1571
+ export async function createRunReporter(configPath = DEFAULT_CONFIG_PATH, reporterPath, modeName, context = {
1572
+ stdout: process.stdout,
1573
+ stderr: process.stderr,
1574
+ }) {
1173
1575
  const resolvedConfigPath = configPath ?? DEFAULT_CONFIG_PATH;
1174
1576
  const loadedConfig = loadConfig(resolvedConfigPath);
1175
1577
  const mode = applyMode(loadedConfig, modeName);
1176
1578
  const config = mode.config;
1177
1579
  const selection = resolveReporterSelection(reporterPath, config.runOptions.reporter);
1178
1580
  const reporter = await loadReporter(selection, resolvedConfigPath, {
1179
- stdout: process.stdout,
1180
- stderr: process.stderr,
1581
+ stdout: context.stdout,
1582
+ stderr: context.stderr,
1181
1583
  });
1182
1584
  const runtimeCommand = resolveRuntimeCommand(getConfiguredRuntimeCmd(config), config.buildOptions.target, false);
1183
1585
  return {