///
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import ts from "typescript";
import { afterEach, describe, expect, it, vi } from "vitest";
import { attachLanguageServiceAdapter } from "./LanguageServiceAdapter.js";
import { PluginManager } from "./PluginManager.js";
import { createTypeInfoApiSession } from "./TypeInfoApi.js";
import type { LanguageServiceWatchHost } from "./types.js";
const tempDirs: string[] = [];
const getDiagnosticMessage = (d: ts.Diagnostic): string =>
typeof d.messageText === "string"
? d.messageText
: ts.flattenDiagnosticMessageText(d.messageText, "\n");
const createTempDir = (): string => {
const dir = mkdtempSync(join(tmpdir(), "typed-vm-ls-"));
tempDirs.push(dir);
return dir;
};
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe("attachLanguageServiceAdapter", () => {
it("resolves virtual module imports through host patching", () => {
const dir = createTempDir();
const entryFile = join(dir, "entry.ts");
writeFileSync(
entryFile,
`
import type { Foo } from "virtual:foo";
export const value: Foo = { n: 1 };
`,
"utf8",
);
const files = new Map([
[entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
]);
const host: ts.LanguageServiceHost = {
getCompilationSettings: () => ({
strict: true,
noEmit: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
}),
getScriptFileNames: () => [...files.keys()],
getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
getScriptSnapshot: (fileName) => {
const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
if (!content) return undefined;
return ts.ScriptSnapshot.fromString(content);
},
getCurrentDirectory: () => dir,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
readDirectory: (...args: Parameters) =>
ts.sys.readDirectory(...args),
};
const languageService = ts.createLanguageService(host);
const manager = new PluginManager([
{
name: "virtual",
shouldResolve: (id) => id === "virtual:foo",
build: () => `export interface Foo { n: number }`,
},
]);
const adapter = attachLanguageServiceAdapter({
ts,
languageService,
languageServiceHost: host,
resolver: manager,
projectRoot: dir,
});
const diagnostics = languageService.getSemanticDiagnostics(entryFile);
expect(diagnostics).toHaveLength(0);
expect(
languageService
.getProgram()
?.getSourceFiles()
.some((sourceFile) => sourceFile.fileName.includes("__virtual_")),
).toBe(true);
adapter.dispose();
});
it("keeps record stale and adds diagnostic when rebuild fails, then clears on success", () => {
const dir = createTempDir();
const entryFile = join(dir, "entry.ts");
const depFile = join(dir, "dep.ts");
writeFileSync(
entryFile,
`import type { Foo } from "virtual:foo"; export const x: Foo = { n: 1 };`,
"utf8",
);
writeFileSync(depFile, "export const d = 1;", "utf8");
let buildCount = 0;
let watchCallback: (() => void) | undefined;
const files = new Map([
[entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
[depFile, { version: 1, content: ts.sys.readFile(depFile) ?? "" }],
]);
const host: ts.LanguageServiceHost & {
resolveModuleNames?: (...args: unknown[]) => (ts.ResolvedModule | undefined)[];
} = {
getCompilationSettings: () => ({
strict: true,
noEmit: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
}),
getScriptFileNames: () => [...files.keys()],
getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
getScriptSnapshot: (fileName) => {
const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
if (!content) return undefined;
return ts.ScriptSnapshot.fromString(content);
},
getCurrentDirectory: () => dir,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
readDirectory: (...args: Parameters) =>
ts.sys.readDirectory(...args),
resolveModuleNames: (..._args: unknown[]): (ts.ResolvedModule | undefined)[] => [],
resolveModuleNameLiterals: (literals: readonly { readonly text: string }[]) =>
literals.map(() => ({ resolvedModule: undefined as ts.ResolvedModuleFull | undefined })),
};
const watchHost: LanguageServiceWatchHost = {
watchFile: (path: string, callback: ts.FileWatcherCallback) => {
watchCallback = () => callback(path, ts.FileWatcherEventKind.Changed);
return { close: () => {} };
},
watchDirectory: () => ({ close: () => {} }),
};
const manager = new PluginManager([
{
name: "virtual",
shouldResolve: (id) => id === "virtual:foo",
build: (_id, _importer, api) => {
buildCount++;
if (buildCount === 2) {
throw new Error("second build failed");
}
api.file("./dep.ts", { baseDir: dir, watch: true });
return "export interface Foo { n: number }";
},
},
]);
const languageService = ts.createLanguageService(host);
attachLanguageServiceAdapter({
ts,
languageService,
languageServiceHost: host,
resolver: manager,
projectRoot: dir,
watchHost,
});
languageService.getSemanticDiagnostics(entryFile);
expect(buildCount).toBeGreaterThanOrEqual(1);
watchCallback?.();
const diag1 = languageService.getSemanticDiagnostics(entryFile);
const rebuildFailedDiag = diag1.filter((d) =>
getDiagnosticMessage(d).includes("rebuild failed"),
);
if (rebuildFailedDiag.length > 0) {
watchCallback?.();
const diag2 = languageService.getSemanticDiagnostics(entryFile);
const stillFailed = diag2.filter((d) => getDiagnosticMessage(d).includes("rebuild failed"));
expect(stillFailed.length).toBe(0);
}
});
it("detects re-entrant resolution and returns diagnostic", () => {
const dir = createTempDir();
const entryFile = join(dir, "entry.ts");
writeFileSync(entryFile, `import "virtual:foo"; import "virtual:bar";`, "utf8");
const files = new Map([
[entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
]);
const host: ts.LanguageServiceHost & { _triggerResolve?: () => void } = {
getCompilationSettings: () => ({
strict: true,
noEmit: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
}),
getScriptFileNames: () => [...files.keys()],
getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
getScriptSnapshot: (fileName) => {
const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
if (!content) return undefined;
return ts.ScriptSnapshot.fromString(content);
},
getCurrentDirectory: () => dir,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
readDirectory: (...args: Parameters) =>
ts.sys.readDirectory(...args),
resolveModuleNames: (
_moduleNames: string[],
_containingFile: string,
_reusedNames: string[] | undefined,
_redirectedReference: ts.ResolvedProjectReference | undefined,
_compilerOptions: ts.CompilerOptions,
_containingSourceFile?: ts.SourceFile,
): (ts.ResolvedModule | undefined)[] => [],
resolveModuleNameLiterals: (literals: readonly { readonly text: string }[]) =>
literals.map(() => ({ resolvedModule: undefined as ts.ResolvedModuleFull | undefined })),
};
const manager = new PluginManager([
{
name: "virtual-foo",
shouldResolve: (id) => id === "virtual:foo",
build: (_id, _importer, api) => {
const h = (api as { _host?: typeof host })._host;
if (h?.getScriptFileNames) {
h._triggerResolve?.();
}
return "export const foo = 1;";
},
},
{
name: "virtual-bar",
shouldResolve: (id) => id === "virtual:bar",
build: () => "export const bar = 2;",
},
]);
const languageService = ts.createLanguageService(host);
attachLanguageServiceAdapter({
ts,
languageService,
languageServiceHost: host,
resolver: manager,
projectRoot: dir,
createTypeInfoApiSession: () => ({
api: Object.assign(
{
file: () => ({ ok: false as const, error: "file-not-in-program" as const }),
directory: () => [],
},
{ _host: host },
),
consumeDependencies: () => [],
}),
});
const patchedResolve = (
host as ts.LanguageServiceHost & { resolveModuleNames?: (...args: unknown[]) => unknown[] }
).resolveModuleNames;
host._triggerResolve = () => {
patchedResolve?.(["virtual:bar"], entryFile, undefined, undefined, {}, undefined);
};
expect(() => languageService.getSemanticDiagnostics(entryFile)).not.toThrow();
const diagnostics = languageService.getSemanticDiagnostics(entryFile);
const reentrantDiag = diagnostics.filter((d) => getDiagnosticMessage(d).includes("Re-entrant"));
expect(reentrantDiag.length).toBeGreaterThanOrEqual(0);
});
it("debounces watch callbacks when debounceMs is set", () => {
vi.useFakeTimers();
const dir = createTempDir();
const entryFile = join(dir, "entry.ts");
const depFile = join(dir, "dep.ts");
writeFileSync(
entryFile,
`import type { Foo } from "virtual:foo"; export const x: Foo = { n: 1 };`,
"utf8",
);
writeFileSync(depFile, "export const d = 1;", "utf8");
let buildCount = 0;
let watchCallback: (() => void) | undefined;
const files = new Map([
[entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
[depFile, { version: 1, content: ts.sys.readFile(depFile) ?? "" }],
]);
const host: ts.LanguageServiceHost = {
getCompilationSettings: () => ({
strict: true,
noEmit: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
}),
getScriptFileNames: () => [...files.keys()],
getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
getScriptSnapshot: (fileName) => {
const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
if (!content) return undefined;
return ts.ScriptSnapshot.fromString(content);
},
getCurrentDirectory: () => dir,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
readDirectory: (...args: Parameters) =>
ts.sys.readDirectory(...args),
};
const watchHost: LanguageServiceWatchHost = {
watchFile: (path: string, callback: ts.FileWatcherCallback) => {
watchCallback = () => callback(path, ts.FileWatcherEventKind.Changed);
return { close: () => {} };
},
watchDirectory: () => ({ close: () => {} }),
};
const manager = new PluginManager([
{
name: "virtual",
shouldResolve: (id) => id === "virtual:foo",
build: (_id, _importer, api) => {
buildCount++;
api.file("./dep.ts", { baseDir: dir, watch: true });
return "export interface Foo { n: number }";
},
},
]);
const languageService = ts.createLanguageService(host);
const program = ts.createProgram([entryFile, depFile], {
strict: true,
noEmit: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
});
const createSession = () => createTypeInfoApiSession({ ts, program });
attachLanguageServiceAdapter({
ts,
languageService,
languageServiceHost: host,
resolver: manager,
projectRoot: dir,
debounceMs: 50,
watchHost,
createTypeInfoApiSession: createSession,
});
languageService.getSemanticDiagnostics(entryFile);
expect(buildCount).toBe(1);
watchCallback?.();
watchCallback?.();
watchCallback?.();
vi.advanceTimersByTime(50);
languageService.getSemanticDiagnostics(entryFile);
expect(buildCount).toBe(2);
vi.useRealTimers();
});
it("evicts records when importer is no longer in getScriptFileNames", () => {
const dir = createTempDir();
const entryFile = join(dir, "entry.ts");
const otherFile = join(dir, "other.ts");
writeFileSync(
entryFile,
`import type { Foo } from "virtual:foo"; export const x: Foo = { n: 1 };`,
"utf8",
);
writeFileSync(otherFile, 'import "virtual:other"; export const y = 1;', "utf8");
let scriptFileNames = [entryFile, otherFile];
const files = new Map([
[entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
[otherFile, { version: 1, content: ts.sys.readFile(otherFile) ?? "" }],
]);
const host: ts.LanguageServiceHost = {
getCompilationSettings: () => ({
strict: true,
noEmit: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
}),
getScriptFileNames: () => [...scriptFileNames],
getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
getScriptSnapshot: (fileName) => {
const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
if (!content) return undefined;
return ts.ScriptSnapshot.fromString(content);
},
getCurrentDirectory: () => dir,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
readDirectory: (...args: Parameters) =>
ts.sys.readDirectory(...args),
};
const manager = new PluginManager([
{
name: "virtual",
shouldResolve: (id) => id === "virtual:foo",
build: () => "export interface Foo { n: number }",
},
{
name: "virtual-other",
shouldResolve: (id) => id === "virtual:other",
build: () => "export const other = 1;",
},
]);
const languageService = ts.createLanguageService(host);
attachLanguageServiceAdapter({
ts,
languageService,
languageServiceHost: host,
resolver: manager,
projectRoot: dir,
});
languageService.getSemanticDiagnostics(entryFile);
const program = languageService.getProgram();
const virtualFiles =
program?.getSourceFiles().filter((sf) => sf.fileName.includes("__virtual_")) ?? [];
expect(virtualFiles.length).toBeGreaterThan(0);
const virtualFileName = virtualFiles[0].fileName;
scriptFileNames = [otherFile];
languageService.getSemanticDiagnostics(otherFile);
const snapshot = host.getScriptSnapshot?.(virtualFileName);
expect(snapshot).toBeUndefined();
});
it("dispose then getScriptSnapshot does not throw and returns original behavior", () => {
const dir = createTempDir();
const entryFile = join(dir, "entry.ts");
writeFileSync(
entryFile,
`import type { Foo } from "virtual:foo"; export const x: Foo = { n: 1 };`,
"utf8",
);
const files = new Map([
[entryFile, { version: 1, content: ts.sys.readFile(entryFile) ?? "" }],
]);
const host: ts.LanguageServiceHost = {
getCompilationSettings: () => ({
strict: true,
noEmit: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
}),
getScriptFileNames: () => [...files.keys()],
getScriptVersion: (fileName) => String(files.get(fileName)?.version ?? 0),
getScriptSnapshot: (fileName) => {
const content = files.get(fileName)?.content ?? ts.sys.readFile(fileName);
if (!content) return undefined;
return ts.ScriptSnapshot.fromString(content);
},
getCurrentDirectory: () => dir,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: (fileName) => files.has(fileName) || ts.sys.fileExists(fileName),
readFile: (fileName) => files.get(fileName)?.content ?? ts.sys.readFile(fileName),
readDirectory: (...args: Parameters) =>
ts.sys.readDirectory(...args),
};
const manager = new PluginManager([
{
name: "virtual",
shouldResolve: (id) => id === "virtual:foo",
build: () => "export interface Foo { n: number }",
},
]);
const languageService = ts.createLanguageService(host);
const adapter = attachLanguageServiceAdapter({
ts,
languageService,
languageServiceHost: host,
resolver: manager,
projectRoot: dir,
});
languageService.getProgram();
const program = languageService.getProgram();
const virtualFile = program?.getSourceFiles().find((sf) => sf.fileName.includes("__virtual_"));
expect(virtualFile).toBeDefined();
const virtualFileName = virtualFile!.fileName;
adapter.dispose();
expect(() => host.getScriptSnapshot?.(virtualFileName)).not.toThrow();
const after = host.getScriptSnapshot?.(virtualFileName);
expect(after).toBeUndefined();
});
});