jest-roblox-assassin 1.0.0 → 1.1.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/README.md +23 -13
- package/package.json +16 -12
- package/src/cache.js +1 -0
- package/src/cli.js +160 -774
- package/src/discovery.js +355 -0
- package/src/rewriter.js +454 -257
- package/src/runJestRoblox.js +838 -0
- package/src/sourcemap.js +243 -0
package/src/discovery.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import process from "process";
|
|
4
|
+
import { createSourcemap } from "./sourcemap.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gets subdirectories of a directory, excluding hidden dirs and node_modules.
|
|
8
|
+
* @param {string} dir The directory to scan.
|
|
9
|
+
* @returns {string[]} Array of subdirectory paths.
|
|
10
|
+
*/
|
|
11
|
+
function getSubdirs(dir) {
|
|
12
|
+
try {
|
|
13
|
+
return fs
|
|
14
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
15
|
+
.filter(
|
|
16
|
+
(dirent) =>
|
|
17
|
+
dirent.isDirectory() &&
|
|
18
|
+
!dirent.name.startsWith(".") &&
|
|
19
|
+
dirent.name !== "node_modules"
|
|
20
|
+
)
|
|
21
|
+
.map((dirent) => path.join(dir, dirent.name));
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Searches upwards from startDir for a file matching the predicate.
|
|
29
|
+
* @param {string} startDir The directory to start searching from.
|
|
30
|
+
* @param {(filePath: string) => boolean} predicate Function to test each file.
|
|
31
|
+
* @returns {string | null} The path to the first matching file, or null.
|
|
32
|
+
*/
|
|
33
|
+
function findFileUpwards(startDir, predicate) {
|
|
34
|
+
let current = startDir;
|
|
35
|
+
while (current !== path.parse(current).root) {
|
|
36
|
+
try {
|
|
37
|
+
const files = fs.readdirSync(current);
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
const filePath = path.join(current, file);
|
|
40
|
+
if (fs.statSync(filePath).isFile() && predicate(filePath)) {
|
|
41
|
+
return filePath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore errors
|
|
46
|
+
}
|
|
47
|
+
current = path.dirname(current);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Searches up to maxDepth levels deep from startDir for a file matching the predicate.
|
|
54
|
+
* @param {string} startDir The directory to start searching from.
|
|
55
|
+
* @param {(filePath: string) => boolean} predicate Function to test each file.
|
|
56
|
+
* @param {number} maxDepth Maximum depth to search.
|
|
57
|
+
* @returns {string | null} The path to the first matching file, or null.
|
|
58
|
+
*/
|
|
59
|
+
function findFileDeep(startDir, predicate, maxDepth = 2) {
|
|
60
|
+
const search = (dirs, depth) => {
|
|
61
|
+
if (depth > maxDepth) return null;
|
|
62
|
+
for (const dir of dirs) {
|
|
63
|
+
try {
|
|
64
|
+
const files = fs.readdirSync(dir);
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const filePath = path.join(dir, file);
|
|
67
|
+
if (fs.statSync(filePath).isFile() && predicate(filePath)) {
|
|
68
|
+
return filePath;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore errors
|
|
73
|
+
}
|
|
74
|
+
const subdirs = getSubdirs(dir);
|
|
75
|
+
const result = search(subdirs, depth + 1);
|
|
76
|
+
if (result) return result;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
return search(getSubdirs(startDir), 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Discovers the Rojo project file and root directory.
|
|
85
|
+
* @param {string | null} projectFile Optional path to a known Rojo project file.
|
|
86
|
+
*/
|
|
87
|
+
export function discoverRojoProject(projectFile = null) {
|
|
88
|
+
if (projectFile && fs.existsSync(projectFile)) {
|
|
89
|
+
return {
|
|
90
|
+
file: projectFile,
|
|
91
|
+
root: path.dirname(projectFile),
|
|
92
|
+
sourcemap: createSourcemap(projectFile),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const startDir = process.cwd();
|
|
97
|
+
const predicate = (filePath) =>
|
|
98
|
+
path.basename(filePath) === "default.project.json";
|
|
99
|
+
|
|
100
|
+
// Search upwards first
|
|
101
|
+
projectFile = findFileUpwards(startDir, predicate);
|
|
102
|
+
|
|
103
|
+
if (!projectFile) {
|
|
104
|
+
// Search up to 2 levels deep
|
|
105
|
+
projectFile = findFileDeep(startDir, predicate, 2);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const projectRoot = projectFile ? path.dirname(projectFile) : startDir;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
file: projectFile,
|
|
112
|
+
root: projectRoot,
|
|
113
|
+
sourcemap: projectFile ? createSourcemap(projectFile) : undefined,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Discovers a Roblox place file (.rbxl or .rbxlx).
|
|
119
|
+
* Searches upwards from cwd, then up to 2 levels deep.
|
|
120
|
+
* @param {string} cwd The current working directory to start searching from.
|
|
121
|
+
* @returns {string | null} The path to the place file, or null if not found.
|
|
122
|
+
*/
|
|
123
|
+
export function findPlaceFile(cwd = process.cwd()) {
|
|
124
|
+
const placeExtensions = [".rbxl", ".rbxlx"];
|
|
125
|
+
|
|
126
|
+
const isPlaceFile = (filePath) => {
|
|
127
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
128
|
+
return placeExtensions.includes(ext);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Search upwards first
|
|
132
|
+
let placeFile = findFileUpwards(cwd, isPlaceFile);
|
|
133
|
+
|
|
134
|
+
if (!placeFile) {
|
|
135
|
+
// Search up to 2 levels deep
|
|
136
|
+
placeFile = findFileDeep(cwd, isPlaceFile, 2);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return placeFile;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Discovers TypeScript compiler options from tsconfig.json.
|
|
144
|
+
* If not found, defaults to rootDir: "src" and outDir: "out".
|
|
145
|
+
* If the specified directories do not exist, will default rootDir to "." and outDir to rootDir.
|
|
146
|
+
* @param {string} file Optional path to a known tsconfig.json file.
|
|
147
|
+
* @returns {{ rootDir: string, outDir: string }} The discovered rootDir and outDir.
|
|
148
|
+
*/
|
|
149
|
+
export function discoverCompilerOptions(file = null) {
|
|
150
|
+
let configPath;
|
|
151
|
+
if (!file || !fs.existsSync(file)) {
|
|
152
|
+
configPath = path.resolve(process.cwd(), "tsconfig.json");
|
|
153
|
+
} else {
|
|
154
|
+
configPath = path.resolve(file);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const configDir = path.dirname(configPath);
|
|
158
|
+
|
|
159
|
+
const stripJsonComments = (text) =>
|
|
160
|
+
text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
161
|
+
|
|
162
|
+
const readJsonWithComments = (jsonPath) => {
|
|
163
|
+
if (!fs.existsSync(jsonPath)) return undefined;
|
|
164
|
+
const raw = fs.readFileSync(jsonPath, "utf-8");
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(stripJsonComments(raw));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const compilerOptions = readJsonWithComments(configPath)?.compilerOptions || {};
|
|
173
|
+
let rootDir = compilerOptions.rootDir || "src";
|
|
174
|
+
let outDir = compilerOptions.outDir || "out";
|
|
175
|
+
|
|
176
|
+
const resolveConfigPath = (value) =>
|
|
177
|
+
path.isAbsolute(value) ? value : path.join(configDir, value);
|
|
178
|
+
|
|
179
|
+
const rootDirPath = resolveConfigPath(rootDir);
|
|
180
|
+
const outDirPath = resolveConfigPath(outDir);
|
|
181
|
+
|
|
182
|
+
if (!fs.existsSync(rootDirPath)) {
|
|
183
|
+
rootDir = ".";
|
|
184
|
+
}
|
|
185
|
+
if (!fs.existsSync(outDirPath)) {
|
|
186
|
+
outDir = rootDir;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
rootDir,
|
|
191
|
+
outDir,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Discovers test files from the filesystem based on jest options.
|
|
197
|
+
* @param {{ rootDir: string, outDir: string }} compilerOptions The TypeScript compiler options.
|
|
198
|
+
* @param {object} jestOptions The Jest configuration options.
|
|
199
|
+
* @returns {string[]} An array of discovered test file paths in roblox-jest format.
|
|
200
|
+
*/
|
|
201
|
+
export function discoverTestFilesFromFilesystem(compilerOptions, jestOptions) {
|
|
202
|
+
const { rootDir, outDir } = compilerOptions;
|
|
203
|
+
|
|
204
|
+
const outDirPath = path.join(outDir);
|
|
205
|
+
|
|
206
|
+
if (!fs.existsSync(outDirPath)) {
|
|
207
|
+
if (jestOptions.verbose) {
|
|
208
|
+
console.log(`Output directory not found: ${outDirPath}`);
|
|
209
|
+
}
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Default test patterns if none specified
|
|
214
|
+
const defaultTestMatch = [
|
|
215
|
+
"**/__tests__/**/*.[jt]s?(x)",
|
|
216
|
+
"**/?(*.)+(spec|test).[jt]s?(x)",
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const testMatchPatterns =
|
|
220
|
+
jestOptions.testMatch && jestOptions.testMatch.length > 0
|
|
221
|
+
? jestOptions.testMatch
|
|
222
|
+
: defaultTestMatch;
|
|
223
|
+
|
|
224
|
+
// Convert glob patterns to work with .luau files in outDir
|
|
225
|
+
const luauPatterns = testMatchPatterns.map((pattern) => {
|
|
226
|
+
// Replace js/ts extensions with luau
|
|
227
|
+
return pattern
|
|
228
|
+
.replace(/\.\[jt\]s\?\(x\)/g, ".luau")
|
|
229
|
+
.replace(/\.\[jt\]sx?/g, ".luau")
|
|
230
|
+
.replace(/\.tsx?/g, ".luau")
|
|
231
|
+
.replace(/\.jsx?/g, ".luau")
|
|
232
|
+
.replace(/\.ts/g, ".luau")
|
|
233
|
+
.replace(/\.js/g, ".luau");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Add patterns for native .luau test files
|
|
237
|
+
if (
|
|
238
|
+
!luauPatterns.some(
|
|
239
|
+
(p) => p.includes(".spec.luau") || p.includes(".test.luau")
|
|
240
|
+
)
|
|
241
|
+
) {
|
|
242
|
+
luauPatterns.push("**/__tests__/**/*.spec.luau");
|
|
243
|
+
luauPatterns.push("**/__tests__/**/*.test.luau");
|
|
244
|
+
luauPatterns.push("**/*.spec.luau");
|
|
245
|
+
luauPatterns.push("**/*.test.luau");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const testFiles = [];
|
|
249
|
+
|
|
250
|
+
// Simple recursive file finder with glob-like pattern matching
|
|
251
|
+
function findFiles(dir, baseDir) {
|
|
252
|
+
try {
|
|
253
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
const fullPath = path.join(dir, entry.name);
|
|
256
|
+
const relativePath = path
|
|
257
|
+
.relative(baseDir, fullPath)
|
|
258
|
+
.replace(/\\/g, "/");
|
|
259
|
+
|
|
260
|
+
if (entry.isDirectory()) {
|
|
261
|
+
// Skip node_modules and hidden directories
|
|
262
|
+
if (
|
|
263
|
+
!entry.name.startsWith(".") &&
|
|
264
|
+
entry.name !== "node_modules"
|
|
265
|
+
) {
|
|
266
|
+
findFiles(fullPath, baseDir);
|
|
267
|
+
}
|
|
268
|
+
} else if (entry.isFile() && entry.name.endsWith(".luau")) {
|
|
269
|
+
// Check if file matches any test pattern
|
|
270
|
+
const isTestFile = luauPatterns.some((pattern) => {
|
|
271
|
+
return matchGlobPattern(relativePath, pattern);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (isTestFile) {
|
|
275
|
+
testFiles.push(relativePath);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
// Ignore errors reading directories
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Simple glob pattern matcher
|
|
285
|
+
function matchGlobPattern(filePath, pattern) {
|
|
286
|
+
// Handle common glob patterns
|
|
287
|
+
let regexPattern = pattern
|
|
288
|
+
.replace(/\./g, "\\.")
|
|
289
|
+
.replace(/\*\*/g, "{{GLOBSTAR}}")
|
|
290
|
+
.replace(/\*/g, "[^/]*")
|
|
291
|
+
.replace(/{{GLOBSTAR}}/g, ".*")
|
|
292
|
+
.replace(/\?/g, ".");
|
|
293
|
+
|
|
294
|
+
// Handle optional groups like ?(x)
|
|
295
|
+
regexPattern = regexPattern.replace(/\\\?\(([^)]+)\)/g, "($1)?");
|
|
296
|
+
|
|
297
|
+
// Handle pattern groups like +(spec|test)
|
|
298
|
+
regexPattern = regexPattern.replace(/\+\(([^)]+)\)/g, "($1)+");
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const regex = new RegExp(`^${regexPattern}$`, "i");
|
|
302
|
+
return regex.test(filePath);
|
|
303
|
+
} catch {
|
|
304
|
+
// If pattern is invalid, fall back to simple check
|
|
305
|
+
return filePath.includes(".spec.") || filePath.includes(".test.");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
findFiles(outDirPath, outDirPath);
|
|
310
|
+
|
|
311
|
+
// Apply testPathIgnorePatterns if specified
|
|
312
|
+
let filteredFiles = testFiles;
|
|
313
|
+
if (
|
|
314
|
+
jestOptions.testPathIgnorePatterns &&
|
|
315
|
+
jestOptions.testPathIgnorePatterns.length > 0
|
|
316
|
+
) {
|
|
317
|
+
filteredFiles = testFiles.filter((file) => {
|
|
318
|
+
return !jestOptions.testPathIgnorePatterns.some((pattern) => {
|
|
319
|
+
try {
|
|
320
|
+
const regex = new RegExp(pattern);
|
|
321
|
+
return regex.test(file);
|
|
322
|
+
} catch {
|
|
323
|
+
return file.includes(pattern);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Apply testPathPattern filter if specified
|
|
330
|
+
if (jestOptions.testPathPattern) {
|
|
331
|
+
const pathPatternRegex = new RegExp(jestOptions.testPathPattern, "i");
|
|
332
|
+
filteredFiles = filteredFiles.filter((file) =>
|
|
333
|
+
pathPatternRegex.test(file)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Convert to roblox-jest path format (e.g., "src/__tests__/add.spec")
|
|
338
|
+
// These paths are relative to projectRoot, use forward slashes, and have no extension
|
|
339
|
+
const jestPaths = filteredFiles.map((file) => {
|
|
340
|
+
// Remove .luau extension
|
|
341
|
+
const withoutExt = file.replace(/\.luau$/, "");
|
|
342
|
+
// Normalize to forward slashes
|
|
343
|
+
const normalizedPath = withoutExt.replace(/\\/g, "/");
|
|
344
|
+
// Prepend the rootDir (since outDir maps to rootDir in the place)
|
|
345
|
+
return `${rootDir}/${normalizedPath}`;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (jestOptions.verbose) {
|
|
349
|
+
console.log(
|
|
350
|
+
`Discovered ${jestPaths.length} test file(s) from filesystem`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return jestPaths;
|
|
355
|
+
}
|