From d6d51a0ef8234cc564fda74d244fa95e6deb30dc Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 4 Sep 2026 15:13:23 +0300 Subject: [PATCH] test(devtools): run the orphaned app component spec `app.component.spec.ts` has been in the tree since the initial DevTools commit in 2020, but no revision of `shell-browser/src/app/BUILD.bazel` has ever listed it, and that file has no globs. Two of the three specs in the directory are wired up; this one has never run. It could not run as written. The setup declared `AppComponent` and imported `RouterModule.forRoot([])`, neither of which applies now that the component is standalone and injects `MessageBus` and `DEEP_LINK_INSTANCE_ID`. It now stubs the `chrome.devtools` surface that `ngOnInit` reads and covers both lifecycle hooks: the listeners registered on init, the two performance track emits, the three guards on the deep link listener, and the listeners removed on destroy. Follow-up to #70570, which fixed the same problem in ng-devtools-backend. --- .../shell-browser/src/app/BUILD.bazel | 19 +++ .../src/app/app.component.spec.ts | 160 +++++++++++++++--- 2 files changed, 159 insertions(+), 20 deletions(-) diff --git a/devtools/projects/shell-browser/src/app/BUILD.bazel b/devtools/projects/shell-browser/src/app/BUILD.bazel index 921fd28111b..527bdb72c95 100644 --- a/devtools/projects/shell-browser/src/app/BUILD.bazel +++ b/devtools/projects/shell-browser/src/app/BUILD.bazel @@ -34,6 +34,25 @@ ng_project( ], ) +ts_test_library( + name = "app_component_test_lib", + srcs = ["app.component.spec.ts"], + deps = [ + ":app", + "//:node_modules/@angular/core", + "//devtools/projects/ng-devtools", + "//devtools/projects/ng-devtools/src/lib/application-providers:deep_link", + "//devtools/projects/protocol", + ], +) + +zoneless_web_test_suite( + name = "app_component_test", + deps = [ + ":app_component_test_lib", + ], +) + ts_project( name = "ng_validate", srcs = [ diff --git a/devtools/projects/shell-browser/src/app/app.component.spec.ts b/devtools/projects/shell-browser/src/app/app.component.spec.ts index 0c1acc0289d..68a6ee2f3d6 100644 --- a/devtools/projects/shell-browser/src/app/app.component.spec.ts +++ b/devtools/projects/shell-browser/src/app/app.component.spec.ts @@ -6,30 +6,150 @@ * found in the LICENSE file at https://angular.dev/license */ -import {TestBed, waitForAsync} from '@angular/core/testing'; -import {RouterModule} from '@angular/router'; -import {ApplicationOperations} from '../../../ng-devtools'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {DevToolsComponent} from '../../../ng-devtools'; +import {DEEP_LINK_INSTANCE_ID} from '../../../ng-devtools/src/lib/application-providers/deep_link'; +import {Events, MessageBus} from '../../../protocol'; import {AppComponent} from './app.component'; -describe('AppComponent', () => { - beforeEach(waitForAsync(() => { - const applicationOperationsSPy = jasmine.createSpyObj('messageBus', ['viewSource']); - TestBed.configureTestingModule({ - declarations: [AppComponent], - imports: [RouterModule.forRoot([])], - providers: [ - { - provide: ApplicationOperations, - useClass: applicationOperationsSPy, - }, - ], - }); - })); +@Component({ + selector: 'ng-devtools', + template: '', +}) +export class MockDevToolsComponent {} - it('should create the app', () => { +interface ChromeEvent { + addListener(callback: () => void): void; + removeListener(callback: () => void): void; +} + +/** An instance id that no test sets, so it detects a listener that should not have fired. */ +const UNTOUCHED = -1; + +/** Dispatches a deep link message on the window. */ +function postDeepLink(data: unknown, origin = window.location.origin): void { + window.dispatchEvent(new MessageEvent('message', {origin, data})); +} + +describe('AppComponent', () => { + let messageBus: jasmine.SpyObj>; + let onNavigated: jasmine.SpyObj; + let onProfilingStarted: jasmine.SpyObj; + let onProfilingStopped: jasmine.SpyObj; + + async function createFixture(): Promise> { const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); + await fixture.whenStable(); + return fixture; + } + + beforeEach(() => { + messageBus = jasmine.createSpyObj('MessageBus', ['on', 'once', 'emit', 'destroy']); + onNavigated = jasmine.createSpyObj('onNavigated', ['addListener', 'removeListener']); + onProfilingStarted = jasmine.createSpyObj('onProfilingStarted', [ + 'addListener', + 'removeListener', + ]); + onProfilingStopped = jasmine.createSpyObj('onProfilingStopped', [ + 'addListener', + 'removeListener', + ]); + + (globalThis as any).chrome = { + devtools: { + network: {onNavigated}, + performance: {onProfilingStarted, onProfilingStopped}, + }, + }; + + TestBed.configureTestingModule({ + providers: [{provide: MessageBus, useValue: messageBus}], + }).overrideComponent(AppComponent, { + remove: {imports: [DevToolsComponent]}, + add: {imports: [MockDevToolsComponent]}, + }); + }); + + afterAll(() => { + delete (globalThis as any).chrome; + }); + + it('should create the app', async () => { + const fixture = await createFixture(); + + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should listen for navigations and profiling on init', async () => { + await createFixture(); + + expect(onNavigated.addListener).toHaveBeenCalled(); + expect(onProfilingStarted.addListener).toHaveBeenCalled(); + expect(onProfilingStopped.addListener).toHaveBeenCalled(); + }); + + it('should emit when profiling starts and stops', async () => { + await createFixture(); + + onProfilingStarted.addListener.calls.mostRecent().args[0](); + expect(messageBus.emit).toHaveBeenCalledWith('enablePerformanceTrack'); + + onProfilingStopped.addListener.calls.mostRecent().args[0](); + expect(messageBus.emit).toHaveBeenCalledWith('disablePerformanceTrack'); + }); + + it('should stop listening for profiling once destroyed', async () => { + const fixture = await createFixture(); + fixture.destroy(); + + expect(onProfilingStarted.removeListener).toHaveBeenCalled(); + expect(onProfilingStopped.removeListener).toHaveBeenCalled(); + }); + + it('should record the instance id sent by a same origin deep link', async () => { + await createFixture(); + + postDeepLink({type: 'angular-devtools-deep-link', instanceId: 7}); + + expect(TestBed.inject(DEEP_LINK_INSTANCE_ID)()).toBe(7); + }); + + it('should ignore a deep link from another origin', async () => { + await createFixture(); + TestBed.inject(DEEP_LINK_INSTANCE_ID).set(UNTOUCHED); + + postDeepLink({type: 'angular-devtools-deep-link', instanceId: 7}, 'https://malicious.example'); + + expect(TestBed.inject(DEEP_LINK_INSTANCE_ID)()).toBe(UNTOUCHED); + }); + + it('should ignore a message of another type', async () => { + await createFixture(); + TestBed.inject(DEEP_LINK_INSTANCE_ID).set(UNTOUCHED); + + postDeepLink({type: 'some-other-message', instanceId: 7}); + + expect(TestBed.inject(DEEP_LINK_INSTANCE_ID)()).toBe(UNTOUCHED); + }); + + it('should ignore a deep link whose instance id is not a number', async () => { + await createFixture(); + TestBed.inject(DEEP_LINK_INSTANCE_ID).set(UNTOUCHED); + + postDeepLink({type: 'angular-devtools-deep-link', instanceId: '7'}); + + expect(TestBed.inject(DEEP_LINK_INSTANCE_ID)()).toBe(UNTOUCHED); + }); + + it('should stop listening for deep links once destroyed', async () => { + const fixture = await createFixture(); + TestBed.inject(DEEP_LINK_INSTANCE_ID).set(UNTOUCHED); + fixture.destroy(); + + postDeepLink({type: 'angular-devtools-deep-link', instanceId: 7}); + + expect(TestBed.inject(DEEP_LINK_INSTANCE_ID)()).toBe(UNTOUCHED); }); });