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
2 changes: 1 addition & 1 deletion apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ The answer also holds:

The answer is cut at 20,000 characters. A graph that is too long first keeps fewer history entries per node, then keeps the first nodes that fit. A note under the JSON says how many history entries and nodes it left out. Pass `node` or `history: false` to see the rest.

With `root` or a route path, the tool switches the page's graph to the effects of that injector and waits up to 1.5 seconds for it. If no injector matches, the answer lists the ones the page knows. On Angular 20.0, the answer says the live graph needs Angular 20.1 or later.
With `root` or a route path, the tool asks the page for the effects of that injector and waits up to 1.5 seconds for the answer. It reads that graph once and leaves the Signals page on the target it shows. If no injector matches, the answer lists the ones the page knows. On Angular 20.0, the answer says the live graph needs Angular 20.1 or later.

<ngmd-callout type="tip" title="One graph per page">
The page reports one graph: the component picked on the Signals page or with <code>highlight</code>, otherwise the deepest component in the primary router outlet. Call <code>highlight</code> first to switch the graph to another component. Only signals a template or an effect has read appear.
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/inspectors/signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Entries are sampled instead of exact in these cases:

### Switch the graph from an agent

The `pangular:highlight` tool also switches the graph to the component it highlights. Calling `pangular:inspect-signals` with `root` or a route path switches it to that injector. The picker does not show either choice.
The `pangular:highlight` tool also switches the graph to the component it highlights. The picker does not show that choice. Calling `pangular:inspect-signals` with `root` or a route path reads that injector's graph once and does not change what the Signals page shows.

### Find a stuck effect in a root service

Expand Down
115 changes: 106 additions & 9 deletions packages/devtools/src/__tests__/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,8 @@ describe('agent tools', () => {
expect(missing).toMatch(/app-x/);
});

