mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
470cf430ce
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.
501 lines
16 KiB
TypeScript
501 lines
16 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 {LanguageServiceTestEnv} from '../testing';
|
|
import {createModuleAndProjectWithDeclarations} from '../testing/src/util';
|
|
|
|
describe('linked editing ranges', () => {
|
|
let env: LanguageServiceTestEnv;
|
|
|
|
beforeEach(() => {
|
|
env = LanguageServiceTestEnv.setup();
|
|
});
|
|
|
|
describe('external templates', () => {
|
|
it('should return linked ranges for element tag in external template', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
templateUrl: './app.html',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
'app.html': '<div>content</div>',
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
// Position on the opening "div" tag
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.html', 2);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
// Opening tag span: <div> - "div" starts at position 1
|
|
expect(linkedRanges!.ranges[0].start).toBe(1);
|
|
expect(linkedRanges!.ranges[0].length).toBe(3);
|
|
// Closing tag span: </div> - "div" starts at position 14 (<div>content</div> = pos 14 for closing div)
|
|
expect(linkedRanges!.ranges[1].start).toBe(14);
|
|
expect(linkedRanges!.ranges[1].length).toBe(3);
|
|
});
|
|
|
|
it('should return linked ranges when on closing tag', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
templateUrl: './app.html',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
'app.html': '<span>text</span>',
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
// Position on the closing "span" tag (</span> starts at position 10, "span" at 13)
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.html', 13);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should return null for void elements', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
templateUrl: './app.html',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
'app.html': '<br>',
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
// Position on "br"
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.html', 2);
|
|
|
|
// Void elements have no closing tag, so no linked ranges
|
|
expect(linkedRanges).toBeNull();
|
|
});
|
|
|
|
it('should return null when not on a tag name', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
templateUrl: './app.html',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
'app.html': '<div class="test">content</div>',
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
// Position on "class" attribute
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.html', 8);
|
|
|
|
expect(linkedRanges).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('inline templates', () => {
|
|
it('should return linked ranges for element tag in inline template', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<div>content</div>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position of opening "div" in the inline template
|
|
const templateStart = appFile.contents.indexOf("template: '") + "template: '".length;
|
|
const divPosition = templateStart + 1; // Skip '<'
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', divPosition);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
expect(linkedRanges!.wordPattern).toBe('[-\\w]+');
|
|
});
|
|
|
|
it('should return linked ranges when on closing tag in inline template', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<span>text</span>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position of closing "span" in the inline template
|
|
// '</span>' - we want to be on the 's' of span
|
|
const closingSpanIndex = appFile.contents.indexOf('</span>') + 2;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', closingSpanIndex);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should return linked ranges for nested elements in inline template', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<div><span>nested</span></div>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position of "span" opening tag
|
|
const templateStart = appFile.contents.indexOf("template: '") + "template: '".length;
|
|
const spanPosition = templateStart + '<div><'.length;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', spanPosition);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should return linked ranges for template literals', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: \`
|
|
<section>
|
|
<article>content</article>
|
|
</section>
|
|
\`,
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position of "section" opening tag
|
|
const sectionStart = appFile.contents.indexOf('<section>') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', sectionStart);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should return null for void elements in inline template', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<img src="test.png">',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position of "img"
|
|
const imgStart = appFile.contents.indexOf('<img') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', imgStart);
|
|
|
|
// Void elements have no closing tag, so no linked ranges
|
|
expect(linkedRanges).toBeNull();
|
|
});
|
|
|
|
it('should handle inline templates with @if control flow', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: \`
|
|
@if (show) {
|
|
<button>Click me</button>
|
|
}
|
|
\`,
|
|
})
|
|
export class AppComponent {
|
|
show = true;
|
|
}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position of "button" opening tag
|
|
const buttonStart = appFile.contents.indexOf('<button>') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', buttonStart);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should return null when position is on attribute name', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<div class="test">content</div>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position of "class" attribute
|
|
const classStart = appFile.contents.indexOf('class=');
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', classStart);
|
|
|
|
expect(linkedRanges).toBeNull();
|
|
});
|
|
|
|
it('should return null when position is on content text', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<div>some content here</div>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position in the middle of the text content
|
|
const contentStart = appFile.contents.indexOf('some content');
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', contentStart);
|
|
|
|
expect(linkedRanges).toBeNull();
|
|
});
|
|
|
|
it('should handle hyphenated tag names', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<my-custom-element>content</my-custom-element>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Find position on the opening tag name
|
|
const tagStart = appFile.contents.indexOf('<my-custom-element>') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', tagStart);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
// Verify the tag name length includes hyphens
|
|
expect(linkedRanges!.ranges[0].length).toBe('my-custom-element'.length);
|
|
expect(linkedRanges!.ranges[1].length).toBe('my-custom-element'.length);
|
|
});
|
|
|
|
it('should handle @for control flow blocks', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: \`
|
|
@for (item of items; track item) {
|
|
<li>{{ item }}</li>
|
|
}
|
|
\`,
|
|
})
|
|
export class AppComponent {
|
|
items = ['a', 'b', 'c'];
|
|
}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
const liStart = appFile.contents.indexOf('<li>') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', liStart);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should handle elements inside ng-template', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: \`
|
|
<ng-template>
|
|
<span>template content</span>
|
|
</ng-template>
|
|
\`,
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
const spanStart = appFile.contents.indexOf('<span>') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', spanStart);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should handle position at exact start of tag name', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<p>paragraph</p>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Position right after '<'
|
|
const pStart = appFile.contents.indexOf('<p>') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', pStart);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should handle position at exact end of tag name', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<div>text</div>',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
// Position right at the end of 'div' (just before '>')
|
|
const divEnd = appFile.contents.indexOf('<div>') + 4; // 1 for '<' + 3 for 'div'
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', divEnd);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
|
|
it('should return null for self-closing elements', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: '<input type="text" />',
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
const inputStart = appFile.contents.indexOf('<input') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', inputStart);
|
|
|
|
// Self-closing elements don't have linked ranges
|
|
expect(linkedRanges).toBeNull();
|
|
});
|
|
|
|
it('should work with SVG elements', () => {
|
|
const files = {
|
|
'app.ts': `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'my-app',
|
|
template: \`
|
|
<svg>
|
|
<rect width="100" height="100"></rect>
|
|
</svg>
|
|
\`,
|
|
})
|
|
export class AppComponent {}
|
|
`,
|
|
};
|
|
|
|
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
|
|
const appFile = project.openFile('app.ts');
|
|
const rectStart = appFile.contents.indexOf('<rect') + 1;
|
|
|
|
const linkedRanges = project.getLinkedEditingRangeAtPosition('app.ts', rectStart);
|
|
|
|
expect(linkedRanges).not.toBeNull();
|
|
expect(linkedRanges!.ranges.length).toBe(2);
|
|
});
|
|
});
|
|
});
|