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.
@@ -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
+ }