From ebcdc8dc96e971f68e30cfe4acda5eba87417d77 Mon Sep 17 00:00:00 2001 From: AleksanderBodurri Date: Thu, 25 Jan 2024 19:35:36 -0500 Subject: [PATCH] refactor(devtools): implement multiframe support in devtools page (#53934) In the Angular DevTools Chrome DevTools page: - Angular DevTools is able to ask the background script to list each frame that has been registered on a page. - Angular Devtools is able to ask the background script to "enable" the connection on a particular frame. This enables the messaging between the content script <-> background script <-> devtools page - Implements detection of non unique urls on the inspected page Limitations: - The `inspectedWindow.eval` API is only able to target frames by frameURL. This means some features that integrate with Chrome DevTools like inspect element and open source will not be available when inspecting frames that do not have a unique url on the page. PR Close #53934 --- .../src/app/devtools-app/BUILD.bazel | 1 + .../devtools-app/devtools-app.component.ts | 2 + .../src/lib/client-event-subscribers.ts | 6 +- .../projects/ng-devtools/src/lib/BUILD.bazel | 61 ++++- .../src/lib/application-environment/index.ts | 9 + .../src/lib/application-operations/index.ts | 6 +- .../src/lib/devtools-tabs/BUILD.bazel | 16 +- .../devtools-tabs.component.html | 42 ++- .../devtools-tabs.component.scss | 19 ++ .../devtools-tabs/devtools-tabs.component.ts | 30 ++- .../lib/devtools-tabs/devtools-tabs.spec.ts | 23 ++ .../directive-explorer/BUILD.bazel | 7 +- .../directive-explorer.component.ts | 58 +++- .../directive-explorer.spec.ts | 255 ++++++++++++++++-- .../property-tab/property-view/BUILD.bazel | 2 + .../property-view-header.component.html | 8 +- .../src/lib/devtools.component.html | 101 ++++--- .../ng-devtools/src/lib/devtools.component.ts | 54 +++- .../ng-devtools/src/lib/devtools_spec.ts | 86 ++++++ .../ng-devtools/src/lib/frame_manager.ts | 133 +++++++++ .../ng-devtools/src/lib/frame_manager_spec.ts | 195 ++++++++++++++ .../shell-browser/src/app/app.module.ts | 2 + .../shell-browser/src/app/tab_manager_spec.ts | 37 ++- devtools/src/app/devtools-app/BUILD.bazel | 1 + .../devtools-app/devtools-app.component.ts | 16 -- .../app/devtools-app/devtools-app.module.ts | 19 ++ devtools/src/demo-application-environment.ts | 1 + 27 files changed, 1024 insertions(+), 166 deletions(-) create mode 100644 devtools/projects/ng-devtools/src/lib/devtools_spec.ts create mode 100644 devtools/projects/ng-devtools/src/lib/frame_manager.ts create mode 100644 devtools/projects/ng-devtools/src/lib/frame_manager_spec.ts diff --git a/devtools/projects/demo-standalone/src/app/devtools-app/BUILD.bazel b/devtools/projects/demo-standalone/src/app/devtools-app/BUILD.bazel index 8f5be35470b..a81b0fbb426 100644 --- a/devtools/projects/demo-standalone/src/app/devtools-app/BUILD.bazel +++ b/devtools/projects/demo-standalone/src/app/devtools-app/BUILD.bazel @@ -7,6 +7,7 @@ ng_module( srcs = ["devtools-app.component.ts"], deps = [ "//devtools/projects/ng-devtools", + "//devtools/projects/ng-devtools/src/lib:frame_manager", "//devtools/projects/protocol", "//devtools/src:iframe_message_bus", "//packages/common", diff --git a/devtools/projects/demo-standalone/src/app/devtools-app/devtools-app.component.ts b/devtools/projects/demo-standalone/src/app/devtools-app/devtools-app.component.ts index c32cb5a15a3..ed4b23fd33a 100644 --- a/devtools/projects/demo-standalone/src/app/devtools-app/devtools-app.component.ts +++ b/devtools/projects/demo-standalone/src/app/devtools-app/devtools-app.component.ts @@ -11,11 +11,13 @@ import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; import {IFrameMessageBus} from '../../../../../src/iframe-message-bus'; import {DevToolsComponent} from 'ng-devtools'; +import {FrameManager} from '../../../../../projects/ng-devtools/src/lib/frame_manager'; @Component({ standalone: true, imports: [DevToolsComponent], providers: [ + {provide: FrameManager, useFactory: () => FrameManager.initialize(null)}, { provide: MessageBus, useFactory(): MessageBus { diff --git a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts index d41a1cbddea..b89bde1aa96 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts @@ -86,6 +86,10 @@ export const subscribeToClientEvents = ( messageBus.on('logProvider', logProvider); + messageBus.on('log', ({message, level}) => { + console[level](`[Angular DevTools]: ${message}`); + }); + if (appIsAngularInDevMode() && appIsSupportedAngularVersion() && appIsAngularIvy()) { setupInspector(messageBus); // Often websites have `scroll` event listener which triggers @@ -216,8 +220,8 @@ const getRoutes = (messageBus: MessageBus) => { const checkForAngular = (messageBus: MessageBus): void => { const ngVersion = getAngularVersion(); const appIsIvy = appIsAngularIvy(); + if (!ngVersion) { - setTimeout(() => checkForAngular(messageBus), 500); return; } diff --git a/devtools/projects/ng-devtools/src/lib/BUILD.bazel b/devtools/projects/ng-devtools/src/lib/BUILD.bazel index 47fa98dd91a..052e8cd15bf 100644 --- a/devtools/projects/ng-devtools/src/lib/BUILD.bazel +++ b/devtools/projects/ng-devtools/src/lib/BUILD.bazel @@ -1,5 +1,7 @@ load("//devtools/tools:ng_module.bzl", "ng_module") load("@io_bazel_rules_sass//:defs.bzl", "sass_binary") +load("//devtools/tools:typescript.bzl", "ts_test_library") +load("//devtools/tools:defaults.bzl", "karma_web_test_suite") package(default_visibility = ["//visibility:public"]) @@ -12,13 +14,18 @@ ng_module( name = "lib", srcs = glob( include = ["*.ts"], - exclude = ["theme-service.ts"], + exclude = [ + "theme-service.ts", + "frame_manager.ts", + "*_spec.ts", + ], ), angular_assets = [ "devtools.component.html", ":devtools_component_styles", ], deps = [ + ":frame_manager", ":theme", "//devtools/projects/ng-devtools/src/lib/devtools-tabs", "//devtools/projects/protocol", @@ -34,6 +41,58 @@ ng_module( ], ) +ts_test_library( + name = "devtools_test", + srcs = ["devtools_spec.ts"], + deps = [ + ":frame_manager", + ":lib", + "//devtools/projects/ng-devtools/src/lib/devtools-tabs", + "//devtools/projects/protocol", + "//packages/core", + "//packages/core/testing", + ], +) + +karma_web_test_suite( + name = "test", + deps = [ + ":devtools_test", + ], +) + +ng_module( + name = "frame_manager", + srcs = glob( + include = ["frame_manager.ts"], + ), + deps = [ + "//devtools/projects/ng-devtools/src/lib/application-environment", + "//devtools/projects/protocol", + "//packages/core", + ], +) + +ts_test_library( + name = "test_frame_manager_lib", + srcs = [ + "frame_manager_spec.ts", + ], + deps = [ + ":frame_manager", + "//devtools/projects/ng-devtools/src/lib/application-environment", + "//devtools/projects/protocol", + "//packages/core/testing", + ], +) + +karma_web_test_suite( + name = "test_frame_manager", + deps = [ + ":test_frame_manager_lib", + ], +) + ng_module( name = "theme", srcs = glob( diff --git a/devtools/projects/ng-devtools/src/lib/application-environment/index.ts b/devtools/projects/ng-devtools/src/lib/application-environment/index.ts index 19cf6111e6e..67afac28c9b 100644 --- a/devtools/projects/ng-devtools/src/lib/application-environment/index.ts +++ b/devtools/projects/ng-devtools/src/lib/application-environment/index.ts @@ -15,6 +15,15 @@ export interface Environment { LATEST_SHA: string; } +export const TOP_LEVEL_FRAME_ID = 0; + +export interface Frame { + id: number; + name: string; + url: URL; +} + export abstract class ApplicationEnvironment { abstract get environment(): Environment; + abstract frameSelectorEnabled: boolean; } diff --git a/devtools/projects/ng-devtools/src/lib/application-operations/index.ts b/devtools/projects/ng-devtools/src/lib/application-operations/index.ts index 820fe9954af..677d78d89c6 100644 --- a/devtools/projects/ng-devtools/src/lib/application-operations/index.ts +++ b/devtools/projects/ng-devtools/src/lib/application-operations/index.ts @@ -9,7 +9,7 @@ import {DirectivePosition, ElementPosition} from 'protocol'; export abstract class ApplicationOperations { - abstract viewSource(position: ElementPosition, directiveIndex?: number): void; - abstract selectDomElement(position: ElementPosition): void; - abstract inspect(directivePosition: DirectivePosition, objectPath: string[]): void; + abstract viewSource(position: ElementPosition, directiveIndex?: number, target?: URL): void; + abstract selectDomElement(position: ElementPosition, target?: URL): void; + abstract inspect(directivePosition: DirectivePosition, objectPath: string[], target?: URL): void; } diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/BUILD.bazel b/devtools/projects/ng-devtools/src/lib/devtools-tabs/BUILD.bazel index 7d939af6c71..8b7f7e58976 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/BUILD.bazel +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/BUILD.bazel @@ -20,6 +20,7 @@ ng_module( ":devtools_tabs_component_styles", ], deps = [ + "//devtools/projects/ng-devtools/src/lib:frame_manager", "//devtools/projects/ng-devtools/src/lib:theme", "//devtools/projects/ng-devtools/src/lib/application-environment", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer", @@ -42,6 +43,7 @@ ts_test_library( srcs = ["devtools-tabs.spec.ts"], deps = [ ":devtools-tabs", + "//devtools/projects/ng-devtools/src/lib:frame_manager", "//devtools/projects/ng-devtools/src/lib:theme", "//devtools/projects/ng-devtools/src/lib/application-environment", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer", @@ -56,7 +58,6 @@ ts_test_library( ], ) -# todo(aleksanderbodurri): fix this test suite karma_web_test_suite( name = "test", deps = [ @@ -65,16 +66,3 @@ karma_web_test_suite( "//packages/platform-browser/animations", ], ) - -# spec_bundle( -# name = "test_bundle", -# deps = [ -# ":devtools_tabs_test", -# "//packages/platform-browser/animations", -# "//packages/animations", -# "//packages/common/http", -# "//packages/core", -# "//packages/core/src/util", -# ], -# platform = "browser", -# ) diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.html index c3d7becfa26..40fc1a1097c 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.html @@ -10,6 +10,21 @@ info + + + @for (tab of tabs; track $index) { {{ tab }} @@ -27,19 +42,23 @@ } + -
- - - - -
+ @if (!applicationEnvironment.frameSelectorEnabled || frameManager.selectedFrame !== null) { +
+ + + + +
+ }
+
@@ -57,6 +76,7 @@
+
library_books diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.scss b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.scss index 5e658b82a4e..f3983078a91 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.scss +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.scss @@ -119,3 +119,22 @@ mat-icon { } } } + +.frame-selector { + background-color: #e2e2e2; + border-radius: 2px; + color: #474747; + border: none; + margin: 4px 4px 2px 4px; + padding: 2px; + outline-offset: -2px; + width: 100px; + font-size: 12px; +} + +:host-context(.dark-theme) { + .frame-selector { + background-color: #464646; + color: #fff; + } +} diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.ts index 212f9762bd3..2136331c758 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.ts @@ -6,7 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ -import {AfterViewInit, Component, Input, OnInit, ViewChild} from '@angular/core'; +import { + AfterViewInit, + Component, + EventEmitter, + inject, + Input, + OnInit, + Output, + ViewChild, +} from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {MatIcon} from '@angular/material/icon'; import {MatMenu, MatMenuItem, MatMenuTrigger} from '@angular/material/menu'; @@ -15,7 +24,8 @@ import {MatTabLink, MatTabNav, MatTabNavPanel} from '@angular/material/tabs'; import {MatTooltip} from '@angular/material/tooltip'; import {Events, MessageBus, Route} from 'protocol'; -import {ApplicationEnvironment} from '../application-environment/index'; +import {ApplicationEnvironment, Frame, TOP_LEVEL_FRAME_ID} from '../application-environment/index'; +import {FrameManager} from '../frame_manager'; import {Theme, ThemeService} from '../theme-service'; import {DirectiveExplorerComponent} from './directive-explorer/directive-explorer.component'; @@ -52,25 +62,28 @@ export class DevToolsTabsComponent implements OnInit, AfterViewInit { @Input() angularVersion: string | undefined = undefined; @Input() isHydrationEnabled = false; + @Output() frameSelected = new EventEmitter(); @ViewChild(DirectiveExplorerComponent) directiveExplorer!: DirectiveExplorerComponent; @ViewChild('navBar', {static: true}) navbar!: MatTabNav; + applicationEnvironment = inject(ApplicationEnvironment); activeTab: Tabs = 'Components'; - inspectorRunning = false; routerTreeEnabled = false; showCommentNodes = false; timingAPIEnabled = false; currentTheme!: Theme; - routes: Route[] = []; + frameManager = inject(FrameManager); + + TOP_LEVEL_FRAME_ID = TOP_LEVEL_FRAME_ID; + constructor( public tabUpdate: TabUpdate, public themeService: ThemeService, private _messageBus: MessageBus, - private _applicationEnvironment: ApplicationEnvironment, ) { this.themeService.currentTheme .pipe(takeUntilDestroyed()) @@ -81,6 +94,11 @@ export class DevToolsTabsComponent implements OnInit, AfterViewInit { }); } + emitSelectedFrame(frameId: string): void { + const frame = this.frameManager.frames.find((frame) => frame.id === parseInt(frameId, 10)); + this.frameSelected.emit(frame); + } + ngOnInit(): void { this.navbar.stretchTabs = false; } @@ -95,7 +113,7 @@ export class DevToolsTabsComponent implements OnInit, AfterViewInit { } get latestSHA(): string { - return this._applicationEnvironment.environment.LATEST_SHA.slice(0, 8); + return this.applicationEnvironment.environment.LATEST_SHA.slice(0, 8); } changeTab(tab: Tabs): void { diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.spec.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.spec.ts index 3919ba1f058..c15c08f2bb3 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.spec.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.spec.ts @@ -19,6 +19,7 @@ import {Theme, ThemeService} from '../theme-service'; import {DevToolsTabsComponent} from './devtools-tabs.component'; import {TabUpdate} from './tab-update/index'; import {DirectiveExplorerComponent} from './directive-explorer/directive-explorer.component'; +import {FrameManager} from '../frame_manager'; @Component({ selector: 'ng-directive-explorer', @@ -36,6 +37,7 @@ describe('DevtoolsTabsComponent', () => { beforeEach(() => { messageBusMock = jasmine.createSpyObj('messageBus', ['on', 'once', 'emit', 'destroy']); applicationEnvironmentMock = jasmine.createSpyObj('applicationEnvironment', ['environment']); + TestBed.configureTestingModule({ imports: [MatTooltip, MatMenuModule, DevToolsTabsComponent], providers: [ @@ -43,6 +45,7 @@ describe('DevtoolsTabsComponent', () => { {provide: ThemeService, useFactory: () => ({currentTheme: new Subject()})}, {provide: MessageBus, useValue: messageBusMock}, {provide: ApplicationEnvironment, useValue: applicationEnvironmentMock}, + {provide: FrameManager, useFactory: () => FrameManager.initialize(123)}, ], }).overrideComponent(DevToolsTabsComponent, { remove: {imports: [DirectiveExplorerComponent]}, @@ -74,4 +77,24 @@ describe('DevtoolsTabsComponent', () => { expect(messageBusMock.emit).toHaveBeenCalledWith('inspectorEnd'); expect(messageBusMock.emit).toHaveBeenCalledWith('removeHighlightOverlay'); }); + + it('should emit a selectedFrame when emitSelectedFrame is called', () => { + let contentScriptConnected: Function = () => {}; + + // mock message bus on method with jasmine fake call in order to pick out callback + // and call it with frame + (messageBusMock.on as any).and.callFake((topic: string, cb: Function) => { + if (topic === 'contentScriptConnected') { + contentScriptConnected = cb; + } + }); + + const frameId = 1; + expect(contentScriptConnected).toEqual(jasmine.any(Function)); + contentScriptConnected(frameId, 'name', 'http://localhost:4200/url'); + spyOn(comp.frameSelected, 'emit'); + comp.emitSelectedFrame('1'); + + expect(comp.frameSelected.emit).toHaveBeenCalledWith(comp.frameManager.frames[0]); + }); }); diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/BUILD.bazel b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/BUILD.bazel index 68d22f35ca6..46147c6d67b 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/BUILD.bazel +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/BUILD.bazel @@ -43,10 +43,14 @@ ts_test_library( srcs = ["directive-explorer.spec.ts"], deps = [ ":directive-explorer", + "//devtools/projects/ng-devtools/src/lib:frame_manager", "//devtools/projects/ng-devtools/src/lib/application-operations", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest", + "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/breadcrumbs", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/index-forest", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-resolver", + "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab", + "//devtools/projects/ng-devtools/src/lib/devtools-tabs/tab-update", "//devtools/projects/protocol", "//packages/core", "//packages/core/testing", @@ -59,8 +63,5 @@ karma_web_test_suite( name = "test", deps = [ ":directive_explorer_test", - "//packages/common/http", - "//packages/platform-browser", - "//packages/platform-browser/animations", ], ) diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts index 26b641da38d..6031f8f23be 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts @@ -11,6 +11,7 @@ import { Component, ElementRef, EventEmitter, + inject, Input, NgZone, OnDestroy, @@ -32,6 +33,7 @@ import { import {SplitComponent} from '../../../lib/vendor/angular-split/public_api'; import {ApplicationOperations} from '../../application-operations/index'; +import {FrameManager} from '../../frame_manager'; import {BreadcrumbsComponent} from './directive-forest/breadcrumbs/breadcrumbs.component'; import {FlatNode} from './directive-forest/component-data-source'; @@ -122,11 +124,12 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { private _refreshRetryTimeout: null | ReturnType = null; constructor( - private _appOperations: ApplicationOperations, - private _messageBus: MessageBus, - private _propResolver: ElementPropertyResolver, - private _cdr: ChangeDetectorRef, - private _ngZone: NgZone, + private readonly _appOperations: ApplicationOperations, + private readonly _messageBus: MessageBus, + private readonly _propResolver: ElementPropertyResolver, + private readonly _cdr: ChangeDetectorRef, + private readonly _ngZone: NgZone, + private readonly _frameManager: FrameManager, ) {} ngOnInit(): void { @@ -198,18 +201,37 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { (directive) => directive.name === directiveName, ); - if (directiveIndex === -1) { - // view the component definition - this._appOperations.viewSource(this.currentSelectedElement.position); + const selectedFrame = this._frameManager.selectedFrame; + if (!this._frameManager.frameHasUniqueUrl(selectedFrame)) { + this._messageBus.emit('log', [ + { + level: 'warn', + message: `The currently inspected frame does not have a unique url on this page. Cannot view source.`, + }, + ]); return; } - // view the directive definition - this._appOperations.viewSource(this.currentSelectedElement.position, directiveIndex); + this._appOperations.viewSource( + this.currentSelectedElement.position, + directiveIndex !== -1 ? directiveIndex : undefined, + new URL(selectedFrame!.url), + ); } handleSelectDomElement(node: IndexedNode): void { - this._appOperations.selectDomElement(node.position); + const selectedFrame = this._frameManager.selectedFrame; + if (!this._frameManager.frameHasUniqueUrl(selectedFrame)) { + this._messageBus.emit('log', [ + { + level: 'warn', + message: `The currently inspected frame does not have a unique url on this page. Cannot select DOM element.`, + }, + ]); + return; + } + + this._appOperations.selectDomElement(node.position, new URL(selectedFrame!.url)); } highlight(node: FlatNode): void { @@ -278,7 +300,19 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { directivePosition: DirectivePosition; }): void { const objectPath = constructPathOfKeysToPropertyValue(node.prop); - this._appOperations.inspect(directivePosition, objectPath); + + const selectedFrame = this._frameManager.selectedFrame; + if (!this._frameManager.frameHasUniqueUrl(selectedFrame)) { + this._messageBus.emit('log', [ + { + level: 'warn', + message: `The currently inspected frame does not have a unique url on this page. Cannot inspect object.`, + }, + ]); + return; + } + + this._appOperations.inspect(directivePosition, objectPath, new URL(selectedFrame!.url)); } hightlightHydrationNodes() { diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts index b6b9e0514e3..9fbda282341 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts @@ -6,37 +6,94 @@ * found in the LICENSE file at https://angular.io/license */ -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {Events, MessageBus, PropertyQueryTypes} from 'protocol'; import {ApplicationOperations} from '../../application-operations'; +import {DirectivePosition, MessageBus, PropType, PropertyQueryTypes} from 'protocol'; import {DirectiveExplorerComponent} from './directive-explorer.component'; import {DirectiveForestComponent} from './directive-forest/directive-forest.component'; import {IndexedNode} from './directive-forest/index-forest'; -import {ElementPropertyResolver} from './property-resolver/element-property-resolver'; import SpyObj = jasmine.SpyObj; import {By} from '@angular/platform-browser'; +import {FrameManager} from '../../frame_manager'; +import {TabUpdate} from '../tab-update'; +import {Component, EventEmitter, Input, Output, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import {ElementPropertyResolver, FlatNode} from './property-resolver/element-property-resolver'; +import {BreadcrumbsComponent} from './directive-forest/breadcrumbs/breadcrumbs.component'; +import {PropertyTabComponent} from './property-tab/property-tab.component'; + +@Component({ + selector: 'ng-directive-forest', + template: '', + standalone: true, +}) +class MockDirectiveForestComponent { + @Input() forest: IndexedNode[] = []; + @Input() currentSelectedElement: IndexedNode | null = null; + @Input() showCommentNodes = false; + @Output() selectNode = new EventEmitter(); + @Output() selectDomElement = new EventEmitter(); + @Output() setParents = new EventEmitter(); + @Output() highlightComponent = new EventEmitter(); + @Output() removeComponentHighlight = new EventEmitter(); + @Output() toggleInspector = new EventEmitter(); +} + +@Component({ + selector: 'ng-breadcrumbs', + template: '', + standalone: true, +}) +class MockBreadcrumbsComponent { + @Input() parents: IndexedNode[] = []; + @Output() handleSelect = new EventEmitter(); + @Output() mouseLeaveNode = new EventEmitter(); + @Output() mouseOverNode = new EventEmitter(); +} + +@Component({ + selector: 'ng-property-tab', + template: '', + standalone: true, +}) +class MockPropertyTabComponent { + @Input() currentSelectedElement: IndexedNode | null = null; + @Output() inspect = new EventEmitter<{node: FlatNode; directivePosition: DirectivePosition}>(); + @Output() viewSource = new EventEmitter(); +} describe('DirectiveExplorerComponent', () => { - let messageBusMock: SpyObj>; + let messageBusMock: SpyObj; let fixture: ComponentFixture; let comp: DirectiveExplorerComponent; let applicationOperationsSpy: SpyObj; + let contentScriptConnected = (frameId: number, name: string, url: string) => {}; + let frameConnected = (frameId: number) => {}; beforeEach(() => { applicationOperationsSpy = jasmine.createSpyObj('_appOperations', [ 'viewSource', 'selectDomElement', + 'inspect', ]); - messageBusMock = jasmine.createSpyObj>('messageBus', [ - 'on', - 'once', - 'emit', - 'destroy', - ]); + + messageBusMock = jasmine.createSpyObj('messageBus', ['on', 'once', 'emit', 'destroy']); + + messageBusMock.on.and.callFake((topic: string, cb: Function) => { + if (topic === 'contentScriptConnected') { + contentScriptConnected = cb as (frameId: number, name: string, url: string) => void; + } + if (topic === 'frameConnected') { + frameConnected = cb as (frameId: number) => void; + } + }); + messageBusMock.emit.and.callFake((topic: string, args: any[]) => { + if (topic === 'enableFrameConnection') { + frameConnected(args[0]); + } + }); TestBed.configureTestingModule({ providers: [ @@ -46,14 +103,24 @@ describe('DirectiveExplorerComponent', () => { provide: ElementPropertyResolver, useValue: new ElementPropertyResolver(messageBusMock), }, + {provide: FrameManager, useFactory: () => FrameManager.initialize(123)}, ], }).overrideComponent(DirectiveExplorerComponent, { add: {schemas: [CUSTOM_ELEMENTS_SCHEMA]}, remove: {imports: [DirectiveForestComponent]}, }); - fixture = TestBed.createComponent(DirectiveExplorerComponent); + fixture = TestBed.overrideComponent(DirectiveExplorerComponent, { + remove: {imports: [DirectiveForestComponent, BreadcrumbsComponent, PropertyTabComponent]}, + add: { + imports: [MockDirectiveForestComponent, MockBreadcrumbsComponent, MockPropertyTabComponent], + }, + }).createComponent(DirectiveExplorerComponent); comp = fixture.componentInstance; + + TestBed.inject(FrameManager); + comp = fixture.componentInstance; + fixture.detectChanges(); }); it('should create instance from class', () => { @@ -61,8 +128,6 @@ describe('DirectiveExplorerComponent', () => { }); it('subscribe to backend events', () => { - comp.subscribeToBackendEvents(); - expect(messageBusMock.on).toHaveBeenCalledTimes(2); expect(messageBusMock.on).toHaveBeenCalledWith( 'latestComponentExplorerView', jasmine.any(Function), @@ -73,7 +138,9 @@ describe('DirectiveExplorerComponent', () => { describe('refresh', () => { it('should emit getLatestComponentExplorerView event on refresh', () => { comp.refresh(); - expect(messageBusMock.emit).toHaveBeenCalledTimes(1); + expect(messageBusMock.emit).toHaveBeenCalledWith('getLatestComponentExplorerView', [ + undefined, + ]); }); it('should emit getLatestComponentExplorerView event with null view query', () => { @@ -110,7 +177,6 @@ describe('DirectiveExplorerComponent', () => { const position = [0]; nodeMock.position = position; comp.handleNodeSelection(nodeMock); - expect(messageBusMock.emit).toHaveBeenCalledTimes(2); expect(messageBusMock.emit).toHaveBeenCalledWith('setSelectedComponent', [nodeMock.position]); expect(messageBusMock.emit).toHaveBeenCalledWith('getLatestComponentExplorerView', [ { @@ -126,11 +192,9 @@ describe('DirectiveExplorerComponent', () => { describe('hydration', () => { it('should highlight hydration nodes', () => { comp.hightlightHydrationNodes(); - expect(messageBusMock.emit).toHaveBeenCalledTimes(1); expect(messageBusMock.emit).toHaveBeenCalledWith('createHydrationOverlay'); comp.removeHydrationNodesHightlights(); - expect(messageBusMock.emit).toHaveBeenCalledTimes(2); expect(messageBusMock.emit).toHaveBeenCalledWith('removeHydrationOverlay'); }); @@ -146,4 +210,161 @@ describe('DirectiveExplorerComponent', () => { expect(toggle2).toBeFalsy(); }); }); + + describe('applicaton operations', () => { + describe('view source', () => { + it('should not call application operations view source if no frames are detected', () => { + const directiveName = 'test'; + comp.currentSelectedElement = { + directives: [{name: directiveName}], + position: [0], + children: [] as IndexedNode[], + } as IndexedNode; + comp.viewSource(directiveName); + expect(applicationOperationsSpy.viewSource).toHaveBeenCalledTimes(0); + }); + + it('should not call application operations view source if a frame is selected that does not have a unique url on the page', () => { + contentScriptConnected(0, 'test1', 'http://localhost:4200/url'); + contentScriptConnected(1, 'test2', 'http://localhost:4200/url'); + + const directiveName = 'test'; + comp.currentSelectedElement = { + directives: [{name: directiveName}], + position: [0], + children: [] as IndexedNode[], + } as IndexedNode; + + comp.viewSource(directiveName); + + expect(applicationOperationsSpy.viewSource).toHaveBeenCalledTimes(0); + expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); + expect(messageBusMock.emit).toHaveBeenCalledWith('log', [ + { + level: 'warn', + message: `The currently inspected frame does not have a unique url on this page. Cannot view source.`, + }, + ]); + }); + + it('should call application operations view source if a frame is selected that has a unique url on the page', () => { + contentScriptConnected(0, 'test1', 'http://localhost:4200/url'); + contentScriptConnected(1, 'test2', 'http://localhost:4200/url2'); + + const directiveName = 'test'; + comp.currentSelectedElement = { + directives: [{name: directiveName}], + position: [0], + children: [] as IndexedNode[], + } as IndexedNode; + + comp.viewSource(directiveName); + + expect(applicationOperationsSpy.viewSource).toHaveBeenCalledTimes(1); + expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); + expect(applicationOperationsSpy.viewSource).toHaveBeenCalledWith( + [0], // current selected element position + 0, // directive index + new URL('http://localhost:4200/url'), // selected frame url + ); + }); + }); + + describe('select dom element', () => { + it('should not call application operations select dom element if no frames are detected', () => { + comp.handleSelectDomElement({position: [0], children: [] as IndexedNode[]} as IndexedNode); + expect(applicationOperationsSpy.selectDomElement).toHaveBeenCalledTimes(0); + }); + + it('should not call application operations select dom element if a frame is selected that does not have a unique url on the page', () => { + contentScriptConnected(0, 'test1', 'http://localhost:4200/url'); + contentScriptConnected(1, 'test2', 'http://localhost:4200/url'); + + comp.handleSelectDomElement({position: [0], children: [] as IndexedNode[]} as IndexedNode); + + expect(applicationOperationsSpy.selectDomElement).toHaveBeenCalledTimes(0); + expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); + expect(messageBusMock.emit).toHaveBeenCalledWith('log', [ + { + level: 'warn', + message: `The currently inspected frame does not have a unique url on this page. Cannot select DOM element.`, + }, + ]); + }); + + it('should call application operations select dom element if a frame is selected that has a unique url on the page', () => { + contentScriptConnected(0, 'test1', 'http://localhost:4200/url'); + contentScriptConnected(1, 'test2', 'http://localhost:4200/url2'); + + comp.handleSelectDomElement({position: [0], children: [] as IndexedNode[]} as IndexedNode); + + expect(applicationOperationsSpy.selectDomElement).toHaveBeenCalledTimes(1); + expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); + expect(applicationOperationsSpy.selectDomElement).toHaveBeenCalledWith( + [0], // current selected element position + new URL('http://localhost:4200/url'), // selected frame url + ); + }); + }); + + describe('inspect', () => { + let node: FlatNode; + let directivePosition: DirectivePosition; + + beforeEach(() => { + node = { + expandable: true, + prop: { + name: 'foo', + parent: null, + descriptor: { + expandable: true, + editable: false, + type: PropType.String, + preview: 'preview', + containerType: null, + }, + }, + level: 1, + }; + directivePosition = {element: [0], directive: 0}; + }); + + it('should not call application operations inspect if no frames are detected', () => { + comp.inspect({node, directivePosition}); + expect(applicationOperationsSpy.selectDomElement).toHaveBeenCalledTimes(0); + }); + + it('should not call application operations inspect if a frame is selected that does not have a unique url on the page', () => { + contentScriptConnected(0, 'test1', 'http://localhost:4200/url'); + contentScriptConnected(1, 'test2', 'http://localhost:4200/url'); + + comp.inspect({node, directivePosition}); + + expect(applicationOperationsSpy.inspect).toHaveBeenCalledTimes(0); + expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); + expect(messageBusMock.emit).toHaveBeenCalledWith('log', [ + { + level: 'warn', + message: `The currently inspected frame does not have a unique url on this page. Cannot inspect object.`, + }, + ]); + }); + + it('should call application operations inspect if a frame is selected that has a unique url on the page', () => { + contentScriptConnected(0, 'test1', 'http://localhost:4200/url'); + contentScriptConnected(1, 'test2', 'http://localhost:4200/url2'); + + comp.inspect({node, directivePosition}); + + expect(applicationOperationsSpy.inspect).toHaveBeenCalledTimes(1); + expect(messageBusMock.emit).toHaveBeenCalledWith('enableFrameConnection', [0, 123]); + expect(applicationOperationsSpy.inspect).toHaveBeenCalledWith( + directivePosition, + ['foo'], + new URL('http://localhost:4200/url'), // selected frame url + ); + }); + }); + }); }); diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/BUILD.bazel b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/BUILD.bazel index 27aa0cdcc3b..ad2c5d62769 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/BUILD.bazel +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/BUILD.bazel @@ -47,6 +47,8 @@ ng_module( "property-tab-body.component.html", ] + _STYLE_LABELS, deps = [ + "//devtools/projects/ng-devtools/src/lib:frame_manager", + "//devtools/projects/ng-devtools/src/lib/application-environment", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection:injector_tree_visualizer", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection:resolution_path", "//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/index-forest", diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html index 50fef455a49..3318f13cae5 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-tab/property-view/property-view-header.component.html @@ -1,6 +1,6 @@ - {{ directive }} - + {{ directive }} + \ No newline at end of file diff --git a/devtools/projects/ng-devtools/src/lib/devtools.component.html b/devtools/projects/ng-devtools/src/lib/devtools.component.html index 9b7f315b551..4fb5b38217b 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools.component.html @@ -1,58 +1,57 @@ -
- @if (angularExists === true) { - @if (angularIsInDevMode) { - @if (supportedVersion) { -
- -
+
+ @switch (angularStatus) { + @case (AngularStatus.EXISTS) { + @if (angularIsInDevMode) { + @if (supportedVersion) { +
+ +
+ } @else { +

+ Angular Devtools only supports Angular versions 12 and above +

+ } } @else { -

- Angular Devtools only supports Angular versions 12 and above +

+ We detected an application built with production configuration. Angular DevTools only supports development build.

} - } @else { -

- We detected an application built with production configuration. Angular DevTools only supports development build. + } + @case (AngularStatus.DOES_NOT_EXIST) { +

+ i + Angular application not detected.

} - } @else { - @if (angularExists === false) { -

- i - Angular application not detected. -

- } @else { - @if (angularExists === null) { -
-
- - - - - - - - - - - - - - - - - - - - - - - -
-
- } - } + @case (AngularStatus.UNKNOWN) { +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
} -
+ } +
\ No newline at end of file diff --git a/devtools/projects/ng-devtools/src/lib/devtools.component.ts b/devtools/projects/ng-devtools/src/lib/devtools.component.ts index 4a50fdf0723..f0fc48f6eae 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools.component.ts @@ -9,13 +9,36 @@ import {animate, style, transition, trigger} from '@angular/animations'; import {Platform} from '@angular/cdk/platform'; import {DOCUMENT} from '@angular/common'; -import {Component, Inject, OnDestroy, OnInit} from '@angular/core'; +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; import {Events, MessageBus} from 'protocol'; import {interval} from 'rxjs'; +import {FrameManager} from './frame_manager'; import {ThemeService} from './theme-service'; -import {MatTooltip} from '@angular/material/tooltip'; +import {MatTooltip, MatTooltipModule} from '@angular/material/tooltip'; import {DevToolsTabsComponent} from './devtools-tabs/devtools-tabs.component'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {Frame} from './application-environment'; + +const DETECT_ANGULAR_ATTEMPTS = 10; + +enum AngularStatus { + /** + * This page may have Angular but we don't know yet. We're still trying to detect it. + */ + UNKNOWN, + + /** + * We've given up on trying to detect Angular. We tried ${DETECT_ANGULAR_ATTEMPTS} times and + * failed. + */ + DOES_NOT_EXIST, + + /** + * Angular was detected somewhere on the page. + */ + EXISTS, +} @Component({ selector: 'ng-devtools', @@ -28,10 +51,11 @@ import {DevToolsTabsComponent} from './devtools-tabs/devtools-tabs.component'; ]), ], standalone: true, - imports: [DevToolsTabsComponent, MatTooltip], + imports: [DevToolsTabsComponent, MatTooltip, MatProgressSpinnerModule, MatTooltipModule], }) export class DevToolsComponent implements OnInit, OnDestroy { - angularExists: boolean | null = null; + AngularStatus = AngularStatus; + angularStatus: AngularStatus = AngularStatus.UNKNOWN; angularVersion: string | boolean | undefined = undefined; angularIsInDevMode = true; hydration: boolean = false; @@ -39,26 +63,28 @@ export class DevToolsComponent implements OnInit, OnDestroy { private readonly _firefoxStyleName = 'firefox_styles.css'; private readonly _chromeStyleName = 'chrome_styles.css'; - - constructor( - private _messageBus: MessageBus, - private _themeService: ThemeService, - private _platform: Platform, - @Inject(DOCUMENT) private _document: Document, - ) {} + private readonly _messageBus = inject>(MessageBus); + private readonly _themeService = inject(ThemeService); + private readonly _platform = inject(Platform); + private readonly _document = inject(DOCUMENT); + private readonly _frameManager = inject(FrameManager); private _interval$ = interval(500).subscribe((attempt) => { - if (attempt === 10) { - this.angularExists = false; + if (attempt === DETECT_ANGULAR_ATTEMPTS) { + this.angularStatus = AngularStatus.DOES_NOT_EXIST; } this._messageBus.emit('queryNgAvailability'); }); + inspectFrame(frame: Frame) { + this._frameManager.inspectFrame(frame); + } + ngOnInit(): void { this._themeService.initializeThemeWatcher(); this._messageBus.once('ngAvailability', ({version, devMode, ivy, hydration}) => { - this.angularExists = !!version; + this.angularStatus = version ? AngularStatus.EXISTS : AngularStatus.DOES_NOT_EXIST; this.angularVersion = version; this.angularIsInDevMode = devMode; this.ivy = ivy; diff --git a/devtools/projects/ng-devtools/src/lib/devtools_spec.ts b/devtools/projects/ng-devtools/src/lib/devtools_spec.ts new file mode 100644 index 00000000000..6c17a3968db --- /dev/null +++ b/devtools/projects/ng-devtools/src/lib/devtools_spec.ts @@ -0,0 +1,86 @@ +/** + * @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.io/license + */ + +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FrameManager} from './frame_manager'; +import {DevToolsComponent} from './devtools.component'; +import {DevToolsTabsComponent} from './devtools-tabs/devtools-tabs.component'; +import {MessageBus} from 'protocol'; + +@Component({ + selector: 'ng-devtools-tabs', + template: '', + standalone: true, +}) +export class MockNgDevToolsTabs {} + +describe('DevtoolsComponent', () => { + let fixture: ComponentFixture; + let component: DevToolsComponent; + + beforeEach(() => { + const mockMessageBus = jasmine.createSpyObj('MessageBus', ['on', 'emit', 'once']); + + TestBed.configureTestingModule({ + providers: [{provide: MessageBus, useValue: mockMessageBus}], + }).overrideComponent(DevToolsComponent, { + remove: {imports: [DevToolsTabsComponent], providers: [FrameManager]}, + add: { + imports: [MockNgDevToolsTabs], + providers: [{provide: FrameManager, useFactory: () => FrameManager.initialize(123)}], + }, + }); + + fixture = TestBed.createComponent(DevToolsComponent); + component = fixture.componentInstance; + }); + + it('should render ng devtools tabs when Angular Status is EXISTS and is in dev mode and is supported version', () => { + component.angularStatus = component.AngularStatus.EXISTS; + component.angularIsInDevMode = true; + component.angularVersion = '0.0.0'; + component.ivy = true; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ng-devtools-tabs')).toBeTruthy(); + }); + + it('should render Angular Devtools dev mode only support text when Angular Status is EXISTS and is angular is not in dev mode', () => { + component.angularStatus = component.AngularStatus.EXISTS; + component.angularIsInDevMode = false; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.devtools').textContent).toContain( + 'We detected an application built with production configuration. Angular DevTools only supports development build.', + ); + }); + + it('should render version support message when Angular Status is EXISTS and angular version is not supported', () => { + component.angularStatus = component.AngularStatus.EXISTS; + component.angularIsInDevMode = true; + component.angularVersion = '1.0.0'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.devtools').textContent).toContain( + 'Angular Devtools only supports Angular versions 12 and above', + ); + }); + + it('should render Angular application not detected when Angular Status is DOES_NOT_EXIST', () => { + component.angularStatus = component.AngularStatus.DOES_NOT_EXIST; + fixture.detectChanges(); + // expect the text to be "Angular application not detected" + expect(fixture.nativeElement.querySelector('.not-detected').textContent).toContain( + 'Angular application not detected', + ); + }); + + it('should render loading svg when Angular Status is UNKNOWN', () => { + component.angularStatus = component.AngularStatus.UNKNOWN; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.loading svg')).toBeTruthy(); + }); +}); diff --git a/devtools/projects/ng-devtools/src/lib/frame_manager.ts b/devtools/projects/ng-devtools/src/lib/frame_manager.ts new file mode 100644 index 00000000000..24ad675dc3e --- /dev/null +++ b/devtools/projects/ng-devtools/src/lib/frame_manager.ts @@ -0,0 +1,133 @@ +/** + * @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.io/license + */ + +import {Injectable, inject} from '@angular/core'; +import {Events, MessageBus} from 'protocol'; + +import {Frame, TOP_LEVEL_FRAME_ID} from './application-environment'; + +@Injectable() +export class FrameManager { + private _selectedFrameId: number | null = null; + private _frames = new Map(); + private _inspectedWindowTabId: number | null = null; + private _frameUrlToFrameIds = new Map>(); + private _messageBus = inject>(MessageBus); + + get frames(): Frame[] { + return Array.from(this._frames.values()); + } + + get selectedFrame(): Frame | null { + if (this._selectedFrameId === null) { + return null; + } + + return this._frames.get(this._selectedFrameId) ?? null; + } + + static initialize(inspectedWindowTabIdTestOnly?: number | null) { + const manager = new FrameManager(); + manager.initialize(inspectedWindowTabIdTestOnly); + return manager; + } + + private initialize(inspectedWindowTabIdTestOnly?: number | null): void { + if (inspectedWindowTabIdTestOnly === undefined) { + this._inspectedWindowTabId = globalThis.chrome.devtools.inspectedWindow.tabId; + } else { + this._inspectedWindowTabId = inspectedWindowTabIdTestOnly; + } + + this._messageBus.on('frameConnected', (frameId: number) => { + if (this._frames.has(frameId)) { + this._selectedFrameId = frameId; + } + }); + + this._messageBus.on('contentScriptConnected', (frameId: number, name: string, url: string) => { + // fragments are not considered when doing URL matching on a page + // https://bugs.chromium.org/p/chromium/issues/detail?id=841429 + const urlWithoutHash = new URL(url); + urlWithoutHash.hash = ''; + + this.addFrame({name, id: frameId, url: urlWithoutHash}); + + if (this.frames.length === 1) { + this.inspectFrame(this._frames.get(frameId)!); + } + }); + + this._messageBus.on('contentScriptDisconnected', (frameId: number) => { + if (!this._frames.has(frameId)) { + return; + } + + this.removeFrame(this._frames.get(frameId)!); + + // Defensive check. This case should never happen, since we're always connected to at least + // the top level frame. + if (this.frames.length === 0) { + this._selectedFrameId = null; + console.error('Angular DevTools is not connected to any frames.'); + return; + } + + if (frameId === this._selectedFrameId) { + this._selectedFrameId = TOP_LEVEL_FRAME_ID; + this.inspectFrame(this._frames.get(this._selectedFrameId!)!); + return; + } + }); + } + + isSelectedFrame(frame: Frame): boolean { + return this._selectedFrameId === frame.id; + } + + inspectFrame(frame: Frame): void { + if (this._inspectedWindowTabId === null) { + return; + } + + if (!this._frames.has(frame.id)) { + throw new Error('Attempted to inspect a frame that is not connected to Angular DevTools.'); + } + + this._selectedFrameId = null; + this._messageBus.emit('enableFrameConnection', [frame.id, this._inspectedWindowTabId]); + } + + frameHasUniqueUrl(frame: Frame | null): boolean { + if (frame === null) { + return false; + } + const frameUrl = frame.url.toString(); + const frameIds = this._frameUrlToFrameIds.get(frameUrl) ?? new Set(); + return frameIds.size === 1; + } + + private addFrame(frame: Frame): void { + this._frames.set(frame.id, frame); + const frameUrl = frame.url.toString(); + const frameIdSet = this._frameUrlToFrameIds.get(frameUrl) ?? new Set(); + frameIdSet.add(frame.id); + this._frameUrlToFrameIds.set(frameUrl, frameIdSet); + } + + private removeFrame(frame: Frame): void { + const frameId = frame.id; + const frameUrl = frame.url.toString(); + const urlFrameIds = this._frameUrlToFrameIds.get(frameUrl) ?? new Set(); + urlFrameIds.delete(frameId); + if (urlFrameIds.size === 0) { + this._frameUrlToFrameIds.delete(frameUrl); + } + this._frames.delete(frameId); + } +} diff --git a/devtools/projects/ng-devtools/src/lib/frame_manager_spec.ts b/devtools/projects/ng-devtools/src/lib/frame_manager_spec.ts new file mode 100644 index 00000000000..8f2bcec2797 --- /dev/null +++ b/devtools/projects/ng-devtools/src/lib/frame_manager_spec.ts @@ -0,0 +1,195 @@ +/** + * @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.io/license + */ + +import {Events, MessageBus} from 'protocol'; +import {FrameManager} from './frame_manager'; +import {TestBed} from '@angular/core/testing'; +import {Frame} from './application-environment'; + +describe('FrameManager', () => { + let frameManager: FrameManager; + let messageBus: MessageBus; + let topicToCallback: {[topic: string]: Function | null}; + + function getFrameFromFrameManager(frameId: number): Frame | undefined { + return frameManager.frames.find((f: Frame) => f.id === frameId); + } + + function frameConnected(frameId: number): void { + topicToCallback['frameConnected']!(frameId); + } + + function contentScriptConnected(frameId: number, name: string, url: string): void { + topicToCallback['contentScriptConnected']!(frameId, name, url); + } + + function contentScriptDisconnected(frameId: number): void { + topicToCallback['contentScriptDisconnected']!(frameId); + } + + const topLevelFrameId = 0; + const otherFrameId = 1; + const tabId = 123; + + beforeEach(() => { + topicToCallback = { + frameConnected: null, + contentScriptConnected: null, + contentScriptDisconnected: null, + }; + messageBus = jasmine.createSpyObj('MessageBus', ['on', 'emit']); + + (messageBus.on as any).and.callFake((topic: string, cb: Function) => { + topicToCallback[topic] = cb; + }); + + (messageBus.emit as any).and.callFake((topic: string, args: any[]) => { + if (topic === 'enableFrameConnection') { + frameConnected(args[0]); + } + }); + + const testModule = TestBed.configureTestingModule({ + providers: [ + {provide: MessageBus, useValue: messageBus}, + {provide: FrameManager, useFactory: () => FrameManager.initialize(123)}, + ], + }); + + frameManager = testModule.inject(FrameManager); + }); + + it('should add frame when contentScriptConnected event is emitted', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + expect(frameManager.frames.length).toBe(1); + expect(frameManager.frames[0].id).toBe(topLevelFrameId); + expect(frameManager.frames[0].name).toBe('name'); + expect(frameManager.frames[0].url.toString()).toBe('http://localhost:4200/url'); + }); + + it('should set the selected frame to the first frame when there is only one frame', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + expect(frameManager.selectedFrame?.id).toBe(topLevelFrameId); + }); + + it('should set selected frame when frameConnected event is emitted', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'http://localhost:4200/url2'); + frameConnected(otherFrameId); + expect(frameManager.selectedFrame?.id).toBe(otherFrameId); + }); + + it('should remove frame when contentScriptDisconnected event is emitted', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'http://localhost:4200/url2'); + expect(frameManager.frames.length).toBe(2); + contentScriptDisconnected(otherFrameId); + expect(frameManager.frames.length).toBe(1); + expect(frameManager.frames[0].id).toBe(topLevelFrameId); + + const errorSpy = spyOn(console, 'error'); + contentScriptDisconnected(topLevelFrameId); + expect(frameManager.frames.length).toBe(0); + expect(errorSpy).toHaveBeenCalledWith('Angular DevTools is not connected to any frames.'); + }); + + it('should set selected frame to top level frame when contentScriptDisconnected event is emitted for selected frame', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'http://localhost:4200/url2'); + frameConnected(otherFrameId); + expect(frameManager.selectedFrame?.id).toBe(otherFrameId); + contentScriptDisconnected(otherFrameId); + expect(frameManager.selectedFrame?.id).toBe(topLevelFrameId); + }); + + it('should not set selected frame to top level frame when contentScriptDisconnected event is emitted for non selected frame', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'http://localhost:4200/url2'); + frameConnected(topLevelFrameId); + expect(frameManager.selectedFrame?.id).toBe(topLevelFrameId); + contentScriptDisconnected(otherFrameId); + expect(frameManager.selectedFrame?.id).toBe(topLevelFrameId); + }); + + it('should not set selected frame to top level frame when contentScriptDisconnected event is emitted for non existing frame', () => { + const nonExistingFrameId = 3; + + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'http://localhost:4200/url2'); + frameConnected(otherFrameId); + expect(frameManager.selectedFrame?.id).toBe(otherFrameId); + contentScriptDisconnected(nonExistingFrameId); + expect(frameManager.selectedFrame?.id).toBe(otherFrameId); + }); + + it('isSelectedFrame should return true when frame matches selected frame', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'http://localhost:4200/url2'); + const topLevelFrame = getFrameFromFrameManager(topLevelFrameId); + const otherFrame = getFrameFromFrameManager(otherFrameId); + expect(topLevelFrame).toBeDefined(); + expect(otherFrame).toBeDefined(); + expect(frameManager.isSelectedFrame(topLevelFrame!)).toBe(true); + }); + + it('isSelectedFrame should return false when frame does not match selected frame', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'http://localhost:4200/url2'); + const topLevelFrame = getFrameFromFrameManager(topLevelFrameId); + const otherFrame = getFrameFromFrameManager(otherFrameId); + expect(topLevelFrame).toBeDefined(); + expect(otherFrame).toBeDefined(); + expect(frameManager.isSelectedFrame(otherFrame!)).toBe(false); + }); + + it('inspectFrame should emit enableFrameConnection message', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + const topLevelFrame = getFrameFromFrameManager(topLevelFrameId); + expect(topLevelFrame).toBeDefined(); + frameManager.inspectFrame(topLevelFrame!); + expect(messageBus.emit).toHaveBeenCalledWith('enableFrameConnection', [topLevelFrameId, tabId]); + }); + + it('inspectFrame should set selected frame', () => { + contentScriptConnected(topLevelFrameId, 'name', 'http://localhost:4200/url'); + contentScriptConnected(otherFrameId, 'name2', 'https://angular.dev/'); + const topLevelFrame = getFrameFromFrameManager(topLevelFrameId); + expect(topLevelFrame).toBeDefined(); + frameManager.inspectFrame(topLevelFrame!); + expect(frameManager.selectedFrame?.id).toBe(topLevelFrameId); + }); + + it('frameHasUniqueUrl should return false when a two frames have the same url', () => { + contentScriptConnected(topLevelFrameId, 'name', 'https://angular.dev/'); + contentScriptConnected(otherFrameId, 'name2', 'https://angular.dev/'); + expect(frameManager.selectedFrame?.url.toString()).toBe('https://angular.dev/'); + expect(frameManager.frameHasUniqueUrl(frameManager.selectedFrame!)).toBe(false); + }); + + it('frameHasUniqueUrl should return true when only one frame has a given url', () => { + contentScriptConnected(topLevelFrameId, 'name', 'https://angular.dev/'); + contentScriptConnected(otherFrameId, 'name', 'https://angular.dev/overview'); + expect(frameManager.selectedFrame?.url.toString()).toBe('https://angular.dev/'); + expect(frameManager.frameHasUniqueUrl(frameManager.selectedFrame!)).toBe(true); + }); + + it('frameHasUniqueUrl should not consider url fragments as part of the url comparison', () => { + contentScriptConnected(topLevelFrameId, 'name', 'https://angular.dev/guide/components'); + contentScriptConnected( + otherFrameId, + 'name', + 'https://angular.dev/guide/components#using-components', + ); + expect(frameManager.selectedFrame?.url.toString()).toBe('https://angular.dev/guide/components'); + expect(frameManager.frameHasUniqueUrl(frameManager.selectedFrame!)).toBe(false); + }); + + it('frameHasUniqueUrl should return false when frame is null', () => { + expect(frameManager.frameHasUniqueUrl(null)).toBe(false); + }); +}); diff --git a/devtools/projects/shell-browser/src/app/app.module.ts b/devtools/projects/shell-browser/src/app/app.module.ts index bc10e6838b9..eb35edb1ca8 100644 --- a/devtools/projects/shell-browser/src/app/app.module.ts +++ b/devtools/projects/shell-browser/src/app/app.module.ts @@ -16,12 +16,14 @@ import {ChromeApplicationEnvironment} from './chrome-application-environment'; import {ChromeApplicationOperations} from './chrome-application-operations'; import {ZoneAwareChromeMessageBus} from './zone-aware-chrome-message-bus'; import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; +import {FrameManager} from '../../../../projects/ng-devtools/src/lib/frame_manager'; @NgModule({ declarations: [AppComponent], imports: [BrowserAnimationsModule, DevToolsComponent, MatSelect], bootstrap: [AppComponent], providers: [ + {provide: FrameManager, useFactory: () => FrameManager.initialize()}, { provide: ApplicationOperations, useClass: ChromeApplicationOperations, diff --git a/devtools/projects/shell-browser/src/app/tab_manager_spec.ts b/devtools/projects/shell-browser/src/app/tab_manager_spec.ts index aa20a74441e..e01c6c775eb 100644 --- a/devtools/projects/shell-browser/src/app/tab_manager_spec.ts +++ b/devtools/projects/shell-browser/src/app/tab_manager_spec.ts @@ -36,18 +36,18 @@ class MockPort { this.sender = properties.sender; } - postMessage(message: any) { + postMessage(message: any): void { this.messagesPosted.push(message); } onMessage = { - addListener: (listener: Function) => { + addListener: (listener: Function): void => { this.onMessageListeners.push(listener); }, }; onDisconnect = { - addListener: (listener: Function) => { + addListener: (listener: Function): void => { this.onDisconnectListeners.push(listener); }, }; @@ -72,22 +72,27 @@ function mockSpyProperty(obj: any, property: string, value: any) { describe('Tab Manager - ', () => { let tabs: Tabs; const tabId = 12345; - // let devtoolsPort: MockPort; let chromeRuntime: jasmine.SpyObj; let tabManager: TabManager; let tab: DevToolsConnection; let chromeRuntimeOnConnectListeners: ((port: MockPort) => void)[] = []; - function connectToChromeRuntime(port: MockPort) { - chromeRuntimeOnConnectListeners.forEach((listener) => listener(port)); + function connectToChromeRuntime(port: MockPort): void { + for (const listener of chromeRuntimeOnConnectListeners) { + listener(port); + } } - function emitMessageToPort(port: MockPort, message: any) { - port.onMessageListeners.forEach((listener) => listener(message)); + function emitMessageToPort(port: MockPort, message: any): void { + for (const listener of port.onMessageListeners) { + listener(message); + } } function emitDisconnectToPort(port: MockPort) { - port.onDisconnectListeners.forEach((listener) => listener()); + for (const listener of port.onDisconnectListeners) { + listener(); + } } function createDevToolsPort() { @@ -131,6 +136,7 @@ describe('Tab Manager - ', () => { }, }); connectToChromeRuntime(port); + emitMessageToPort(port, {topic: 'backendReady'}); return port; } @@ -152,7 +158,7 @@ describe('Tab Manager - ', () => { }); it('should set frame connection as enabled when an enableFrameConnection message is recieved', () => { - createContentScriptPort(); + const contentScriptPort = createContentScriptPort(); const devtoolsPort = createDevToolsPort(); tab = tabs[tabId]!; @@ -192,24 +198,27 @@ describe('Tab Manager - ', () => { it('should not pipe messages from the content script and devtools script to each other when the content script frame is disabled', () => { const contentScriptPort = createContentScriptPort(); const devtoolsPort = createDevToolsPort(); + tab = tabs[tabId]!; expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(false); + emitMessageToPort(contentScriptPort, TEST_MESSAGE_ONE); - expect(contentScriptPort.messagesPosted.length).toBe(0); + assertArrayDoesNotHaveObj(contentScriptPort.messagesPosted, TEST_MESSAGE_ONE); emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO); - expect(devtoolsPort.messagesPosted.length).toBe(0); + assertArrayDoesNotHaveObj(devtoolsPort.messagesPosted, TEST_MESSAGE_TWO); }); it('should set backendReady when the contentPort recieves the backendReady message', () => { const contentScriptPort = createContentScriptPort(); const devtoolsPort = createDevToolsPort(); + tab = tabs[tabId]!; emitMessageToPort(devtoolsPort, { topic: 'enableFrameConnection', args: [contentScriptFrameId, tabId], }); - emitMessageToPort(contentScriptPort, {topic: 'backendReady'}); + expect(tab?.contentScripts[contentScriptFrameId]?.backendReady).toBe(true); assertArrayHasObj(devtoolsPort.messagesPosted, { topic: 'contentScriptConnected', @@ -220,6 +229,7 @@ describe('Tab Manager - ', () => { it('should set tab.devtools to null when the devtoolsPort disconnects', () => { const contentScriptPort = createContentScriptPort(); const devtoolsPort = createDevToolsPort(); + tab = tabs[tabId]!; emitMessageToPort(devtoolsPort, { topic: 'enableFrameConnection', @@ -311,6 +321,7 @@ describe('Tab Manager - ', () => { const devtoolsPort = createDevToolsPort(); createTopLevelContentScriptPort(); createChildContentScriptPort(); + tab = tabs[tabId]!; expect(tab?.contentScripts[topLevelFrameId]?.enabled).toBe(false); expect(tab?.contentScripts[childFrameId]?.enabled).toBe(false); diff --git a/devtools/src/app/devtools-app/BUILD.bazel b/devtools/src/app/devtools-app/BUILD.bazel index 714883c52d4..b33f51dd752 100644 --- a/devtools/src/app/devtools-app/BUILD.bazel +++ b/devtools/src/app/devtools-app/BUILD.bazel @@ -20,6 +20,7 @@ ng_module( ], deps = [ "//devtools/projects/ng-devtools", + "//devtools/projects/ng-devtools/src/lib:frame_manager", "//devtools/projects/protocol", "//devtools/src:iframe_message_bus", "//packages/common", diff --git a/devtools/src/app/devtools-app/devtools-app.component.ts b/devtools/src/app/devtools-app/devtools-app.component.ts index ce54e819700..9057b688329 100644 --- a/devtools/src/app/devtools-app/devtools-app.component.ts +++ b/devtools/src/app/devtools-app/devtools-app.component.ts @@ -7,7 +7,6 @@ */ import {Component, ElementRef, ViewChild} from '@angular/core'; -import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; import {IFrameMessageBus} from '../../iframe-message-bus'; import {DevToolsComponent} from 'ng-devtools'; @@ -15,21 +14,6 @@ import {DevToolsComponent} from 'ng-devtools'; @Component({ templateUrl: './devtools-app.component.html', styleUrls: ['./devtools-app.component.scss'], - providers: [ - { - provide: MessageBus, - useFactory(): MessageBus { - return new PriorityAwareMessageBus( - new IFrameMessageBus( - 'angular-devtools', - 'angular-devtools-backend', - // tslint:disable-next-line: no-non-null-assertion - () => (document.querySelector('#sample-app') as HTMLIFrameElement).contentWindow!, - ), - ); - }, - }, - ], standalone: true, imports: [DevToolsComponent], }) diff --git a/devtools/src/app/devtools-app/devtools-app.module.ts b/devtools/src/app/devtools-app/devtools-app.module.ts index b723f729919..1b029cfdf4c 100644 --- a/devtools/src/app/devtools-app/devtools-app.module.ts +++ b/devtools/src/app/devtools-app/devtools-app.module.ts @@ -11,6 +11,9 @@ import {NgModule} from '@angular/core'; import {RouterModule} from '@angular/router'; import {AppDevToolsComponent} from './devtools-app.component'; +import {FrameManager} from '../../../projects/ng-devtools/src/lib/frame_manager'; +import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; +import {IFrameMessageBus} from '../../../src/iframe-message-bus'; @NgModule({ imports: [ @@ -24,5 +27,21 @@ import {AppDevToolsComponent} from './devtools-app.component'; ]), AppDevToolsComponent, ], + providers: [ + { + provide: MessageBus, + useFactory(): MessageBus { + return new PriorityAwareMessageBus( + new IFrameMessageBus( + 'angular-devtools', + 'angular-devtools-backend', + // tslint:disable-next-line: no-non-null-assertion + () => (document.querySelector('#sample-app') as HTMLIFrameElement).contentWindow!, + ), + ); + }, + }, + {provide: FrameManager, useFactory: () => FrameManager.initialize(null)}, + ], }) export class DevToolsModule {} diff --git a/devtools/src/demo-application-environment.ts b/devtools/src/demo-application-environment.ts index 470867b7917..9f7c4108d51 100644 --- a/devtools/src/demo-application-environment.ts +++ b/devtools/src/demo-application-environment.ts @@ -11,6 +11,7 @@ import {ApplicationEnvironment, Environment} from 'ng-devtools'; import {environment} from './environments/environment'; export class DemoApplicationEnvironment extends ApplicationEnvironment { + frameSelectorEnabled = false; override get environment(): Environment { return environment; }