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'],