Skip to content
4 changes: 2 additions & 2 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Markers follow the id:
| `[directives: RouterLinkActive, ...]` | The other directives on the host. |
| `(+3 below)` | `depth` hides 3 instances under this one. |

The outline carries no input or property values. Pass an id to `inspect-component` for those, or to `highlight` to show the instance. The answer stops at 20,000 characters and says how to narrow it, and it says when the page's tree stopped at a [limit](../inspectors/components.md#selection).
Without `page`, it lists the most recent tab that has a component tree and names the other tabs that have one. The outline carries no input or property values. Pass an id to `inspect-component` for those, or to `highlight` to show the instance. The answer stops at 20,000 characters and says how to narrow it, and it says when the page's tree stopped at a [limit](../inspectors/components.md#selection).

### defer-blocks

Expand All @@ -135,7 +135,7 @@ Change detection cycles recorded with Angular's profiler: the slowest components
| `record` | no | `start` starts a fresh recording, `stop` stops it and keeps the cycles, `clear` empties it. Leave out to read. |
| `limit` | no | Rows per list. Default 10, at most 50. |

Without `page`, it reads the page that is recording, and `record` goes to every connected page. The answer starts with the change detection mode of the page it reports on: zoneless, zone.js, or zoneless with zone.js still loaded. Recording is off until the panel or this tool starts it. Call it with `record: "start"`, use the app, then call it again without `record`. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many.
Without `page`, it reads the page that is recording, and `record` goes to every connected page. A `page` that names no connected tab gets the list of connected tabs. A connected tab that has not recorded yet gets a hint to start a recording there. The answer starts with the change detection mode of the page it reports on: zoneless, zone.js, or zoneless with zone.js still loaded. Recording is off until the panel or this tool starts it. Call it with `record: "start"`, use the app, then call it again without `record`. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many.

### inspect-signals

Expand Down
67 changes: 67 additions & 0 deletions packages/devtools/src/__tests__/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,73 @@ describe('agent tools', () => {
expect(await push('ping-change-detection', 'p1')).toEqual({ known: false });
});

it('answer change detection for an unknown page with the connected tabs', async () => {
const { push, call } = await boot();
await push('push-injector-tree', {
pageId: 'live',
roots: [injectorRoot('app-root')],
environment: [],
zone: 'zone',
});
const reading = await call('change-detection', '', { page: 'gone' });
expect(reading).toMatch(
/^No page `gone` is reporting change detection\. Pages that report change detection: `live`\./,
);
// Recording on a closed tab is not reported as started.
const recording = await call('change-detection', '', { page: 'gone', record: 'start' });
expect(recording).toMatch(/^No page `gone` is reporting change detection/);
expect(recording).not.toMatch(/Recording started/);
// A connected tab that has not recorded yet can still start a recording.
expect(await call('change-detection', '', { page: 'live', record: 'start' })).toMatch(
/Recording started/,
);
expect(await call('change-detection', '', { page: 'live' })).toContain(
'Page `live` has no change detection recording yet.',
);
});

it('treat a tab whose connection dropped as unknown to change detection', async () => {
const { ctx, push, call } = await boot();
const host = ctx.rpc as unknown as {
getCurrentRpcSession: () => unknown;
_emitSessionDisconnected: (meta: { id: number }) => void;
};
const cdReport = (pageId: string) => ({
pageId,
supported: true,
recording: true,
startedAt: 1,
dropped: 0,
cycles: [{ id: 1, at: 1, ms: 2, passes: 1, checks: 1, components: [] }],
components: [{ name: 'Cart', checks: 1, ms: 2, maxMs: 2, cycles: 1 }],
hosts: {},
});
host.getCurrentRpcSession = () => ({ meta: { id: 1 } });
await push('push-injector-tree', {
pageId: 'tab-1',
roots: [injectorRoot('app-root')],
environment: [],
zone: 'zone',
});
await push('push-change-detection', cdReport('tab-1'));
host.getCurrentRpcSession = () => ({ meta: { id: 2 } });
await push('push-change-detection', cdReport('tab-2'));

// tab-1 stays loaded but its connection drops: no record request reaches it.
host._emitSessionDisconnected({ id: 1 });
const start = await call('change-detection', '', { page: 'tab-1', record: 'start' });
expect(start).toMatch(/^No page `tab-1` is reporting change detection/);
expect(start).toContain('`tab-2`');
expect(await call('change-detection', '', { page: 'tab-1' })).toMatch(/^No page `tab-1`/);

// Once it reports again on a new connection, it is known again.
host.getCurrentRpcSession = () => ({ meta: { id: 3 } });
await push('ping-injector-tree', 'tab-1');
expect(await call('change-detection', '', { page: 'tab-1', record: 'start' })).toMatch(
/Recording started/,
);
});

