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
58 changes: 58 additions & 0 deletions app/src/__tests__/pipes-record-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { scopeToPage } from '../page-id';
import { PipesInspector } from '../pages/pipes-inspector';

function client(instrumented: string[] = []) {
const requests: unknown[] = [];
const rpc = {
call: (name: string, arg?: unknown) => {
if (name === 'request-instrument-pipes') {
requests.push(arg);
return Promise.resolve({ pages: 1 });
}
return Promise.resolve([]);
},
callEvent: () => Promise.resolve(),
sharedState: () =>
Promise.resolve({
value: () => ({ pipes: [], async: [], reportedAt: 0, instrumented }),
on: () => () => {},
}),
};
const rpcClient = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
return { rpcClient, requests };
}

async function render(rpc: DevframeRpcClient) {
const fixture = TestBed.createComponent(PipesInspector);
fixture.componentRef.setInput('rpc', rpc);
for (let i = 0; i < 4; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
return fixture;
}

describe('pipe recording request', () => {
afterEach(() => {
scopeToPage(null);
document.body.innerHTML = '';
});

it('asks to record on the page the panel belongs to', async () => {
scopeToPage('A');
const { rpcClient, requests } = client();
const fixture = await render(rpcClient);
await fixture.componentInstance.toggleInstrument();
expect(requests).toEqual([{ pageId: 'A', on: true }]);
});

it('stops recording without a page id so every recording page stops', async () => {
const { rpcClient, requests } = client(['A', 'B']);
const fixture = await render(rpcClient);
await fixture.componentInstance.toggleInstrument();
expect(requests).toEqual([{ on: false }]);
});
});
5 changes: 4 additions & 1 deletion app/src/pages/pipes-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
signal,
} from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
import { hostPageId } from '../page-id';
import { Select, type SelectOption } from '../ui/select';

