Skip to content

Commit

Permalink
test(rsbuild-plugin-angular): test component resolver and devkit for …
Browse files Browse the repository at this point in the history
…ng versions (Coly010#52)
  • Loading branch information
BioPhoton committed Jan 14, 2025
1 parent 11ff043 commit ee6830a
Show file tree
Hide file tree
Showing 9 changed files with 369 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { SourceFileCache } from './source-file-cache';
import { SourceFileCache } from '../utils/source-file-cache';
export interface CompilerPluginOptions {
sourcemap: boolean;
tsconfig: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,9 @@
*/

import { dirname, resolve } from 'node:path';
import {
ArrayLiteralExpression,
Project,
PropertyAssignment,
SyntaxKind,
} from 'ts-morph';
import { Project, SyntaxKind } from 'ts-morph';
import { normalize } from 'path';
import { getAllTextByProperty, getTextByProperty } from './utils';

interface StyleUrlsCacheEntry {
matchedStyleUrls: string[];
Expand All @@ -52,17 +48,23 @@ export class StyleUrlsResolver {
// ]
// })
// The `matchedStyleUrls` would result in: `styleUrls: [\n './app.component.scss'\n ]`.
const matchedStyleUrls = getStyleUrls(code);
const matchedStyleUrls = getStyleUrls(code)
// for type narrowing
.filter(v => v !== undefined);
const entry = this.styleUrlsCache.get(id);
// We're using `matchedStyleUrls` as a key because the code may be changing continuously,
// resulting in the resolver being called multiple times. While the code changes, the
// `styleUrls` may remain constant, which means we should always return the previously
// resolved style URLs.
if (entry && entry.matchedStyleUrls === matchedStyleUrls) {
if (
entry &&
entry.matchedStyleUrls.join(',') === matchedStyleUrls.join(',')
) {
return entry.styleUrls;
}

const styleUrls = matchedStyleUrls.map((styleUrlPath) => {
const styleUrls = matchedStyleUrls
.map((styleUrlPath) => {
return `${styleUrlPath}|${normalize(resolve(dirname(id), styleUrlPath))}`;
});

Expand All @@ -71,29 +73,14 @@ export class StyleUrlsResolver {
}
}

function getTextByProperty(name: string, properties: PropertyAssignment[]) {
return properties
.filter((property) => property.getName() === name)
.map((property) =>
property.getInitializer()?.getText().replace(/['"`]/g, '')
)
.filter((url): url is string => url !== undefined);
}

export function getStyleUrls(code: string) {
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('cmp.ts', code);
const properties = sourceFile.getDescendantsOfKind(
SyntaxKind.PropertyAssignment
);
const styleUrl = getTextByProperty('styleUrl', properties);
const styleUrls = properties
.filter((property) => property.getName() === 'styleUrls')
.map((property) => property.getInitializer() as ArrayLiteralExpression)
.flatMap((array) =>
array.getElements().map((el) => el.getText().replace(/['"`]/g, ''))
);

const styleUrls = getAllTextByProperty('styleUrls', properties);
return [...styleUrls, ...styleUrl];
}

Expand All @@ -106,7 +93,7 @@ export function getTemplateUrls(code: string) {
return getTextByProperty('templateUrl', properties);
}

interface TemplateUrlsCacheEntry {
export interface TemplateUrlsCacheEntry {
code: string;
templateUrlPaths: string[];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect } from 'vitest';
import {
getStyleUrls,
getTemplateUrls,
StyleUrlsResolver,
TemplateUrlsResolver,
} from './component-resolvers';

describe('getStyleUrls', () => {
it('should include values form styleUrl', () => {
const styleUrl = 'button.component.scss';
const code = `
@Component({
styleUrl: '${styleUrl}',
})
export class ButtonComponent {}
`;
expect(getStyleUrls(code)).toStrictEqual([styleUrl]);
});

it('should include values form styleUrls', () => {
const styleUrls = ['color.scss', 'button.component.scss'];
const code = `
@Component({
styleUrls: [${styleUrls.map((v) => `'${v}'`).join(', ')}],
})
export class ButtonComponent {}
`;
expect(getStyleUrls(code)).toStrictEqual(styleUrls);
});

it('should include values form styleUrl and styleUrls', () => {
const styleUrl = 'theme.scss';
const styleUrls = ['color.scss', 'button.component.scss'];
const code = `
@Component({
styleUrl: '${styleUrl}',
styleUrls: [${styleUrls.map((v) => `'${v}'`).join(', ')}],
})
export class ButtonComponent {}
`;
expect(getStyleUrls(code)).toStrictEqual([...styleUrls, styleUrl]);
});

it('should return empty array if no styles are present in the component', () => {
const code = `
@Component({})
export class ButtonComponent {}
`;
expect(getStyleUrls(code)).toStrictEqual([]);
});
});

describe('StyleUrlsResolver', () => {
it('should return parse code and return styleUrlsPaths', () => {
const resolver = new StyleUrlsResolver();
// @ts-expect-error: Accessing private property for testing
const spyGet = vi.spyOn(resolver.styleUrlsCache, 'get');
// @ts-expect-error: Accessing private property for testing
const spySet = vi.spyOn(resolver.styleUrlsCache, 'set');
const code = `
@Component({
styleUrl: 'theme.scss',
styleUrls: ['color.scss', 'button.component.scss'],
})
export class ButtonComponent {}
`;
const id = 'button.component.ts';

expect(resolver.resolve(code, id)).toStrictEqual([
expect.stringMatching(/^(color.scss)\|.*\1$/),
expect.stringMatching(/^(button.component.scss)\|.*\1$/),
expect.stringMatching(/^(theme.scss)\|.*\1$/),
]);
expect(spyGet).toHaveBeenCalledTimes(1);
expect(spySet).toHaveBeenCalledTimes(1);
});

it('should return styleUrlsPaths from cache if the code is the same', () => {
const resolver = new StyleUrlsResolver();
// @ts-expect-error: Accessing private property for testing
const spyGet = vi.spyOn(resolver.styleUrlsCache, 'get');
// @ts-expect-error: Accessing private property for testing
const spySet = vi.spyOn(resolver.styleUrlsCache, 'set');
const code = `
@Component({
styleUrl: 'theme.scss',
styleUrls: ['color.scss', 'button.component.scss'],
})
export class ButtonComponent {}
`;
const id = 'button.component.ts';

expect(() => resolver.resolve(code, id)).not.toThrow();
expect(() => resolver.resolve(code, id)).not.toThrow();
expect(spyGet).toHaveBeenCalledTimes(2);
expect(spySet).toHaveBeenCalledTimes(1);
});
});

describe('getTemplateUrls', () => {
it('should include values form templateUrl', () => {
const templateUrl = 'button.component.html';
const code = `
@Component({
templateUrl: '${templateUrl}',
})
export class ButtonComponent {}
`;
expect(getTemplateUrls(code)).toStrictEqual([templateUrl]);
});

it('should return empty array if no template is present in the component', () => {
const code = `
@Component({})
export class ButtonComponent {}
`;
expect(getTemplateUrls(code)).toStrictEqual([]);
});
});

describe('TemplateUrlsResolver', () => {
it('should return parse code and return templateUrlPaths', () => {
const resolver = new TemplateUrlsResolver();
// @ts-expect-error: Accessing private property for testing
const spyGet = vi.spyOn(resolver.templateUrlsCache, 'get');
// @ts-expect-error: Accessing private property for testing
const spySet = vi.spyOn(resolver.templateUrlsCache, 'set');

const code = `
@Component({
templateUrl: 'button.component.html',
})
export class ButtonComponent {}
`;
const id = 'button.component.ts';
expect(resolver.resolve(code, id)).toStrictEqual([
expect.stringContaining('button.component.html'),
]);
expect(spyGet).toHaveBeenCalledTimes(1);
expect(spySet).toHaveBeenCalledTimes(1);
});

it('should return templateUrlPaths from cache if the code is the same', () => {
const resolver = new TemplateUrlsResolver();
// @ts-expect-error: Accessing private property for testing
const spyGet = vi.spyOn(resolver.templateUrlsCache, 'get');
// @ts-expect-error: Accessing private property for testing
const spySet = vi.spyOn(resolver.templateUrlsCache, 'set');

const code = `
@Component({
templateUrl: 'button.component.html',
})
export class ButtonComponent {}
`;
const id = '1';
expect(resolver.resolve(code, id)).toStrictEqual([
expect.stringContaining('button.component.html'),
]);
expect(resolver.resolve(code, id)).toStrictEqual([
expect.stringContaining('button.component.html'),
]);
expect(spyGet).toHaveBeenCalledTimes(2);
expect(spySet).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect } from 'vitest';

vi.mock('@angular/compiler-cli', async () => {
const actual = await vi.importActual('@angular/compiler-cli');
return {
...actual,
VERSION: {
major: 16,
minor: 4,
patch: 2,
},
};
});

describe('devkit importing an angular version >=16 & <18', async () => {
// @TODO fins a way to mock require calls instead of testing the error
it('should return the exports', async () => {
expect(import('./devkit.ts')).rejects.toThrow(
'@angular-devkit/build-angular/src/tools/esbuild/angular/compiler-plugin.js'
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect } from 'vitest';

vi.mock('@angular/compiler-cli', async () => {
const actual = await vi.importActual('@angular/compiler-cli');
return {
...actual,
VERSION: {
major: 19,
minor: 4,
patch: 2,
},
};
});

describe('devkit importing an angular version >=19', async () => {
it('should return the exports', async () => {
expect(import('./devkit.ts')).resolves.toStrictEqual(
expect.objectContaining({
JavaScriptTransformer: expect.any(Function),
SourceFileCache: expect.any(Function),
angularMajor: 19,
angularMinor: 4,
angularPatch: 2,
createJitResourceTransformer: expect.any(Function),
})
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect } from 'vitest';

vi.mock('@angular/compiler-cli', async () => {
const actual = await vi.importActual('@angular/compiler-cli');
return {
...actual,
VERSION: {
major: 15,
minor: 4,
patch: 2,
},
};
});

describe('devkit importing an angular version >=15 & <16', async () => {
// @TODO fins a way to mock require calls instead of testing the error
it('should return the exports', async () => {
expect(import('./devkit.ts')).rejects.toThrow(
'@angular-devkit/build-angular/src/builders/browser-esbuild/compiler-plugin.js'
);
});
});
27 changes: 27 additions & 0 deletions packages/rsbuild-plugin-angular/src/lib/plugin/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
import { platform } from 'node:os';
import { ArrayLiteralExpression, PropertyAssignment } from 'ts-morph';

export const isUsingWindows = () => platform() === 'win32';

export function getTextByProperty(
name: string,
properties: PropertyAssignment[]
) {
return properties
.filter((property) => property.getName() === name)
.map((property) => normalizeQuotes(property.getInitializer()?.getText()))
.filter((url): url is string => url !== undefined);
}

export function getAllTextByProperty(
name: string,
properties: PropertyAssignment[]
) {
return properties
.filter((property) => property.getName() === name)
.map((property) => property.getInitializer() as ArrayLiteralExpression)
.flatMap((array) =>
array.getElements().map((el) => normalizeQuotes(el.getText()))
);
}

export function normalizeQuotes(str?: string) {
return str ? str.replace(/['"`]/g, '') : str;
}
Loading

0 comments on commit ee6830a

Please sign in to comment.