From 9f77083b561f5a283d3a15c3a9fe04c7b85f461c Mon Sep 17 00:00:00 2001 From: kimyenac Date: Mon, 24 Aug 2026 15:58:12 +0900 Subject: [PATCH] [ZEPPELIN-6637] Compile Angular decorators in the shell unit test setup The shell setup can run a spec that constructs a directive by hand, but not one that goes through TestBed. Three things are missing. Vitest 4 transforms specs with oxc, and that transform does not apply the decorator options the application build uses: they live in tsconfig.base.json, which src/tsconfig.json extends while excluding **/*.spec.ts. A decorated spec therefore fails to parse with "Invalid or unexpected token". Declaring the options on the vitest config makes the transform independent of that lookup. emitDecoratorMetadata then compiles to a __metadata helper that is a silent no-op unless Reflect.metadata exists, so constructor injection fails with NG0202. src/polyfills.ts pairs zone.js with core-js/es7/reflect for the application, and the setup already mirrors the first half, so mirroring the second needs no new dependency. Finally the setup never calls TestBed.initTestEnvironment(), and with vitest globals disabled Angular cannot install its per-test reset hook, so the second spec in a file hits "test module has already been instantiated". Adds a TestBed spec for ReactMountDirective as the first consumer, alongside the existing hand-driven one. It renders the directive from a host template, so the decorator metadata, the @Input bindings and the constructor injection all have to resolve for it to run at all. It also provides zone change detection the way main.ts does, without which the zone assertions would pass vacuously against TestBed's zoneless default. --- .../react-mount.directive.testbed.spec.ts | 95 +++++++++++++++++++ zeppelin-web-angular/test/test-setup.ts | 13 +++ zeppelin-web-angular/vitest.shell.config.mts | 9 ++ 3 files changed, 117 insertions(+) create mode 100644 zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.testbed.spec.ts diff --git a/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.testbed.spec.ts b/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.testbed.spec.ts new file mode 100644 index 00000000000..17e621ab33c --- /dev/null +++ b/zeppelin-web-angular/src/app/share/react-mount/react-mount.directive.testbed.spec.ts @@ -0,0 +1,95 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, NgZone, provideZoneChangeDetection } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Mock, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ReactExposedModule, ReactMountHandle, ReactProps } from './react-mount-handle'; +import { ReactMountDirective } from './react-mount.directive'; +import { ReactRemoteLoaderService } from './react-remote-loader.service'; + +@Component({ + standalone: false, + template: ` +
+ ` +}) +class HostComponent { + module = 'paragraph-footer'; + reactProps: ReactProps = { paragraphId: 'p1' }; +} + +/** + * Companion to react-mount.directive.spec.ts, which drives the directive by + * hand. Going through TestBed puts the decorator metadata itself under test: + * the template bindings and constructor injection have to resolve to get here. + */ +describe('ReactMountDirective (TestBed)', () => { + let fixture: ComponentFixture; + let handle: ReactMountHandle; + let mountedElements: HTMLElement[]; + let mountZoneStates: boolean[]; + let loadModule: Mock<(module: string) => Promise>; + + beforeEach(() => { + mountedElements = []; + mountZoneStates = []; + handle = { update: vi.fn(), unmount: vi.fn() }; + const remote: ReactExposedModule = { + mount: (element: HTMLElement) => { + mountedElements.push(element); + mountZoneStates.push(NgZone.isInAngularZone()); + return handle; + } + }; + loadModule = vi.fn(async () => remote); + + TestBed.configureTestingModule({ + declarations: [HostComponent, ReactMountDirective], + // TestBed defaults to zoneless, which would make the zone assertions + // below pass vacuously. main.ts bootstraps with zones, so mirror it. + providers: [provideZoneChangeDetection(), { provide: ReactRemoteLoaderService, useValue: { loadModule } }] + }); + + fixture = TestBed.createComponent(HostComponent); + }); + + it('mounts the remote on the host element outside the Angular zone', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(loadModule).toHaveBeenCalledWith('paragraph-footer'); + expect(mountedElements).toEqual([fixture.nativeElement.querySelector('div')]); + expect(mountZoneStates).toEqual([false]); + }); + + it('forwards later reactProps changes to the mount handle', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.reactProps = { paragraphId: 'p2' }; + fixture.detectChanges(); + + expect(handle.update).toHaveBeenCalledWith({ paragraphId: 'p2' }); + expect(loadModule).toHaveBeenCalledOnce(); + }); + + it('unmounts when the host component is destroyed', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.destroy(); + + expect(handle.unmount).toHaveBeenCalledOnce(); + }); +}); diff --git a/zeppelin-web-angular/test/test-setup.ts b/zeppelin-web-angular/test/test-setup.ts index 7d0997c797d..ae120021fd3 100644 --- a/zeppelin-web-angular/test/test-setup.ts +++ b/zeppelin-web-angular/test/test-setup.ts @@ -11,3 +11,16 @@ */ import 'zone.js'; +// The pair src/polyfills.ts loads for the application. Without Reflect.metadata +// the emitted `__metadata` helper is a silent no-op and injection fails NG0202. +import 'core-js/es7/reflect'; + +import { getTestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach } from 'vitest'; + +getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + +// Vitest globals are disabled, so Angular cannot install its own reset hook and +// the test module stays locked after the first spec instantiates it. +afterEach(() => getTestBed().resetTestingModule()); diff --git a/zeppelin-web-angular/vitest.shell.config.mts b/zeppelin-web-angular/vitest.shell.config.mts index 4799e537f6e..ed931317fcf 100644 --- a/zeppelin-web-angular/vitest.shell.config.mts +++ b/zeppelin-web-angular/vitest.shell.config.mts @@ -13,6 +13,15 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + // oxc does not apply the decorator options from tsconfig.base.json to specs, + // which src/tsconfig.json excludes. Undeclared, a decorated spec fails to + // parse with "Invalid or unexpected token". + oxc: { + decorator: { + emitDecoratorMetadata: true, + legacy: true + } + }, test: { environment: 'jsdom', include: ['src/**/*.spec.ts'],