it('switches inspect-signals to the root injector and waits for its graph', async () => {
const { push, call } = await boot();
it('reads a root injector graph for inspect-signals without switching the panel', async () => {
const { ctx, push, call } = await boot();
const card = {
pageId: 'p1',
nodes: [{ id: 'a', kind: 'signal', label: 'open', epoch: 0 }],
Expand All @@ -287,23 +287,120 @@ describe('agent tools', () => {
],
};
await push('push-signal-graph', card);
const answer = call('inspect-signals', 'root');
await new Promise((resolve) => setTimeout(resolve, 20));
await push('push-signal-graph', {
pageId: 'p1',
const rootGraph = {
nodes: [{ id: 'e', kind: 'effect', label: 'persistTrips', epoch: 1 }],
edges: [],
injector: { id: 'inj-1', name: 'Root' },
environments: card.environments,
source: 'selected',
});
expect(JSON.parse(await answer)).toMatchObject({
};
const broadcast = vi.spyOn(ctx.rpc, 'broadcast');
const asked: unknown[] = [];
broadcast.mockImplementation(async (options: { method: string; args: unknown[] }) => {
if (options.method !== 'pangular:peek-signal-graph') return;
const request = options.args[0] as { requestId: string; pageId?: string; env: string };
asked.push(request);
setTimeout(() => {
void push('signal-graph-peek-result', {
requestId: request.requestId,
pageId: 'p1',
graph: { ...rootGraph, pageId: 'p1' },
});
}, 10);
});
expect(JSON.parse(await call('inspect-signals', 'root'))).toMatchObject({
pageId: 'p1',
injector: { name: 'Root' },
nodes: [{ label: 'persistTrips' }],
});
expect(JSON.parse(await call('inspect-signals', 'inj-1'))).toMatchObject({
expect(asked).toEqual([{ requestId: expect.any(String), env: 'root' }]);
expect(JSON.parse(await call('inspect-signals', 'inj-1', { page: 'p1' }))).toMatchObject({
injector: { id: 'inj-1' },
});
expect(asked[1]).toMatchObject({ pageId: 'p1', env: 'inj-1' });
// The panel keeps showing the component it showed before.
const methods = broadcast.mock.calls.map(([options]) => (options as { method: string }).method);
expect(methods).not.toContain('pangular:select-signal-component');
const state = (
await (
ctx.rpc as unknown as {
sharedState: {
get: (key: string) => Promise<{ value: () => Record<string, unknown> }>;
};
}
).sharedState.get('pangular:signal-graph')
).value() as { graph: { pageId: string }; pages: Record<string, unknown> };
expect(state.graph).toMatchObject({ componentSelector: 'app-card' });
expect(state.pages['p1']).toMatchObject({ componentSelector: 'app-card' });
expect(JSON.parse(await call('inspect-signals', 'app-card'))).toMatchObject({
component: { id: 'c2' },
});
});

it('ignores a peek answer for another request or a non-matching injector', async () => {
const { ctx, push, call } = await boot();
await push('push-signal-graph', {
pageId: 'p1',
nodes: [],
edges: [],
componentSelector: 'app-card',
environments: [{ id: 'inj-1', name: 'Root' }],
});
vi.spyOn(ctx.rpc, 'broadcast').mockImplementation(
async (options: { method: string; args: unknown[] }) => {
if (options.method !== 'pangular:peek-signal-graph') return;
const { requestId } = options.args[0] as { requestId: string };
void push('signal-graph-peek-result', { requestId: 'other', pageId: 'p1', graph: null });
void push('signal-graph-peek-result', {
requestId,
pageId: 'p1',
graph: { nodes: [], edges: [], injector: { id: 'inj-1', name: 'Root' } },
});
},
);
const missing = await call('inspect-signals', '/admin');
expect(missing).toMatch(/No signal graph for `\/admin`/);
expect(missing).toMatch(/the page knows `Root`/);
}, 10_000);

it('waits for a known page when a page without a signal graph answers first', async () => {
const { ctx, push, call } = await boot();
const environments = [{ id: 'inj-1', name: 'Root' }];
for (const pageId of ['p1', 'p2']) {
await push('push-signal-graph', {
pageId,
nodes: [],
edges: [],
componentSelector: 'app-card',
environments,
});
}
vi.spyOn(ctx.rpc, 'broadcast').mockImplementation(
async (options: { method: string; args: unknown[] }) => {
if (options.method !== 'pangular:peek-signal-graph') return;
const { requestId } = options.args[0] as { requestId: string };
// p3 never reported a graph; its empty answer must not end the wait.
void push('signal-graph-peek-result', { requestId, pageId: 'p3', graph: null });
void push('signal-graph-peek-result', { requestId, pageId: 'p2', graph: null });
setTimeout(() => {
void push('signal-graph-peek-result', {
requestId,
pageId: 'p1',
graph: {
nodes: [{ id: 'e', kind: 'effect', label: 'persistTrips', epoch: 1 }],
edges: [],
injector: { id: 'inj-1', name: 'Root' },
environments,
},
});
}, 20);
},
);
expect(JSON.parse(await call('inspect-signals', 'root'))).toMatchObject({
pageId: 'p1',
injector: { name: 'Root' },
nodes: [{ label: 'persistTrips' }],
});
});

it('names the known injectors when a route path matches none', async () => {
Expand Down
188 changes: 188 additions & 0 deletions packages/devtools/src/__tests__/signal-peek.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { collectSignalGraph, type SignalDebugNg, type SignalTarget } from '../signal-graph.ts';
import { createSignalHistory } from '../signal-history.ts';
import { PEEK_EVENT, PEEK_RESULT, registerSignalPeek, toPeekRequest } from '../signal-peek.ts';
import { createSignalPeeks } from '../rpc/signal-peeks.ts';

class Root {}
class Page {}

const platform = { scopes: new Set(['platform']) };
const root = { scopes: new Set(['environment', 'root']) };
const environment = new Set<object>([platform, root]);

/** A fake `ng` with one routed component and effects in the root injector. */
function fakeNg(): SignalDebugNg {
const components: Record<string, object> = { 'APP-ROOT': new Root(), 'APP-PAGE': new Page() };
return {
getComponent: (el) => components[el.tagName] ?? null,
getInjector: (el) => el,
ɵgetSignalGraph: (injector) =>
injector === (root as unknown)
? {
nodes: [
{ id: '10', kind: 'effect', label: 'persistTrips', epoch: 2 },
{ id: '11', kind: 'signal', label: 'trips', epoch: 2, value: 3 },
],
edges: [{ consumer: 0, producer: 1 }],
}
: {
nodes: [
{
id: '1',
kind: 'signal',
label: (injector as Element).tagName.toLowerCase(),
epoch: 1,
value: 1,
},
],
edges: [],
},
ɵgetInjectorResolutionPath: (injector) => [injector, root, platform],
ɵgetInjectorMetadata: (injector) =>
environment.has(injector as object)
? { type: 'environment', source: null }
: { type: 'element', source: injector },
};
}

function fakeRpc() {
const handlers = new Map<string, (request: unknown) => void>();
const calls: [string, unknown][] = [];
return {
handlers,
calls,
rpc: {
register: (fn: { name: string; handler: (request: unknown) => void }) =>
handlers.set(fn.name, fn.handler),
call: async (name: string, arg: unknown) => {
calls.push([name, arg]);
},
},
};
}

const page = `
<app-root ng-version="22.0.0">
<router-outlet></router-outlet><app-page></app-page>
</app-root>`;

describe('registerSignalPeek', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('answers with the root graph and leaves the selected target alone', async () => {
document.body.innerHTML = page;
const ng = fakeNg();
const session = fakeRpc();
const history = createSignalHistory((value) => value);
const collect = vi.fn((wanted: SignalTarget) => collectSignalGraph(ng, wanted));
registerSignalPeek(session.rpc, 'p1', collect, history);

session.handlers.get(PEEK_EVENT)!({ requestId: 'peek1', env: 'root' });
await Promise.resolve();

// Only the injector was read; nothing re-targets the page's own graph.
expect(collect.mock.calls).toEqual([[{ env: 'root' }]]);
expect(session.handlers.has('select-signal-component')).toBe(false);
const [name, answer] = session.calls[0]!;
expect(name).toBe(PEEK_RESULT);
expect(answer).toMatchObject({
requestId: 'peek1',
pageId: 'p1',
graph: {
pageId: 'p1',
injector: { name: 'Root' },
nodes: [{ label: 'persistTrips' }, { label: 'trips' }],
history: { '11': [{ epoch: 2, value: 3, source: 'initial' }] },
},
});
});

it('answers null instead of the fallback component graph when no injector matches', async () => {
document.body.innerHTML = page;
const session = fakeRpc();
registerSignalPeek(session.rpc, 'p1', (wanted) => collectSignalGraph(fakeNg(), wanted));
session.handlers.get(PEEK_EVENT)!({ requestId: 'peek2', env: '/nope' });
await Promise.resolve();
expect(session.calls).toEqual([
[PEEK_RESULT, { requestId: 'peek2', pageId: 'p1', graph: null }],
]);
});

it('ignores requests for another page and malformed requests', async () => {
document.body.innerHTML = page;
const session = fakeRpc();
registerSignalPeek(session.rpc, 'p1', (wanted) => collectSignalGraph(fakeNg(), wanted));
session.handlers.get(PEEK_EVENT)!({ requestId: 'peek3', env: 'root', pageId: 'p2' });
session.handlers.get(PEEK_EVENT)!({ env: 'root' });
session.handlers.get(PEEK_EVENT)!(null);
await Promise.resolve();
expect(session.calls).toEqual([]);
expect(toPeekRequest({ requestId: 'r', env: 'root', pageId: 'p1' }, 'p1')).toEqual({
requestId: 'r',
env: 'root',
});
});
});

describe('createSignalPeeks', () => {
afterEach(() => vi.useRealTimers());

it('resolves with the first graph from the asked page and ignores other pages', async () => {
const peeks = createSignalPeeks();
const sent: { requestId: string; env: string; pageId?: string }[] = [];
const answer = peeks.request((message) => sent.push(message), 'root', 'p1', ['p1'], 1000);
const { requestId } = sent[0]!;
expect(sent[0]).toEqual({ requestId, env: 'root', pageId: 'p1' });
peeks.answer({ requestId, pageId: 'p2', graph: { nodes: [], edges: [] } });
peeks.answer({ requestId, pageId: 'p1', graph: { nodes: [{ id: 'e' }], edges: [] } });
expect(await answer).toMatchObject({ pageId: 'p1', nodes: [{ id: 'e' }] });
});

it('resolves null once every asked page answered without a graph', async () => {
const peeks = createSignalPeeks();
let requestId = '';
const answer = peeks.request(
(m) => (requestId = m.requestId),
'root',
undefined,
['p1', 'p2'],
60_000,
);
peeks.answer({ requestId, pageId: 'p1', graph: null });
peeks.answer({ requestId, pageId: 'p1', graph: null });
peeks.answer({ requestId, pageId: 'p2', graph: null });
expect(await answer).toBeNull();
});

it('does not count an empty answer from a page that was not expected', async () => {
const peeks = createSignalPeeks();
let requestId = '';
let settled = false;
const answer = peeks.request(
(m) => (requestId = m.requestId),
'root',
undefined,
['p1', 'p2'],
60_000,
);
void answer.then(() => (settled = true));
peeks.answer({ requestId, pageId: 'p3', graph: null });
peeks.answer({ requestId, pageId: 'p2', graph: null });
await Promise.resolve();
expect(settled).toBe(false);
peeks.answer({ requestId, pageId: 'p1', graph: { nodes: [{ id: 'e' }], edges: [] } });
expect(await answer).toMatchObject({ pageId: 'p1', nodes: [{ id: 'e' }] });
});

it('resolves null when no page answers in time', async () => {
vi.useFakeTimers();
const peeks = createSignalPeeks();
const answer = peeks.request(() => {}, 'root', undefined, ['p1'], 1500);
await vi.advanceTimersByTimeAsync(1500);
expect(await answer).toBeNull();
});
});
1 change: 1 addition & 0 deletions packages/devtools/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ export const RPC_INSPECTOR: Record<string, PangularInspector> = {
'ping-signal-graph': 'signals',
'forget-signal-page': 'signals',
'select-signal-target': 'signals',
'signal-graph-peek-result': 'signals',
'get-providers': 'injectors',
'push-injector-tree': 'injectors',
'ping-injector-tree': 'injectors',
Expand Down
Loading
Loading