Files
Andrew Scott 470cf430ce test(language-service): optimize language service test environment and flatten test setup
Optimization Goals:
The primary goals of this optimization are to dramatically reduce the execution time of the language-service test suite and stabilize the mock file system infrastructure. Previously, the suite suffered from significant overhead due to recreating the `MockFileSystem`, `MockServerHost`, and TypeScript `ProjectService` for every single test block, leading to redundant parsing operations, slow test initialization, and reduced spec performance.

How the Goals Were Achieved:
1. **Mock File System Optimizations (Shared State)**:
   - Evaluated that standard test files (e.g., TS/Angular lib definitions) are immutable across tests.
   - Introduced and utilized `lockMockFileSystem()` to initialize the mock `FileSystem` with `loadStandardTestFiles()` only once per test suite run rather than repeatedly per test.
   - Refactored `LanguageServiceTestEnv.setup()` to reuse the singleton file system, completely skipping redundant module loading by eagerly flagging `fsInitialized = true`.

2. **Language Service Test Environment Enhancements (TypeScript Project Reuse)**:
   - Implemented partial configuration reloads in the `Project` class via the `update()` method, removing the need to tear down and rebuild the entire `MockServerHost` and TypeScript `ProjectService` from scratch when minimal file changes (like HTML templates or local TS edits) are made dynamically by a test.
   - Applied `projectService.reloadProjects()` and `scriptInfo.reloadFromFile()` to synchronously push mock file tree invalidations to the active TS program, skipping expensive environment initialization and saving considerable latency across tests.
   - Added `projectName` identifiers inside complex isolate tests (e.g. module alias aliasing) so custom environment injections can sandbox safely without invalidating the global default environment cache.

3. **Test Suite Unification**:
   - Flattened fragmented test groups (`grp1`, `grp3`, `grp4`) into a cohesive single directory at `packages/language-service/test/`. This simplifies execution config, improves test runner concurrency, and unifies local development targeting.
   - Cleaned out broken inline debug logging and unneeded config reloading loops.

4. **Maintaining Test Isolation**:
   - **Explicit TypeScript Configuration**: While the underlying `MockFileSystem` ("disk") is aggressively reused across tests, the TypeScript `ProjectService` and its execution environment are entirely recreated for every test run to ensure isolated ASTs and module resolution caches.
   - **Strict tsconfig.json Files Array**: When a project is initialized, it explicitly defines its boundary using the strict `files: [ ... ]` array in `tsconfig.json`. This ensures that any leftover files physically on the mock disk from an older test run are completely invisible to the TS Compiler.
   - **Namespace Sandboxing**: For tests doing custom modifications (e.g., overriding module resolution paths), they utilize localized `projectName` arguments (like `"test_alias_completions"`) to configure sandboxed working directories.
2026-03-16 14:49:34 -06:00

