@triflare/fx 2.1.0-beta.1 → 2.1.0-beta.2

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Test-file glob matching.
3
+ *
4
+ * Kept free of Node built-ins so the browser-safe linter can share the same
5
+ * matcher as `fx test`, the build, and the VS Code extension.
6
+ */
7
+
8
+ /** Default test-file glob, relative to the project directory. */
9
+ export const DEFAULT_TEST_PATTERN = "test/**/*.test.tfx";
10
+
11
+ const REGEX_SPECIAL = /[.+^${}()|[\]\\]/g;
12
+
13
+ /** Escape regular-expression syntax so a glob is matched literally. */
14
+ function escapeRegex(text) {
15
+ return text.replace(REGEX_SPECIAL, "\\$&");
16
+ }
17
+
18
+ /**
19
+ * Translate a small glob (`**`, `*`, `?`) into an anchored regular expression.
20
+ * A double star followed by a slash also matches zero directories, so
21
+ * `test/**\/*.test.tfx` matches `test/score.test.tfx`.
22
+ *
23
+ * @param {string} pattern
24
+ * @returns {RegExp}
25
+ */
26
+ export function globToRegExp(pattern) {
27
+ const normalized = String(pattern).replace(/\\/g, "/").replace(/^\.\//, "");
28
+ let source = "";
29
+ for (let index = 0; index < normalized.length; index += 1) {
30
+ const char = normalized[index];
31
+ if (char === "*") {
32
+ if (normalized[index + 1] === "*") {
33
+ index += 1;
34
+ if (normalized[index + 1] === "/") {
35
+ index += 1;
36
+ source += "(?:.*/)?";
37
+ } else {
38
+ source += ".*";
39
+ }
40
+ } else {
41
+ source += "[^/]*";
42
+ }
43
+ } else if (char === "?") {
44
+ source += "[^/]";
45
+ } else {
46
+ source += escapeRegex(char);
47
+ }
48
+ }
49
+ return new RegExp(`^${source}$`);
50
+ }
51
+
52
+ /**
53
+ * Whether a project-relative path (posix separators) is a test file.
54
+ * @param {string} relativePath
55
+ * @param {string} [pattern]
56
+ */
57
+ export function matchesTestPattern(
58
+ relativePath,
59
+ pattern = DEFAULT_TEST_PATTERN,
60
+ ) {
61
+ const normalized = String(relativePath).replace(/\\/g, "/");
62
+ return globToRegExp(pattern).test(normalized);
63
+ }
64
+
65
+ /**
66
+ * Whether `file` matches `pattern` relative to `root`.
67
+ *
68
+ * `root` may be absent, in which case the path is matched as given. Both
69
+ * separators are accepted so callers can pass filesystem paths or URIs.
70
+ *
71
+ * @param {string} file
72
+ * @param {string|null|undefined} root
73
+ * @param {string} [pattern]
74
+ */
75
+ export function isTestPath(file, root, pattern = DEFAULT_TEST_PATTERN) {
76
+ if (!pattern) return false;
77
+ const normalized = String(file).replace(/\\/g, "/");
78
+ const base = root ? String(root).replace(/\\/g, "/").replace(/\/+$/, "") : "";
79
+ const relative =
80
+ base && normalized.startsWith(`${base}/`)
81
+ ? normalized.slice(base.length + 1)
82
+ : normalized;
83
+ return matchesTestPattern(relative, pattern);
84
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Test reporters.
3
+ *
4
+ * Every reporter consumes the runner's structured events and owns all
5
+ * formatting, so terminal, JSON, JUnit, and future editors see idential data.
6
+ */
7
+ import path from "node:path";
8
+
9
+ export const REPORTER_NAMES = ["default", "verbose", "dot", "json", "junit"];
10
+
11
+ const green = (color, text) => (color ? `\x1b[32m${text}\x1b[0m` : text);
12
+ const red = (color, text) => (color ? `\x1b[31m${text}\x1b[0m` : text);
13
+ const yellow = (color, text) => (color ? `\x1b[33m${text}\x1b[0m` : text);
14
+ const dim = (color, text) => (color ? `\x1b[2m${text}\x1b[0m` : text);
15
+ const bold = (color, text) => (color ? `\x1b[1m${text}\x1b[0m` : text);
16
+
17
+ /** Human-readable duration for terminal output. */
18
+ function seconds(ms) {
19
+ if (ms < 1000) return `${ms}ms`;
20
+ return `${(ms / 1000).toFixed(2)}s`;
21
+ }
22
+
23
+ /** Right-align a value in a fixed-width field. */
24
+ function pad(value, width) {
25
+ return String(value).padStart(width);
26
+ }
27
+
28
+ /** Summary block printed after a terminal run. */
29
+ function summarize(result) {
30
+ const lines = [];
31
+ const { counts } = result;
32
+ const files = `${dim(true, "Test Files")} ${
33
+ counts.failedFiles ? `${red(true, `${counts.failedFiles} failed`)} | ` : ""
34
+ }${green(true, `${counts.passedFiles} passed`)} (${counts.files})`;
35
+ const tests = `${dim(true, " Tests")} ${
36
+ counts.failed ? `${red(true, `${counts.failed} failed`)} | ` : ""
37
+ }${green(true, `${counts.passed} passed`)} (${counts.tests})`;
38
+ lines.push(files, tests);
39
+ if (counts.pending)
40
+ lines.push(`${dim(true, " Pending")} ${yellow(true, counts.pending)}`);
41
+ if (counts.errors)
42
+ lines.push(`${dim(true, " Errors")} ${red(true, counts.errors)}`);
43
+ const start = new Date(result.startTime);
44
+ lines.push(
45
+ `${dim(true, " Start at")} ${start.toLocaleTimeString([], {
46
+ hour12: false,
47
+ })}`,
48
+ );
49
+ lines.push(`${dim(true, " Duration")} ${seconds(result.durationMs)}`);
50
+ return lines.join("\n");
51
+ }
52
+
53
+ /** Failure detail blocks for a terminal run, with source locations. */
54
+ function failureBlocks(result, dir) {
55
+ const blocks = [];
56
+ for (const file of result.files) {
57
+ for (const test of file.tests) {
58
+ if (test.status !== "failed" && test.status !== "error") continue;
59
+ if (!test.error) continue;
60
+ const error = test.error;
61
+ // A test file is reported relative to the project, so resolve it before
62
+ // asking for a path relative to the project root.
63
+ const where = error.file
64
+ ? `${error.file}${error.line ? `:${error.line}:${error.column ?? 0}` : ""}`
65
+ : path.relative(dir, path.resolve(dir, file.file));
66
+ const label = test.status === "error" ? "ERROR" : "FAIL";
67
+ blocks.push(
68
+ [
69
+ red(true, `${label} ${file.file} > ${test.name}`),
70
+ `${error.name}: ${error.message}`,
71
+ dim(true, `❯ ${where}`),
72
+ ].join("\n"),
73
+ );
74
+ }
75
+ }
76
+ return blocks;
77
+ }
78
+
79
+ /** JSON document for `fx test --reporter json` and `--json`. */
80
+ export function jsonReport(result, { events = [], warnings = [] } = {}) {
81
+ return {
82
+ schemaVersion: 1,
83
+ command: "test",
84
+ ok: result.ok,
85
+ warnings,
86
+ data: { ...result, events },
87
+ };
88
+ }
89
+
90
+ /** Remove XML 1.0-forbidden C0 controls, keeping tab, newline and CR. */
91
+ function stripInvalidXmlChars(text) {
92
+ return [...String(text)]
93
+ .filter((char) => {
94
+ const code = char.charCodeAt(0);
95
+ return code === 0x9 || code === 0xa || code === 0xd || code >= 0x20;
96
+ })
97
+ .join("");
98
+ }
99
+
100
+ /** Escape text for use in JUnit attribute values and bodies. */
101
+ function escapeXml(text) {
102
+ // Terminal color sequences in an error message must not make the report
103
+ // unparseable, even when their control characters are escaped.
104
+ return stripInvalidXmlChars(text)
105
+ .replace(/&/g, "&amp;")
106
+ .replace(/</g, "&lt;")
107
+ .replace(/>/g, "&gt;")
108
+ .replace(/"/g, "&quot;")
109
+ .replace(/'/g, "&apos;");
110
+ }
111
+
112
+ /** JUnit XML for CI integrations. */
113
+ export function junitReport(result) {
114
+ const diagnostics = result.files.length ? [] : (result.diagnostics ?? []);
115
+ const files = diagnostics.length
116
+ ? [
117
+ {
118
+ file: "<project>",
119
+ durationMs: result.durationMs,
120
+ tests: diagnostics.map((diagnostic, index) => ({
121
+ name: `diagnostic ${index + 1}`,
122
+ status: "error",
123
+ durationMs: 0,
124
+ error: diagnostic,
125
+ })),
126
+ },
127
+ ]
128
+ : result.files;
129
+ const errors = files.reduce(
130
+ (total, file) =>
131
+ total + file.tests.filter((test) => test.status === "error").length,
132
+ 0,
133
+ );
134
+ const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
135
+ lines.push(
136
+ `<testsuites name="fx" tests="${result.counts.tests + diagnostics.length}" failures="${result.counts.failed}" errors="${errors}" time="${(result.durationMs / 1000).toFixed(3)}">`,
137
+ );
138
+ for (const file of files) {
139
+ lines.push(
140
+ ` <testsuite name="${escapeXml(file.file)}" tests="${file.tests.length}" failures="${file.tests.filter((t) => t.status === "failed").length}" errors="${file.tests.filter((t) => t.status === "error").length}" time="${(file.durationMs / 1000).toFixed(3)}">`,
141
+ );
142
+ for (const test of file.tests) {
143
+ const time = (test.durationMs / 1000).toFixed(3);
144
+ if (test.status === "failed") {
145
+ const message = test.error?.message ?? "failed";
146
+ lines.push(
147
+ ` <testcase name="${escapeXml(test.name)}" classname="${escapeXml(file.file)}" time="${time}"><failure message="${escapeXml(message)}" type="${escapeXml(test.error?.name ?? "AssertionError")}">${escapeXml(
148
+ `${test.error?.name ?? "Error"}: ${message}${test.error?.file ? `\n at ${test.error.file}:${test.error.line ?? 0}` : ""}`,
149
+ )}</failure></testcase>`,
150
+ );
151
+ } else if (test.status === "error") {
152
+ const message = test.error?.message ?? "execution error";
153
+ lines.push(
154
+ ` <testcase name="${escapeXml(test.name)}" classname="${escapeXml(file.file)}" time="${time}"><error message="${escapeXml(message)}" type="${escapeXml(test.error?.name ?? "Error")}">${escapeXml(message)}</error></testcase>`,
155
+ );
156
+ } else if (test.status === "pending") {
157
+ lines.push(
158
+ ` <testcase name="${escapeXml(test.name)}" classname="${escapeXml(file.file)}" time="${time}"><skipped/></testcase>`,
159
+ );
160
+ } else {
161
+ lines.push(
162
+ ` <testcase name="${escapeXml(test.name)}" classname="${escapeXml(file.file)}" time="${time}"/>`,
163
+ );
164
+ }
165
+ }
166
+ lines.push(" </testsuite>");
167
+ }
168
+ lines.push("</testsuites>");
169
+ return lines.join("\n");
170
+ }
171
+
172
+ /**
173
+ * @param {object} [options]
174
+ * @param {"default"|"verbose"|"dot"|"json"|"junit"} [options.name]
175
+ * @param {NodeJS.WriteStream} [options.stream]
176
+ * @param {boolean} [options.color]
177
+ * @param {boolean} [options.tty]
178
+ * @param {string} [options.dir] project root, for failure locations
179
+ */
180
+ export function createTestReporter({
181
+ name = "default",
182
+ stream = process.stdout,
183
+ color = false,
184
+ tty = false,
185
+ dir = process.cwd(),
186
+ } = {}) {
187
+ const events = [];
188
+ let liveLine = "";
189
+
190
+ const write = (text) => stream.write(text);
191
+ const clearLive = () => {
192
+ if (!liveLine) return;
193
+ write("\r\x1b[2K");
194
+ liveLine = "";
195
+ };
196
+ const live = (text) => {
197
+ clearLive();
198
+ liveLine = text;
199
+ write(`\r${text}`);
200
+ };
201
+
202
+ const onEvent = (event) => {
203
+ if (event.event === "run-start") events.length = 0;
204
+ events.push(event);
205
+ switch (event.event) {
206
+ case "run-start":
207
+ if (name === "default" || name === "verbose")
208
+ write(
209
+ `\n ${bold(color, "RUN")} ${dim(color, `v${event.version}`)} ${event.dir}\n\n`,
210
+ );
211
+ break;
212
+ case "discovered":
213
+ if (name === "default") {
214
+ for (const file of event.files)
215
+ for (const test of file.tests)
216
+ write(
217
+ ` ${dim(color, "PENDING")} ${file.file} > ${test.name}\n`,
218
+ );
219
+ } else if (name === "verbose") {
220
+ for (const file of event.files)
221
+ for (const test of file.tests)
222
+ write(
223
+ ` ${yellow(color, "↓")} ${file.file} > ${test.name} ${dim(color, test.status)}\n`,
224
+ );
225
+ }
226
+ break;
227
+ case "test-start":
228
+ if (name === "verbose")
229
+ write(
230
+ ` ${dim(color, "▸")} ${event.file} > ${event.name} ${dim(color, event.status)}\n`,
231
+ );
232
+ else if (name === "default") {
233
+ if (tty) live(dim(color, ` ▸ ${event.file} > ${event.name}`));
234
+ else
235
+ write(
236
+ ` ${dim(color, "RUNNING")} ${event.file} > ${event.name}\n`,
237
+ );
238
+ }
239
+ break;
240
+ case "test-result":
241
+ clearLive();
242
+ if (name === "verbose") {
243
+ const mark =
244
+ event.status === "passed"
245
+ ? green(color, "✓")
246
+ : event.status === "failed" || event.status === "error"
247
+ ? red(color, "×")
248
+ : yellow(color, "↓");
249
+ write(
250
+ ` ${mark} ${event.name} ${dim(color, seconds(event.durationMs))}\n`,
251
+ );
252
+ } else if (name === "default") {
253
+ const mark =
254
+ event.status === "passed"
255
+ ? green(color, "✓")
256
+ : event.status === "failed" || event.status === "error"
257
+ ? red(color, "×")
258
+ : yellow(color, "↓");
259
+ write(
260
+ ` ${mark} ${event.file} > ${event.name} ${dim(color, seconds(event.durationMs))}\n`,
261
+ );
262
+ } else if (name === "dot")
263
+ write(
264
+ event.status === "passed"
265
+ ? green(color, "·")
266
+ : event.status === "failed" || event.status === "error"
267
+ ? red(color, "×")
268
+ : yellow(color, "↓"),
269
+ );
270
+ break;
271
+ case "file-result": {
272
+ if (name !== "default" && name !== "verbose") break;
273
+ const failed = event.tests.filter((test) => test.status === "failed");
274
+ const errors = event.tests.filter((test) => test.status === "error");
275
+ const detail = [
276
+ `${event.tests.length} ${event.tests.length === 1 ? "test" : "tests"}`,
277
+ ...(failed.length ? [`${failed.length} failed`] : []),
278
+ ...(errors.length ? [`${errors.length} errors`] : []),
279
+ ].join(" | ");
280
+ const mark =
281
+ event.status === "passed" ? green(color, "✓") : red(color, "×");
282
+ write(
283
+ `${mark} ${event.file} ${dim(color, `(${detail}) ${seconds(event.durationMs)}`)}\n`,
284
+ );
285
+ break;
286
+ }
287
+ case "diagnostic":
288
+ clearLive();
289
+ // JSON and JUnit own the whole stream: a plain diagnostic line would
290
+ // make the document unparseable. Both reporters carry diagnostics in
291
+ // their structured payload instead.
292
+ if (name === "json" || name === "junit") break;
293
+ write(
294
+ `${red(color, "×")} ${event.file ?? "<project>"}${
295
+ event.line ? `:${event.line}` : ""
296
+ } ${event.message}\n`,
297
+ );
298
+ break;
299
+ case "run-end":
300
+ clearLive();
301
+ if (name === "dot") write("\n");
302
+ if (name === "json") {
303
+ write(`${JSON.stringify(jsonReport(event.result, { events }))}\n`);
304
+ break;
305
+ }
306
+ if (name === "junit") {
307
+ write(`${junitReport(event.result)}\n`);
308
+ break;
309
+ }
310
+ if (name === "default" || name === "verbose") {
311
+ const blocks = failureBlocks(event.result, dir);
312
+ if (blocks.length) {
313
+ const totalErrors =
314
+ event.result.counts.failed + (event.result.counts.errors ?? 0);
315
+ write(`\n${red(color, `⎯⎯⎯ Failed Tests ${totalErrors} ⎯⎯⎯`)}\n\n`);
316
+ write(`${blocks.join("\n\n")}\n`);
317
+ }
318
+ for (const diagnostic of event.result.diagnostics)
319
+ if (!event.result.files.length)
320
+ write(`${red(color, "×")} ${diagnostic.message}\n`);
321
+ write(`\n${summarize(event.result)}\n\n`);
322
+ }
323
+ break;
324
+ default:
325
+ break;
326
+ }
327
+ };
328
+
329
+ return { name, onEvent, events };
330
+ }
331
+
332
+ export { pad, seconds };