Files
socraticode/tests/unit/graph-aliases.test.ts
Csaba Tuncsik c7e160cb5c feat: add CSS @import tracking and path alias resolution to dependency graph
Add CSS @import extraction from Svelte/Vue <style> blocks and standalone
CSS/SCSS/SASS/LESS files. Add path alias resolution from tsconfig.json /
jsconfig.json compilerOptions.paths with extends chain support.
2026-03-18 20:18:44 +01:00

319 lines
9.0 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadPathAliases, parsePathAliases } from "../../src/services/graph-aliases.js";
describe("parsePathAliases", () => {
const projectPath = "/project";
it("parses tsconfig paths with wildcard patterns", () => {
const json = JSON.stringify({
compilerOptions: {
paths: {
"$lib/*": ["src/lib/*"],
"@/*": ["./src/*"],
},
},
});
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
expect(aliases.entries.get("@/")).toEqual(["src"]);
});
it("respects baseUrl", () => {
const json = JSON.stringify({
compilerOptions: {
baseUrl: "./app",
paths: {
"@/*": ["src/*"],
},
},
});
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("@/")).toEqual([path.join("app", "src")]);
});
it("handles exact (non-wildcard) patterns", () => {
const json = JSON.stringify({
compilerOptions: {
paths: {
"~": ["./src"],
},
},
});
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("~")).toEqual(["src"]);
});
it("handles multiple targets for a single alias", () => {
const json = JSON.stringify({
compilerOptions: {
paths: {
"@/*": ["src/*", "generated/*"],
},
},
});
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("@/")).toEqual(["src", "generated"]);
});
it("handles JSON with single-line comments", () => {
const json = `{
// This is a comment
"compilerOptions": {
"paths": {
"$lib/*": ["src/lib/*"] // inline comment
}
}
}`;
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
});
it("handles JSON with multi-line comments", () => {
const json = `{
/* Multi-line
comment */
"compilerOptions": {
"paths": {
"@/*": ["src/*"]
}
}
}`;
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("@/")).toEqual(["src"]);
});
it("preserves // inside string values when stripping comments", () => {
const json = `{
// comment
"compilerOptions": {
"baseUrl": "./dist//subdir", // trailing comment
"paths": {
"@/*": ["src/*"]
}
}
}`;
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("@/")).toEqual([path.join("dist//subdir", "src")]);
});
it("returns empty entries for config without paths", () => {
const json = JSON.stringify({
compilerOptions: {
strict: true,
},
});
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.size).toBe(0);
});
it("returns empty entries for invalid JSON", () => {
const aliases = parsePathAliases("not json at all", projectPath);
expect(aliases.entries.size).toBe(0);
});
it("skips entries with empty targets array", () => {
const json = JSON.stringify({
compilerOptions: {
paths: {
"@/*": [],
"$lib/*": ["src/lib/*"],
},
},
});
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.has("@/")).toBe(false);
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
});
it("skips non-string targets gracefully", () => {
const json = '{"compilerOptions":{"paths":{"@/*":[42, null, "./src/*"]}}}';
const aliases = parsePathAliases(json, projectPath);
expect(aliases.entries.get("@/")).toEqual(["src"]);
});
});
describe("loadPathAliases", () => {
let tmpDir: string | null = null;
afterEach(() => {
if (tmpDir) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
tmpDir = null;
}
});
it("returns empty aliases for non-existent project", async () => {
const aliases = await loadPathAliases("/non/existent/path");
expect(aliases.entries.size).toBe(0);
});
it("reads aliases from tsconfig.json", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
compilerOptions: {
paths: { "$lib/*": ["src/lib/*"] },
},
}));
const aliases = await loadPathAliases(tmpDir);
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
});
it("falls back to jsconfig.json when tsconfig.json is absent", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "jsconfig.json"), JSON.stringify({
compilerOptions: {
paths: { "@/*": ["./src/*"] },
},
}));
const aliases = await loadPathAliases(tmpDir);
expect(aliases.entries.get("@/")).toEqual(["src"]);
});
it("falls through to jsconfig.json when tsconfig.json has no paths", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true },
}));
fs.writeFileSync(path.join(tmpDir, "jsconfig.json"), JSON.stringify({
compilerOptions: {
paths: { "@/*": ["./src/*"] },
},
}));
const aliases = await loadPathAliases(tmpDir);
expect(aliases.entries.get("@/")).toEqual(["src"]);
});
it("prefers tsconfig.json over jsconfig.json", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
compilerOptions: {
paths: { "$lib/*": ["src/lib/*"] },
},
}));
fs.writeFileSync(path.join(tmpDir, "jsconfig.json"), JSON.stringify({
compilerOptions: {
paths: { "@/*": ["./src/*"] },
},
}));
const aliases = await loadPathAliases(tmpDir);
// Should use tsconfig.json paths, not jsconfig.json
expect(aliases.entries.has("$lib/")).toBe(true);
expect(aliases.entries.has("@/")).toBe(false);
});
it("follows extends chain to find paths in parent config", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
extends: "./tsconfig.base.json",
compilerOptions: { strict: true },
}));
fs.writeFileSync(path.join(tmpDir, "tsconfig.base.json"), JSON.stringify({
compilerOptions: {
paths: { "$lib/*": ["src/lib/*"] },
},
}));
const aliases = await loadPathAliases(tmpDir);
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
});
it("follows multi-level extends chain", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
extends: "./tsconfig.app.json",
}));
fs.writeFileSync(path.join(tmpDir, "tsconfig.app.json"), JSON.stringify({
extends: "./tsconfig.base.json",
compilerOptions: { strict: true },
}));
fs.writeFileSync(path.join(tmpDir, "tsconfig.base.json"), JSON.stringify({
compilerOptions: {
paths: { "@/*": ["src/*"] },
},
}));
const aliases = await loadPathAliases(tmpDir);
expect(aliases.entries.get("@/")).toEqual(["src"]);
});
it("handles extends without .json extension", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
extends: "./base",
}));
fs.writeFileSync(path.join(tmpDir, "base.json"), JSON.stringify({
compilerOptions: {
paths: { "$lib/*": ["src/lib/*"] },
},
}));
const aliases = await loadPathAliases(tmpDir);
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
});
it("handles circular extends gracefully", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
extends: "./tsconfig.base.json",
}));
fs.writeFileSync(path.join(tmpDir, "tsconfig.base.json"), JSON.stringify({
extends: "./tsconfig.json",
}));
const aliases = await loadPathAliases(tmpDir);
// Should not infinite loop, returns empty
expect(aliases.entries.size).toBe(0);
});
it("stops when extended file does not exist", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
extends: "./nonexistent.json",
}));
const aliases = await loadPathAliases(tmpDir);
expect(aliases.entries.size).toBe(0);
});
});