Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions app/src/__tests__/extension-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ExtensionBridge,
insideExtension,
requestPanelAction,
type PanelActionResult,
} from '../extension-bridge';
import { ComponentTree } from '../pages/component-tree';

const root = { id: 'c1', name: 'Card', tag: 'app-card', children: [] };

function detail(source?: { file: string; line: number }) {
return {
id: 'c1',
name: 'Card',
tag: 'app-card',
path: 'app-card',
...(source ? { source } : {}),
inputs: [],
outputs: [],
properties: [],
listeners: [],
directives: [],
dependencies: [],
};
}

function fakeClient(pages: Record<string, unknown>): DevframeRpcClient {
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () => (name === 'component-tree' ? { pages } : null),
on: () => () => {},
}),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

function fakeBridge(available: boolean, result: PanelActionResult = { ok: true }) {
return {
available,
reveal: vi.fn(async () => result),
openSource: vi.fn(async () => result),
};
}

async function render(
bridge: ReturnType<typeof fakeBridge>,
{ platform, source }: { platform?: string; source?: { file: string; line: number } } = {},
) {
TestBed.configureTestingModule({ providers: [{ provide: ExtensionBridge, useValue: bridge }] });
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput(
'rpc',
fakeClient({
p1: {
pageId: 'p1',
platform,
roots: [root],
count: 1,
detail: detail(source),
reportedAt: 1000,
},
}),
);
await settle(fixture);
if (fixture.componentInstance.selectedId() !== 'c1') fixture.componentInstance.select('c1');
await settle(fixture);
return fixture;
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

function button(fixture: ComponentFixture<unknown>, label: string) {
return [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find(
(b) => b.textContent?.trim() === label,
);
}

describe('Chrome DevTools actions on a component', () => {
afterEach(() => {
TestBed.resetTestingModule();
document.body.innerHTML = '';
});

it('are hidden outside the extension', async () => {
const fixture = await render(fakeBridge(false));
const title = (fixture.nativeElement as HTMLElement).querySelector('#ct-detail-title');
expect(title?.textContent).toBe('Card');
expect(button(fixture, 'Reveal in Elements')).toBeUndefined();
expect(button(fixture, 'Open source')).toBeUndefined();
});

it('are hidden for an Angular Native page', async () => {
const fixture = await render(fakeBridge(true), { platform: 'angular-native' });
const title = (fixture.nativeElement as HTMLElement).querySelector('#ct-detail-title');
expect(title?.textContent).toBe('Card');
expect(button(fixture, 'Reveal in Elements')).toBeUndefined();
});

it('show as labelled buttons in the extension and send the page and instance', async () => {
const bridge = fakeBridge(true);
const fixture = await render(bridge, { source: { file: 'src/app/card.ts', line: 12 } });
const reveal = button(fixture, 'Reveal in Elements')!;
const open = button(fixture, 'Open source')!;
expect(reveal.type).toBe('button');
expect(reveal.closest('[role="group"]')?.getAttribute('aria-label')).toBe('Chrome DevTools');
expect((fixture.nativeElement as HTMLElement).textContent).toContain('src/app/card.ts:12');

reveal.click();
open.click();
await settle(fixture);
expect(bridge.reveal).toHaveBeenCalledWith('p1', 'c1');
expect(bridge.openSource).toHaveBeenCalledWith('p1', 'c1', {
file: 'src/app/card.ts',
line: 12,
});
});

it('shows the path to open by hand when Sources has no such file', async () => {
const bridge = fakeBridge(true, { ok: false, error: 'not-found' });
const fixture = await render(bridge, { source: { file: 'src/app/card.ts', line: 12 } });
button(fixture, 'Open source')!.click();
await settle(fixture);
const status = (fixture.nativeElement as HTMLElement).querySelector(
'.action-message[role="status"]',
);
expect(status?.textContent).toBe(
'Not found in Sources. Open src/app/card.ts:12 in your editor.',
);
});
});

describe('extension bridge requests', () => {
function fakeWindow(protocol = 'chrome-extension:') {
const listeners = new Set<(event: MessageEvent) => void>();
const parent = { postMessage: vi.fn() };
const win = {
location: { protocol, origin: 'chrome-extension://ext' },
parent,
addEventListener: (_: string, l: (event: MessageEvent) => void) => listeners.add(l),
removeEventListener: (_: string, l: (event: MessageEvent) => void) => listeners.delete(l),
setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
};
const send = (data: unknown, source: unknown = parent, origin = 'chrome-extension://ext') =>
[...listeners].forEach((l) => l({ data, source, origin } as MessageEvent));
return { win: win as unknown as Window, parent, send, listeners };
}

it('knows it runs inside the extension panel only in a chrome-extension frame', () => {
expect(insideExtension(fakeWindow().win)).toBe(true);
expect(insideExtension(fakeWindow('http:').win)).toBe(false);
const top = fakeWindow().win as unknown as { parent: unknown };
top.parent = top;
expect(insideExtension(top as unknown as Window)).toBe(false);
});

it('posts to the parent and resolves with the matching answer only', async () => {
const { win, parent, send, listeners } = fakeWindow();
const pending = requestPanelAction(
{ type: 'pangular:reveal-element', pageId: 'p1', id: 'c1' },
win,
);
const [[message, origin]] = parent.postMessage.mock.calls;
expect(origin).toBe('chrome-extension://ext');
expect(message).toMatchObject({ type: 'pangular:reveal-element', pageId: 'p1', id: 'c1' });
const { requestId } = message as { requestId: string };

send({ type: 'pangular:panel-action-result', requestId: 'other', ok: true });
send(
{ type: 'pangular:panel-action-result', requestId, ok: true },
{},
'chrome-extension://ext',
);
send({ type: 'pangular:panel-action-result', requestId, ok: true }, parent, 'https://evil');
expect(listeners.size).toBe(1);
send({ type: 'pangular:panel-action-result', requestId, ok: true, opened: 'file' });
await expect(pending).resolves.toEqual({ ok: true, opened: 'file' });
expect(listeners.size).toBe(0);
});

it('gives up after a timeout when the bridge does not answer', async () => {
vi.useFakeTimers();
try {
const { win, listeners } = fakeWindow();
const pending = requestPanelAction({ type: 'pangular:open-source' }, win);
await vi.advanceTimersByTimeAsync(6000);
await expect(pending).resolves.toEqual({ ok: false, error: 'timeout' });
expect(listeners.size).toBe(0);
} finally {
vi.useRealTimers();
}
});
});
75 changes: 75 additions & 0 deletions app/src/extension-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Service } from '@angular/core';

export interface SourceLocation {
file: string;
line: number;
}

export interface PanelActionResult {
ok: boolean;
opened?: 'file' | 'class';
error?: string;
}

type BridgeWindow = Pick<
Window,
'location' | 'parent' | 'addEventListener' | 'removeEventListener' | 'setTimeout' | 'clearTimeout'
>;

const RESULT = 'pangular:panel-action-result';
const TIMEOUT_MS = 6000;
let seq = 0;

export function insideExtension(win: BridgeWindow = window): boolean {
return win.location.protocol === 'chrome-extension:' && win.parent !== (win as unknown);
}

export function requestPanelAction(
message: Record<string, unknown>,
win: BridgeWindow = window,
): Promise<PanelActionResult> {
const requestId = `pa-${Date.now().toString(36)}-${++seq}`;
return new Promise((resolve) => {
const done = (result: PanelActionResult) => {
win.clearTimeout(timer);
win.removeEventListener('message', onMessage);
resolve(result);
};
const onMessage = ({ source, origin, data }: MessageEvent<unknown>) => {
if (source !== win.parent || origin !== win.location.origin) return;
const reply = data as { type?: unknown; requestId?: unknown; ok?: unknown } | null;
if (reply?.type !== RESULT || reply.requestId !== requestId) return;
const { opened, error } = data as { opened?: unknown; error?: unknown };
done({
ok: reply.ok === true,
...(opened === 'file' || opened === 'class' ? { opened } : {}),
...(typeof error === 'string' ? { error } : {}),
});
};
const timer = win.setTimeout(() => done({ ok: false, error: 'timeout' }), TIMEOUT_MS);
win.addEventListener('message', onMessage);
win.parent.postMessage({ ...message, requestId }, win.location.origin);
});
}

@Service()
export class ExtensionBridge {
readonly available = insideExtension();

reveal(pageId: string, id: string): Promise<PanelActionResult> {
return requestPanelAction({ type: 'pangular:reveal-element', pageId, id });
}

openSource(
pageId: string,
id: string,
source: SourceLocation | null,
): Promise<PanelActionResult> {
return requestPanelAction({
type: 'pangular:open-source',
pageId,
id,
...(source ? { file: source.file, line: source.line } : {}),
});
}
}
53 changes: 52 additions & 1 deletion app/src/pages/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
untracked,
} from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
import { ExtensionBridge, type SourceLocation } from '../extension-bridge';
import { hostPageId } from '../page-id';
import { pickPage } from '../live-pages';
import { isAngularNativePage } from '../native-page';
Expand Down Expand Up @@ -70,6 +71,7 @@ interface Detail {
name: string;
tag: string;
path: string;
source?: SourceLocation;
changeDetection?: string;
encapsulation?: string;
inputs: Prop[];
Expand Down Expand Up @@ -279,8 +281,17 @@ function bare(name: string): string {
<span class="badge">component</span>
<h2 id="ct-detail-title" class="mono">{{ sel.name }}</h2>
<span class="mono muted">&lt;{{ sel.tag }}&gt;</span>
@if (whereOf(sel); as where) {
<p class="where mono">{{ where.file }}:{{ where.line }}</p>
}
@if (devtoolsActions()) {
<div class="actions" role="group" aria-label="Chrome DevTools">
<button type="button" (click)="revealInElements(sel)">Reveal in Elements</button>
<button type="button" (click)="openSource(sel)">Open source</button>
</div>
<p class="where action-message" role="status">{{ actionMessage() }}</p>
}
@if (sourceFor(sel); as src) {
<p class="where mono">{{ src.file }}:{{ src.line }}</p>
@if (formsIn(src.file).length) {
<div class="actions">
@for (form of formsIn(src.file); track form.formId) {
Expand Down Expand Up @@ -1347,6 +1358,11 @@ export class ComponentTree {
private pickPageId: string | null = null;
private pickSeq = 0;
readonly focusId = signal<string | null>(null);
private readonly bridge = inject(ExtensionBridge);
readonly actionMessage = linkedSignal<string | null, string>({
source: this.selectedId,
computation: () => '',
});
readonly collapsed = signal<ReadonlySet<string>>(new Set());
readonly openKey = signal<string | null>(null);
private readonly outlets = signal<OutletInfo[]>([]);
Expand Down Expand Up @@ -1381,6 +1397,9 @@ export class ComponentTree {

readonly live = computed(() => (this.page()?.roots.length ?? 0) > 0);
readonly native = computed(() => isAngularNativePage(this.page()));
readonly devtoolsActions = computed(
() => this.bridge.available && !!this.page() && !this.native() && !this.staticReport(),
);

private readonly cdPages = signal<Record<string, CdPage>>({});
readonly cdPage = computed(() => {
Expand Down Expand Up @@ -1595,6 +1614,38 @@ export class ComponentTree {
return this.formOwners().filter((form) => form.file === file);
}

whereOf(node: LiveNode): SourceLocation | null {
return this.detail()?.source ?? this.sourceFor(node);
}

async revealInElements(node: LiveNode) {
const pageId = this.page()?.pageId;
if (!pageId) return;
this.actionMessage.set('');
const result = await this.bridge.reveal(pageId, node.id);
if (this.selectedId() !== node.id) return;
this.actionMessage.set(
result.ok
? ''
: 'Could not reveal it. The component must be rendered in the tab these DevTools inspect.',
);
}

async openSource(node: LiveNode) {
const pageId = this.page()?.pageId;
if (!pageId) return;
const where = this.whereOf(node);
this.actionMessage.set('');
const result = await this.bridge.openSource(pageId, node.id, where);
if (this.selectedId() !== node.id) return;
if (result.ok) return;
this.actionMessage.set(
where
? `Not found in Sources. Open ${where.file}:${where.line} in your editor.`
: 'Could not open the source. The component must be rendered in the tab these DevTools inspect.',
);
}

sourceFor(node: LiveNode): SourceComponent | null {
const matches = (this.sourceByClass().get(bare(node.name)) ?? []).filter(
(c) => c.kind === 'component',
Expand Down
Loading
Loading