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
148 changes: 148 additions & 0 deletions app/src/__tests__/cd-views.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CdRecording, type CdPage } from '../pages/cd-recording';

let fixture: ComponentFixture<CdRecording>;
let rpcCall: ReturnType<typeof vi.fn>;

function page(options?: CdPage['options'], supported = true): CdPage {
return {
pageId: 'p1',
supported,
recording: false,
startedAt: null,
dropped: 0,
cycles: [],
components: [],
hosts: {},
...(options ? { options } : {}),
};
}

const host = () => fixture.nativeElement as HTMLElement;
const live = () => host().querySelector('[role="status"]')!.textContent!;
const toggle = (text: string) =>
[...host().querySelectorAll<HTMLButtonElement>('.views button')].find(
(b) => b.textContent?.trim() === text,
)!;
const pressed = (text: string) => toggle(text).getAttribute('aria-pressed');

async function show(value: CdPage | null) {
fixture.componentRef.setInput('page', value);
await fixture.whenStable();
}

async function click(text: string) {
toggle(text).click();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}

describe('CdRecording live views', () => {
beforeEach(async () => {
rpcCall = vi.fn(() => Promise.resolve({ ok: true }));
const client = { scope: () => ({ rpc: { call: rpcCall } }) };
fixture = TestBed.createComponent(CdRecording);
fixture.componentRef.setInput('pageId', 'p1');
fixture.componentRef.setInput('rpc', client as unknown as DevframeRpcClient);
await show(null);
});

afterEach(() => {
fixture?.destroy();
TestBed.resetTestingModule();
});

it('shows the three toggles off in a labelled group', () => {
const group = host().querySelector('.views')!;
expect(group.getAttribute('role')).toBe('group');
expect(host().querySelector(`#${group.getAttribute('aria-labelledby')}`)!.textContent).toBe(
'Live views',
);
expect(pressed('Flash checks')).toBe('false');
expect(pressed('Performance track')).toBe('false');
expect(pressed('Angular profiling')).toBe('false');
for (const button of host().querySelectorAll<HTMLButtonElement>('.views button')) {
expect(button.type).toBe('button');
expect(button.disabled).toBe(false);
}
});

it('turns flash checks on and off for the page and announces it', async () => {
await click('Flash checks');
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
pageId: 'p1',
flash: true,
});
expect(pressed('Flash checks')).toBe('true');
expect(live()).toBe('Flash checks on.');

await show(
page({ flash: true, track: false, angularProfiling: false, angularProfilingAvailable: true }),
);
await click('Flash checks');
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
pageId: 'p1',
flash: false,
});
expect(pressed('Flash checks')).toBe('false');
});

it('turns the Performance track on', async () => {
await click('Performance track');
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
pageId: 'p1',
track: true,
});
expect(live()).toContain('Performance track on.');
});

it('turns Angular profiling on once and says it stays on', async () => {
await click('Angular profiling');
expect(rpcCall).toHaveBeenLastCalledWith('request-change-detection-options', {
pageId: 'p1',
angularProfiling: true,
});
expect(pressed('Angular profiling')).toBe('true');
expect(toggle('Angular profiling').disabled).toBe(true);
expect(host().textContent).toContain('Angular profiling stays on until the page reloads.');
});

it('shows the state the page reports', async () => {
await show(
page({ flash: false, track: true, angularProfiling: true, angularProfilingAvailable: true }),
);
expect(pressed('Performance track')).toBe('true');
expect(pressed('Angular profiling')).toBe('true');
expect(toggle('Angular profiling').disabled).toBe(true);
});

it('explains when the page has no ng.enableProfiling', async () => {
await show(
page({
flash: false,
track: false,
angularProfiling: false,
angularProfilingAvailable: false,
}),
);
expect(toggle('Angular profiling').disabled).toBe(true);
expect(host().textContent).toContain('It needs Angular 20 or later in a development build.');
});

it('puts a toggle back when the page cannot be reached', async () => {
rpcCall.mockImplementation(() => Promise.reject(new Error('offline')));
await click('Flash checks');
expect(pressed('Flash checks')).toBe('false');
expect(live()).toBe('Could not reach the page.');
});