it('report the zone mode of the page that recorded, not the latest tab', async () => {
vi.useFakeTimers();
const { push, call } = await boot();
Expand Down
28 changes: 28 additions & 0 deletions packages/devtools/src/__tests__/cd-recorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,32 @@ describe('change detection agent text', () => {
);
expect(toCdPage({ pageId: 5 }, 10)).toBeNull();
});

it('falls back to the default limit when `limit` is not a number', () => {
const components = Array.from({ length: 15 }, (_, i) => ({
name: `Row${i}`,
checks: i + 1,
ms: i + 1,
maxMs: 1,
cycles: 1,
}));
const page = toCdPage(
{
pageId: 'p1',
supported: true,
recording: true,
cycles: components.map((_, i) => ({ id: i, at: i, ms: 1, passes: 1, checks: 1 })),
components,
},
200,
)!;
const state: CdState = { pages: { p1: page } };
const slowest = (limit: unknown) =>
changeDetectionText(state, { limit: limit as never })
.split('**Slowest components**\n')[1]!
.split('\n\n')[0]!
.split('\n').length;
for (const limit of ['abc', Number.NaN, null, Infinity]) expect(slowest(limit)).toBe(10);
expect(slowest('3')).toBe(3);
});
});
10 changes: 10 additions & 0 deletions packages/devtools/src/__tests__/http-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ describe('list-http-calls text', () => {
expect(isFailedCall(call())).toBe(false);
});

it('falls back to the default limit when `limit` is not a number', () => {
for (const limit of ['abc', Number.NaN, null, Infinity]) {
const text = listHttpCallsText(mixed(), { limit: limit as never }, NOW);
expect(rowsOf(text)).toHaveLength(7);
expect(text).not.toContain('none match');
}
expect(rowsOf(listHttpCallsText(mixed(), { limit: '2' as never }, NOW))).toHaveLength(2);
expect(rowsOf(listHttpCallsText(mixed(), { limit: -5 }, NOW))).toHaveLength(1);
});

