From 9e5522c131a33f25b1e5d58228ee5c7f59846f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 17 Sep 2026 13:23:27 +0000 Subject: [PATCH] :truck: Home test helpers behind #test-utils/* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/util/__tests__/ holds shared test helpers, reached from anywhere in the source tree as `#test-utils/.ts` through package.json#imports. A bare `~/…` is not a valid imports key — Node requires `#` — and a direction-free specifier keeps import/no-relative-parent-imports from ever firing. The `__tests__` folder name is the pattern tsconfig.build.json already excludes, so helpers can never ship. Lint scoping: import/no-nodejs-modules off for the folder (the probe spawns a language server) and typescript/promise-function-async off to match its promise-returning style. The LSP probe moves in from scripts/; a smoke test pins the specifier's resolution and typing without starting a server. --- .oxlintrc.json | 9 +- package.json | 3 + src/util/__tests__/lsp-completion.test.ts | 32 ++ src/util/__tests__/lsp-completion.ts | 355 ++++++++++++++++++++++ 4 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/util/__tests__/lsp-completion.test.ts create mode 100644 src/util/__tests__/lsp-completion.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index a75aed8..e0bae8b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,7 +22,8 @@ "eslint/sort-imports": "off", "import/consistent-type-specifier-style": "off", "unicorn/prefer-export-from": "off", - "typescript/method-signature-style": "off" + "typescript/method-signature-style": "off", + "typescript/promise-function-async": "off" }, "options": { "typeAware": true }, "env": { "builtin": true, "es2024": true, "node": true }, @@ -41,6 +42,12 @@ "rules": { "import/no-nodejs-modules": "off" } + }, + { + "files": ["src/util/__tests__/**"], + "rules": { + "import/no-nodejs-modules": "off" + } } ], "ignorePatterns": ["dist", "node_modules", "coverage"] diff --git a/package.json b/package.json index bba558a..7e83d0f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ ], "type": "module", "sideEffects": false, + "imports": { + "#test-utils/*": "./src/util/__tests__/*" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/src/util/__tests__/lsp-completion.test.ts b/src/util/__tests__/lsp-completion.test.ts new file mode 100644 index 0000000..fcaab51 --- /dev/null +++ b/src/util/__tests__/lsp-completion.test.ts @@ -0,0 +1,32 @@ +/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { expectTypeOf } from "expect-type"; + +import { + type CompletionResult, + type CompletionTarget, + LspSession, +} from "#test-utils/lsp-completion.ts"; + +// The `#test-utils/*` self-reference (package.json#imports) is the one route +// scattered test files take to reach test helpers; this pins that it resolves +// and types without starting a language server +// (development/testing.md § Test helpers). +test("test-helper home: `#test-utils/…` resolves to the helper and types it", () => { + // Arrange + const target: CompletionTarget = { + file: "src/util/__tests__/lsp-completion.ts", + source: "", + }; + + // Assert — no session is constructed: the constructor spawns `tsc --lsp`, + // so only the resolved values and their types are checked here. + expectTypeOf(LspSession).toBeConstructibleWith("repo-root"); + expectTypeOf().toMatchTypeOf<{ + labels: readonly string[]; + }>(); + assert.equal(typeof LspSession, "function"); + assert.equal(target.file, "src/util/__tests__/lsp-completion.ts"); +}); diff --git a/src/util/__tests__/lsp-completion.ts b/src/util/__tests__/lsp-completion.ts new file mode 100644 index 0000000..b54a703 --- /dev/null +++ b/src/util/__tests__/lsp-completion.ts @@ -0,0 +1,355 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +/** + * Print the completion labels TypeScript's language server offers at a marker + * inside a source file. + * + * Usage: node --strip-types src/util/__tests__/lsp-completion.ts [] + * + * The marker (default `DEFAULT_MARKER`, a `COMPLETE` block comment) is stripped + * from the source before it is sent; the request position is where the marker + * stood. Point it at a scratch file whose expected type is a matcher pattern to + * inspect the popup. + * + * `LspSession` exposes the same probe to the test suite, so completion is + * asserted against the server rather than the type system. + * + * Why a script and not a type assertion: completion is a contextual-type + * property that `expect-type` cannot observe, and `Parameters<…>` resolves only + * a single overload. The server is the only ground truth. See + * development/testing.md § Autocomplete. + */ + +const DEFAULT_MARKER = "/*COMPLETE*/"; +const ARGV_PREFIX_LENGTH = 2; +const EXIT_DELAY_MS = 200; +const FAILURE_EXIT_CODE = 1; +const USAGE = "usage: lsp-completion.ts []"; + +export interface Position { + readonly line: number; + readonly character: number; +} + +/** A document to probe: `source` carries `marker`, which is stripped. */ +export interface CompletionTarget { + /** Path relative to the session's repo root; drives URI and resolution. */ + readonly file: string; + readonly source: string; + readonly marker?: string; +} + +export interface CompletionResult { + readonly position: Position; + readonly labels: readonly string[]; +} + +interface CompletionItem { + readonly label: string; +} + +interface RpcMessage { + readonly id?: string | number; + readonly method?: string; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: unknown; +} + +interface Deferred { + readonly resolve: (message: RpcMessage) => void; + readonly reject: (error: Error) => void; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isRpcMessage = (value: unknown): value is RpcMessage => { + if (!isRecord(value)) { + return false; + } + const { id, method } = value; + return ( + (id === undefined || + typeof id === "number" || + typeof id === "string") && + (method === undefined || typeof method === "string") + ); +}; + +const isCompletionItem = (value: unknown): value is CompletionItem => + isRecord(value) && typeof value["label"] === "string"; + +const serverRequestResult = (message: RpcMessage): readonly null[] | null => { + if (message.method !== "workspace/configuration") { + return null; + } + const items = isRecord(message.params) + ? message.params["items"] + : undefined; + return Array.isArray(items) ? items.map(() => null) : []; +}; + +const completionLabels = (result: unknown): readonly string[] => { + if (!isRecord(result)) { + return []; + } + const { items } = result; + if (!Array.isArray(items)) { + return []; + } + return items + .filter(isCompletionItem) + .map((item) => item.label) + .toSorted((left, right) => left.localeCompare(right)); +}; + +const frame = (message: object): string => { + const body = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; +}; + +const markerPosition = (source: string, marker: string): Position => { + const index = source.indexOf(marker); + if (index === -1) { + throw new Error(`marker not found: ${marker}`); + } + const lines = source.slice(0, index).split("\n"); + const lineIndex = lines.length - 1; + const line = lines[lineIndex]; + return { line: lineIndex, character: line === undefined ? 0 : line.length }; +}; + +/** Minimal JSON-RPC client for `tsc --lsp --stdio`. */ +class LspClient { + readonly #child: ChildProcessWithoutNullStreams; + readonly #pending = new Map(); + #buffer = Buffer.alloc(0); + #nextId = 1; + + public constructor(repoRoot: string) { + this.#child = spawn( + path.join(repoRoot, "node_modules", ".bin", "tsc"), + ["--lsp", "--stdio"], + { cwd: repoRoot }, + ); + this.#child.stdout.on("data", (chunk: Buffer) => { + this.#consume(chunk); + }); + this.#child.stderr.on("data", (chunk: Buffer) => { + process.stderr.write(chunk); + }); + } + + /** Send a request and settle when the server answers it. */ + public request(method: string, params?: unknown): Promise { + const id = this.#nextId; + this.#nextId += 1; + const message = frame({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }); + this.#child.stdin.write(message); + }); + } + + public notify(method: string, params: unknown): void { + this.#child.stdin.write(frame({ jsonrpc: "2.0", method, params })); + } + + public dispose(): void { + this.#child.stdin.end(); + this.#child.kill(); + } + + #consume(chunk: Buffer): void { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + for (;;) { + const headerEnd = this.#buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + return; + } + const header = this.#buffer.subarray(0, headerEnd).toString(); + const match = /Content-Length: (\d+)/i.exec(header); + const lengthText = match === null ? undefined : match[1]; + if (lengthText === undefined) { + return; + } + const bodyEnd = headerEnd + 4 + Number(lengthText); + if (this.#buffer.length < bodyEnd) { + return; + } + const body = this.#buffer + .subarray(headerEnd + 4, bodyEnd) + .toString(); + this.#buffer = this.#buffer.subarray(bodyEnd); + const parsed: unknown = JSON.parse(body); + if (isRpcMessage(parsed)) { + this.#handle(parsed); + } + } + } + + #handle(message: RpcMessage): void { + // Server -> client request: answer so the server does not stall. + if (message.method !== undefined && message.id !== undefined) { + this.#child.stdin.write( + frame({ + jsonrpc: "2.0", + id: message.id, + result: serverRequestResult(message), + }), + ); + return; + } + // Response to one of our requests. + const { id } = message; + if (id === undefined) { + return; + } + const deferred = this.#pending.get(id); + if (deferred === undefined) { + return; + } + this.#pending.delete(id); + if (message.error === undefined) { + deferred.resolve(message); + } else { + deferred.reject(new Error(JSON.stringify(message.error))); + } + } +} + +/** + * One language server, initialized on first use, shared across probes. Callers + * own the lifecycle and must `close()` it. + */ +export class LspSession { + readonly #client: LspClient; + readonly #repoRoot: string; + #ready: Promise | undefined; + + public constructor(repoRoot: string) { + this.#repoRoot = repoRoot; + this.#client = new LspClient(repoRoot); + } + + public completionLabelsAt( + target: CompletionTarget, + ): Promise { + return this.#ensureInitialized().then(() => { + const { uri, position } = this.#open(target); + return this.#client + .request("textDocument/completion", { + textDocument: { uri }, + position, + context: { triggerKind: 1 }, + }) + .then((response) => ({ + position, + labels: completionLabels(response.result), + })); + }); + } + + /** `shutdown` + `exit`, then kill the server; safe after a failed probe. */ + public close(): Promise { + return this.#client + .request("shutdown") + .then(() => { + this.#client.notify("exit", null); + return delay(EXIT_DELAY_MS); + }) + .finally(() => { + this.#client.dispose(); + }); + } + + #ensureInitialized(): Promise { + this.#ready ??= this.#client + .request("initialize", { + processId: process.pid, + rootUri: pathToFileURL(this.#repoRoot).href, + workspaceFolders: [ + { uri: pathToFileURL(this.#repoRoot).href, name: "repo" }, + ], + capabilities: { + textDocument: { + completion: { + completionItem: { snippetSupport: false }, + }, + publishDiagnostics: {}, + }, + }, + }) + .then(() => { + this.#client.notify("initialized", {}); + }); + return this.#ready; + } + + #open(target: CompletionTarget): { + readonly uri: string; + readonly position: Position; + } { + const absolute = path.resolve(this.#repoRoot, target.file); + const marker = target.marker ?? DEFAULT_MARKER; + const position = markerPosition(target.source, marker); + const text = target.source.replace(marker, ""); + const uri = pathToFileURL(absolute).href; + // The server handles `didOpen` in order before the completion request, + // so no settle delay is needed. + this.#client.notify("textDocument/didOpen", { + textDocument: { + uri, + languageId: "typescript", + version: 1, + text, + }, + }); + return { uri, position }; + } +} + +const main = (args: readonly string[]): Promise => { + const [file, markerArgument] = args; + if (file === undefined) { + process.stderr.write(`${USAGE}\n`); + process.exitCode = FAILURE_EXIT_CODE; + return Promise.resolve(); + } + + const repoRoot = path.resolve(import.meta.dirname, "../../.."); + const absolute = path.resolve(repoRoot, file); + const source = fs.readFileSync(absolute, "utf8"); + const session = new LspSession(repoRoot); + return session + .completionLabelsAt({ + file, + source, + marker: markerArgument ?? DEFAULT_MARKER, + }) + .then(({ position, labels }) => { + process.stdout.write( + `\n[${file}] completions @ ${position.line}:${position.character}:\n${labels.join(", ")}\n`, + ); + }) + .finally(() => session.close()); +}; + +const isEntryPoint = (): boolean => { + const [entry] = process.argv.slice(1, 2); + return entry !== undefined && import.meta.url === pathToFileURL(entry).href; +}; + +if (isEntryPoint()) { + main(process.argv.slice(ARGV_PREFIX_LENGTH)).catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = FAILURE_EXIT_CODE; + }); +}