Revert "refactor(devtools): implement multiframe support in devtools page (#53934)" (#54629)

This reverts commit ebcdc8dc96.

PR Close #54629
This commit is contained in:
Doug Parker
2024-02-27 09:12:43 -08:00
committed by Dylan Hunn
parent 3e0b3a1ec4
commit 133319eba0
27 changed files with 166 additions and 1024 deletions
@@ -7,7 +7,6 @@ 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",
@@ -11,13 +11,11 @@ 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> {
@@ -86,10 +86,6 @@ 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
@@ -220,8 +216,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;
}
@@ -1,7 +1,5 @@
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"])
@@ -14,18 +12,13 @@ ng_module(
name = "lib",
srcs = glob(
include = ["*.ts"],
exclude = [
"theme-service.ts",
"frame_manager.ts",
"*_spec.ts",
],
exclude = ["theme-service.ts"],
),
angular_assets = [
"devtools.component.html",
":devtools_component_styles",
],
deps = [
":frame_manager",
":theme",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs",
"//devtools/projects/protocol",
@@ -41,58 +34,6 @@ 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(
@@ -15,15 +15,6 @@ 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;
}
@@ -9,7 +9,7 @@
import {DirectivePosition, ElementPosition} from 'protocol';
export abstract class ApplicationOperations {
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;
abstract viewSource(position: ElementPosition, directiveIndex?: number): void;
abstract selectDomElement(position: ElementPosition): void;
abstract inspect(directivePosition: DirectivePosition, objectPath: string[]): void;
}
@@ -20,7 +20,6 @@ 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",
@@ -43,7 +42,6 @@ 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",
@@ -58,6 +56,7 @@ ts_test_library(
],
)
# todo(aleksanderbodurri): fix this test suite
karma_web_test_suite(
name = "test",
deps = [
@@ -66,3 +65,16 @@ 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",
# )
@@ -10,21 +10,6 @@
<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 }}
@@ -42,23 +27,19 @@
</section>
}
</nav>
<mat-tab-nav-panel #tabPanel>
@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>
}
<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">
@@ -76,7 +57,6 @@
</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>
@@ -119,22 +119,3 @@ 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;
}
}
@@ -6,16 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {
AfterViewInit,
Component,
EventEmitter,
inject,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import {AfterViewInit, Component, Input, OnInit, 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';
@@ -24,8 +15,7 @@ import {MatTabLink, MatTabNav, MatTabNavPanel} from '@angular/material/tabs';
import {MatTooltip} from '@angular/material/tooltip';
import {Events, MessageBus, Route} from 'protocol';
import {ApplicationEnvironment, Frame, TOP_LEVEL_FRAME_ID} from '../application-environment/index';
import {FrameManager} from '../frame_manager';
import {ApplicationEnvironment} from '../application-environment/index';
import {Theme, ThemeService} from '../theme-service';
import {DirectiveExplorerComponent} from './directive-explorer/directive-explorer.component';
@@ -62,28 +52,25 @@ 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())
@@ -94,11 +81,6 @@ 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;
}
@@ -113,7 +95,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 {
@@ -19,7 +19,6 @@ 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',
@@ -37,7 +36,6 @@ describe('DevtoolsTabsComponent', () => {
beforeEach(() => {
messageBusMock = jasmine.createSpyObj('messageBus', ['on', 'once', 'emit', 'destroy']);
applicationEnvironmentMock = jasmine.createSpyObj('applicationEnvironment', ['environment']);
TestBed.configureTestingModule({
imports: [MatTooltip, MatMenuModule, DevToolsTabsComponent],
providers: [
@@ -45,7 +43,6 @@ 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]},
@@ -77,24 +74,4 @@ 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]);
});
});
@@ -43,14 +43,10 @@ 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",
@@ -63,5 +59,8 @@ karma_web_test_suite(
name = "test",
deps = [
":directive_explorer_test",
"//packages/common/http",
"//packages/platform-browser",
"//packages/platform-browser/animations",
],
)
@@ -11,7 +11,6 @@ import {
Component,
ElementRef,
EventEmitter,
inject,
Input,
NgZone,
OnDestroy,
@@ -33,7 +32,6 @@ 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';
@@ -124,12 +122,11 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy {
private _refreshRetryTimeout: null | ReturnType<typeof setTimeout> = null;
constructor(
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,
private _appOperations: ApplicationOperations,
private _messageBus: MessageBus<Events>,
private _propResolver: ElementPropertyResolver,
private _cdr: ChangeDetectorRef,
private _ngZone: NgZone,
) {}
ngOnInit(): void {
@@ -201,37 +198,18 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy {
(directive) => directive.name === directiveName,
);
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.`,
},
]);
if (directiveIndex === -1) {
// view the component definition
this._appOperations.viewSource(this.currentSelectedElement.position);
return;
}
this._appOperations.viewSource(
this.currentSelectedElement.position,
directiveIndex !== -1 ? directiveIndex : undefined,
new URL(selectedFrame!.url),
);
// view the directive definition
this._appOperations.viewSource(this.currentSelectedElement.position, directiveIndex);
}
handleSelectDomElement(node: IndexedNode): void {
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));
this._appOperations.selectDomElement(node.position);
}
highlight(node: FlatNode): void {
@@ -300,19 +278,7 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy {
directivePosition: DirectivePosition;
}): void {
const objectPath = constructPathOfKeysToPropertyValue(node.prop);
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));
this._appOperations.inspect(directivePosition, objectPath);
}
hightlightHydrationNodes() {
@@ -6,94 +6,37 @@
* 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<any>;
let messageBusMock: SpyObj<MessageBus<Events>>;
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', ['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]);
}
});
messageBusMock = jasmine.createSpyObj<MessageBus<Events>>('messageBus', [
'on',
'once',
'emit',
'destroy',
]);
TestBed.configureTestingModule({
providers: [
@@ -103,24 +46,14 @@ 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.overrideComponent(DirectiveExplorerComponent, {
remove: {imports: [DirectiveForestComponent, BreadcrumbsComponent, PropertyTabComponent]},
add: {
imports: [MockDirectiveForestComponent, MockBreadcrumbsComponent, MockPropertyTabComponent],
},
}).createComponent(DirectiveExplorerComponent);
fixture = TestBed.createComponent(DirectiveExplorerComponent);
comp = fixture.componentInstance;
TestBed.inject(FrameManager);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should create instance from class', () => {
@@ -128,6 +61,8 @@ describe('DirectiveExplorerComponent', () => {
});
it('subscribe to backend events', () => {
comp.subscribeToBackendEvents();
expect(messageBusMock.on).toHaveBeenCalledTimes(2);
expect(messageBusMock.on).toHaveBeenCalledWith(
'latestComponentExplorerView',
jasmine.any(Function),
@@ -138,9 +73,7 @@ describe('DirectiveExplorerComponent', () => {
describe('refresh', () => {
it('should emit getLatestComponentExplorerView event on refresh', () => {
comp.refresh();
expect(messageBusMock.emit).toHaveBeenCalledWith('getLatestComponentExplorerView', [
undefined,
]);
expect(messageBusMock.emit).toHaveBeenCalledTimes(1);
});
it('should emit getLatestComponentExplorerView event with null view query', () => {
@@ -177,6 +110,7 @@ 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', [
{
@@ -192,9 +126,11 @@ 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');
});
@@ -210,161 +146,4 @@ 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
);
});
});
});
});
@@ -47,8 +47,6 @@ 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",
@@ -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>
@@ -1,57 +1,58 @@
<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>
}
<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>
} @else {
<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 class="text-message">
Angular Devtools only supports Angular versions 12 and above
</p>
}
}
@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.
} @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.
</p>
}
@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>
} @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>
}
}
}
}
</div>
</div>
@@ -9,36 +9,13 @@
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, MatTooltipModule} from '@angular/material/tooltip';
import {MatTooltip} 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',
@@ -51,11 +28,10 @@ enum AngularStatus {
]),
],
standalone: true,
imports: [DevToolsTabsComponent, MatTooltip, MatProgressSpinnerModule, MatTooltipModule],
imports: [DevToolsTabsComponent, MatTooltip],
})
export class DevToolsComponent implements OnInit, OnDestroy {
AngularStatus = AngularStatus;
angularStatus: AngularStatus = AngularStatus.UNKNOWN;
angularExists: boolean | null = null;
angularVersion: string | boolean | undefined = undefined;
angularIsInDevMode = true;
hydration: boolean = false;
@@ -63,28 +39,26 @@ export class DevToolsComponent implements OnInit, OnDestroy {
private readonly _firefoxStyleName = 'firefox_styles.css';
private readonly _chromeStyleName = 'chrome_styles.css';
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);
constructor(
private _messageBus: MessageBus<Events>,
private _themeService: ThemeService,
private _platform: Platform,
@Inject(DOCUMENT) private _document: Document,
) {}
private _interval$ = interval(500).subscribe((attempt) => {
if (attempt === DETECT_ANGULAR_ATTEMPTS) {
this.angularStatus = AngularStatus.DOES_NOT_EXIST;
if (attempt === 10) {
this.angularExists = false;
}
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.angularStatus = version ? AngularStatus.EXISTS : AngularStatus.DOES_NOT_EXIST;
this.angularExists = !!version;
this.angularVersion = version;
this.angularIsInDevMode = devMode;
this.ivy = ivy;
@@ -1,86 +0,0 @@
/**
* @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();
});
});
@@ -1,133 +0,0 @@
/**
* @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);
}
}
@@ -1,195 +0,0 @@
/**
* @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);
});
});
@@ -16,14 +16,12 @@ 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,
@@ -36,18 +36,18 @@ class MockPort {
this.sender = properties.sender;
}
postMessage(message: any): void {
postMessage(message: any) {
this.messagesPosted.push(message);
}
onMessage = {
addListener: (listener: Function): void => {
addListener: (listener: Function) => {
this.onMessageListeners.push(listener);
},
};
onDisconnect = {
addListener: (listener: Function): void => {
addListener: (listener: Function) => {
this.onDisconnectListeners.push(listener);
},
};
@@ -72,27 +72,22 @@ 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): void {
for (const listener of chromeRuntimeOnConnectListeners) {
listener(port);
}
function connectToChromeRuntime(port: MockPort) {
chromeRuntimeOnConnectListeners.forEach((listener) => listener(port));
}
function emitMessageToPort(port: MockPort, message: any): void {
for (const listener of port.onMessageListeners) {
listener(message);
}
function emitMessageToPort(port: MockPort, message: any) {
port.onMessageListeners.forEach((listener) => listener(message));
}
function emitDisconnectToPort(port: MockPort) {
for (const listener of port.onDisconnectListeners) {
listener();
}
port.onDisconnectListeners.forEach((listener) => listener());
}
function createDevToolsPort() {
@@ -136,7 +131,6 @@ describe('Tab Manager - ', () => {
},
});
connectToChromeRuntime(port);
emitMessageToPort(port, {topic: 'backendReady'});
return port;
}
@@ -158,7 +152,7 @@ describe('Tab Manager - ', () => {
});
it('should set frame connection as enabled when an enableFrameConnection message is recieved', () => {
const contentScriptPort = createContentScriptPort();
createContentScriptPort();
const devtoolsPort = createDevToolsPort();
tab = tabs[tabId]!;
@@ -198,27 +192,24 @@ 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);
assertArrayDoesNotHaveObj(contentScriptPort.messagesPosted, TEST_MESSAGE_ONE);
expect(contentScriptPort.messagesPosted.length).toBe(0);
emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO);
assertArrayDoesNotHaveObj(devtoolsPort.messagesPosted, TEST_MESSAGE_TWO);
expect(devtoolsPort.messagesPosted.length).toBe(0);
});
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',
@@ -229,7 +220,6 @@ 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',
@@ -321,7 +311,6 @@ 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);
@@ -20,7 +20,6 @@ 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",
@@ -7,6 +7,7 @@
*/
import {Component, ElementRef, ViewChild} from '@angular/core';
import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol';
import {IFrameMessageBus} from '../../iframe-message-bus';
import {DevToolsComponent} from 'ng-devtools';
@@ -14,6 +15,21 @@ 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],
})
@@ -11,9 +11,6 @@ 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: [
@@ -27,21 +24,5 @@ import {IFrameMessageBus} from '../../../src/iframe-message-bus';
]),
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 {}
@@ -11,7 +11,6 @@ import {ApplicationEnvironment, Environment} from 'ng-devtools';
import {environment} from './environments/environment';
export class DemoApplicationEnvironment extends ApplicationEnvironment {
frameSelectorEnabled = false;
override get environment(): Environment {
return environment;
}