|
| 1 | +import type { Dirent } from "node:fs"; |
| 2 | + |
1 | 3 | import assert from "node:assert/strict"; |
2 | 4 | import fsPromises from "node:fs/promises"; |
3 | 5 | import path from "node:path"; |
@@ -32,10 +34,35 @@ import { |
32 | 34 | readJsonFileAsStream, |
33 | 35 | writeJsonFileAsStream, |
34 | 36 | mkdtemp, |
| 37 | + readdirOrEmpty, |
35 | 38 | } from "../src/fs.js"; |
36 | 39 |
|
37 | 40 | import { useTmpDir } from "./helpers/fs.js"; |
38 | 41 |
|
| 42 | +function withUnknownDirentType(dirent: Dirent): Dirent { |
| 43 | + const cloned: Dirent = Object.create(dirent); |
| 44 | + cloned.isDirectory = () => false; |
| 45 | + cloned.isFile = () => false; |
| 46 | + cloned.isSymbolicLink = () => false; |
| 47 | + cloned.isBlockDevice = () => false; |
| 48 | + cloned.isCharacterDevice = () => false; |
| 49 | + cloned.isFIFO = () => false; |
| 50 | + cloned.isSocket = () => false; |
| 51 | + return cloned; |
| 52 | +} |
| 53 | + |
| 54 | +function withKnownFileDirentType(dirent: Dirent): Dirent { |
| 55 | + const cloned: Dirent = Object.create(dirent); |
| 56 | + cloned.isDirectory = () => false; |
| 57 | + cloned.isFile = () => true; |
| 58 | + cloned.isSymbolicLink = () => false; |
| 59 | + cloned.isBlockDevice = () => false; |
| 60 | + cloned.isCharacterDevice = () => false; |
| 61 | + cloned.isFIFO = () => false; |
| 62 | + cloned.isSocket = () => false; |
| 63 | + return cloned; |
| 64 | +} |
| 65 | + |
39 | 66 | describe("File system utils", () => { |
40 | 67 | const getTmpDir = useTmpDir("fs"); |
41 | 68 |
|
@@ -209,6 +236,78 @@ describe("File system utils", () => { |
209 | 236 | ); |
210 | 237 | }); |
211 | 238 |
|
| 239 | + it("Should recurse into directories when the dirent type is unknown", async (t) => { |
| 240 | + const originalReaddir = fsPromises.readdir; |
| 241 | + const originalLstat = fsPromises.lstat; |
| 242 | + |
| 243 | + const unknownDirPath = path.join(getTmpDir(), "dir-with-files"); |
| 244 | + const knownFilePath = path.join(getTmpDir(), "file-1.txt"); |
| 245 | + const lstatPaths: string[] = []; |
| 246 | + |
| 247 | + t.mock.method(fsPromises, "readdir", async (...args: any[]) => { |
| 248 | + const [absolutePathToDir, options] = args; |
| 249 | + const dirents: Dirent[] = await Reflect.apply( |
| 250 | + originalReaddir, |
| 251 | + fsPromises, |
| 252 | + args, |
| 253 | + ); |
| 254 | + |
| 255 | + if ( |
| 256 | + absolutePathToDir !== getTmpDir() || |
| 257 | + options?.withFileTypes !== true |
| 258 | + ) { |
| 259 | + return dirents; |
| 260 | + } |
| 261 | + |
| 262 | + return dirents.map((dirent) => { |
| 263 | + if (dirent.name === path.basename(unknownDirPath)) { |
| 264 | + return withUnknownDirentType(dirent); |
| 265 | + } |
| 266 | + |
| 267 | + if (dirent.name === path.basename(knownFilePath)) { |
| 268 | + return withKnownFileDirentType(dirent); |
| 269 | + } |
| 270 | + |
| 271 | + return dirent; |
| 272 | + }); |
| 273 | + }); |
| 274 | + |
| 275 | + t.mock.method(fsPromises, "lstat", async (...args: any[]) => { |
| 276 | + lstatPaths.push(String(args[0])); |
| 277 | + return await Reflect.apply(originalLstat, fsPromises, args); |
| 278 | + }); |
| 279 | + |
| 280 | + const files = await getAllFilesMatching(getTmpDir()); |
| 281 | + |
| 282 | + assert.deepEqual( |
| 283 | + new Set(files), |
| 284 | + new Set([ |
| 285 | + path.join(getTmpDir(), "file-1.txt"), |
| 286 | + path.join(getTmpDir(), "file-2.txt"), |
| 287 | + path.join(getTmpDir(), "file-3.json"), |
| 288 | + path.join(getTmpDir(), "dir-with-files", "inner-file-1.json"), |
| 289 | + path.join(getTmpDir(), "dir-with-files", "inner-file-2.txt"), |
| 290 | + path.join(getTmpDir(), "dir-with-extension.txt", "inner-file-3.txt"), |
| 291 | + path.join(getTmpDir(), "dir-with-extension.txt", "inner-file-4.json"), |
| 292 | + path.join(getTmpDir(), "dir-WithCasing", "file-WithCASING"), |
| 293 | + path.join( |
| 294 | + getTmpDir(), |
| 295 | + "dir-with-files", |
| 296 | + "dir-within-dir", |
| 297 | + "file-deep", |
| 298 | + ), |
| 299 | + ]), |
| 300 | + ); |
| 301 | + assert.ok( |
| 302 | + lstatPaths.includes(unknownDirPath), |
| 303 | + `expected lstat to be used for unknown dirent path ${unknownDirPath}`, |
| 304 | + ); |
| 305 | + assert.ok( |
| 306 | + !lstatPaths.includes(knownFilePath), |
| 307 | + `expected lstat to not be used for known file path ${knownFilePath}`, |
| 308 | + ); |
| 309 | + }); |
| 310 | + |
212 | 311 | it("Should throw NotADirectoryError if the path is not a directory", async () => { |
213 | 312 | const dirPath = path.join(getTmpDir(), "file-1.txt"); |
214 | 313 |
|
@@ -361,6 +460,58 @@ describe("File system utils", () => { |
361 | 460 | [path.join(getTmpDir(), "dir-with-files")], |
362 | 461 | ); |
363 | 462 | }); |
| 463 | + |
| 464 | + it("Should match directories when the dirent type is unknown", async (t) => { |
| 465 | + const originalReaddir = fsPromises.readdir; |
| 466 | + const originalLstat = fsPromises.lstat; |
| 467 | + |
| 468 | + const unknownDirPath = path.join(getTmpDir(), "dir-with-files"); |
| 469 | + const lstatPaths: string[] = []; |
| 470 | + |
| 471 | + t.mock.method(fsPromises, "readdir", async (...args: any[]) => { |
| 472 | + const [absolutePathToDir, options] = args; |
| 473 | + const dirents: Dirent[] = await Reflect.apply( |
| 474 | + originalReaddir, |
| 475 | + fsPromises, |
| 476 | + args, |
| 477 | + ); |
| 478 | + |
| 479 | + if ( |
| 480 | + absolutePathToDir !== getTmpDir() || |
| 481 | + options?.withFileTypes !== true |
| 482 | + ) { |
| 483 | + return dirents; |
| 484 | + } |
| 485 | + |
| 486 | + return dirents.map((dirent) => |
| 487 | + dirent.name === path.basename(unknownDirPath) |
| 488 | + ? withUnknownDirentType(dirent) |
| 489 | + : dirent, |
| 490 | + ); |
| 491 | + }); |
| 492 | + |
| 493 | + t.mock.method(fsPromises, "lstat", async (...args: any[]) => { |
| 494 | + lstatPaths.push(String(args[0])); |
| 495 | + return await Reflect.apply(originalLstat, fsPromises, args); |
| 496 | + }); |
| 497 | + |
| 498 | + const dirs = await getAllDirectoriesMatching(getTmpDir()); |
| 499 | + |
| 500 | + assert.deepEqual( |
| 501 | + new Set(dirs), |
| 502 | + new Set([ |
| 503 | + path.join(getTmpDir(), "dir-empty"), |
| 504 | + path.join(getTmpDir(), "dir-with-files"), |
| 505 | + path.join(getTmpDir(), "dir-with-subdir"), |
| 506 | + path.join(getTmpDir(), "dir-with-extension.txt"), |
| 507 | + path.join(getTmpDir(), "dir-WithCasing"), |
| 508 | + ]), |
| 509 | + ); |
| 510 | + assert.ok( |
| 511 | + lstatPaths.includes(unknownDirPath), |
| 512 | + `expected lstat to be used for unknown dirent path ${unknownDirPath}`, |
| 513 | + ); |
| 514 | + }); |
364 | 515 | }); |
365 | 516 |
|
366 | 517 | describe("getFileTrueCase", () => { |
@@ -935,6 +1086,52 @@ describe("File system utils", () => { |
935 | 1086 | }); |
936 | 1087 | }); |
937 | 1088 |
|
| 1089 | + describe("readdirOrEmpty", () => { |
| 1090 | + it("Should return the files in a directory", async () => { |
| 1091 | + const dirPath = path.join(getTmpDir(), "dir"); |
| 1092 | + await mkdir(dirPath); |
| 1093 | + |
| 1094 | + const files = ["file1.txt", "file2.txt", "file3.json"]; |
| 1095 | + for (const file of files) { |
| 1096 | + await createFile(path.join(dirPath, file)); |
| 1097 | + } |
| 1098 | + |
| 1099 | + const subDirPath = path.join(dirPath, "subdir"); |
| 1100 | + await mkdir(subDirPath); |
| 1101 | + await createFile(path.join(subDirPath, "file4.txt")); |
| 1102 | + |
| 1103 | + // should include the subdir but not its content as it's not recursive |
| 1104 | + assert.deepEqual( |
| 1105 | + new Set(await readdirOrEmpty(dirPath)), |
| 1106 | + new Set(["file1.txt", "file2.txt", "file3.json", "subdir"]), |
| 1107 | + ); |
| 1108 | + }); |
| 1109 | + |
| 1110 | + it("Should return an empty array if the directory doesn't exist", async () => { |
| 1111 | + const dirPath = path.join(getTmpDir(), "not-exists"); |
| 1112 | + |
| 1113 | + assert.deepEqual(await readdirOrEmpty(dirPath), []); |
| 1114 | + }); |
| 1115 | + |
| 1116 | + it("Should throw NotADirectoryError if the path is not a directory", async () => { |
| 1117 | + const filePath = path.join(getTmpDir(), "file"); |
| 1118 | + await createFile(filePath); |
| 1119 | + |
| 1120 | + await assert.rejects(readdirOrEmpty(filePath), { |
| 1121 | + name: "NotADirectoryError", |
| 1122 | + message: `Path ${filePath} is not a directory`, |
| 1123 | + }); |
| 1124 | + }); |
| 1125 | + |
| 1126 | + it("Should throw FileSystemAccessError if a different error is thrown", async () => { |
| 1127 | + const invalidPath = "\0"; |
| 1128 | + |
| 1129 | + await assert.rejects(readdirOrEmpty(invalidPath), { |
| 1130 | + name: "FileSystemAccessError", |
| 1131 | + }); |
| 1132 | + }); |
| 1133 | + }); |
| 1134 | + |
938 | 1135 | describe("mkdir", () => { |
939 | 1136 | it("Should create a directory", async () => { |
940 | 1137 | const dirPath = path.join(getTmpDir(), "dir"); |
|
0 commit comments