as-test 0.5.1 → 0.5.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.
package/bin/init.js CHANGED
@@ -148,11 +148,9 @@ function printPlan(root, target, example) {
148
148
  if (example != "none") {
149
149
  console.log(chalk.dim(" assembly/__tests__/example.spec.ts"));
150
150
  }
151
- if (target == "wasi") {
151
+ if (target == "wasi" || target == "bindings") {
152
152
  console.log(chalk.dim(" .as-test/runners/default.wasi.js"));
153
- }
154
- if (target == "bindings") {
155
- console.log(chalk.dim(" .as-test/runners/default.run.js"));
153
+ console.log(chalk.dim(" .as-test/runners/default.bindings.js"));
156
154
  }
157
155
  console.log(chalk.dim(" package.json"));
158
156
  console.log("");
@@ -171,15 +169,17 @@ function applyInit(root, target, example, force) {
171
169
  if (target == "wasi" || target == "bindings") {
172
170
  ensureDir(root, ".as-test/runners", summary);
173
171
  }
172
+ ensureGitignoreIncludesAsTestDirs(root, summary);
174
173
  const configPath = path.join(root, "as-test.config.json");
175
174
  const config = loadConfig(configPath, false);
176
175
  config.$schema = "./node_modules/as-test/as-test.config.schema.json";
177
176
  config.buildOptions.target = target;
177
+ config.runOptions.reporter = "default";
178
178
  if (target == "wasi") {
179
179
  config.runOptions.runtime.cmd = "node ./.as-test/runners/default.wasi.js <file>";
180
180
  }
181
181
  else {
182
- config.runOptions.runtime.cmd = "node ./.as-test/runners/default.run.js <file>";
182
+ config.runOptions.runtime.cmd = "node ./.as-test/runners/default.bindings.js <file>";
183
183
  }
184
184
  writeJson(configPath, config, summary, "as-test.config.json");
185
185
  if (example != "none") {
@@ -187,13 +187,13 @@ function applyInit(root, target, example, force) {
187
187
  const content = example == "minimal" ? buildMinimalExampleSpec() : buildFullExampleSpec();
188
188
  writeManagedFile(examplePath, content, force, summary, "assembly/__tests__/example.spec.ts");
189
189
  }
190
- if (target == "wasi") {
190
+ if (target == "wasi" || target == "bindings") {
191
191
  const runnerPath = path.join(root, ".as-test/runners/default.wasi.js");
192
192
  writeManagedFile(runnerPath, buildWasiRunner(), force, summary, ".as-test/runners/default.wasi.js");
193
193
  }
194
- if (target == "bindings") {
195
- const runnerPath = path.join(root, ".as-test/runners/default.run.js");
196
- writeManagedFile(runnerPath, buildBindingsRunner(), force, summary, ".as-test/runners/default.run.js");
194
+ if (target == "wasi" || target == "bindings") {
195
+ const runnerPath = path.join(root, ".as-test/runners/default.bindings.js");
196
+ writeManagedFile(runnerPath, buildBindingsRunner(), force, summary, ".as-test/runners/default.bindings.js");
197
197
  }
198
198
  const pkgPath = path.join(root, "package.json");
199
199
  const pkg = existsSync(pkgPath)
@@ -232,6 +232,31 @@ function ensureDir(root, rel, summary) {
232
232
  mkdirSync(full, { recursive: true });
233
233
  summary.created.push(rel + "/");
234
234
  }
235
+ function ensureGitignoreIncludesAsTestDirs(root, summary) {
236
+ const rel = ".gitignore";
237
+ const fullPath = path.join(root, rel);
238
+ const entries = ["!.as-test/runners/", "!.as-test/snapshots/"];
239
+ const existed = existsSync(fullPath);
240
+ const source = existed ? readFileSync(fullPath, "utf8") : "";
241
+ const lines = source.split(/\r?\n/);
242
+ const missing = entries.filter((entry) => !lines.some((line) => line.trim() == entry));
243
+ if (!missing.length) {
244
+ return;
245
+ }
246
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
247
+ let output = source;
248
+ if (output.length &&
249
+ !output.endsWith("\n") &&
250
+ !output.endsWith("\r\n")) {
251
+ output += eol;
252
+ }
253
+ output += missing.join(eol) + eol;
254
+ writeFileSync(fullPath, output);
255
+ if (existed)
256
+ summary.updated.push(rel);
257
+ else
258
+ summary.created.push(rel);
259
+ }
235
260
  function writeJson(fullPath, value, summary, displayPath) {
236
261
  const rel = displayPath ??
237
262
  path.relative(process.cwd(), fullPath) ??
@@ -449,7 +474,7 @@ function withNodeIo(imports = {}) {
449
474
 
450
475
  const wasmPathArg = process.argv[2];
451
476
  if (!wasmPathArg) {
452
- process.stderr.write("usage: node ./.as-test/runners/default.run.js <file.wasm>\\n");
477
+ process.stderr.write("usage: node ./.as-test/runners/default.bindings.js <file.wasm>\\n");
453
478
  process.exit(1);
454
479
  }
455
480
 
@@ -13,9 +13,11 @@ class DefaultReporter {
13
13
  this.renderedLines = 0;
14
14
  this.fileHasWarning = false;
15
15
  this.verboseMode = false;
16
+ this.cleanMode = false;
16
17
  }
17
18
  canRewriteLine() {
18
- return Boolean(this.context.stdout.isTTY);
19
+ return (!this.cleanMode &&
20
+ Boolean(this.context.stdout.isTTY));
19
21
  }
20
22
  badgeRunning() {
21
23
  return chalk.bgBlackBright.white(" .... ");
@@ -115,18 +117,15 @@ class DefaultReporter {
115
117
  }
116
118
  onRunStart(event) {
117
119
  this.verboseMode = Boolean(event.verbose);
118
- if (event.clean)
119
- return;
120
- if (event.snapshotEnabled) {
121
- this.context.stdout.write(chalk.bgBlue(" SNAPSHOT ") +
122
- ` ${chalk.dim(event.updateSnapshots ? "update mode enabled" : "read-only mode")}\n\n`);
123
- }
120
+ this.cleanMode = Boolean(event.clean);
124
121
  }
125
122
  onFileStart(event) {
126
123
  this.currentFile = event.file;
127
124
  this.openSuites = [];
128
125
  this.verboseSuites = [];
129
126
  this.fileHasWarning = false;
127
+ if (this.cleanMode)
128
+ return;
130
129
  if (this.verboseMode && this.canRewriteLine()) {
131
130
  this.renderVerboseState();
132
131
  return;
@@ -157,6 +156,8 @@ class DefaultReporter {
157
156
  this.fileHasWarning = false;
158
157
  }
159
158
  onSuiteStart(event) {
159
+ if (this.cleanMode)
160
+ return;
160
161
  const depth = Math.max(event.depth, 0);
161
162
  if (this.verboseMode && this.canRewriteLine()) {
162
163
  if (this.currentFile !== event.file)
@@ -180,6 +181,8 @@ class DefaultReporter {
180
181
  this.renderLiveState();
181
182
  }
182
183
  onSuiteEnd(event) {
184
+ if (this.cleanMode)
185
+ return;
183
186
  const depth = Math.max(event.depth, 0);
184
187
  const verdict = String(event.verdict ?? "none");
185
188
  if (this.verboseMode && this.canRewriteLine()) {
@@ -239,7 +242,7 @@ class DefaultReporter {
239
242
  renderCoveragePoints(event.coverageSummary.files);
240
243
  }
241
244
  }
242
- renderTotals(event.stats);
245
+ renderTotals(event.stats, event);
243
246
  }
244
247
  }
245
248
  function renderFailedSuites(failedEntries) {
@@ -311,7 +314,7 @@ function renderSnapshotSummary(snapshotSummary) {
311
314
  console.log("");
312
315
  console.log(`${chalk.bold("Snapshots:")} ${chalk.greenBright(snapshotSummary.matched)} matched, ${chalk.blueBright(snapshotSummary.created)} created, ${chalk.blueBright(snapshotSummary.updated)} updated, ${snapshotSummary.failed ? chalk.red(snapshotSummary.failed) : chalk.greenBright("0")} failed`);
313
316
  }
314
- function renderTotals(stats) {
317
+ function renderTotals(stats, event) {
315
318
  console.log("");
316
319
  process.stdout.write(chalk.bold("Files: "));
317
320
  process.stdout.write(stats.failedFiles
@@ -344,8 +347,22 @@ function renderTotals(stats) {
344
347
  ? chalk.gray(stats.skippedTests + " skipped")
345
348
  : chalk.gray("0 skipped")));
346
349
  process.stdout.write(", " + (stats.failedTests + stats.passedTests + stats.skippedTests) + " total\n");
350
+ if (event.modeSummary) {
351
+ renderModeSummary(event.modeSummary);
352
+ }
347
353
  process.stdout.write(chalk.bold("Time: ") + formatTime(stats.time) + "\n");
348
354
  }
355
+ function renderModeSummary(summary) {
356
+ process.stdout.write(chalk.bold("Modes: "));
357
+ process.stdout.write(summary.failed
358
+ ? chalk.bold.red(summary.failed + " failed")
359
+ : chalk.bold.greenBright("0 failed"));
360
+ process.stdout.write(", " +
361
+ (summary.skipped
362
+ ? chalk.gray(summary.skipped + " skipped")
363
+ : chalk.gray("0 skipped")));
364
+ process.stdout.write(", " + summary.total + " total\n");
365
+ }
349
366
  function renderCoverageSummary(summary) {
350
367
  const pct = summary.total
351
368
  ? ((summary.covered * 100) / summary.total).toFixed(2)
@@ -0,0 +1,294 @@
1
+ import * as path from "path";
2
+ import { mkdirSync, writeFileSync } from "fs";
3
+ export function createTapReporter(context, config = {}) {
4
+ return new TapReporter(context, normalizeTapConfig(config));
5
+ }
6
+ export const createReporter = (context) => {
7
+ return createTapReporter(context);
8
+ };
9
+ class TapReporter {
10
+ constructor(context, config) {
11
+ this.context = context;
12
+ this.config = config;
13
+ }
14
+ onRunComplete(event) {
15
+ const points = collectTapPoints(event.reports);
16
+ const output = buildTapDocument(points);
17
+ this.context.stdout.write(output);
18
+ for (const point of points) {
19
+ if (point.status != "fail")
20
+ continue;
21
+ emitGitHubAnnotation(this.context, point);
22
+ }
23
+ this.writeArtifacts(points, output);
24
+ }
25
+ writeArtifacts(points, output) {
26
+ if (this.config.mode == "per-file") {
27
+ this.writePerFileArtifacts(points);
28
+ return;
29
+ }
30
+ const outFile = path.resolve(process.cwd(), this.config.outFile);
31
+ mkdirSync(path.dirname(outFile), { recursive: true });
32
+ writeFileSync(outFile, output);
33
+ }
34
+ writePerFileArtifacts(points) {
35
+ const groups = new Map();
36
+ for (const point of points) {
37
+ const key = point.file?.length ? point.file : "unknown";
38
+ if (!groups.has(key))
39
+ groups.set(key, []);
40
+ groups.get(key).push(point);
41
+ }
42
+ let unknownIndex = 0;
43
+ const outDir = path.resolve(process.cwd(), this.config.outDir);
44
+ mkdirSync(outDir, { recursive: true });
45
+ for (const [fileKey, filePoints] of groups) {
46
+ const fileName = fileKey == "unknown"
47
+ ? `unknown-${++unknownIndex}.tap`
48
+ : toTapFileName(fileKey);
49
+ const outFile = path.join(outDir, fileName);
50
+ writeFileSync(outFile, buildTapDocument(filePoints));
51
+ }
52
+ }
53
+ }
54
+ function normalizeTapConfig(config) {
55
+ const mode = config.mode == "per-file" ? "per-file" : "single-file";
56
+ const outDir = typeof config.outDir == "string" && config.outDir.trim().length
57
+ ? config.outDir.trim()
58
+ : "./.as-test/reports";
59
+ const outFile = typeof config.outFile == "string" && config.outFile.trim().length
60
+ ? config.outFile.trim()
61
+ : path.join(outDir, "report.tap");
62
+ return {
63
+ mode,
64
+ outDir,
65
+ outFile,
66
+ };
67
+ }
68
+ function toTapFileName(file) {
69
+ const normalized = file
70
+ .replace(/^[.\\/]+/, "")
71
+ .replace(/[\\/]/g, "__")
72
+ .replace(/\.[^.]+$/, "");
73
+ return `${normalized}.tap`;
74
+ }
75
+ function buildTapDocument(points) {
76
+ const totals = {
77
+ pass: 0,
78
+ fail: 0,
79
+ skip: 0,
80
+ };
81
+ const lines = [];
82
+ lines.push("TAP version 13");
83
+ lines.push(`1..${points.length}`);
84
+ for (let i = 0; i < points.length; i++) {
85
+ const point = points[i];
86
+ const id = i + 1;
87
+ const name = sanitizeTap(point.name.length ? point.name : `test ${id}`);
88
+ if (point.status == "fail") {
89
+ totals.fail++;
90
+ lines.push(`not ok ${id} - ${name}`);
91
+ lines.push(...buildFailDetails(point));
92
+ continue;
93
+ }
94
+ if (point.status == "skip") {
95
+ totals.skip++;
96
+ lines.push(`ok ${id} - ${name} # SKIP`);
97
+ continue;
98
+ }
99
+ totals.pass++;
100
+ lines.push(`ok ${id} - ${name}`);
101
+ }
102
+ lines.push(`# tests ${points.length}`);
103
+ lines.push(`# pass ${totals.pass}`);
104
+ if (totals.skip) {
105
+ lines.push(`# skip ${totals.skip}`);
106
+ }
107
+ lines.push(`# fail ${totals.fail}`);
108
+ return lines.join("\n") + "\n";
109
+ }
110
+ function buildFailDetails(point) {
111
+ const lines = [" ---", ` message: ${JSON.stringify(point.message ?? "assertion failed")}`];
112
+ if (point.file) {
113
+ lines.push(` file: ${JSON.stringify(point.file)}`);
114
+ }
115
+ if (point.line) {
116
+ lines.push(` line: ${point.line}`);
117
+ }
118
+ if (point.column) {
119
+ lines.push(` column: ${point.column}`);
120
+ }
121
+ if (point.matcher) {
122
+ lines.push(` matcher: ${JSON.stringify(point.matcher)}`);
123
+ }
124
+ if (point.expected != null) {
125
+ lines.push(` expected: ${JSON.stringify(point.expected)}`);
126
+ }
127
+ if (point.actual != null) {
128
+ lines.push(` actual: ${JSON.stringify(point.actual)}`);
129
+ }
130
+ if (point.durationMs != null) {
131
+ lines.push(` duration_ms: ${Math.round(point.durationMs * 1000) / 1000}`);
132
+ }
133
+ lines.push(" ...");
134
+ return lines;
135
+ }
136
+ function collectTapPoints(reports) {
137
+ const points = [];
138
+ if (!Array.isArray(reports))
139
+ return points;
140
+ for (const report of reports) {
141
+ const reportAny = report;
142
+ const file = String(reportAny.file ?? "");
143
+ const suites = Array.isArray(reportAny.suites)
144
+ ? reportAny.suites
145
+ : [];
146
+ for (const suite of suites) {
147
+ collectTapPointsFromSuite(suite, file, [], points);
148
+ }
149
+ }
150
+ return points;
151
+ }
152
+ function collectTapPointsFromSuite(suite, file, pathStack, points) {
153
+ const suiteAny = suite;
154
+ const description = String(suiteAny.description ?? "suite");
155
+ const fullPath = [...pathStack, description];
156
+ const localFile = suiteAny.file ? String(suiteAny.file) : file;
157
+ const childSuites = Array.isArray(suiteAny.suites)
158
+ ? suiteAny.suites
159
+ : [];
160
+ const tests = Array.isArray(suiteAny.tests)
161
+ ? suiteAny.tests
162
+ : [];
163
+ const suiteKind = String(suiteAny.kind ?? "");
164
+ const durationMs = suiteDuration(suiteAny.time);
165
+ if (tests.length > 0) {
166
+ for (let i = 0; i < tests.length; i++) {
167
+ const test = tests[i];
168
+ const location = parseLocation(test.location);
169
+ const name = tests.length > 1
170
+ ? `${fullPath.join(" > ")} #${i + 1}`
171
+ : fullPath.join(" > ");
172
+ const status = normalizeStatus(test.verdict);
173
+ const matcher = stringifyValue(test.instr);
174
+ const expected = stringifyValue(test.right);
175
+ const actual = stringifyValue(test.left);
176
+ const message = buildFailureMessage(stringifyValue(test.message), matcher, expected, actual);
177
+ points.push({
178
+ name,
179
+ status,
180
+ file: localFile,
181
+ line: location.line,
182
+ column: location.column,
183
+ matcher,
184
+ expected,
185
+ actual,
186
+ message,
187
+ durationMs,
188
+ });
189
+ }
190
+ }
191
+ else if (childSuites.length == 0 &&
192
+ (suiteKind == "test" ||
193
+ suiteKind == "it" ||
194
+ suiteKind == "xtest" ||
195
+ suiteKind == "xit")) {
196
+ points.push({
197
+ name: fullPath.join(" > "),
198
+ status: normalizeStatus(suiteAny.verdict),
199
+ file: localFile,
200
+ durationMs,
201
+ });
202
+ }
203
+ for (const child of childSuites) {
204
+ collectTapPointsFromSuite(child, localFile, fullPath, points);
205
+ }
206
+ }
207
+ function suiteDuration(value) {
208
+ const time = value;
209
+ if (!time)
210
+ return undefined;
211
+ const start = Number(time.start ?? 0);
212
+ const end = Number(time.end ?? 0);
213
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
214
+ return undefined;
215
+ }
216
+ return end - start;
217
+ }
218
+ function parseLocation(value) {
219
+ const text = String(value ?? "").trim();
220
+ if (!text.length)
221
+ return {};
222
+ const match = /^(\d+)(?::(\d+))?$/.exec(text);
223
+ if (!match)
224
+ return {};
225
+ const line = Number(match[1]);
226
+ const column = match[2] ? Number(match[2]) : undefined;
227
+ return {
228
+ line: Number.isFinite(line) && line > 0 ? line : undefined,
229
+ column: typeof column == "number" && Number.isFinite(column) && column > 0
230
+ ? column
231
+ : undefined,
232
+ };
233
+ }
234
+ function normalizeStatus(verdict) {
235
+ const value = String(verdict ?? "none");
236
+ if (value == "fail")
237
+ return "fail";
238
+ if (value == "ok")
239
+ return "ok";
240
+ return "skip";
241
+ }
242
+ function stringifyValue(value) {
243
+ if (value == null)
244
+ return "";
245
+ if (typeof value == "string")
246
+ return value;
247
+ try {
248
+ return JSON.stringify(value);
249
+ }
250
+ catch {
251
+ return String(value);
252
+ }
253
+ }
254
+ function buildFailureMessage(message, matcher, expected, actual) {
255
+ if (message.length)
256
+ return message;
257
+ if (matcher.length && expected.length && actual.length) {
258
+ return `${matcher} expected ${expected} but received ${actual}`;
259
+ }
260
+ if (matcher.length)
261
+ return `${matcher} failed`;
262
+ return "assertion failed";
263
+ }
264
+ function sanitizeTap(name) {
265
+ return name.replace(/\s+/g, " ").replace(/#/g, "\\#").trim();
266
+ }
267
+ function emitGitHubAnnotation(context, point) {
268
+ if (process.env.GITHUB_ACTIONS != "true" || point.status != "fail")
269
+ return;
270
+ const properties = [];
271
+ if (point.file) {
272
+ properties.push(`file=${escapeGithubValue(point.file, true)}`);
273
+ }
274
+ if (point.line) {
275
+ properties.push(`line=${point.line}`);
276
+ }
277
+ if (point.column) {
278
+ properties.push(`col=${point.column}`);
279
+ }
280
+ properties.push(`title=${escapeGithubValue("as-test", true)}`);
281
+ const message = point.message?.length ? point.message : "assertion failed";
282
+ const detail = `${message} | test=${point.name}`;
283
+ context.stdout.write(`::error ${properties.join(",")}::${escapeGithubValue(detail)}\n`);
284
+ }
285
+ function escapeGithubValue(value, property = false) {
286
+ let output = value
287
+ .replace(/%/g, "%25")
288
+ .replace(/\r/g, "%0D")
289
+ .replace(/\n/g, "%0A");
290
+ if (property) {
291
+ output = output.replace(/:/g, "%3A").replace(/,/g, "%2C");
292
+ }
293
+ return output;
294
+ }