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
This commit is contained in:
AleksanderBodurri 2024-01-25 19:35:36 -05:00 committed by Andrew Kushnir
parent dd3dac9cc9
commit ebcdc8dc96
27 changed files with 1024 additions and 166 deletions

View file

@ -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",

View file

@ -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<Events> {

View file

@ -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<Events>) => {
const checkForAngular = (messageBus: MessageBus<Events>): void => {
const ngVersion = getAngularVersion();
const appIsIvy = appIsAngularIvy();
if (!ngVersion) {
setTimeout(() => checkForAngular(messageBus), 500);
return;
}

View file

@ -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(

View file

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

View file

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

View file

@ -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",
# )

View file

@ -10,6 +10,21 @@
<mat-icon> info </mat-icon>
</button>
</div>
<select matTooltip="Select a frame to inspect with Angular Devtools" class="frame-selector" (change)="emitSelectedFrame($event.target.value)">
@for (frame of frameManager.frames; track frame.id) {
<option [value]="frame.id" [selected]="frameManager.isSelectedFrame(frame)">
@if (frame.id === TOP_LEVEL_FRAME_ID) {
top
} @else {
{{ frame.name }} ({{ frame.id }})
}
</option>
} @empty {
<option value="0" selected>top</option>
}
</select>
@for (tab of tabs; track $index) {
<a class="mat-tab-link" mat-tab-link (click)="changeTab(tab)" [active]="activeTab === tab">
{{ tab }}
@ -27,19 +42,23 @@
</section>
}
</nav>
<mat-tab-nav-panel #tabPanel>
<div class="tab-content">
<ng-directive-explorer
[showCommentNodes]="showCommentNodes"
[isHydrationEnabled]="isHydrationEnabled"
[class.hidden]="activeTab !== 'Components'"
(toggleInspector)="toggleInspector()"
/>
<ng-profiler [class.hidden]="activeTab !== 'Profiler'"/>
<ng-router-tree [routes]="routes" [class.hidden]="activeTab !== 'Router Tree'"/>
<ng-injector-tree [class.hidden]="activeTab !== 'Injector Tree'"/>
</div>
@if (!applicationEnvironment.frameSelectorEnabled || frameManager.selectedFrame !== null) {
<div class="tab-content">
<ng-directive-explorer
[showCommentNodes]="showCommentNodes"
[isHydrationEnabled]="isHydrationEnabled"
[class.hidden]="activeTab !== 'Components'"
(toggleInspector)="toggleInspector()"
/>
<ng-profiler [class.hidden]="activeTab !== 'Profiler'"/>
<ng-router-tree [routes]="routes" [class.hidden]="activeTab !== 'Router Tree'"/>
<ng-injector-tree [class.hidden]="activeTab !== 'Injector Tree'"/>
</div>
}
</mat-tab-nav-panel>
<mat-menu #menu="matMenu">
<div mat-menu-item disableRipple (click)="$event.stopPropagation(); toggleTimingAPI()">
<mat-slide-toggle [checked]="timingAPIEnabled">
@ -57,6 +76,7 @@
</mat-slide-toggle>
</div>
</mat-menu>
<mat-menu #info="matMenu">
<a mat-menu-item href="https://angular.io/devtools" target="_blank">
<mat-icon>library_books</mat-icon>

View file

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

View file

@ -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<Frame>();
@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<Events>,
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 {

View file

@ -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<Theme>()})},
{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]);
});
});

View file

@ -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",
],
)

View file

@ -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<typeof setTimeout> = null;
constructor(
private _appOperations: ApplicationOperations,
private _messageBus: MessageBus<Events>,
private _propResolver: ElementPropertyResolver,
private _cdr: ChangeDetectorRef,
private _ngZone: NgZone,
private readonly _appOperations: ApplicationOperations,
private readonly _messageBus: MessageBus<Events>,
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() {

View file

@ -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<IndexedNode>();
@Output() selectDomElement = new EventEmitter<IndexedNode>();
@Output() setParents = new EventEmitter<IndexedNode>();
@Output() highlightComponent = new EventEmitter<IndexedNode>();
@Output() removeComponentHighlight = new EventEmitter<void>();
@Output() toggleInspector = new EventEmitter<void>();
}
@Component({
selector: 'ng-breadcrumbs',
template: '',
standalone: true,
})
class MockBreadcrumbsComponent {
@Input() parents: IndexedNode[] = [];
@Output() handleSelect = new EventEmitter<any>();
@Output() mouseLeaveNode = new EventEmitter<any>();
@Output() mouseOverNode = new EventEmitter<any>();
}
@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<string>();
}
describe('DirectiveExplorerComponent', () => {
let messageBusMock: SpyObj<MessageBus<Events>>;
let messageBusMock: SpyObj<any>;
let fixture: ComponentFixture<DirectiveExplorerComponent>;
let comp: DirectiveExplorerComponent;
let applicationOperationsSpy: SpyObj<ApplicationOperations>;
let contentScriptConnected = (frameId: number, name: string, url: string) => {};
let frameConnected = (frameId: number) => {};
beforeEach(() => {
applicationOperationsSpy = jasmine.createSpyObj<ApplicationOperations>('_appOperations', [
'viewSource',
'selectDomElement',
'inspect',
]);
messageBusMock = jasmine.createSpyObj<MessageBus<Events>>('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
);
});
});
});
});

View file

@ -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",

View file

