graphql-language-service-server
Version:
Server process backing the GraphQL Language Service
234 lines (213 loc) • 5.98 kB
text/typescript
import { vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
import { MessageProcessor } from '../../MessageProcessor';
import { Logger as VSCodeLogger } from 'vscode-jsonrpc';
import { URI } from 'vscode-uri';
import { FileChangeType } from 'vscode-languageserver';
import { FileChangeTypeKind } from 'graphql-language-service';
export type MockFile = [filename: string, text: string];
export class MockLogger implements VSCodeLogger {
error = vi.fn();
warn = vi.fn();
info = vi.fn();
log = vi.fn();
}
type File = [filename: string, text: string];
type Files = File[];
// Track live instances so a process exit handler can clean up any that
// `dispose()` didn't reach (e.g. if a test throws mid-setup).
const liveInstances = new Set<MockProject>();
let exitHandlerRegistered = false;
function registerExitHandler() {
if (exitHandlerRegistered) {
return;
}
exitHandlerRegistered = true;
process.on('exit', () => {
for (const inst of liveInstances) {
try {
inst.dispose();
} catch {
// best-effort cleanup on exit
}
}
});
}
export class MockProject {
private root: string;
private fileCache: Map<string, string>;
private messageProcessor: MessageProcessor;
constructor({
files = [],
root,
settings,
}: {
files: Files;
root?: string;
settings?: Record<string, any>;
}) {
registerExitHandler();
// Unique tmpdir per instance. `gls-test-` prefix makes leaked dirs
// identifiable across test runs.
this.root = root ?? fs.mkdtempSync(path.join(tmpdir(), 'gls-test-'));
this.fileCache = new Map(files);
liveInstances.add(this);
this.writeFiles();
this.messageProcessor = new MessageProcessor({
connection: {
get workspace() {
return {
async getConfiguration() {
return settings;
},
};
},
},
logger: new MockLogger(),
loadConfigOptions: {
rootDir: this.root,
},
});
}
public async init(filename?: string, fileText?: string) {
await this.lsp.handleInitializeRequest({
rootPath: this.root,
rootUri: this.root,
capabilities: {},
processId: 200,
workspaceFolders: null,
});
return this.lsp.handleDidOpenOrSaveNotification({
textDocument: {
uri: this.uri(filename || 'query.graphql'),
version: 1,
text:
this.fileCache.get('query.graphql') ||
(filename && this.fileCache.get(filename)) ||
fileText,
},
});
}
/**
* Synchronously writes every cached file to disk. Creates parent dirs as needed.
* Called on construction and after every cache mutation so the on-disk state
* always matches `fileCache`.
*/
private writeFiles() {
for (const [filename, text] of this.fileCache) {
const dest = this.filePath(filename);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, text);
}
}
public filePath(filename: string) {
return `${this.root}/${filename}`;
}
public uri(filename: string) {
return URI.file(this.filePath(filename)).toString();
}
changeFile(filename: string, text: string) {
this.fileCache.set(filename, text);
const dest = this.filePath(filename);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, text);
}
async addFile(filename: string, text: string, watched = false) {
this.changeFile(filename, text);
if (watched) {
await this.lsp.handleWatchedFilesChangedNotification({
changes: [
{
uri: this.uri(filename),
type: FileChangeTypeKind.Created,
},
],
});
}
await this.lsp.handleDidChangeNotification({
contentChanges: [
{
type: FileChangeTypeKind.Created,
text,
},
],
textDocument: {
uri: this.uri(filename),
version: 2,
},
});
}
async changeWatchedFile(filename: string, text: string) {
this.changeFile(filename, text);
await this.lsp.handleWatchedFilesChangedNotification({
changes: [
{
uri: this.uri(filename),
type: FileChangeType.Changed,
},
],
});
}
async saveOpenFile(filename: string, text: string) {
this.changeFile(filename, text);
await this.lsp.handleDidOpenOrSaveNotification({
textDocument: {
uri: this.uri(filename),
version: 2,
text,
},
});
}
async addWatchedFile(filename: string, text: string) {
this.changeFile(filename, text);
await this.lsp.handleDidChangeNotification({
contentChanges: [
{
type: FileChangeTypeKind.Created,
text,
},
],
textDocument: {
uri: this.uri(filename),
version: 2,
},
});
}
async deleteFile(filename: string) {
this.fileCache.delete(filename);
try {
fs.rmSync(this.filePath(filename), { force: true });
} catch {
// ignore — file may already be gone
}
await this.lsp.handleWatchedFilesChangedNotification({
changes: [
{
type: FileChangeType.Deleted,
uri: this.uri(filename),
},
],
});
}
/**
* Remove this project's tmpdir and forget it. Idempotent.
* Callers should invoke in afterEach (or equivalent) for every instance.
*/
public dispose() {
if (!liveInstances.has(this)) {
return;
}
liveInstances.delete(this);
fs.rmSync(this.root, { recursive: true, force: true });
}
/** Public accessor for the tmpdir path — tests need this to build assertions
* that reference graphql-config's project key (which derives from rootDir). */
public get rootDir() {
return this.root;
}
get lsp() {
return this.messageProcessor;
}
}