it('adds response previews only when asked', () => {
expect(listHttpCallsText(mixed(), {}, NOW)).not.toContain('Response previews');
const text = listHttpCallsText(mixed(), { preview: true }, NOW);
Expand Down
20 changes: 20 additions & 0 deletions packages/devtools/src/__tests__/signal-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ describe('signalGraphText', () => {
);
});

it('keeps the note and no stray JSON when the budget is smaller than the note', () => {
const text = signalGraphText(bigGraph(20, 50), {}, 300);
expect(text).toMatch(/^_Cut to fit 300 characters/);
expect(text).toContain('No part of the graph fits.');
expect(text).toContain('Pass `node`');
expect(text).not.toContain('"nodes"');
});

it('drops history with `history: false`', () => {
const view = JSON.parse(signalGraphText(smallGraph(), { history: false })) as SignalGraph;
expect(view.history).toBeUndefined();
Expand Down Expand Up @@ -188,6 +196,18 @@ describe('inspect-signals tool', () => {
});
});

describe('inspect-signals tool with a long selector', () => {
it('keeps the cut note when the selector it echoes is long', async () => {
const { push, call } = await boot();
await push('push-signal-graph', { pageId: 'p1', ...bigGraph() });
const text = await call('inspect-signals', { selector: 'x'.repeat(19_800) });
expect(text.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX);
// The answer starts with the untrusted-data notice, then the heading.
expect(text).toMatch(/No signal graph for `x{200}…`/);
expect(text).toMatch(/Cut to fit [\d,]+ characters/);
});
});

describe('navigate answer', () => {
it('leaves a short result whole', () => {
const text = routerActionText({ ok: true });
Expand Down
20 changes: 19 additions & 1 deletion packages/devtools/src/__tests__/ssr-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { pangularHttpInterceptor } from '../http.ts';
import { httpRegistry, type HttpCall } from '../http-rules.ts';
import { createSsrMiddleware, renderModeOf } from '../ssr-middleware.ts';
import { SSR_REQUEST_HEADER, ssrRegistry, type SsrRequest } from '../ssr-registry.ts';
import { explainSsrRequestText, sanitizeSsrRequest, ssrRequestStory } from '../rpc/ssr-tools.ts';
import {
explainSsrRequestText,
listSsrRequestsText,
sanitizeSsrRequest,
ssrRequestStory,
} from '../rpc/ssr-tools.ts';
import type { HttpPage, HttpState } from '../types.ts';

class FakeResponse extends EventEmitter {
Expand Down Expand Up @@ -319,6 +324,19 @@ const page = (overrides: Partial<HttpPage> = {}): HttpPage => ({
...overrides,
});

describe('list-ssr-requests limit', () => {
it('falls back to the default limit when `limit` is not a number', () => {
const requests = Array.from({ length: 25 }, (_, i) => ssrRequest({ id: `r${i}`, at: i }));
const rows = (limit: unknown) =>
listSsrRequestsText(requests, [], { limit: limit as never }, 100)
.split('\n')
.filter((line) => /^\| `r\d+` \|/.test(line)).length;
for (const limit of ['abc', Number.NaN, null, Infinity]) expect(rows(limit)).toBe(20);
expect(rows('3')).toBe(3);
expect(rows(-5)).toBe(1);
});
});

describe('ssrRequestStory', () => {
it('links the server calls and the page, and finds browser refetches', () => {
const server = [
Expand Down
46 changes: 43 additions & 3 deletions packages/devtools/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ import {
type SetHttpRuleArgs,
} from './rpc/http-rule-tools.ts';
import {
cdUnknownPageText,
changeDetectionText,
expireCdPages,
pickCdPage,
Expand All @@ -172,6 +173,7 @@ import {
unknownPageText,
} from './rpc/pages.ts';
import { registerHubDocks } from './hub-docks.ts';
import { clip } from './text.ts';
import { setRedaction } from './forms-privacy.ts';
import {
FORM_WRITE_ACTIONS,
Expand Down Expand Up @@ -851,6 +853,9 @@ const pangular = defineDevframe({
if (stale.length) forgetSignalPages(stale.map(([id]) => id));
if (expireComponentPages(componentPages, Date.now(), liveTtl)) applyComponentPages();
if (expireCdPages(cdPages, Date.now(), liveTtl)) applyCd();
for (const id of goneCdPages) {
if (!componentPages.has(id) && !injectorPages.has(id)) goneCdPages.delete(id);
}
const staleInjectors = [...injectorPages].filter(
([id, p]) => Date.now() - p.reportedAt > liveTtl(id),
);
Expand Down Expand Up @@ -1193,23 +1198,43 @@ const pangular = defineDevframe({
},
});

// A tab whose connection dropped gets no change detection record request,
// so its report goes at once and the tool does not count it as connected
// until it reports again. Bound from the component, injector and change
// detection reports, the ones the tool reads tabs from.
const cdPages = new Map<string, CdPage>();
const goneCdPages = new Set<string>();
let applyCd = () => {};
const cdSessions = trackPageSessions(ctx.rpc, (pageIds) => {
let removed = false;
for (const pageId of pageIds) {
goneCdPages.add(pageId);
removed = cdPages.delete(pageId) || removed;
}
if (removed) applyCd();
});
const cdSeen = (pageId: string) => {
cdSessions.bind(pageId);
goneCdPages.delete(pageId);
};

register({
name: 'push-component-tree',
type: 'action',
jsonSerializable: true,
handler: (report: unknown) => {
if (!isComponentReport(report)) return;
componentPages.set(report.pageId, toComponentPage(report));
cdSeen(report.pageId);
applyComponentPages();
for (const check of [...componentWaiters]) check();
},
});

const cdPages = new Map<string, CdPage>();
const cdState = await my.rpc.sharedState('change-detection', {
initialValue: { pages: {} } as CdState,
});
const applyCd = () =>
applyCd = () =>
cdState.mutate((draft) => {
draft.pages = Object.fromEntries(cdPages);
});
Expand All @@ -1231,6 +1256,7 @@ const pangular = defineDevframe({
const page = toCdPage(report, limits.cdCycles);
if (!page) return;
cdPages.set(page.pageId, page);
cdSeen(page.pageId);
applyCd();
},
});
Expand All @@ -1243,6 +1269,7 @@ const pangular = defineDevframe({
const page = typeof pageId === 'string' ? cdPages.get(pageId) : undefined;
if (!page) return { known: false };
cdPages.set(page.pageId, { ...page, reportedAt: Date.now() });
cdSeen(page.pageId);
return { known: true };
},
});
Expand Down Expand Up @@ -1273,6 +1300,7 @@ const pangular = defineDevframe({
const page = typeof pageId === 'string' ? componentPages.get(pageId) : undefined;
if (!page) return { known: false };
componentPages.set(page.pageId, { ...page, reportedAt: Date.now() });
cdSeen(page.pageId);
return { known: true };
},
});
Expand Down Expand Up @@ -1472,6 +1500,7 @@ const pangular = defineDevframe({
...(zone ? { zone } : {}),
reportedAt: Date.now(),
});
cdSeen(pageId);
applyInjectorPages();
},
});
Expand All @@ -1484,6 +1513,7 @@ const pangular = defineDevframe({
const page = typeof pageId === 'string' ? injectorPages.get(pageId) : undefined;
if (!page) return { known: false };
injectorPages.set(page.pageId, { ...page, reportedAt: Date.now() });
cdSeen(page.pageId);
return { known: true };
},
});
Expand Down Expand Up @@ -1891,6 +1921,16 @@ const pangular = defineDevframe({
},
},
handler: async (args: { record?: string; page?: string; limit?: number }) => {
const unknown =
args?.page &&
cdUnknownPageText(
args.page,
cdState.value() as CdState,
[...componentPages.values(), ...injectorPages.values()].filter(
(page) => !goneCdPages.has(page.pageId),
),
);
if (unknown) return { markdown: unknown };
if (args?.record === 'start' || args?.record === 'stop' || args?.record === 'clear') {
requestCdRecord({
pageId: args.page,
Expand Down Expand Up @@ -2000,7 +2040,7 @@ const pangular = defineDevframe({
const hint = isEnvironmentRequest(args.selector)
? ` No environment injector on the page matches it${known ? `; the page knows ${known}` : ''}.`
: '';
const head = `No signal graph for \`${args.selector}\`.${hint} The live graph covers \`${covers}\`:\n\n`;
const head = `No signal graph for \`${clip(args.selector, 200)}\`.${hint} The live graph covers \`${covers}\`:\n\n`;
return {
markdown: untrusted(
(head + signalGraphText(graph, view, Math.max(0, budget - head.length))).slice(
Expand Down
27 changes: 26 additions & 1 deletion packages/devtools/src/rpc/__tests__/component-outline.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import type { ComponentPage, LiveComponentNode } from '../../types.ts';
import { componentOutlineText, listComponentsText, routedHosts } from '../component-outline.ts';
import {
LIST_COMPONENTS_DESCRIPTION,
componentOutlineText,
listComponentsText,
routedHosts,
} from '../component-outline.ts';

const node = (
id: string,
Expand Down Expand Up @@ -148,3 +153,23 @@ describe('list-components answer', () => {
);
});
});

describe('list-components description', () => {
it('describes the answers the tool gives', () => {
// No page answers with a note, not an empty result.
expect(listComponentsText([], () => undefined)).toMatch(/^No component tree has been reported/);
expect(LIST_COMPONENTS_DESCRIPTION).not.toMatch(/Empty when no page is connected/);
expect(LIST_COMPONENTS_DESCRIPTION).toContain('no component tree has been reported');
// Without `page` it skips a newer page with no tree and names the others.
const empty = page({ pageId: 'p2', roots: [], reportedAt: 5 });
const text = listComponentsText(
[page(), empty, page({ pageId: 'p3', reportedAt: 0 })],
() => undefined,
);
expect(text).toContain('Page `p1`');
expect(text).toContain('Other pages report a component tree too: `p3`');
expect(LIST_COMPONENTS_DESCRIPTION).toContain('most recent page that has a component tree');
expect(LIST_COMPONENTS_DESCRIPTION).toContain('names the other tabs');
expect(LIST_COMPONENTS_DESCRIPTION).toContain('(+N below)');
});
});
Loading
Loading