interface UsageSite {
Expand Down Expand Up @@ -1179,7 +1180,9 @@ export class PipesInspector {
this.pendingRecord.set(on);
let result: { pages?: number } | undefined;
try {
result = (await client.scope('pangular').rpc.call('request-instrument-pipes', on)) as
// Start on this panel's page (or the latest page); stop every page that records.
const request = on ? { pageId: hostPageId() ?? undefined, on } : { on };
result = (await client.scope('pangular').rpc.call('request-instrument-pipes', request)) as
{ pages?: number } | undefined;
} catch {
this.settleRecord("Couldn't reach the devtools server. Try again.");
Expand Down
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 @@ -131,7 +131,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 that last reported its injectors: 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. 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
2 changes: 1 addition & 1 deletion apps/docs/src/content/getting-started/nativescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The overlay sends no platform marker, so `list-pages` lists a NativeScript app w

`10.0.2.2` is how the Android emulator reaches its host. A physical device needs your machine's LAN address as `baseURL`.

`initNativeScriptOverlay()` returns a function that stops the overlay and tells the server to forget the app's component, injector and NgRx reports.
`initNativeScriptOverlay()` returns a function that stops the overlay and tells the server to forget the app's component, signal, injector and NgRx reports.

## How it works

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/inspectors/pipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Live discovery walks the rendered views with `ng.getComponent` and related debug

### Recording

Click **Record calls** to count calls and keep the last input and output of each pipe. Recording patches each pipe's `transform` in the inspected page, on every connected tab. It covers the pipes found on the page. Click **Stop recording** when you are done.
Click **Record calls** to count calls and keep the last input and output of each pipe. Recording patches each pipe's `transform` in one tab: the page the panel belongs to, or the page that reported last when the panel runs on its own. Other open tabs stay unpatched. It covers the pipes found on the page. **Stop recording** stops every tab that records. Click **Stop recording** when you are done.

Each recording starts from zero, so a second recording measures one interaction on its own. The button switches to **Stop recording** once a page confirms that it is recording. If no page is connected, the tab says so and stays on **Record calls**.

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion extension/ui/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

it('report the zone mode of the page that recorded, not the latest tab', async () => {
vi.useFakeTimers();
const { push, call } = await boot();
await push('push-injector-tree', {
pageId: 'rec',
roots: [injectorRoot('app-root')],
environment: [],
zone: 'zone',
});
await push('push-change-detection', {
pageId: 'rec',
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: {},
});
await vi.advanceTimersByTimeAsync(1000);
await push('push-injector-tree', {
pageId: 'other',
roots: [injectorRoot('app-root')],
environment: [],
zone: 'zoneless',
});
const text = await call('change-detection', '');
expect(text).toMatch(/^Page `rec` /);
expect(text).not.toContain('`other`');
expect(await call('change-detection', '', { page: 'other' })).toMatch(/^Page `other` /);
});

it('report the change detection mode and what services inject', async () => {
const { push, call, injectorState } = await boot();
const rootEnv = {
Expand Down
25 changes: 25 additions & 0 deletions packages/devtools/src/__tests__/cd-recorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,31 @@ describe('change detection in the overlay', () => {
expect(profilers).toHaveLength(0);
});

it('sends a stopped recording again after leave, as on a bfcache restore', async () => {
const { cd, sent, handlers, emit } = setup('22.1.0');
handlers.get('change-detection-record')!({ pageId: 'p1', on: true });
emit(E.ChangeDetectionStart);
emit(E.ComponentStart);
emit(E.ComponentEnd, new Shell());
emit(E.ChangeDetectionEnd);
handlers.get('change-detection-record')!({ pageId: 'p1', on: false });
await Promise.resolve();
await cd.push();
cd.leave();
sent.length = 0;
await cd.push();
const pushes = sent.filter((s) => s.name === 'push-change-detection');
expect(pushes).toHaveLength(1);
expect(pushes[0].args[0]).toMatchObject({ recording: false, cycles: [{ id: 1 }] });
sent.length = 0;
handlers.get('change-detection-record')!({ pageId: 'p1', clear: true });
await Promise.resolve();
cd.leave();
sent.length = 0;
await cd.push();
expect(sent).toEqual([]);
});

it('reports that it cannot record before Angular 20', async () => {
const { ng, sent, handlers } = setup('19.2.0');
handlers.get('change-detection-record')!({ on: true });
Expand Down
19 changes: 19 additions & 0 deletions packages/devtools/src/__tests__/hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { connect } from 'node:net';
import { Hono } from 'hono';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { hubDefaultOrigins, initPangularHub, type PangularHubOptions } from '../hub.ts';
import { httpRegistry } from '../http-rules.ts';
import { makeProject } from './analog-fixture.ts';

const hubs: { close: () => Promise<void> }[] = [];
Expand Down Expand Up @@ -287,3 +288,21 @@ describe('Pangular Inspector hub on a server that reloads server.ts', () => {
if (first.port !== second.port) expect(await listening(first.port)).toBe(false);
});
});

describe('Pangular Inspector hub server state', () => {
it('turns off server HTTP capture when the hub that owns it closes', async () => {
const { hub, ctx } = await boot(makeProject({ 'package.json': '{}' }));
expect(httpRegistry().owner).toBe(ctx);
expect(typeof httpRegistry().record).toBe('function');
await hub.close();
expect(httpRegistry().record).toBeUndefined();
});

it('leaves a newer hub its capture when an older one closes', async () => {
const first = await boot(makeProject({ 'package.json': '{}' }));
const second = await boot(makeProject({ 'package.json': '{}' }));
await first.hub.close();
expect(httpRegistry().owner).toBe(second.ctx);
expect(typeof httpRegistry().record).toBe('function');
});
});
106 changes: 106 additions & 0 deletions packages/devtools/src/__tests__/overlay-nativescript-dispose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const realSetImmediate = setImmediate;
const calls: [string, unknown][] = [];
const handlers = new Map<string, (...args: unknown[]) => unknown>();
let offline = false;

const host = {
typeName: 'AppHostView',
parent: null,
isLoaded: true,
eachChildView: () => undefined,
};
class AppComponent {}
Object.assign(AppComponent, { ɵcmp: { selectors: [['ns-app']] } });
const app = new AppComponent();

vi.mock('@nativescript/core', () => ({
Application: { getRootView: () => host },
isAndroid: false,
}));

vi.mock('devframe/client', () => ({
connectDevframe: vi.fn(async () => ({
status: 'connected',
close: vi.fn(),
scope: () => ({
rpc: {
register: (def: { name: string; handler: (...args: unknown[]) => unknown }) =>
handlers.set(def.name, def.handler),
call: vi.fn(async (name: string, payload: unknown) => {
calls.push([name, payload]);
if (offline && name.startsWith('push-')) throw new Error('offline');
return name === 'push-signal-graph' ? { delta: true } : undefined;
}),
},
}),
})),
}));

vi.mock('../signal-history.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('../signal-history.ts')>()),
installSignalWriteHook: vi.fn(async () => () => undefined),
}));

vi.mock('../ngrx-overlay.ts', () => ({
attachNgrx: () => ({ push: async () => undefined, stop: () => undefined }),
}));

async function flush() {
for (let i = 0; i < 40; i++) await Promise.resolve();
}

describe('NativeScript overlay disconnect', () => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);

beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('WebSocket', class {});
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
calls.length = 0;
handlers.clear();
unhandled.length = 0;
offline = false;
process.on('unhandledRejection', onUnhandled);
(globalThis as Record<string, unknown>)['ng'] = {
getComponent: (h: unknown) => (h === host ? app : null),
getRootComponents: () => [app],
getHostElement: (c: unknown) => (c === app ? host : null),
getInjector: (h: unknown) => h,
ɵgetSignalGraph: () => ({
nodes: [{ id: '1', kind: 'signal', label: 'count', epoch: 0, value: 0 }],
}),
};
});

afterEach(() => {
process.off('unhandledRejection', onUnhandled);
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
delete (globalThis as Record<string, unknown>)['ng'];
});

it('swallows failed pushes the panel asked for and forgets the signal page on dispose', async () => {
const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts');
const dispose = initNativeScriptOverlay({ baseURL: 'http://localhost:9999/', intervalMs: 100 });
await flush();
const pageId = (calls.find(([name]) => name === 'push-signal-graph')![1] as { pageId: string })
.pageId;

offline = true;
handlers.get('select-signal-component')!({ pageId, id: null });
handlers.get('inspect-component-in-page')!({ pageId, id: null });
await flush();
await new Promise((resolve) => realSetImmediate(resolve));
expect(unhandled).toEqual([]);

calls.length = 0;
dispose();
await flush();
expect(calls).toContainEqual(['forget-signal-page', pageId]);
});
});
19 changes: 19 additions & 0 deletions packages/devtools/src/__tests__/pipes-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,25 @@ describe('pipes collector', () => {
expect(currency.call?.lastResult).toBe('$5.00');
});

