Skip to content

Commit 885c783

Browse files
committed
fix(@angular/build): link partial Ivy dependencies in Vitest runner
1 parent 9b7a14a commit 885c783

3 files changed

Lines changed: 165 additions & 16 deletions

File tree

‎packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts‎

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type * as Vite from 'vite' with {
1414
'resolution-mode': 'import',
1515
};
1616
import type { Vitest } from 'vitest/node';
17+
import { JavaScriptTransformer } from '../../../../tools/javascript-transformer/javascript-transformer';
1718
import {
1819
DevServerExternalResultMetadata,
1920
updateExternalMetadata,
@@ -25,6 +26,11 @@ import {
2526
type ResultFile,
2627
ResultKind,
2728
} from '../../../application/results';
29+
import {
30+
getSupportedBrowsers,
31+
isZonelessApp,
32+
transformSupportedBrowsersToTargets,
33+
} from '../../../dev-server/internal';
2834
import { NormalizedUnitTestBuilderOptions } from '../../options';
2935
import type { TestExecutor } from '../api';
3036
import { setupBrowserConfiguration } from './browser-provider';
@@ -57,6 +63,12 @@ export class VitestExecutor implements TestExecutor {
5763
// Example: `Map<'/path/to/src/app.spec.ts', 'spec-src-app-spec'>`
5864
private readonly testFileToEntryPoint = new Map<string, string>();
5965
private readonly entryPointToTestFile = new Map<string, string>();
66+
private readonly prebundleTransformer = new JavaScriptTransformer({
67+
sourcemap: true,
68+
jit: true,
69+
thirdPartySourcemaps: false,
70+
maxConcurrency: 1,
71+
});
6072

6173
constructor(
6274
projectName: string,
@@ -193,7 +205,7 @@ export class VitestExecutor implements TestExecutor {
193205
// Vitest does not return a failure result when coverage thresholds are not met.
194206
// Instead, it sets the process exit code to 1.
195207
// We check this exit code to determine if the test run should be considered a failure.
196-
if (success && process.exitCode === 1) {
208+
if (success && this.options.coverage?.enabled && process.exitCode === 1) {
197209
success = false;
198210
finalResultReason = 'Test run failed due to unmet coverage thresholds.';
199211
// Reset the exit code to prevent it from carrying over to subsequent runs/builds
@@ -218,17 +230,20 @@ export class VitestExecutor implements TestExecutor {
218230
const timeoutMs = 10_000;
219231

220232
try {
221-
await Promise.race([
222-
this.vitest.close(),
223-
setTimeout(timeoutMs, undefined, { signal: controller.signal, ref: false })
224-
.then(() => {
225-
this.logger.warn(
226-
`Vitest instance failed to close cleanly within ${timeoutMs}ms. Continuing teardown...`,
227-
);
228-
})
229-
.catch(() => {
230-
// Suppress AbortError triggered by controller.abort() when close() resolves first
231-
}),
233+
await Promise.all([
234+
this.prebundleTransformer.close(),
235+
Promise.race([
236+
this.vitest.close(),
237+
setTimeout(timeoutMs, undefined, { signal: controller.signal, ref: false })
238+
.then(() => {
239+
this.logger.warn(
240+
`Vitest instance failed to close cleanly within ${timeoutMs}ms. Continuing teardown...`,
241+
);
242+
})
243+
.catch(() => {
244+
// Suppress AbortError triggered by controller.abort() when close() resolves first
245+
}),
246+
]),
232247
]);
233248
} catch (error: unknown) {
234249
assertIsError(error);
@@ -334,6 +349,7 @@ export class VitestExecutor implements TestExecutor {
334349
buildResultFiles: this.buildResultFiles,
335350
testFileToEntryPoint: this.testFileToEntryPoint,
336351
setupFiles: testSetupFiles,
352+
prebundleTransformer: this.prebundleTransformer,
337353
});
338354

339355
const debugOptions = debug
@@ -379,6 +395,13 @@ export class VitestExecutor implements TestExecutor {
379395
});
380396
this.debugLog(DebugLogLevel.Verbose, 'Included test files (after filtering):', include);
381397

398+
const supportedBrowsers = getSupportedBrowsers(projectRoot, this.logger);
399+
const target = transformSupportedBrowsersToTargets(supportedBrowsers);
400+
if (!isZonelessApp(this.options.polyfills)) {
401+
// Rolldown doesn't have an option to support Zone.js/async-await, so we need to support es2016.
402+
target.push('es2016');
403+
}
404+
382405
const vitestConfig = {
383406
config: externalConfigPath,
384407
root: workspaceRoot,
@@ -412,6 +435,8 @@ export class VitestExecutor implements TestExecutor {
412435
watch,
413436
isolate: this.options.isolate,
414437
preserveSymlinks: this.options.preserveSymlinks,
438+
prebundleTransformer: this.prebundleTransformer,
439+
target,
415440
}),
416441
],
417442
};

‎packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { needsLinking } from '@angular/compiler-cli/linker';
910
import assert from 'node:assert';
1011
import { readFile } from 'node:fs/promises';
1112
import { platform } from 'node:os';
@@ -18,7 +19,9 @@ import type {
1819
Vite,
1920
VitestPluginContext,
2021
} from 'vitest/node';
22+
import type { JavaScriptTransformer } from '../../../../tools/javascript-transformer/javascript-transformer';
2123
import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/assets-middleware';
24+
import { getDepOptimizationConfig } from '../../../../tools/vite/utils';
2225
import { toPosixPath } from '../../../../utils/path';
2326
import { createProjectResolver } from '../../../../utils/resolve-project';
2427
import type { ResultFile } from '../../../application/results';
@@ -37,6 +40,7 @@ interface PluginOptions {
3740
projectName: string;
3841
buildResultFiles: ReadonlyMap<string, ResultFile>;
3942
testFileToEntryPoint: ReadonlyMap<string, string>;
43+
prebundleTransformer: JavaScriptTransformer;
4044
setupFiles: readonly string[];
4145
}
4246

@@ -55,6 +59,8 @@ interface VitestConfigPluginOptions {
5559
watch: boolean;
5660
isolate: boolean | undefined;
5761
preserveSymlinks?: boolean;
62+
prebundleTransformer: JavaScriptTransformer;
63+
target: string[];
5864
}
5965

6066
async function findTestEnvironment(
@@ -253,11 +259,21 @@ export async function createVitestConfigPlugin(
253259
// Default to `false` to align with the Karma/Jasmine experience.
254260
isolate: false,
255261
sequence: { setupFiles: 'list' },
262+
server: {
263+
deps: {
264+
inline: options.optimizeDepsInclude.filter((dep) => !dep.startsWith('@angular/')),
265+
},
266+
},
256267
},
257-
optimizeDeps: {
258-
noDiscovery: true,
268+
optimizeDeps: getDepOptimizationConfig({
269+
target: options.target,
270+
disabled: false,
271+
exclude: [],
259272
include: options.optimizeDepsInclude,
260-
},
273+
prebundleTransformer: options.prebundleTransformer,
274+
thirdPartySourcemaps: false,
275+
define: undefined,
276+
}),
261277
resolve: {
262278
mainFields: ['es2020', 'module', 'main'],
263279
conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])],
@@ -314,7 +330,13 @@ async function loadResultFile(file: ResultFile): Promise<string> {
314330
}
315331

316332
export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] {
317-
const { workspaceRoot, buildResultFiles, testFileToEntryPoint, setupFiles } = pluginOptions;
333+
const {
334+
workspaceRoot,
335+
buildResultFiles,
336+
testFileToEntryPoint,
337+
setupFiles,
338+
prebundleTransformer,
339+
} = pluginOptions;
318340
const isWindows = platform() === 'win32';
319341
const setupFileSet = new Set(
320342
setupFiles.map((file) =>
@@ -324,6 +346,19 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
324346
let vitestConfig: ResolvedConfig;
325347

326348
return [
349+
{
350+
name: 'angular:linker',
351+
enforce: 'pre',
352+
async transform(code: string, id: string) {
353+
if (!needsLinking(id, code)) {
354+
return null;
355+
}
356+
357+
const transformedData = await prebundleTransformer.transformData(id, code);
358+
359+
return Buffer.from(transformedData).toString('utf-8');
360+
},
361+
},
327362
{
328363
name: 'angular:test-in-memory-provider',
329364
enforce: 'pre',
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 { execute } from '../../index';
10+
import {
11+
BASE_OPTIONS,
12+
describeBuilder,
13+
UNIT_TEST_BUILDER_INFO,
14+
setupApplicationTarget,
15+
} from '../setup';
16+
17+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18+
describe('Behavior: "Vitest Angular Linker"', () => {
19+
it('should link partially-compiled packages with cross-class references', async () => {
20+
setupApplicationTarget(harness);
21+
22+
harness.useTarget('test', {
23+
...BASE_OPTIONS,
24+
});
25+
26+
await harness.writeFile(
27+
'node_modules/test-lib/package.json',
28+
JSON.stringify({
29+
name: 'test-lib',
30+
version: '1.0.0',
31+
type: 'module',
32+
main: './fesm2022/test-lib.mjs',
33+
types: './index.d.ts',
34+
exports: {
35+
'.': {
36+
types: './index.d.ts',
37+
default: './fesm2022/test-lib.mjs',
38+
},
39+
},
40+
}),
41+
);
42+
43+
await harness.writeFile(
44+
'node_modules/test-lib/index.d.ts',
45+
`
46+
export declare class CascadeSelectSub {}
47+
export declare class CascadeSelect {}
48+
`,
49+
);
50+
51+
await harness.writeFile(
52+
'node_modules/test-lib/fesm2022/test-lib.mjs',
53+
`
54+
import * as i0 from "@angular/core";
55+
export class CascadeSelectSub {
56+
static ɵfac = i0.ɵɵngDeclareFactory({
57+
minVersion: "12.0.0",
58+
version: "14.0.0",
59+
ngImport: i0,
60+
type: CascadeSelectSub,
61+
deps: [{ token: CascadeSelect }],
62+
target: i0.ɵɵFactoryTarget.Component
63+
});
64+
}
65+
export class CascadeSelect {}
66+
`,
67+
);
68+
69+
await harness.writeFile(
70+
'src/app/app.component.spec.ts',
71+
`
72+
import { describe, it, expect } from 'vitest';
73+
import { CascadeSelectSub, CascadeSelect } from 'test-lib';
74+
75+
describe('CascadeSelect test', () => {
76+
it('loads without ReferenceError', () => {
77+
expect(CascadeSelectSub).toBeDefined();
78+
expect(CascadeSelect).toBeDefined();
79+
});
80+
});
81+
`,
82+
);
83+
84+
const { result } = await harness.executeOnce();
85+
86+
expect(result?.success).toBeTrue();
87+
});
88+
});
89+
});

0 commit comments

Comments
 (0)