test(language-service): [Ivy] return cursor position in overwritten template (#38552)
In many testing scenarios, there is a common pattern: 1. Overwrite template (inline or external) 2. Find cursor position 3. Call one of language service APIs 4. Inspect spans in result In order to faciliate this pattern, this commit refactors `MockHost.overwrite()` and `MockHost.overwriteInlineTemplate()` to allow a faux cursor symbol `¦` to be injected into the template, and the methods will automatically remove it before updating the script snapshot. Both methods will return the cursor position and the new text without the cursor symbol. This makes testing very convenient. Here's a typical example: ```ts const {position, text} = mockHost.overwrite('template.html', `{{ ti¦tle }}`); const quickInfo = ngLS.getQuickInfoAtPosition('template.html', position); const {start, length} = quickInfo!.textSpan; expect(text.substring(start, start + length)).toBe('title'); ``` PR Close #38552
This commit is contained in:
parent
b48cc6ead5
commit
4985267211
|
@ -26,13 +26,13 @@ describe('diagnostic', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should report member does not exist', () => {
|
it('should report member does not exist', () => {
|
||||||
const content = service.overwriteInlineTemplate(APP_COMPONENT, '{{ nope }}');
|
const {text} = service.overwriteInlineTemplate(APP_COMPONENT, '{{ nope }}');
|
||||||
const diags = ngLS.getSemanticDiagnostics(APP_COMPONENT);
|
const diags = ngLS.getSemanticDiagnostics(APP_COMPONENT);
|
||||||
expect(diags.length).toBe(1);
|
expect(diags.length).toBe(1);
|
||||||
const {category, file, start, length, messageText} = diags[0];
|
const {category, file, start, length, messageText} = diags[0];
|
||||||
expect(category).toBe(ts.DiagnosticCategory.Error);
|
expect(category).toBe(ts.DiagnosticCategory.Error);
|
||||||
expect(file?.fileName).toBe(APP_COMPONENT);
|
expect(file?.fileName).toBe(APP_COMPONENT);
|
||||||
expect(content.substring(start!, start! + length!)).toBe('nope');
|
expect(text.substring(start!, start! + length!)).toBe('nope');
|
||||||
expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`);
|
expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -105,6 +105,17 @@ export function setup() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OverwriteResult {
|
||||||
|
/**
|
||||||
|
* Position of the cursor, -1 if there isn't one.
|
||||||
|
*/
|
||||||
|
position: number;
|
||||||
|
/**
|
||||||
|
* Overwritten content without the cursor.
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
class MockService {
|
class MockService {
|
||||||
private readonly overwritten = new Set<ts.server.NormalizedPath>();
|
private readonly overwritten = new Set<ts.server.NormalizedPath>();
|
||||||
|
|
||||||
|
@ -113,20 +124,32 @@ class MockService {
|
||||||
private readonly ps: ts.server.ProjectService,
|
private readonly ps: ts.server.ProjectService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
overwrite(fileName: string, newText: string): string {
|
/**
|
||||||
|
* Overwrite the entire content of `fileName` with `newText`. If cursor is
|
||||||
|
* present in `newText`, it will be removed and the position of the cursor
|
||||||
|
* will be returned.
|
||||||
|
*/
|
||||||
|
overwrite(fileName: string, newText: string): OverwriteResult {
|
||||||
const scriptInfo = this.getScriptInfo(fileName);
|
const scriptInfo = this.getScriptInfo(fileName);
|
||||||
this.overwriteScriptInfo(scriptInfo, preprocess(newText));
|
return this.overwriteScriptInfo(scriptInfo, newText);
|
||||||
return newText;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
overwriteInlineTemplate(fileName: string, newTemplate: string): string {
|
/**
|
||||||
|
* Overwrite an inline template defined in `fileName` and return the entire
|
||||||
|
* content of the source file (not just the template). If a cursor is present
|
||||||
|
* in `newTemplate`, it will be removed and the position of the cursor in the
|
||||||
|
* source file will be returned.
|
||||||
|
*/
|
||||||
|
overwriteInlineTemplate(fileName: string, newTemplate: string): OverwriteResult {
|
||||||
const scriptInfo = this.getScriptInfo(fileName);
|
const scriptInfo = this.getScriptInfo(fileName);
|
||||||
const snapshot = scriptInfo.getSnapshot();
|
const snapshot = scriptInfo.getSnapshot();
|
||||||
const originalContent = snapshot.getText(0, snapshot.getLength());
|
const originalText = snapshot.getText(0, snapshot.getLength());
|
||||||
const newContent =
|
const {position, text} =
|
||||||
originalContent.replace(/template: `([\s\S]+)`/, `template: \`${newTemplate}\``);
|
replaceOnce(originalText, /template: `([\s\S]+?)`/, `template: \`${newTemplate}\``);
|
||||||
this.overwriteScriptInfo(scriptInfo, preprocess(newContent));
|
if (position === -1) {
|
||||||
return newContent;
|
throw new Error(`${fileName} does not contain a component with template`);
|
||||||
|
}
|
||||||
|
return this.overwriteScriptInfo(scriptInfo, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
|
@ -151,16 +174,37 @@ class MockService {
|
||||||
return scriptInfo;
|
return scriptInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string) {
|
/**
|
||||||
|
* Remove the cursor from `newText`, then replace `scriptInfo` with the new
|
||||||
|
* content and return the position of the cursor.
|
||||||
|
* @param scriptInfo
|
||||||
|
* @param newText Text that possibly contains a cursor
|
||||||
|
*/
|
||||||
|
private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string): OverwriteResult {
|
||||||
|
const result = replaceOnce(newText, /¦/, '');
|
||||||
const snapshot = scriptInfo.getSnapshot();
|
const snapshot = scriptInfo.getSnapshot();
|
||||||
scriptInfo.editContent(0, snapshot.getLength(), newText);
|
scriptInfo.editContent(0, snapshot.getLength(), result.text);
|
||||||
this.overwritten.add(scriptInfo.fileName);
|
this.overwritten.add(scriptInfo.fileName);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const REGEX_CURSOR = /¦/g;
|
/**
|
||||||
function preprocess(text: string): string {
|
* Replace at most one occurence that matches `regex` in the specified
|
||||||
return text.replace(REGEX_CURSOR, '');
|
* `searchText` with the specified `replaceText`. Throw an error if there is
|
||||||
|
* more than one occurrence.
|
||||||
|
*/
|
||||||
|
function replaceOnce(searchText: string, regex: RegExp, replaceText: string): OverwriteResult {
|
||||||
|
regex = new RegExp(regex.source, regex.flags + 'g' /* global */);
|
||||||
|
let position = -1;
|
||||||
|
const text = searchText.replace(regex, (...args) => {
|
||||||
|
if (position !== -1) {
|
||||||
|
throw new Error(`${regex} matches more than one occurrence in text: ${searchText}`);
|
||||||
|
}
|
||||||
|
position = args[args.length - 2]; // second last argument is always the index
|
||||||
|
return replaceText;
|
||||||
|
});
|
||||||
|
return {position, text};
|
||||||
}
|
}
|
||||||
|
|
||||||
const REF_MARKER = /«(((\w|\-)+)|([^ᐱ]*ᐱ(\w+)ᐱ.[^»]*))»/g;
|
const REF_MARKER = /«(((\w|\-)+)|([^ᐱ]*ᐱ(\w+)ᐱ.[^»]*))»/g;
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import * as ts from 'typescript/lib/tsserverlibrary';
|
import * as ts from 'typescript/lib/tsserverlibrary';
|
||||||
|
|
||||||
import {APP_MAIN, setup, TEST_SRCDIR} from './mock_host';
|
import {APP_COMPONENT, APP_MAIN, setup, TEST_SRCDIR} from './mock_host';
|
||||||
|
|
||||||
describe('mock host', () => {
|
describe('mock host', () => {
|
||||||
const {project, service, tsLS} = setup();
|
const {project, service, tsLS} = setup();
|
||||||
|
@ -58,13 +58,93 @@ describe('mock host', () => {
|
||||||
expect(getText(scriptInfo)).toBe('const x: string = 0');
|
expect(getText(scriptInfo)).toBe('const x: string = 0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can find the cursor', () => {
|
describe('overwrite()', () => {
|
||||||
const content = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
|
it('will return the cursor position', () => {
|
||||||
// content returned by overwrite() is the original content with cursor
|
const {position} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
|
||||||
expect(content).toBe(`const fo¦o = 'hello world';`);
|
expect(position).toBe(8);
|
||||||
const scriptInfo = service.getScriptInfo(APP_MAIN);
|
});
|
||||||
// script info content should not contain cursor
|
|
||||||
expect(getText(scriptInfo)).toBe(`const foo = 'hello world';`);
|
it('will remove the cursor in overwritten text', () => {
|
||||||
|
const {text} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
|
||||||
|
expect(text).toBe(`const foo = 'hello world';`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will update script info without cursor', () => {
|
||||||
|
const {text} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`);
|
||||||
|
const scriptInfo = service.getScriptInfo(APP_MAIN);
|
||||||
|
const snapshot = getText(scriptInfo);
|
||||||
|
expect(snapshot).toBe(`const foo = 'hello world';`);
|
||||||
|
expect(snapshot).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will throw if there is more than one cursor', () => {
|
||||||
|
expect(() => service.overwrite(APP_MAIN, `const f¦oo = 'hello wo¦rld';`))
|
||||||
|
.toThrowError(/matches more than one occurrence in text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will return -1 if cursor is not present', () => {
|
||||||
|
const {position} = service.overwrite(APP_MAIN, `const foo = 'hello world';`);
|
||||||
|
expect(position).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('overwriteInlineTemplate()', () => {
|
||||||
|
it('will return the cursor position', () => {
|
||||||
|
const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`);
|
||||||
|
// The position returned should be relative to the start of the source
|
||||||
|
// file, not the start of the template.
|
||||||
|
expect(position).not.toBe(5);
|
||||||
|
expect(text.substring(position, position + 4)).toBe('o }}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will remove the cursor in overwritten text', () => {
|
||||||
|
const {text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`);
|
||||||
|
expect(text).toContain(`{{ foo }}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will return the entire content of the source file', () => {
|
||||||
|
const {text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`);
|
||||||
|
expect(text).toContain(`@Component`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will update script info without cursor', () => {
|
||||||
|
service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`);
|
||||||
|
const scriptInfo = service.getScriptInfo(APP_COMPONENT);
|
||||||
|
expect(getText(scriptInfo)).toContain(`{{ foo }}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will throw if there is no template in file', () => {
|
||||||
|
expect(() => service.overwriteInlineTemplate(APP_MAIN, `{{ foo }}`))
|
||||||
|
.toThrowError(/does not contain a component with template/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will throw if there is more than one cursor', () => {
|
||||||
|
expect(() => service.overwriteInlineTemplate(APP_COMPONENT, `{{ f¦o¦o }}`))
|
||||||
|
.toThrowError(/matches more than one occurrence in text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will return -1 if cursor is not present', () => {
|
||||||
|
const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`);
|
||||||
|
expect(position).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will throw if there is more than one component with template', () => {
|
||||||
|
service.overwrite(APP_COMPONENT, `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: \`<h1></h1>\`,
|
||||||
|
})
|
||||||
|
export class ComponentA {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: \`<h2></h2>\`,
|
||||||
|
})
|
||||||
|
export class ComponentB {}
|
||||||
|
`);
|
||||||
|
expect(() => service.overwriteInlineTemplate(APP_COMPONENT, `<p></p>`))
|
||||||
|
.toThrowError(/matches more than one occurrence in text/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue