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; }