Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export abstract class ApplicationOperations {
abstract selectDomElement(position: ElementPosition, target: Frame): void;
abstract inspect(directivePosition: DirectivePosition, objectPath: string[], target: Frame): void;
abstract inspectSignal(position: SignalNodePosition, target: Frame): void;
abstract setSignalBreakpoint(position: SignalNodePosition, target: Frame): void;
abstract removeSignalBreakpoint(position: SignalNodePosition, target: Frame): void;
abstract viewSourceFromRouter(name: string, type: string, target: Frame): void;
abstract setStorageItems(items: {[key: string]: unknown}): Promise<void>;
abstract getStorageItems(items: string[]): Promise<{[key: string]: unknown}>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ export class AppOperationsMock extends ApplicationOperations {
throw new Error('Method not implemented.');
}

override setSignalBreakpoint(position: SignalNodePosition, target: Frame): void {
throw new Error('Method not implemented.');
}

override removeSignalBreakpoint(position: SignalNodePosition, target: Frame): void {
throw new Error('Method not implemented.');
}

override viewSourceFromRouter(name: string, type: string, target: Frame): void {
throw new Error('Method not implemented.');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
[node]="node"
[graph]="signalGraph.graph()!"
[element]="signalGraph.element()!"
[hasBreakpoint]="hasBreakpoint(node)"
(gotoSource)="gotoSource($event)"
(setBreakpoint)="setBreakpoint($event)"
(removeBreakpoint)="removeBreakpoint($event)"
(expandCluster)="expandCluster($event)"
(highlightDeps)="highlightDeps($event)"
(close)="detailsVisible.set(false)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ export class SignalGraphPaneComponent {

protected readonly detailsVisible = signal(false);

// Track active breakpoints for the current inspected element.
// Resets when the inspected element changes.
protected readonly activeBreakpoints = linkedSignal<ElementPosition | undefined, Set<string>>({
source: () => this.signalGraph.element(),
computation: () => new Set(),
});

protected hasBreakpoint(node: DevtoolsSignalGraphNode | undefined): boolean {
if (!node) return false;
return this.activeBreakpoints().has(node.id);
}

protected empty = computed(() => !(this.signalGraph.graph()?.nodes.length! > 0));

onNodeClick(node: DevtoolsSignalGraphNode) {
Expand All @@ -104,6 +116,38 @@ export class SignalGraphPaneComponent {
);
}

setBreakpoint(node: DevtoolsSignalGraphNode) {
const frame = this.frameManager.selectedFrame();
this.appOperations.setSignalBreakpoint(
{
element: this.signalGraph.element()!,
signalId: node.id,
},
frame!,
);
this.activeBreakpoints.update((set) => {
const newSet = new Set(set);
newSet.add(node.id);
return newSet;
});
}

removeBreakpoint(node: DevtoolsSignalGraphNode) {
const frame = this.frameManager.selectedFrame();
this.appOperations.removeSignalBreakpoint(
{
element: this.signalGraph.element()!,
signalId: node.id,
},
frame!,
);
this.activeBreakpoints.update((set) => {
const newSet = new Set(set);
newSet.delete(node.id);
return newSet;
});
}

expandCluster(clusterId: string) {
this.visualizer().expandCluster(clusterId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
>
<mat-icon> code </mat-icon>
</button>
<button
ng-button
size="compact"
class="action-btn"
btnType="secondary"
(click)="hasBreakpoint() ? removeBreakpoint.emit(node) : setBreakpoint.emit(node)"
[disabled]="!node.debuggable"
[matTooltip]="hasBreakpoint() ? 'Remove breakpoint' : 'Set breakpoint'"
>
<mat-icon [style.color]="hasBreakpoint() ? '#f44336' : 'inherit'"> fiber_manual_record </mat-icon>
</button>
} @else if (isClusterNode) {
<button
ng-button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export class SignalDetailsComponent {
protected readonly element = input.required<ElementPosition>();

protected readonly gotoSource = output<DevtoolsSignalGraphNode>();
protected readonly setBreakpoint = output<DevtoolsSignalGraphNode>();
protected readonly removeBreakpoint = output<DevtoolsSignalGraphNode>();
protected readonly hasBreakpoint = input<boolean>(false);
protected readonly expandCluster = output<string>();
protected readonly highlightDeps = output<{
node: DevtoolsSignalGraphNode;
Expand Down
158 changes: 158 additions & 0 deletions devtools/projects/shell-browser/src/app/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,162 @@ if (chrome !== undefined && chrome.runtime !== undefined) {

const tabs = {};
TabManager.initialize(tabs);

const scriptMap = new Map<string, string>();

chrome.debugger.onEvent.addListener((source, method, params) => {
if (method === 'Debugger.scriptParsed' && params) {
const {scriptId, url} = params as any;
scriptMap.set(scriptId, url);
}
});

const activeBreakpoints = new Map<number, Map<string, string>>();

function serializePosition(position: any): string {
return JSON.stringify({
element: position.element,
signalId: position.signalId,
});
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'setSignalBreakpoint') {
const {tabId, position} = message;
setBreakpointViaCDP(tabId, position)
.then((result) => sendResponse({success: true, result}))
.catch((err) => {
console.error('CDP Error:', err);
sendResponse({success: false, error: err.message || err});
});
return true; // Keep channel open
} else if (message.action === 'removeSignalBreakpoint') {
const {tabId, position} = message;
removeBreakpointViaCDP(tabId, position)
.then((result) => sendResponse({success: true, result}))
.catch((err) => {
console.error('CDP Error:', err);
sendResponse({success: false, error: err.message || err});
});
return true; // Keep channel open
}
return false;
});

function attachDebugger(target: chrome.debugger.Debuggee, version: string): Promise<void> {
return new Promise((resolve, reject) => {
chrome.debugger.attach(target, version, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve();
}
});
});
}

function sendDebuggerCommand(
target: chrome.debugger.Debuggee,
method: string,
commandParams?: {[key: string]: any},
): Promise<any> {
return new Promise((resolve, reject) => {
chrome.debugger.sendCommand(target, method, commandParams, (result) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve(result);
}
});
});
}

async function setBreakpointViaCDP(tabId: number, position: any) {
const target = {tabId};

try {
await attachDebugger(target, '1.3');
} catch (err: any) {
if (!err.message || !err.message.includes('Already attached')) {
throw err;
}
}

await sendDebuggerCommand(target, 'Debugger.enable');

const expression = `inspectedApplication.findSignalNodeByPosition('${JSON.stringify(position)}')`;
const evalResult = await sendDebuggerCommand(target, 'Runtime.evaluate', {
expression,
objectGroup: 'angular-devtools',
});

if (evalResult.exceptionDetails) {
throw new Error('Evaluation failed: ' + evalResult.exceptionDetails.exception.description);
}

const objectId = evalResult.result.objectId;
if (!objectId) {
throw new Error('Could not find function object');
}

const propsResult = await sendDebuggerCommand(target, 'Runtime.getProperties', {
objectId,
});

const internalProps = propsResult.internalProperties || [];
const locationProp = internalProps.find((p: any) => p.name === '[[FunctionLocation]]');

if (!locationProp || !locationProp.value || !locationProp.value.value) {
throw new Error('Could not find [[FunctionLocation]]');
}

const {scriptId, lineNumber, columnNumber} = locationProp.value.value;

let bpResult;
const url = scriptMap.get(scriptId);
if (!url) {
console.warn('Could not find URL for scriptId:', scriptId, 'falling back to scriptId');
bpResult = await sendDebuggerCommand(target, 'Debugger.setBreakpoint', {
location: {scriptId, lineNumber, columnNumber},
});
} else {
bpResult = await sendDebuggerCommand(target, 'Debugger.setBreakpointByUrl', {
url,
lineNumber,
columnNumber,
});
}

if (bpResult && bpResult.breakpointId) {
if (!activeBreakpoints.has(tabId)) {
activeBreakpoints.set(tabId, new Map());
}
const posKey = serializePosition(position);
activeBreakpoints.get(tabId)!.set(posKey, bpResult.breakpointId);
}

return bpResult;
}

async function removeBreakpointViaCDP(tabId: number, position: any) {
const target = {tabId};
const tabBps = activeBreakpoints.get(tabId);
if (!tabBps) {
throw new Error('No active breakpoints for this tab');
}
const posKey = serializePosition(position);
const breakpointId = tabBps.get(posKey);
if (!breakpointId) {
throw new Error('No active breakpoint found for this signal');
}

await sendDebuggerCommand(target, 'Debugger.removeBreakpoint', {
breakpointId,
});

tabBps.delete(posKey);
if (tabBps.size === 0) {
activeBreakpoints.delete(tabId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,42 @@ export class ChromeApplicationOperations extends ApplicationOperations {
this.runInInspectedWindow(inspectSignal, target);
}

override setSignalBreakpoint(position: SignalNodePosition, target: Frame): void {
const tabId = chrome.devtools.inspectedWindow.tabId;
chrome.runtime.sendMessage(
{
action: 'setSignalBreakpoint',
tabId,
position,
},
(response) => {
if (response && response.success) {
console.log('Breakpoint set successfully via CDP');
} else {
console.error('Failed to set breakpoint via CDP:', response?.error);
}
},
);
}

override removeSignalBreakpoint(position: SignalNodePosition, target: Frame): void {
const tabId = chrome.devtools.inspectedWindow.tabId;
chrome.runtime.sendMessage(
{
action: 'removeSignalBreakpoint',
tabId,
position,
},
(response) => {
if (response && response.success) {
console.log('Breakpoint removed successfully via CDP');
} else {
console.error('Failed to remove breakpoint via CDP:', response?.error);
}
},
);
}

override viewSourceFromRouter(name: string, type: string, target: Frame): void {
const viewSource = `inspect(inspectedApplication.findConstructorByNameForRouter('${name}', '${type}'))`;
this.runInInspectedWindow(viewSource, target);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"background": {
"service_worker": "app/background_bundle.js"
},
"permissions": ["scripting", "activeTab", "storage"],
"permissions": ["scripting", "activeTab", "storage", "debugger"],
"host_permissions": ["<all_urls>"],
"content_scripts": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"background": {
"scripts": ["app/background_bundle.js"]
},
"permissions": ["activeTab", "storage", "http://*/*", "https://*/*", "file:///*"],
"permissions": ["activeTab", "storage", "http://*/*", "https://*/*", "file:///*", "debugger"],
"content_scripts": [
{
"matches": ["<all_urls>"],
Expand Down
14 changes: 14 additions & 0 deletions devtools/src/demo-application-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ export class DemoApplicationOperations extends ApplicationOperations {
return;
}

override setSignalBreakpoint(position: SignalNodePosition): void {
console.warn(
'setSignalBreakpoint() is not implemented because the demo app runs in an Iframe',
);
return;
}

override removeSignalBreakpoint(position: SignalNodePosition): void {
console.warn(
'removeSignalBreakpoint() is not implemented because the demo app runs in an Iframe',
);
return;
}

override viewSourceFromRouter(name: string, type: string): void {
console.warn(
'viewSourceFromRouter() is not implemented because the demo app runs in an Iframe',
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/render3/util/signal_debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
debuggableFn: consumer.lView?.[CONTEXT]?.constructor as (() => unknown) | undefined,
id,
});
} else if (isEffectNode(consumer)) {
debugSignalGraphNodes.push({
label: consumer.debugName,
kind: consumer.kind,
epoch: consumer.version,
debuggableFn: consumer.fn,
id,
});
} else {
debugSignalGraphNodes.push({
label: consumer.debugName,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/acceptance/signal_debug_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ describe('getSignalGraph', () => {

const effectNode = nodes.find((node) => node.label === 'primitiveSignalEffect')!;
expect(effectNode).toBeDefined();
expect(effectNode.debuggableFn).toBeDefined();
expect(typeof effectNode.debuggableFn).toBe('function');

const signalNode = nodes.find((node) => node.label === 'primitiveSignal')!;
expect(signalNode).toBeDefined();
Expand Down
Loading