diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27a872672df7..1ca75c01c9ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,7 @@ jobs: - run: pnpm -C packages/zone.js jest:test - run: pnpm -C packages/zone.js jest:nodetest - run: pnpm -C packages/zone.js vitest:test + - run: pnpm -C packages/zone.js vitest-globals:test - run: pnpm -C packages/zone.js electrontest - run: pnpm -C packages/zone.js/test/typings test diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bcfc1237bcf2..6099a89bd551 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -174,5 +174,6 @@ jobs: - run: pnpm -C packages/zone.js jest:test - run: pnpm -C packages/zone.js jest:nodetest - run: pnpm -C packages/zone.js vitest:test + - run: pnpm -C packages/zone.js vitest-globals:test - run: pnpm -C packages/zone.js electrontest - run: pnpm -C packages/zone.js/test/typings test diff --git a/packages/zone.js/bundles.bzl b/packages/zone.js/bundles.bzl index e1866a708145..3a30fb7f4b91 100644 --- a/packages/zone.js/bundles.bzl +++ b/packages/zone.js/bundles.bzl @@ -119,4 +119,7 @@ BUNDLES_ENTRY_POINTS = { "zone-testing": { "entrypoint": _DIR + "testing/rollup-zone-testing", }, + "vitest-patch": { + "entrypoint": _DIR + "vitest/rollup-vitest", + }, } diff --git a/packages/zone.js/lib/vitest/rollup-vitest.ts b/packages/zone.js/lib/vitest/rollup-vitest.ts new file mode 100644 index 000000000000..aaeb7795b4d2 --- /dev/null +++ b/packages/zone.js/lib/vitest/rollup-vitest.ts @@ -0,0 +1,11 @@ +/** + * @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 {patchVitest} from './vitest'; + +patchVitest(Zone); diff --git a/packages/zone.js/lib/vitest/vitest.ts b/packages/zone.js/lib/vitest/vitest.ts new file mode 100644 index 000000000000..99bae331d340 --- /dev/null +++ b/packages/zone.js/lib/vitest/vitest.ts @@ -0,0 +1,176 @@ +/** + * @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 {ZoneType} from '../zone-impl'; + +/** + * The ZoneType with additional testing related members added. + * The additional members must be patched onto the type via the + * `zone.js/testing` package entry point. + */ +interface TestingZoneType extends ZoneType { + ProxyZoneSpec?: typeof import('../zone-spec/proxy').ProxyZoneSpec; + SyncTestZoneSpec?: {new (namePrefix: string): ZoneSpec}; +} + +/** + * The list of method names for the describe/suite factories. + * Example: `describe.skip('...', () => { ... });` + * Sourced from https://vitest.dev/api/#describe + */ +const DESCRIBE_FACTORY_NAMES = [ + 'skip', + 'skipIf', + 'runIf', + 'only', + 'concurrent', + 'sequential', + 'shuffle', + 'todo', + 'each', + 'for', +] as const; + +/** + * The list of method names for the test/it factories. + * Example: `test.skip('...', () => { ... });` + * Sourced from https://vitest.dev/api/#test + */ +const TEST_FACTORY_NAMES = [ + 'skip', + 'skipIf', + 'runIf', + 'only', + 'concurrent', + 'sequential', + 'shuffle', + 'todo', + 'each', + 'for', +] as const; + +export function patchVitest(Zone: ZoneType): void { + Zone.__load_patch('vitest', (context: any, Zone: TestingZoneType) => { + // Vitest global variable set by the Vitest runner during test execution + const vitestGlobal = context['vitest'] as {['__zone_patch__']?: boolean} | undefined; + + // Skip patching if vitest is not present or has already been patched + if (typeof vitestGlobal === 'undefined' || vitestGlobal['__zone_patch__']) { + return; + } + vitestGlobal['__zone_patch__'] = true; + + // Ensure other testing related Zone.js patches have been applied + if (!Zone.ProxyZoneSpec) { + throw new Error('Missing ProxyZoneSpec'); + } + if (!Zone.SyncTestZoneSpec) { + throw new Error('Missing SyncTestZoneSpec'); + } + + // Setup testing related Zone instances + const rootZone = Zone.current; + const syncZone = rootZone.fork(new Zone.SyncTestZoneSpec('vitest.describe')); + const proxyZone = rootZone.fork(new Zone.ProxyZoneSpec()); + + /** + * Gets a function wrapping the body of a vitest `describe` block to execute in a + * synchronous-only zone. + */ + function wrapDescribeInZone(describeBody: Function): Function { + return function (this: unknown, ...args: unknown[]) { + return syncZone.run(describeBody, this, args); + }; + } + + /** + * Gets a function wrapping the body of a vitest `it/beforeEach/afterEach` block to + * execute in a ProxyZone zone. + * This will run in the `proxyZone`. + */ + function wrapTestInZone(testBody: Function): Function { + if (typeof testBody !== 'function') { + return testBody; + } + const wrappedFunc = function () { + return proxyZone.run(testBody, null, arguments as any); + }; + // Update the length of wrappedFunc to be the same as the length of the testBody + // So vitest core can handle whether the test function has `done()` or not correctly + Object.defineProperty(wrappedFunc, 'length', { + configurable: true, + writable: true, + enumerable: false, + }); + wrappedFunc.length = testBody.length; + return wrappedFunc; + } + + ['suite', 'describe'].forEach((methodName) => { + let originalVitestFn: Function & Record<(typeof DESCRIBE_FACTORY_NAMES)[number], Function> = + context[methodName]; + // Skip if already patched + if (context[Zone.__symbol__(methodName)]) { + return; + } + + context[Zone.__symbol__(methodName)] = originalVitestFn; + context[methodName] = function (this: unknown, ...args: [unknown, Function, ...unknown[]]) { + args[1] = wrapDescribeInZone(args[1]); + return originalVitestFn.apply(this, args); + }; + + for (const factoryName of DESCRIBE_FACTORY_NAMES) { + context[methodName][factoryName] = function (this: unknown, ...factoryArgs: unknown[]) { + const originalDescribeFn = originalVitestFn.apply(this, factoryArgs); + return function (this: unknown, ...args: [unknown, Function, ...unknown[]]) { + args[1] = wrapDescribeInZone(args[1]); + return originalDescribeFn.apply(this, args); + }; + }; + } + }); + + ['it', 'test'].forEach((methodName) => { + let originalVitestFn: Function & Record<(typeof TEST_FACTORY_NAMES)[number], Function> = + context[methodName]; + // Skip if already patched + if (context[Zone.__symbol__(methodName)]) { + return; + } + + context[Zone.__symbol__(methodName)] = originalVitestFn; + context[methodName] = function (this: unknown, ...args: [unknown, Function, ...unknown[]]) { + args[1] = wrapTestInZone(args[1]); + return originalVitestFn.apply(this, args); + }; + + for (const factoryName of TEST_FACTORY_NAMES) { + context[methodName][factoryName] = function (this: unknown, ...factoryArgs: unknown[]) { + return function (this: unknown, ...args: [unknown, Function, ...unknown[]]) { + args[1] = wrapTestInZone(args[1]); + return originalVitestFn.apply(this, factoryArgs).apply(this, args); + }; + }; + } + }); + + ['beforeEach', 'afterEach', 'beforeAll', 'afterAll'].forEach((methodName) => { + const originalVitestFn: Function = context[methodName]; + if (context[Zone.__symbol__(methodName)]) { + return; + } + + context[Zone.__symbol__(methodName)] = originalVitestFn; + context[methodName] = function (this: unknown, ...args: [Function, ...unknown[]]) { + args[0] = wrapTestInZone(args[0]); + return originalVitestFn.apply(this, args); + }; + }); + }); +} diff --git a/packages/zone.js/package.json b/packages/zone.js/package.json index eb729d3b69af..f46248249ba1 100644 --- a/packages/zone.js/package.json +++ b/packages/zone.js/package.json @@ -35,7 +35,8 @@ "electrontest": "cd test/extra && node electron.js", "jest:test": "jest --config ./test/jest/jest.config.js", "jest:nodetest": "jest --config ./test/jest/jest.node.config.js", - "vitest:test": "vitest ./test/vitest/vitest.spec.js", + "vitest:test": "vitest ./test/vitest/*.spec.js --exclude ./test/vitest/*-globals.spec.js", + "vitest-globals:test": "vitest ./test/vitest/*-globals.spec.js --globals", "promisefinallytest": "mocha ./test/promise/promise.finally.spec.mjs", "release": "node tools/release.mts" }, diff --git a/packages/zone.js/test/npm_package/npm_package.spec.ts b/packages/zone.js/test/npm_package/npm_package.spec.ts index 06f3c3ade772..f3a6232a8073 100644 --- a/packages/zone.js/test/npm_package/npm_package.spec.ts +++ b/packages/zone.js/test/npm_package/npm_package.spec.ts @@ -123,6 +123,8 @@ describe('Zone.js npm_package', () => { 'sync-test.min.js', 'task-tracking.js', 'task-tracking.min.js', + 'vitest-patch.js', + 'vitest-patch.min.js', 'webapis-media-query.js', 'webapis-media-query.min.js', 'webapis-notification.js', diff --git a/packages/zone.js/test/vitest/vitest-patch-globals.spec.js b/packages/zone.js/test/vitest/vitest-patch-globals.spec.js new file mode 100644 index 000000000000..fcf95c93c929 --- /dev/null +++ b/packages/zone.js/test/vitest/vitest-patch-globals.spec.js @@ -0,0 +1,58 @@ +require('../../../../dist/bin/packages/zone.js/npm_package/bundles/zone.umd.js'); +require('../../../../dist/bin/packages/zone.js/npm_package/bundles/zone-testing.umd.js'); + +// Apply the Vitest patch (i.e. `patchVitest`). +require('../../../../dist/bin/packages/zone.js/npm_package/bundles/vitest-patch.umd.js'); + +const {fakeAsync, tick} = Zone[Zone.__symbol__('fakeAsyncTest')]; + +describe('patchVitest `describe`', () => { + test('verify that the patch is installed', () => { + expect(globalThis['vitest']['__zone_patch__']).toBe(true); + }); + + // Expect `beforeEach` to be patched and working with `fakeAsync`. + beforeEach(fakeAsync(() => { + expect(createAsyncTesterFlag()).toBe(true); + })); + + // Expect `afterEach` to be patched and working with `fakeAsync`. + afterEach(fakeAsync(() => { + expect(createAsyncTesterFlag()).toBe(true); + })); + + // Expect `beforeAll` to be patched and working with `fakeAsync`. + beforeAll(fakeAsync(() => { + expect(createAsyncTesterFlag()).toBe(true); + })); + + // Expect `afterAll` to be patched and working with `fakeAsync`. + afterAll(fakeAsync(() => { + expect(createAsyncTesterFlag()).toBe(true); + })); + + it('expect `it` to be patched and working with `fakeAsync`', fakeAsync(() => { + expect(createAsyncTesterFlag()).toBe(true); + })); + + test('expect `it` to be patched and working with `fakeAsync`', fakeAsync(() => { + expect(createAsyncTesterFlag()).toBe(true); + })); +}); + +suite('patchVitest `suite`', () => { + it('should work with a patched `suite`', fakeAsync(() => { + expect(createAsyncTesterFlag()).toBe(true); + })); +}); + +function createAsyncTesterFlag() { + let flag = false; + setTimeout(() => { + flag = true; + }, 100); + + tick(110); + + return flag; +} diff --git a/packages/zone.js/test/vitest/vitest.spec.js b/packages/zone.js/test/vitest/with-proxy-zone.spec.js similarity index 100% rename from packages/zone.js/test/vitest/vitest.spec.js rename to packages/zone.js/test/vitest/with-proxy-zone.spec.js