it('only instruments when the request names this page or no page', async () => {
await mount(Receipt);
const h = harness('pg');
h.handlers.get('instrument-pipes')!({ pageId: 'other', on: true });
h.collector.push();
await Promise.resolve();
expect(h.reports().at(-1)?.instrumented).toBe(false);
h.handlers.get('instrument-pipes')!({ pageId: 'pg', on: true });
await Promise.resolve();
expect(h.reports().at(-1)?.instrumented).toBe(true);
h.handlers.get('instrument-pipes')!({ pageId: 'other', on: false });
h.collector.push();
await Promise.resolve();
expect(h.reports().at(-1)?.instrumented).toBe(true);
h.handlers.get('instrument-pipes')!({ on: false });
await Promise.resolve();
expect(h.reports().at(-1)?.instrumented).toBe(false);
});

it('stops instrumenting and the next report reflects it', async () => {
await mount(Receipt);
const h = harness();
Expand Down
41 changes: 41 additions & 0 deletions packages/devtools/src/__tests__/pipes-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,45 @@ describe('pipe recording requests', () => {
await invoke('push-pipes', page('tab-1'));
expect(await invoke('request-instrument-pipes', true)).toEqual({ pages: 1 });
});

it('does not start recording on every tab before any page has reported', async () => {
const { ctx, invoke } = await boot();
const broadcast = vi.spyOn(ctx.rpc, 'broadcast');
expect(await invoke('request-instrument-pipes', true)).toEqual({ pages: 0 });
const starts = broadcast.mock.calls.filter(
([options]) => (options as { method: string }).method === 'pangular:instrument-pipes',
);
expect(starts).toHaveLength(0);
});

it('sends the page id with the request so other tabs stay unpatched', async () => {
vi.useFakeTimers();
try {
const { ctx, invoke } = await boot();
const broadcast = vi.spyOn(ctx.rpc, 'broadcast');
const sent = () =>
broadcast.mock.calls
.map(([options]) => options as { method: string; args: unknown[] })
.filter((options) => options.method === 'pangular:instrument-pipes')
.at(-1)?.args;
await invoke('push-pipes', page('tab-1'));
await vi.advanceTimersByTimeAsync(1000);
await invoke('push-pipes', page('tab-2'));

expect(await invoke('request-instrument-pipes', { pageId: 'tab-1', on: true })).toEqual({
pages: 1,
});
expect(sent()).toEqual([{ pageId: 'tab-1', on: true }]);
expect(await invoke('request-instrument-pipes', { pageId: 'gone', on: true })).toEqual({
pages: 0,
});
// Without a page id, start picks the latest page and stop reaches every page.
await invoke('request-instrument-pipes', true);
expect(sent()).toEqual([{ pageId: 'tab-2', on: true }]);
expect(await invoke('request-instrument-pipes', { on: false })).toEqual({ pages: 2 });
expect(sent()).toEqual([{ on: false }]);
} finally {
vi.useRealTimers();
}
});
});
Loading
Loading