|
1 | 1 | import { test, expect, beforeAll, afterAll } from "bun:test"; |
2 | | -import { expandImports, hasImports } from "./imports"; |
3 | | -import { mkdtemp, rm } from "node:fs/promises"; |
| 2 | +import { expandImports, hasImports, toCanonicalPath } from "./imports"; |
| 3 | +import { mkdtemp, rm, symlink } from "node:fs/promises"; |
4 | 4 | import { tmpdir } from "node:os"; |
5 | 5 | import { join } from "node:path"; |
6 | 6 |
|
@@ -270,3 +270,87 @@ test("expandImports handles glob patterns", async () => { |
270 | 270 | expect(result).toContain("<a path="); |
271 | 271 | expect(result).toContain("<b path="); |
272 | 272 | }); |
| 273 | + |
| 274 | +// Canonical path tests |
| 275 | +test("toCanonicalPath resolves symlinks to real path", async () => { |
| 276 | + // Create a real file |
| 277 | + const realFile = join(testDir, "real-file.md"); |
| 278 | + await Bun.write(realFile, "Real content"); |
| 279 | + |
| 280 | + // Create a symlink to it |
| 281 | + const linkFile = join(testDir, "link-to-real.md"); |
| 282 | + await symlink(realFile, linkFile); |
| 283 | + |
| 284 | + // Both should resolve to the same canonical path |
| 285 | + const canonicalReal = toCanonicalPath(realFile); |
| 286 | + const canonicalLink = toCanonicalPath(linkFile); |
| 287 | + |
| 288 | + expect(canonicalReal).toBe(canonicalLink); |
| 289 | +}); |
| 290 | + |
| 291 | +test("toCanonicalPath returns original path for non-existent files", () => { |
| 292 | + const nonExistent = join(testDir, "does-not-exist.md"); |
| 293 | + const result = toCanonicalPath(nonExistent); |
| 294 | + expect(result).toBe(nonExistent); |
| 295 | +}); |
| 296 | + |
| 297 | +test("toCanonicalPath handles regular files without symlinks", async () => { |
| 298 | + const regularFile = join(testDir, "regular.md"); |
| 299 | + await Bun.write(regularFile, "Regular content"); |
| 300 | + |
| 301 | + const canonical = toCanonicalPath(regularFile); |
| 302 | + // For a regular file, canonical path resolves system symlinks too (e.g., /var -> /private/var on macOS) |
| 303 | + // The canonical path should end with the same relative path |
| 304 | + expect(canonical.endsWith("regular.md")).toBe(true); |
| 305 | + // And calling it twice should give the same result |
| 306 | + expect(toCanonicalPath(canonical)).toBe(canonical); |
| 307 | +}); |
| 308 | + |
| 309 | +test("expandImports detects circular import via symlink", async () => { |
| 310 | + // Create a file that imports itself via a symlink |
| 311 | + // File A imports symlink-to-A, which points to A -> circular! |
| 312 | + const fileA = join(testDir, "symlink-cycle-a.md"); |
| 313 | + const symlinkToA = join(testDir, "symlink-to-a.md"); |
| 314 | + |
| 315 | + // Create the symlink first |
| 316 | + await Bun.write(fileA, "placeholder"); |
| 317 | + await symlink(fileA, symlinkToA); |
| 318 | + |
| 319 | + // Now update fileA to import via the symlink |
| 320 | + await Bun.write(fileA, "A imports @./symlink-to-a.md"); |
| 321 | + |
| 322 | + // This should detect the cycle even though paths are different |
| 323 | + const content = "@./symlink-cycle-a.md"; |
| 324 | + await expect(expandImports(content, testDir)).rejects.toThrow("Circular import detected"); |
| 325 | +}); |
| 326 | + |
| 327 | +test("expandImports detects indirect circular import via symlink", async () => { |
| 328 | + // A -> B -> symlink-to-A (which points to A) |
| 329 | + const fileA = join(testDir, "indirect-a.md"); |
| 330 | + const fileB = join(testDir, "indirect-b.md"); |
| 331 | + const symlinkToA = join(testDir, "indirect-link-to-a.md"); |
| 332 | + |
| 333 | + // Create files and symlink |
| 334 | + await Bun.write(fileA, "A imports @./indirect-b.md"); |
| 335 | + await Bun.write(fileB, "B imports @./indirect-link-to-a.md"); |
| 336 | + await symlink(fileA, symlinkToA); |
| 337 | + |
| 338 | + // This should detect the cycle: A -> B -> symlink-to-A (= A) |
| 339 | + const content = "@./indirect-a.md"; |
| 340 | + await expect(expandImports(content, testDir)).rejects.toThrow("Circular import detected"); |
| 341 | +}); |
| 342 | + |
| 343 | +test("expandImports allows same content via different files (not symlinks)", async () => { |
| 344 | + // Two different files with the same content should NOT be a cycle |
| 345 | + const file1 = join(testDir, "same-content-1.md"); |
| 346 | + const file2 = join(testDir, "same-content-2.md"); |
| 347 | + |
| 348 | + await Bun.write(file1, "Same content"); |
| 349 | + await Bun.write(file2, "Same content"); |
| 350 | + |
| 351 | + const content = "@./same-content-1.md @./same-content-2.md"; |
| 352 | + const result = await expandImports(content, testDir); |
| 353 | + |
| 354 | + // Should work fine - not a cycle |
| 355 | + expect(result).toBe("Same content Same content"); |
| 356 | +}); |
0 commit comments