it('disables the toggles on a page that cannot record', async () => {
await show(page(undefined, false));
for (const button of host().querySelectorAll<HTMLButtonElement>('.views button')) {
expect(button.disabled).toBe(true);
}
});
});
112 changes: 107 additions & 5 deletions app/src/pages/cd-recording.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, computed, input, output, signal } from '@angular/core';
import type { CdReport } from '@pangular-inspector/devtools/config';
import { Component, computed, input, linkedSignal, output, signal } from '@angular/core';
import type { CdOptions, CdReport } from '@pangular-inspector/devtools/config';
import type { DevframeRpcClient } from 'devframe/client';
import { panelConfig } from '../devtools-config';
import { time } from '../format';
Expand Down Expand Up @@ -59,6 +59,57 @@ const SHOWN_CYCLES = 30;
@if (page()?.supported === false) {
<p class="note">This page can't record change detection. It needs Angular 20 or later.</p>
}
@if (!imported()) {
<div class="views" role="group" aria-labelledby="cd-views-label">
<span id="cd-views-label" class="views-label">Live views</span>
<button
type="button"
class="view"
[attr.aria-pressed]="views().flash"
[disabled]="!canShowViews()"
(click)="setView('flash', !views().flash)"
>
<span class="dot" aria-hidden="true"></span>
Flash checks
</button>
<button
type="button"
class="view"
[attr.aria-pressed]="views().track"
[disabled]="!canShowViews()"
(click)="setView('track', !views().track)"
>
<span class="dot" aria-hidden="true"></span>
Performance track
</button>
<button
type="button"
class="view"
[attr.aria-pressed]="views().angularProfiling"
[disabled]="
!canShowViews() || views().angularProfiling || !views().angularProfilingAvailable
"
(click)="setView('angularProfiling', true)"
>
<span class="dot" aria-hidden="true"></span>
Angular profiling
</button>
</div>
<p class="hint">
Flash checks draws a box over each component on the page as Angular checks it, with its
name and how often it was checked, fading over a second. Performance track adds a Pangular
track to the Chrome Performance panel with every component check, hook and output. Angular
profiling turns on Angular's own track there.
</p>
@if (views().angularProfiling) {
<p class="note">Angular profiling stays on until the page reloads.</p>
} @else if (!views().angularProfilingAvailable) {
<p class="note">
This page has no <span class="mono">ng.enableProfiling</span>. It needs Angular 20 or
later in a development build.
</p>
}
}
<app-limit-note
[dropped]="page()?.dropped ?? 0"
[max]="maxCycles()"
Expand Down Expand Up @@ -215,6 +266,22 @@ const SHOWN_CYCLES = 30;
cursor: not-allowed;
opacity: 0.6;
}
.views {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.views-label {
@include m.label;
}
button.view[aria-pressed='true'] {
border-color: var(--accent-line);
}
button.view[aria-pressed='true'] .dot {
background: var(--accent);
}
.dot {
width: 8px;
height: 8px;
Expand Down Expand Up @@ -329,6 +396,18 @@ export class CdRecording {
/** The recording on screen: an imported one, else the page's. */
readonly page = computed(() => this.imported()?.page ?? this.livePage());

/** The views the page has on, set ahead of the page's next report when a toggle is used. */
readonly views = linkedSignal<CdOptions>(
() =>
this.livePage()?.options ?? {
flash: false,
track: false,
angularProfiling: false,
angularProfilingAvailable: true,
},
);
readonly canShowViews = computed(() => !!this.pageId() && this.livePage()?.supported !== false);

protected readonly message = signal('');
/**
* What a screen reader hears. It changes only when Record, Stop or Clear finishes or fails.
Expand Down Expand Up @@ -426,24 +505,47 @@ export class CdRecording {
await this.send({ clear: true }, '', () => 'Cleared the recorded cycles.');
}

/** Turns a live view on or off on the page. Angular profiling only turns on. */
async setView(view: 'flash' | 'track' | 'angularProfiling', on: boolean) {
const before = this.views();
this.views.set({ ...before, [view]: on });
const done = {
flash: on ? 'Flash checks on.' : 'Flash checks off.',
track: on
? 'Performance track on. Record in the Chrome Performance panel to see it.'
: 'Performance track off.',
angularProfiling: 'Angular profiling on. It stays on until the page reloads.',
}[view];
const sent = await this.send(
{ [view]: on },
'',
() => done,
'request-change-detection-options',
);
if (!sent) this.views.set(before);
}

private async send(
request: { on?: boolean; clear?: boolean },
request: { on?: boolean; clear?: boolean; flash?: boolean; track?: boolean },
pending: string,
done: () => string,
) {
rpc = 'request-change-detection-record',
): Promise<boolean> {
this.message.set(pending);
try {
if (!this.rpc()) throw new Error('no client');
await call(this.rpc(), 'request-change-detection-record', {
await call(this.rpc(), rpc, {
pageId: this.pageId(),
...request,
});
this.message.set('');
this.announce(done());
return true;
} catch {
const failed = 'Could not reach the page.';
this.message.set(failed);
this.announce(failed);
return false;
}
}

Expand Down
19 changes: 12 additions & 7 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ Every tool has one of three safety tiers. The server sends the tier to your clie
The twelve <code>action</code> and <code>destructive</code> tools act on the app. Your client can ask you before it runs them.
</ngmd-alert>

When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran <tool>` message to the hub, so you can see what the agent changed. `change-detection` posts only when it starts, stops or clears a recording. See [Messages and badges](../getting-started/popup-and-hub.md#messages-and-badges).
When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran <tool>` message to the hub, so you can see what the agent changed. `change-detection` posts only when it starts, stops or clears a recording, or turns a live view on or off. See [Messages and badges](../getting-started/popup-and-hub.md#messages-and-badges).

### Turn tools off

The server decides which tools exist. Set `agent.readOnly` to keep only the `read` tools. `change-detection` stays as a `read` tool without its `record` argument, so it reads a recording the panel started. Set `agent.tools.<inspector>` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays for `instrument` and `resolve-lazy`, and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools).
The server decides which tools exist. Set `agent.readOnly` to keep only the `read` tools. `change-detection` stays as a `read` tool without its `record`, `flash`, `track` and `angularProfiling` arguments, so it reads a recording the panel started. Set `agent.tools.<inspector>` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays for `instrument` and `resolve-lazy`, and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools).

## Source scan

Expand Down Expand Up @@ -160,14 +160,19 @@ Each page lists at most 50 blocks, with a note on how many more are not shown, a

Change detection cycles recorded with Angular's profiler: the slowest components by self time, the most often checked components, lifecycle hook time per class and hook, output listener time, `afterRender` callback time, and the latest cycles with their duration, component checks, sync passes, hook and afterRender time and the output that ran before each one. Reads: page. Needs Angular 20 or later.

| Argument | Required | Value |
| -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `record` | no | `start` starts a fresh recording on a page that is not recording and leaves a running one as it is, `stop` stops it and keeps the cycles, `clear` empties it. Leave out to read. |
| `limit` | no | Rows per list. Default 10, at most 50. |
| `export` | no | `json` returns the recording as the file the panel's **Import** reads. `trace` returns it in the Trace Event Format for Perfetto. Can't be combined with `record`. |
| Argument | Required | Value |
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `record` | no | `start` starts a fresh recording on a page that is not recording and leaves a running one as it is, `stop` stops it and keeps the cycles, `clear` empties it. Leave out to read. |
| `limit` | no | Rows per list. Default 10, at most 50. |
| `export` | no | `json` returns the recording as the file the panel's **Import** reads. `trace` returns it in the Trace Event Format for Perfetto. Can't be combined with `record`. |
| `flash` | no | `true` turns the flash overlay on: a fading box with the class name and check count over each component Angular checks. `false` turns it off. |
| `track` | no | `true` writes each component check, lifecycle hook and output listener to a **Pangular** track in the Chrome Performance panel. `false` turns it off. |
| `angularProfiling` | no | `true` calls `ng.enableProfiling()` for Angular's own Performance panel track. It stays on until the page reloads, so `false` is refused. |

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`. With `agent.readOnly`, the tool has no `record` argument and is marked read-only, so start the recording from the panel. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many. A recording lists at most 100 component classes and counts checks for up to 2000 instances. When it checked more, the answer says how many were left out.

`flash`, `track` and `angularProfiling` go to the page in `page`, or to every connected page, and can be combined with `record`, but not with `export`. They are the **Live views** toggles of the panel; see [Watch checks live](../inspectors/components.md#watch-checks-live). Once a page reports them, the answer ends with a line that says which views are on. With `agent.readOnly`, the tool has none of these three arguments.

`export` only reads, so it stays available with `agent.readOnly`. The file is masked like other answers (JWTs, bearer tokens and secret names), has no instance check counts, and comes in a `json` code block to save as a `.json` file. An export over 100,000 characters is not returned, because cut JSON doesn't parse. The answer gives its size and the counts of cycles, timed spans, component classes, hook rows and output rows instead. Export it from the panel then, or clear the recording and record a shorter session. See [Export and import a recording](../inspectors/components.md#export-and-import-a-recording).

### Edit a component <ngmd-badge variant="alpha">Action</ngmd-badge>
Expand Down
Loading
Loading