Skip to content

Commit ae3fb85

Browse files
committed
refactor(@schematics/angular): add Karma configuration analyzer and comparer
Introduces a new utility for analyzing and comparing Karma configuration files. This is a foundational step for migrating from Karma to other testing frameworks like Jest and Vitest, as it allows the migration schematic to understand the user's existing setup. The new `analyzeKarmaConfig` function uses TypeScript's AST parser to safely extract settings from a `karma.conf.js` file. It can identify common patterns, including `require` calls, and flags configurations that are too complex for static analysis. The `compareKarmaConfigs` function provides the ability to diff a user's Karma configuration against a default template. This will be used to determine which custom settings need to be migrated. Known limitations of the analyzer: - It does not resolve variables or complex expressions. Any value that is not a literal (string, number, boolean, array, object) or a direct `require` call will be marked as an unsupported value. - It does not support Karma configuration files that use ES Modules (import/export syntax).
1 parent b6b2578 commit ae3fb85

File tree

4 files changed

+772
-0
lines changed

4 files changed

+772
-0
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
10+
11+
export interface RequireInfo {
12+
module: string;
13+
export?: string;
14+
isCall?: boolean;
15+
arguments?: KarmaConfigValue[];
16+
}
17+
18+
export type KarmaConfigValue =
19+
| string
20+
| boolean
21+
| number
22+
| KarmaConfigValue[]
23+
| { [key: string]: KarmaConfigValue }
24+
| RequireInfo
25+
| undefined;
26+
27+
export interface KarmaConfigAnalysis {
28+
settings: Map<string, KarmaConfigValue>;
29+
hasUnsupportedValues: boolean;
30+
}
31+
32+
function isRequireInfo(value: KarmaConfigValue): value is RequireInfo {
33+
return typeof value === 'object' && value !== null && !Array.isArray(value) && 'module' in value;
34+
}
35+
36+
/**
37+
* Analyzes the content of a Karma configuration file to extract its settings.
38+
*
39+
* @param content The string content of the `karma.conf.js` file.
40+
* @returns An object containing the configuration settings and a flag indicating if unsupported values were found.
41+
*/
42+
export function analyzeKarmaConfig(content: string): KarmaConfigAnalysis {
43+
const sourceFile = ts.createSourceFile('karma.conf.js', content, ts.ScriptTarget.Latest, true);
44+
const settings = new Map<string, KarmaConfigValue>();
45+
let hasUnsupportedValues = false;
46+
47+
function visit(node: ts.Node) {
48+
// The Karma configuration is defined within a `config.set({ ... })` call.
49+
if (
50+
ts.isCallExpression(node) &&
51+
ts.isPropertyAccessExpression(node.expression) &&
52+
node.expression.expression.getText(sourceFile) === 'config' &&
53+
node.expression.name.text === 'set' &&
54+
node.arguments.length === 1 &&
55+
ts.isObjectLiteralExpression(node.arguments[0])
56+
) {
57+
// We found `config.set`, now we extract the properties from the object literal.
58+
for (const prop of node.arguments[0].properties) {
59+
if (
60+
ts.isPropertyAssignment(prop) &&
61+
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))
62+
) {
63+
const key = prop.name.text;
64+
const value = extractValue(prop.initializer);
65+
settings.set(key, value);
66+
}
67+
}
68+
} else {
69+
ts.forEachChild(node, visit);
70+
}
71+
}
72+
73+
function extractValue(node: ts.Expression): KarmaConfigValue {
74+
if (ts.isStringLiteral(node)) {
75+
return node.text;
76+
}
77+
if (ts.isNumericLiteral(node)) {
78+
return Number(node.text);
79+
}
80+
if (node.kind === ts.SyntaxKind.TrueKeyword) {
81+
return true;
82+
}
83+
if (node.kind === ts.SyntaxKind.FalseKeyword) {
84+
return false;
85+
}
86+
if (ts.isIdentifier(node) && (node.text === '__dirname' || node.text === '__filename')) {
87+
return node.text;
88+
}
89+
90+
if (ts.isCallExpression(node)) {
91+
// Handle require('...')
92+
if (
93+
ts.isIdentifier(node.expression) &&
94+
node.expression.text === 'require' &&
95+
node.arguments.length === 1 &&
96+
ts.isStringLiteral(node.arguments[0])
97+
) {
98+
return { module: node.arguments[0].text };
99+
}
100+
101+
// Handle calls on a require, e.g. require('path').join()
102+
const calleeValue = extractValue(node.expression);
103+
if (isRequireInfo(calleeValue)) {
104+
return {
105+
...calleeValue,
106+
isCall: true,
107+
arguments: node.arguments.map(extractValue),
108+
};
109+
}
110+
}
111+
112+
// Handle require('...').someExport
113+
if (ts.isPropertyAccessExpression(node)) {
114+
const value = extractValue(node.expression);
115+
if (isRequireInfo(value)) {
116+
const currentExport = value.export ? `${value.export}.${node.name.text}` : node.name.text;
117+
118+
return { ...value, export: currentExport };
119+
}
120+
}
121+
122+
if (ts.isArrayLiteralExpression(node)) {
123+
return node.elements.map(extractValue);
124+
}
125+
if (ts.isObjectLiteralExpression(node)) {
126+
const obj: { [key: string]: KarmaConfigValue } = {};
127+
for (const prop of node.properties) {
128+
if (
129+
ts.isPropertyAssignment(prop) &&
130+
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))
131+
) {
132+
// Recursively extract values for nested objects.
133+
obj[prop.name.text] = extractValue(prop.initializer);
134+
} else {
135+
hasUnsupportedValues = true;
136+
}
137+
}
138+
139+
return obj;
140+
}
141+
// For complex expressions (like variables) that we don't need to resolve,
142+
// we mark the analysis as potentially incomplete.
143+
hasUnsupportedValues = true;
144+
145+
return undefined;
146+
}
147+
148+
visit(sourceFile);
149+
150+
return { settings, hasUnsupportedValues };
151+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { RequireInfo, analyzeKarmaConfig } from './karma-config-analyzer';
10+
import { generateDefaultKarmaConfig } from './karma-config-comparer';
11+
12+
describe('Karma Config Analyzer', () => {
13+
it('should parse a basic karma config file', () => {
14+
const karmaConf = `
15+
module.exports = function (config) {
16+
config.set({
17+
basePath: '',
18+
frameworks: ['jasmine', '@angular-devkit/build-angular'],
19+
plugins: [
20+
require('karma-jasmine'),
21+
require('karma-chrome-launcher'),
22+
require('karma-jasmine-html-reporter'),
23+
require('karma-coverage'),
24+
require('@angular-devkit/build-angular/plugins/karma'),
25+
],
26+
client: {
27+
clearContext: false, // leave Jasmine Spec Runner output visible in browser
28+
},
29+
jasmineHtmlReporter: {
30+
suppressAll: true, // removes the duplicated traces
31+
},
32+
coverageReporter: {
33+
dir: require('path').join(__dirname, './coverage/test-project'),
34+
subdir: '.',
35+
reporters: [{ type: 'html' }, { type: 'text-summary' }],
36+
},
37+
reporters: ['progress', 'kjhtml'],
38+
port: 9876,
39+
colors: true,
40+
logLevel: config.LOG_INFO,
41+
autoWatch: true,
42+
browsers: ['Chrome'],
43+
singleRun: false,
44+
restartOnFileChange: true,
45+
});
46+
};
47+
`;
48+
49+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf);
50+
51+
expect(settings.get('basePath') as unknown).toBe('');
52+
expect(settings.get('frameworks') as unknown).toEqual([
53+
'jasmine',
54+
'@angular-devkit/build-angular',
55+
]);
56+
expect(settings.get('port') as unknown).toBe(9876);
57+
expect(settings.get('autoWatch') as boolean).toBe(true);
58+
expect(settings.get('singleRun') as boolean).toBe(false);
59+
expect(settings.get('reporters') as unknown).toEqual(['progress', 'kjhtml']);
60+
61+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
62+
const plugins = settings.get('plugins') as any[];
63+
expect(plugins).toBeInstanceOf(Array);
64+
expect(plugins.length).toBe(5);
65+
expect(plugins[0]).toEqual({ module: 'karma-jasmine' });
66+
expect(plugins[4]).toEqual({ module: '@angular-devkit/build-angular/plugins/karma' });
67+
68+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
69+
const coverageReporter = settings.get('coverageReporter') as any;
70+
const dirInfo = coverageReporter.dir as RequireInfo;
71+
expect(dirInfo.module).toBe('path');
72+
expect(dirInfo.export).toBe('join');
73+
expect(dirInfo.isCall).toBe(true);
74+
expect(dirInfo.arguments as unknown).toEqual(['__dirname', './coverage/test-project']);
75+
76+
// config.LOG_INFO is a variable, so it should be flagged as unsupported
77+
expect(hasUnsupportedValues).toBe(true);
78+
});
79+
80+
it('should return an empty map for an empty config file', () => {
81+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig('');
82+
83+
expect(settings.size).toBe(0);
84+
expect(hasUnsupportedValues).toBe(false);
85+
});
86+
87+
it('should handle a config file with no config.set call', () => {
88+
const karmaConf = `
89+
module.exports = function (config) {
90+
// No config.set call
91+
};
92+
`;
93+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf);
94+
95+
expect(settings.size).toBe(0);
96+
expect(hasUnsupportedValues).toBe(false);
97+
});
98+
99+
it('should detect unsupported values like variables', () => {
100+
const karmaConf = `
101+
const myBrowsers = ['Chrome', 'Firefox'];
102+
module.exports = function (config) {
103+
config.set({
104+
browsers: myBrowsers,
105+
});
106+
};
107+
`;
108+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf);
109+
110+
expect(settings.get('browsers')).toBeUndefined();
111+
expect(hasUnsupportedValues).toBe(true);
112+
});
113+
114+
it('should correctly parse require with nested exports', () => {
115+
const karmaConf = `
116+
module.exports = function (config) {
117+
config.set({
118+
reporter: require('some-plugin').reporter.type,
119+
});
120+
};
121+
`;
122+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf);
123+
124+
const reporter = settings.get('reporter') as RequireInfo;
125+
expect(reporter.module).toBe('some-plugin');
126+
expect(reporter.export).toBe('reporter.type');
127+
expect(hasUnsupportedValues).toBe(false);
128+
});
129+
130+
it('should handle an array with mixed values', () => {
131+
const karmaConf = `
132+
module.exports = function (config) {
133+
config.set({
134+
plugins: [
135+
'karma-jasmine',
136+
require('karma-chrome-launcher'),
137+
true,
138+
],
139+
});
140+
};
141+
`;
142+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf);
143+
144+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
145+
const plugins = settings.get('plugins') as any[];
146+
expect(plugins).toEqual(['karma-jasmine', { module: 'karma-chrome-launcher' }, true]);
147+
expect(hasUnsupportedValues).toBe(false);
148+
});
149+
150+
it('should not report unsupported values when all values are literals or requires', () => {
151+
const karmaConf = `
152+
module.exports = function (config) {
153+
config.set({
154+
autoWatch: true,
155+
browsers: ['Chrome'],
156+
plugins: [require('karma-jasmine')],
157+
});
158+
};
159+
`;
160+
const { hasUnsupportedValues } = analyzeKarmaConfig(karmaConf);
161+
162+
expect(hasUnsupportedValues).toBe(false);
163+
});
164+
165+
it('should handle path.join with variables and flag as unsupported', () => {
166+
const karmaConf = `
167+
const myPath = './coverage/test-project';
168+
module.exports = function (config) {
169+
config.set({
170+
coverageReporter: {
171+
dir: require('path').join(__dirname, myPath),
172+
},
173+
});
174+
};
175+
`;
176+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf);
177+
178+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
179+
const coverageReporter = settings.get('coverageReporter') as any;
180+
const dirInfo = coverageReporter.dir as RequireInfo;
181+
expect(dirInfo.module).toBe('path');
182+
expect(dirInfo.export).toBe('join');
183+
expect(dirInfo.isCall).toBe(true);
184+
expect(dirInfo.arguments as unknown).toEqual(['__dirname', undefined]); // myPath is a variable
185+
expect(hasUnsupportedValues).toBe(true);
186+
});
187+
188+
it('should correctly parse the default karma config template', async () => {
189+
const defaultConfig = await generateDefaultKarmaConfig('..', 'test-project', true);
190+
const { settings, hasUnsupportedValues } = analyzeKarmaConfig(defaultConfig);
191+
192+
expect(hasUnsupportedValues).toBe(false);
193+
expect(settings.get('basePath') as unknown).toBe('');
194+
expect(settings.get('frameworks') as unknown).toEqual([
195+
'jasmine',
196+
'@angular-devkit/build-angular',
197+
]);
198+
expect(settings.get('plugins') as unknown).toEqual([
199+
{ module: 'karma-jasmine' },
200+
{ module: 'karma-chrome-launcher' },
201+
{ module: 'karma-jasmine-html-reporter' },
202+
{ module: 'karma-coverage' },
203+
{ module: '@angular-devkit/build-angular/plugins/karma' },
204+
]);
205+
expect(settings.get('client') as unknown).toEqual({
206+
jasmine: {},
207+
});
208+
expect(settings.get('jasmineHtmlReporter') as unknown).toEqual({
209+
suppressAll: true,
210+
});
211+
const coverageReporter = settings.get('coverageReporter') as {
212+
dir: RequireInfo;
213+
subdir: string;
214+
reporters: { type: string }[];
215+
};
216+
expect(coverageReporter.dir.module).toBe('path');
217+
expect(coverageReporter.dir.export).toBe('join');
218+
expect(coverageReporter.dir.isCall).toBe(true);
219+
expect(coverageReporter.dir.arguments as unknown).toEqual([
220+
'__dirname',
221+
'../coverage/test-project',
222+
]);
223+
expect(coverageReporter.subdir).toBe('.');
224+
expect(coverageReporter.reporters).toEqual([{ type: 'html' }, { type: 'text-summary' }]);
225+
expect(settings.get('reporters') as unknown).toEqual(['progress', 'kjhtml']);
226+
expect(settings.get('browsers') as unknown).toEqual(['Chrome']);
227+
expect(settings.get('restartOnFileChange') as unknown).toBe(true);
228+
});
229+
});

0 commit comments

Comments
 (0)