235 lines
8.0 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import ts from 'typescript';
import {
addElementToArrayLiteral,
collectMemberMethods,
ensureArrayWithIdentifier,
findTightestNode,
generateImport,
nonCollidingImportName,
objectPropertyAssignmentForKey,
updateImport,
updateObjectValueForKey,
} from '../src/utils/ts_utils';
import {LanguageServiceTestEnv, OpenBuffer, Project} from '../testing';
describe('TS util', () => {
describe('collectMemberMethods', () => {
it('gets only methods in class, not getters, setters, or properties', () => {
const files = {
'app.ts': `
export class AppCmp {
prop!: string;
get myString(): string {
return '';
}
set myString(v: string) {
}
one() {}
two() {}
}`,
};
const env = LanguageServiceTestEnv.setup();
const project = env.addProject('test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('AppC¦mp');
const memberMethods = getMemberMethodNames(project, appFile);
expect(memberMethods).toEqual(['one', 'two']);
});
it('gets inherited methods in class', () => {
const files = {
'app.ts': `
export class BaseClass {
baseMethod() {}
}
export class AppCmp extends BaseClass {}`,
};
const env = LanguageServiceTestEnv.setup();
const project = env.addProject('test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('AppC¦mp');
const memberMethods = getMemberMethodNames(project, appFile);
expect(memberMethods).toEqual(['baseMethod']);
});
it('does not return duplicates if base method is overridden', () => {
const files = {
'app.ts': `
export class BaseClass {
baseMethod() {}
}
export class AppCmp extends BaseClass {
baseMethod() {}
}`,
};
const env = LanguageServiceTestEnv.setup();
const project = env.addProject('test', files);
const appFile = project.openFile('app.ts');
appFile.moveCursorToText('AppC¦mp');
const memberMethods = getMemberMethodNames(project, appFile);
expect(memberMethods).toEqual(['baseMethod']);
});
function getMemberMethodNames(project: Project, file: OpenBuffer): string[] {
const sf = project.getSourceFile('app.ts')!;
const node = findTightestNode(sf, file.cursor)!;
expect(ts.isClassDeclaration(node.parent)).toBe(true);
return collectMemberMethods(node.parent as ts.ClassDeclaration, project.getTypeChecker())
.map((m) => m.name.getText())
.sort();
}
});
describe('AST method', () => {
let printer: ts.Printer;
let sourceFile: ts.SourceFile;
function print(node: ts.Node): string {
return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
}
beforeAll(() => {
printer = ts.createPrinter();
sourceFile = ts.createSourceFile(
'placeholder.ts',
'',
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TS,
);
});
describe('addElementToArrayLiteral', () => {
it('transforms an empty array literal expression', () => {
const oldArr = ts.factory.createArrayLiteralExpression([], false);
const newArr = addElementToArrayLiteral(oldArr, ts.factory.createStringLiteral('a'));
expect(print(newArr)).toEqual('["a"]');
});
it('transforms an existing array literal expression', () => {
const oldArr = ts.factory.createArrayLiteralExpression(
[ts.factory.createStringLiteral('a')],
false,
);
const newArr = addElementToArrayLiteral(oldArr, ts.factory.createStringLiteral('b'));
expect(print(newArr)).toEqual('["a", "b"]');
});
it('addElementToArrayLiteral', () => {
let arr = ensureArrayWithIdentifier('foo', ts.factory.createIdentifier('foo'));
arr = addElementToArrayLiteral(arr!, ts.factory.createIdentifier('bar'));
expect(print(arr)).toEqual('[foo, bar]');
});
});
describe('objectPropertyAssignmentForKey', () => {
let oldObj: ts.ObjectLiteralExpression;
beforeEach(() => {
oldObj = ts.factory.createObjectLiteralExpression(
[
ts.factory.createPropertyAssignment(
ts.factory.createIdentifier('foo'),
ts.factory.createStringLiteral('bar'),
),
],
false,
);
});
it('returns null when no property exists', () => {
const prop = objectPropertyAssignmentForKey(oldObj, 'oops');
expect(prop).toBeNull();
});
it('returns the requested property assignment', () => {
const prop = objectPropertyAssignmentForKey(oldObj, 'foo');
expect(print(prop!)).toEqual('foo: "bar"');
});
});
describe('updateObjectValueForKey', () => {
let oldObj: ts.ObjectLiteralExpression;
const valueAppenderFn = (oldValue?: ts.Expression) => {
if (!oldValue) return ts.factory.createStringLiteral('baz');
if (!ts.isStringLiteral(oldValue)) return oldValue;
return ts.factory.createStringLiteral(oldValue.text + 'baz');
};
beforeEach(() => {
oldObj = ts.factory.createObjectLiteralExpression(
[
ts.factory.createPropertyAssignment(
ts.factory.createIdentifier('foo'),
ts.factory.createStringLiteral('bar'),
),
],
false,
);
});
it('creates a non-existent property', () => {
const obj = updateObjectValueForKey(oldObj, 'newKey', valueAppenderFn);
expect(print(obj)).toBe('newKey: "baz"');
});
it('updates an existing property', () => {
const obj = updateObjectValueForKey(oldObj, 'foo', valueAppenderFn);
expect(print(obj)).toBe('foo: "barbaz"');
});
});
it('ensureArrayWithIdentifier', () => {
let arr = ensureArrayWithIdentifier('foo', ts.factory.createIdentifier('foo'));
expect(print(arr!)).toEqual('[foo]');
arr = ensureArrayWithIdentifier('bar', ts.factory.createIdentifier('bar'), arr!);
expect(print(arr!)).toEqual('[foo, bar]');
arr = ensureArrayWithIdentifier('bar', ts.factory.createIdentifier('bar'), arr!);
expect(arr).toEqual(null);
});
it('generateImport', () => {
let imp = generateImport('Foo', null, './foo');
expect(print(imp)).toEqual(`import { Foo } from "./foo";`);
imp = generateImport('Foo', 'Bar', './foo');
expect(print(imp)).toEqual(`import { Bar as Foo } from "./foo";`);
});
it('updateImport', () => {
let imp = generateImport('Foo', null, './foo');
let namedImp = updateImport(imp, 'Bar', null);
expect(print(namedImp!)).toEqual(`{ Foo, Bar }`);
namedImp = updateImport(imp, 'Foo_2', 'Foo');
expect(print(namedImp!)).toEqual(`{ Foo, Foo as Foo_2 }`);
namedImp = updateImport(imp, 'Bar', 'Bar');
expect(print(namedImp!)).toEqual(`{ Foo, Bar }`);
namedImp = updateImport(imp, 'default', 'Baz');
expect(print(namedImp!)).toEqual(`Baz, { Foo }`);
imp = generateImport('default', 'Foo', './foo');
namedImp = updateImport(imp, 'Bar', null);
expect(print(namedImp!)).toEqual(`Foo, { Bar }`);
});
it('nonCollidingImportName', () => {
let imps = [
generateImport('Foo', null, './foo'),
generateImport('Bar', 'ExternalBar', './bar'),
];
expect(nonCollidingImportName(imps, 'Other')).toEqual('Other');
expect(nonCollidingImportName(imps, 'Foo')).toEqual('Foo_1');
expect(nonCollidingImportName(imps, 'ExternalBar')).toEqual('ExternalBar');
});
});
});