From 8452ca321c917c189b202e93e6bdd35a6d8a1b9f Mon Sep 17 00:00:00 2001 From: rictic Date: Thu, 21 Aug 2025 19:47:59 -0700 Subject: [PATCH 1/3] Reuse the project service by doing a bit of cleanup. --- .../src/test/lib/lit-language-service_test.ts | 5 +++-- .../src/test/project-service.ts | 20 ++++++++++++++++--- .../no-binding-like-attribute-names_test.ts | 5 +++-- .../no-unassignable-property-bindings_test.ts | 11 ++++++---- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/labs/tsserver-plugin/src/test/lib/lit-language-service_test.ts b/packages/labs/tsserver-plugin/src/test/lib/lit-language-service_test.ts index 94d004bb72..75666f8dc7 100644 --- a/packages/labs/tsserver-plugin/src/test/lib/lit-language-service_test.ts +++ b/packages/labs/tsserver-plugin/src/test/lib/lit-language-service_test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert'; import * as path from 'node:path'; import {describe as suite, test} from 'node:test'; -import {createTestProjectService} from '../project-service.js'; +import {getReusableTestProjectService} from '../project-service.js'; import {getLitTemplateExpressions} from '@lit-labs/analyzer/lib/lit/template.js'; import ts from 'typescript'; @@ -10,7 +10,8 @@ function setupLanguageService(pathName: string): { program: ts.Program; testSourceFile: ts.SourceFile; } { - const projectService = createTestProjectService(); + using cleanup = getReusableTestProjectService(); + const projectService = cleanup.projectService; const result = projectService.openClientFile(pathName); assert.ok(result.configFileName); diff --git a/packages/labs/tsserver-plugin/src/test/project-service.ts b/packages/labs/tsserver-plugin/src/test/project-service.ts index 70a4045863..e749d9fe12 100644 --- a/packages/labs/tsserver-plugin/src/test/project-service.ts +++ b/packages/labs/tsserver-plugin/src/test/project-service.ts @@ -2,9 +2,7 @@ import ts from 'typescript'; /* eslint-disable @typescript-eslint/no-explicit-any */ -const fakeFileWatcher: ts.FileWatcher = { - close() {}, -}; +const fakeFileWatcher: ts.FileWatcher = {close() {}}; const serverHost: ts.server.ServerHost = { ...ts.sys, @@ -91,3 +89,19 @@ export const createTestProjectService = () => { }); return projectService; }; + +let reusableProjectService: ts.server.ProjectService | undefined; +export const getReusableTestProjectService = () => { + if (!reusableProjectService) { + reusableProjectService = createTestProjectService(); + } + const projectService = reusableProjectService; + return { + projectService, + [Symbol.dispose]() { + for (const [path] of projectService.openFiles) { + projectService.closeClientFile(path); + } + }, + }; +}; diff --git a/packages/labs/tsserver-plugin/src/test/rules/no-binding-like-attribute-names_test.ts b/packages/labs/tsserver-plugin/src/test/rules/no-binding-like-attribute-names_test.ts index bbbb005c39..cf002e9428 100644 --- a/packages/labs/tsserver-plugin/src/test/rules/no-binding-like-attribute-names_test.ts +++ b/packages/labs/tsserver-plugin/src/test/rules/no-binding-like-attribute-names_test.ts @@ -1,12 +1,13 @@ import assert from 'node:assert'; import * as path from 'node:path'; import {describe as suite, test} from 'node:test'; -import {createTestProjectService} from '../project-service.js'; import {LitDiagnosticCode} from '../../lib/diagnostic-codes.js'; +import {getReusableTestProjectService} from '../project-service.js'; suite('no-binding-like-attribute-names', () => { test('Reports on property-binding-like attribute names', () => { - const projectService = createTestProjectService(); + using cleanup = getReusableTestProjectService(); + const projectService = cleanup.projectService; const pathName = path.resolve( 'test-files/basic-templates/src/bad-attribute-name.ts' diff --git a/packages/labs/tsserver-plugin/src/test/rules/no-unassignable-property-bindings_test.ts b/packages/labs/tsserver-plugin/src/test/rules/no-unassignable-property-bindings_test.ts index c5d6cddc77..2ebba7128b 100644 --- a/packages/labs/tsserver-plugin/src/test/rules/no-unassignable-property-bindings_test.ts +++ b/packages/labs/tsserver-plugin/src/test/rules/no-unassignable-property-bindings_test.ts @@ -7,7 +7,7 @@ import assert from 'node:assert'; import * as path from 'node:path'; import {describe as suite, test} from 'node:test'; -import {createTestProjectService} from '../project-service.js'; +import {getReusableTestProjectService} from '../project-service.js'; import {LitDiagnosticCode} from '../../lib/diagnostic-codes.js'; import type {Diagnostic} from 'typescript'; @@ -21,7 +21,8 @@ function assertDiagnosticMessages( suite('no-unassignable-property-bindings', () => { test('Unknown property diagnostic', () => { - const projectService = createTestProjectService(); + using cleanup = getReusableTestProjectService(); + const projectService = cleanup.projectService; const file = path.resolve( 'test-files/basic-templates/src/property-binding-unknown.ts' ); @@ -40,7 +41,8 @@ suite('no-unassignable-property-bindings', () => { }); test('No diagnostics for assignable bindings', () => { - const projectService = createTestProjectService(); + using cleanup = getReusableTestProjectService(); + const projectService = cleanup.projectService; const file = path.resolve( 'test-files/basic-templates/src/property-binding-assignable.ts' ); @@ -60,7 +62,8 @@ suite('no-unassignable-property-bindings', () => { }); test('Unassignable bindings produce diagnostics', () => { - const projectService = createTestProjectService(); + using cleanup = getReusableTestProjectService(); + const projectService = cleanup.projectService; const file = path.resolve( 'test-files/basic-templates/src/property-binding-unassignable.ts' ); From 0021bdd9764351d7a2b03acacedf0260c0cb6724 Mon Sep 17 00:00:00 2001 From: rictic Date: Wed, 27 Aug 2025 11:54:11 -0700 Subject: [PATCH 2/3] Clean up and optimize other tests too. --- .../src/test/lib/fake-lit-html-types.ts | 57 ---------- .../src/test/lib/lit-expression-type_test.ts | 100 ++++++++---------- .../src/test/project-service.ts | 26 +++-- .../basic-templates/src/expression-types.ts | 47 ++++++++ 4 files changed, 106 insertions(+), 124 deletions(-) delete mode 100644 packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts create mode 100644 packages/labs/tsserver-plugin/test-files/basic-templates/src/expression-types.ts diff --git a/packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts b/packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts deleted file mode 100644 index 8b144075f8..0000000000 --- a/packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Test-only fake lit-html type declarations used to exercise - * `getLitExpressionType` logic without depending on the real lit-html. - * - * The filename intentionally contains `lit-html` so that the helper's - * `isSpecialValue` logic (which checks the source file path) treats these - * exports as coming from lit-html. - */ - -// Sentinel unique symbols (mimic lit-html exports). Using simple Symbol() so -// the declared type is a distinct unique symbol. -export const noChange = Symbol('noChange'); -export const nothing = Symbol('nothing'); - -// Minimal DirectiveResult generic. Only the generic parameter (a constructor) -// is used by the code under test; the shape is irrelevant. -export interface DirectiveResult< - C extends abstract new (...args: unknown[]) => unknown, -> { - /** brand */ readonly __brand?: 'DirectiveResult'; - // Reference the generic so it is not flagged as unused. - readonly __c?: C; -} - -// Two fake directive classes with different render return types so we can -// verify unwrapping (including union return types). -export class MyDirective { - render(): number { - return 42; - } -} - -export class OtherDirective { - render(): string | boolean { - return ''; - } -} - -// Export some typed values (the actual runtime values are not important; -// we just need the static types for the TypeScript type checker). -export const directiveResultNumber = {} as unknown as DirectiveResult< - typeof MyDirective ->; -export const directiveResultUnion = {} as unknown as DirectiveResult< - typeof OtherDirective ->; -export const directiveResultOrString = {} as unknown as - | DirectiveResult - | string; -export const sentinelOnly = noChange; -export const sentinelUnion: typeof noChange | number = noChange; -export const nothingValue = nothing; -export const nothingUnion: typeof nothing | string = nothing; - -// Plain non-special exports for negative test cases. -export const plainNumber = 123; -export const plainUnion: number | string = 0 as unknown as number | string; diff --git a/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts b/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts index 6b117a0cae..f348205bf3 100644 --- a/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts +++ b/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts @@ -3,25 +3,23 @@ import path from 'node:path'; import {describe as suite, test} from 'node:test'; import * as ts from 'typescript'; import {getLitExpressionType} from '../../lib/type-helpers/lit-expression-type.js'; +import {getReusableTestProjectService} from '../project-service.js'; -// Ensure the declarations are part of the program (side-effect import only). -import './fake-lit-html-types.js'; - -// Helper to build a Program including our fake lit-html types file. -function buildProgram(): ts.Program { - // Build the path relative to the package root (cwd during tests) to avoid - // accidentally duplicating path segments. The previous value incorrectly - // prefixed the repository path twice when running inside the package. - const fakeTypesPath = path.resolve('src/test/lib/fake-lit-html-types.ts'); - const options: ts.CompilerOptions = { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - strict: true, - esModuleInterop: true, - skipLibCheck: true, - }; - return ts.createProgram([fakeTypesPath], options); +function getProgram(): ts.Program { + using cleanup = getReusableTestProjectService(); + const projectService = cleanup.projectService; + const expressionTypesPath = path.resolve( + 'test-files/basic-templates/src/expression-types.ts' + ); + const result = projectService.openClientFile(expressionTypesPath); + if (!result.configFileName) { + throw new Error('Did not associate file with a config'); + } + const info = projectService.getScriptInfo(expressionTypesPath)!; + const project = info.containingProjects[0]; + const program = project.getLanguageService().getProgram(); + if (!program) throw new Error('No program'); + return program; } function getExportType( @@ -30,7 +28,7 @@ function getExportType( ): ts.Type | undefined { const sourceFiles = program.getSourceFiles(); const fakeFile = sourceFiles.find((f) => - /fake-lit-html-types\.ts$/.test(f.fileName) + /expression-types\.ts$/.test(f.fileName) ); if (!fakeFile) return undefined; const checker = program.getTypeChecker(); @@ -54,7 +52,7 @@ suite('getLitExpressionType', () => { } test('sentinel unique symbols map to any', () => { - const program = buildProgram(); + const program = getProgram(); const checker = program.getTypeChecker(); // Use the direct exported sentinel symbols so the type has the correct symbol name. const noChangeType = getExportType(program, 'noChange'); @@ -70,7 +68,7 @@ suite('getLitExpressionType', () => { }); test('DirectiveResult unwraps to render return type (primitive)', () => { - const program = buildProgram(); + const program = getProgram(); const checker = program.getTypeChecker(); const directiveResultNumber = getExportType( program, @@ -86,7 +84,7 @@ suite('getLitExpressionType', () => { }); test('DirectiveResult unwraps to union of render return types', () => { - const program = buildProgram(); + const program = getProgram(); const checker = program.getTypeChecker(); const directiveResultUnion = getExportType(program, 'directiveResultUnion'); assert.strictEqual( @@ -110,7 +108,7 @@ suite('getLitExpressionType', () => { }); test('Union containing DirectiveResult unwraps only that member', () => { - const program = buildProgram(); + const program = getProgram(); const checker = program.getTypeChecker(); const mixed = getExportType(program, 'directiveResultOrString'); assert.strictEqual( @@ -134,7 +132,7 @@ suite('getLitExpressionType', () => { }); test('Union with sentinel symbol filters out the sentinel', () => { - const program = buildProgram(); + const program = getProgram(); const checker = program.getTypeChecker(); const sentinelUnion = getExportType(program, 'sentinelUnion'); const transformed = getLitExpressionType(sentinelUnion!, ts, program); @@ -146,21 +144,21 @@ suite('getLitExpressionType', () => { }); test('Plain non-special type is unchanged', () => { - const plainProgram = buildProgram(); - const checker = plainProgram.getTypeChecker(); - const plainNumber = getExportType(plainProgram, 'plainNumber'); + const program = getProgram(); + const checker = program.getTypeChecker(); + const plainNumber = getExportType(program, 'plainNumber'); assert.strictEqual(checker.typeToString(plainNumber!), '123'); - const result = getLitExpressionType(plainNumber!, ts, plainProgram); + const result = getLitExpressionType(plainNumber!, ts, program); assert.strictEqual(result, plainNumber); // literal 123 keeps its literal type. assert.strictEqual(checker.typeToString(result), '123'); }); test('Union without special values is unchanged', () => { - const plainProgram = buildProgram(); - const checker = plainProgram.getTypeChecker(); - const plainUnion = getExportType(plainProgram, 'plainUnion'); - const result = getLitExpressionType(plainUnion!, ts, plainProgram); + const program = getProgram(); + const checker = program.getTypeChecker(); + const plainUnion = getExportType(program, 'plainUnion'); + const result = getLitExpressionType(plainUnion!, ts, program); assert.strictEqual(result, plainUnion); assert.strictEqual( normalizeUnion(checker.typeToString(plainUnion!)), @@ -171,39 +169,27 @@ suite('getLitExpressionType', () => { test('Sentinel-like symbol outside lit-html scope not treated as special', () => { // Create a synthetic unique symbol type named noChange but from a different file. const sourceText = 'export const noChange = Symbol("local");'; - const tempFile = path.resolve('src/test/lib/local-file.ts'); - const compilerOptions: ts.CompilerOptions = { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - strict: true, - }; - const host = ts.createCompilerHost(compilerOptions); - host.readFile = (fileName) => { - if (fileName === tempFile) return sourceText; - return ts.sys.readFile(fileName); - }; - host.fileExists = (fileName) => { - if (fileName === tempFile) return true; - return ts.sys.fileExists(fileName); - }; - const shadowProgram = ts.createProgram( - [path.resolve('src/test/lib/fake-lit-html-types.ts'), tempFile], - compilerOptions, - host + const tempFile = path.resolve( + 'test-files/basic-templates/src/local-sentinel.ts' ); - const checker = shadowProgram.getTypeChecker(); - const sf = shadowProgram + using cleanup = getReusableTestProjectService(); + const projectService = cleanup.projectService; + // Simulate the file existing in memory only: open with content. + projectService.openClientFile(tempFile, sourceText); + const info = projectService.getScriptInfo(tempFile)!; + const project = info.containingProjects[0]; + const program = project.getLanguageService().getProgram()!; + const checker = program.getTypeChecker(); + const sf = program .getSourceFiles() - .find((f) => /local-file\.ts$/.test(f.fileName))!; + .find((f) => /local-sentinel\.ts$/.test(f.fileName))!; const moduleSymbol = checker.getSymbolAtLocation(sf)!; const localSymbol = checker .getExportsOfModule(moduleSymbol) .find((s) => s.getEscapedName() === 'noChange')!; const localType = checker.getTypeOfSymbol(localSymbol); assert.ok(localType, 'got localtype'); - const result = getLitExpressionType(localType, ts, shadowProgram); - // Should be unchanged (unique symbol stays unique symbol) not widened to any. + const result = getLitExpressionType(localType, ts, program); assert.strictEqual(result, localType); assert.strictEqual(checker.typeToString(localType), 'unique symbol'); assert.strictEqual(checker.typeToString(result), 'unique symbol'); diff --git a/packages/labs/tsserver-plugin/src/test/project-service.ts b/packages/labs/tsserver-plugin/src/test/project-service.ts index e749d9fe12..92c842d485 100644 --- a/packages/labs/tsserver-plugin/src/test/project-service.ts +++ b/packages/labs/tsserver-plugin/src/test/project-service.ts @@ -27,23 +27,29 @@ const serverHost: ts.server.ServerHost = { }, setTimeout( - _callback: (...args: any[]) => void, - _ms: number, - ..._args: any[] + callback: (...args: any[]) => void, + ms: number, + ...args: any[] ): any { - throw new Error('Method not implemented.'); + return globalThis.setTimeout(callback, ms, ...args); }, - clearTimeout(_timeoutId: any): void { - throw new Error('Method not implemented.'); + clearTimeout(timeoutId: any): void { + globalThis.clearTimeout(timeoutId); }, - setImmediate(_callback: (...args: any[]) => void, ..._args: any[]): any { - throw new Error('Method not implemented.'); + setImmediate(callback: (...args: any[]) => void, ...args: any[]): any { + return (globalThis as any).setImmediate + ? (globalThis as any).setImmediate(callback, ...args) + : globalThis.setTimeout(callback, 0, ...args); }, - clearImmediate(_timeoutId: any): void { - throw new Error('Method not implemented.'); + clearImmediate(timeoutId: any): void { + if ((globalThis as any).clearImmediate) { + (globalThis as any).clearImmediate(timeoutId); + } else { + globalThis.clearTimeout(timeoutId); + } }, }; diff --git a/packages/labs/tsserver-plugin/test-files/basic-templates/src/expression-types.ts b/packages/labs/tsserver-plugin/test-files/basic-templates/src/expression-types.ts new file mode 100644 index 0000000000..91d8a34f51 --- /dev/null +++ b/packages/labs/tsserver-plugin/test-files/basic-templates/src/expression-types.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {noChange, nothing} from 'lit'; +// Re-export the real sentinel values so tests can access their original symbol names. +export {noChange, nothing}; +import {Directive, DirectiveResult} from 'lit/directive.js'; + +// Two directive classes with different render signatures so we can verify +// unwrapping (including union return types) of DirectiveResult. +export class MyDirective extends Directive { + render(): number { + return 42; + } +} + +export class OtherDirective extends Directive { + render(): string | boolean { + return ''; + } +} + +// Export some typed values (the actual runtime values are not important; we +// just need the static types for the TypeScript type checker). Use a simple +// cast to avoid needing to construct real DirectiveResult values. +export const directiveResultNumber = {} as unknown as DirectiveResult< + typeof MyDirective +>; +export const directiveResultUnion = {} as unknown as DirectiveResult< + typeof OtherDirective +>; +export const directiveResultOrString = {} as unknown as + | DirectiveResult + | string; + +// Sentinel only / sentinel containing unions. +export const sentinelOnly: typeof noChange = noChange; +export const sentinelUnion: typeof noChange | number = noChange; +export const nothingValue: typeof nothing = nothing; +export const nothingUnion: typeof nothing | string = nothing; + +// Plain non-special exports for negative test cases. +export const plainNumber = 123; +export const plainUnion: number | string = 0; From ab2742baecd267e57cb18d754a8acccdac8e99a7 Mon Sep 17 00:00:00 2001 From: rictic Date: Wed, 27 Aug 2025 12:02:23 -0700 Subject: [PATCH 3/3] Changeset. --- .changeset/ninety-apples-lick.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-apples-lick.md diff --git a/.changeset/ninety-apples-lick.md b/.changeset/ninety-apples-lick.md new file mode 100644 index 0000000000..10c214d0ca --- /dev/null +++ b/.changeset/ninety-apples-lick.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/tsserver-plugin': patch +--- + +Optimize tests a bit.