@ -1,6 +1,6 @@
<mat-toolbar>
{{ directive }}
<button matTooltip="Open source" (click)="handleViewSource($event)">
<mat-icon> code </mat-icon>
</button>
{{ directive }}
<button matTooltip="Open source" (click)="handleViewSource($event)">
<mat-icon> code </mat-icon>
</button>
</mat-toolbar>

View file

@ -1,58 +1,57 @@
<div class="mat-typography mat-app-background" style="height: 100%;">
@if (angularExists === true) {
@if (angularIsInDevMode) {
@if (supportedVersion) {
<div class="devtools-wrapper noselect" [@enterAnimation]>
<ng-devtools-tabs [angularVersion]="angularVersion" [isHydrationEnabled]="hydration"/>
</div>
<div class="devtools mat-typography mat-app-background" style="height: 100%;">
@switch (angularStatus) {
@case (AngularStatus.EXISTS) {
@if (angularIsInDevMode) {
@if (supportedVersion) {
<div class="devtools-wrapper noselect" [@enterAnimation]>
<ng-devtools-tabs (frameSelected)="inspectFrame($event)" [isHydrationEnabled]="hydration" [angularVersion]="angularVersion"></ng-devtools-tabs>
</div>
} @else {
<p class="text-message">
Angular Devtools only supports Angular versions 12 and above
</p>
}
} @else {
<p class="text-message">
Angular Devtools only supports Angular versions 12 and above
<p class="text-message" matTooltip="A dev build is when the `optimization` flag is set to `false` in the angular.json config file.">
We detected an application built with production configuration. Angular DevTools only supports development build.
</p>
}
} @else {
<p class="text-message" title="A dev build is when the `optimization` flag is set to `true` in the angular.json config file.">
We detected an application built with production configuration. Angular DevTools only supports development build.
}
@case (AngularStatus.DOES_NOT_EXIST) {
<p class="text-message not-detected">
<span class="info-icon" matTooltip="You see this message because the app is still loading, or this is not an Angular application">i</span>
Angular application not detected.
</p>
}
} @else {
@if (angularExists === false) {
<p class="text-message">
<span class="info-icon" matTooltip="You see this message because the app is still loading, or this is not an Angular application">i</span
>
Angular application not detected.
</p>
} @else {
@if (angularExists === null) {
<div class="initializing">
<div class="loading">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 223 236" width="120">
<g clip-path="url(#a)">
<path fill="url(#b)" d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"></path>
<path fill="url(#c)" d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"></path>
</g>
<defs>
<linearGradient id="b" x1="49.009" x2="225.829" y1="213.75" y2="129.722" gradientUnits="userSpaceOnUse">
<stop stop-color="#E40035"></stop>
<stop offset=".24" stop-color="#F60A48"></stop>
<stop offset=".352" stop-color="#F20755"></stop>
<stop offset=".494" stop-color="#DC087D"></stop>
<stop offset=".745" stop-color="#9717E7"></stop>
<stop offset="1" stop-color="#6C00F5"></stop>
</linearGradient>
<linearGradient id="c" x1="41.025" x2="156.741" y1="28.344" y2="160.344" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF31D9"></stop>
<stop offset="1" stop-color="#FF5BE1" stop-opacity="0"></stop>
</linearGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h223v236H0z"></path>
</clipPath>
</defs>
</svg>
</div>
</div>
}
}
@case (AngularStatus.UNKNOWN) {
<div class="initializing">
<div class="loading">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 223 236" width="120">
<g clip-path="url(#a)">
<path fill="url(#b)" d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"></path>
<path fill="url(#c)" d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"></path>
</g>
<defs>
<linearGradient id="b" x1="49.009" x2="225.829" y1="213.75" y2="129.722" gradientUnits="userSpaceOnUse">
<stop stop-color="#E40035"></stop>
<stop offset=".24" stop-color="#F60A48"></stop>
<stop offset=".352" stop-color="#F20755"></stop>
<stop offset=".494" stop-color="#DC087D"></stop>
<stop offset=".745" stop-color="#9717E7"></stop>
<stop offset="1" stop-color="#6C00F5"></stop>
</linearGradient>
<linearGradient id="c" x1="41.025" x2="156.741" y1="28.344" y2="160.344" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF31D9"></stop>
<stop offset="1" stop-color="#FF5BE1" stop-opacity="0"></stop>
</linearGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h223v236H0z"></path>
</clipPath>
</defs>
</svg>
</div>
</div>
}
</div>
}
</div>

View file

@ -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<Events>,
private _themeService: ThemeService,
private _platform: Platform,
@Inject(DOCUMENT) private _document: Document,
) {}
private readonly _messageBus = inject<MessageBus<Events>>(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;

View file

@ -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<DevToolsComponent>;
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();
});
});

View file

@ -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<number, Frame>();
private _inspectedWindowTabId: number | null = null;
private _frameUrlToFrameIds = new Map<string, Set<number>>();
private _messageBus = inject<MessageBus<Events>>(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<number>();
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<number>();
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<number>();
urlFrameIds.delete(frameId);
if (urlFrameIds.size === 0) {
this._frameUrlToFrameIds.delete(frameUrl);
}
this._frames.delete(frameId);
}
}

View file

@ -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<Events>;
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);
});
});

View file

@ -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,

View file

@ -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<typeof chrome.runtime>;
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);

View file

@ -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",

View file

@ -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<Events> {
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],
})

View file

@ -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<Events> {
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 {}

View file

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