diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7776f178..3867870e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -96,14 +96,16 @@ jobs:
run: pnpm test:axe
# The e2e jobs below are slow, so they run only when the package, the panel, the SSR demo, the
- # tools docs (e2e:mcp reads them) or the scripts and fixtures they use change. A diff that cannot
- # be computed (a new branch, a force push) runs them.
+ # tools docs (e2e:mcp reads them) or the scripts and fixtures they use change. The engines-floor
+ # job runs only when what the packed package is built from changes. A diff that cannot be
+ # computed (a new branch, a force push) runs them all.
changes:
name: Changed paths
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
e2e: ${{ steps.diff.outputs.e2e }}
+ package: ${{ steps.diff.outputs.package }}
steps:
- uses: actions/checkout@v7
with:
@@ -121,6 +123,61 @@ jobs:
else
echo "e2e=true" >> "$GITHUB_OUTPUT"
fi
+ # What the packed package is built from: the package, the panel it bundles and the toolchain.
+ packed=(packages/devtools app package.json pnpm-lock.yaml pnpm-workspace.yaml .nvmrc
+ .github/actions/setup .github/workflows/ci.yml)
+ if git diff --quiet "$BASE" HEAD -- "${packed[@]}"; then
+ echo "package=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "package=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ # The source tests can't run on 22.12 (Node runs .ts files only from 22.18, and the build tools
+ # need newer versions), so this job checks the packed package there instead.
+ engines-floor:
+ name: Packed package on Node.js 22.12
+ needs: changes
+ if: needs.changes.outputs.package == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup
+
+ # `prepack` builds the library and the panel first, on the `.nvmrc` version.
+ - name: Pack the package
+ run: pnpm --filter @pangular-inspector/devtools pack --pack-destination "$RUNNER_TEMP/pack"
+
+ # The engines floor: the lowest version `engines.node` in packages/devtools/package.json allows.
+ # Keep the two in step.
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22.12.0
+
+ - name: Install the tarball into an empty project
+ working-directory: ${{ runner.temp }}
+ run: |
+ mkdir floor
+ cd floor
+ npm init -y > /dev/null
+ npm install --no-audit --no-fund "$RUNNER_TEMP"/pack/*.tgz
+
+ # The Node.js entry points from `exports` in packages/devtools/package.json. The browser
+ # entries (overlay, popup and the hooks) need a DOM. `http` is left out too: it imports
+ # the optional Angular peers, which load in plain Node.js only with `@angular/compiler`
+ # (JIT), and the app's build bundles it.
+ - name: Run the CLI and load the entry points
+ working-directory: ${{ runner.temp }}/floor
+ run: |
+ node --version
+ npx pangular --version
+ for entry in '' /devframe /config /cli /hub /ssr /vite; do
+ node -e "import('@pangular-inspector/devtools$entry')"
+ done
+ node -e "require('@pangular-inspector/devtools/hub')"
e2e-security:
name: Security e2e
diff --git a/app/src/__tests__/analog-inspector-pages.test.ts b/app/src/__tests__/analog-inspector-pages.test.ts
index 925e7b9c..f960edf1 100644
--- a/app/src/__tests__/analog-inspector-pages.test.ts
+++ b/app/src/__tests__/analog-inspector-pages.test.ts
@@ -110,3 +110,41 @@ describe('Analog route filter', () => {
expect(box.value).toBe('');
});
});
+
+describe('Analog open pages', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('marks pages in named outlets with the outlet name', async () => {
+ const route = (file: string) => ({
+ id: file,
+ fullPath: '/',
+ file,
+ kind: 'page' as const,
+ params: [],
+ children: [],
+ });
+ const fixture = await render(
+ client({
+ state: {
+ pages: [
+ {
+ pageId: 'A',
+ url: '/inbox(sidebar:chat)',
+ chain: [
+ { path: '/inbox', file: '/src/app/pages/inbox.page.ts' },
+ { path: '/chat', file: '/src/app/pages/chat.page.ts', outlet: 'sidebar' },
+ ],
+ },
+ ],
+ },
+ }),
+ );
+ const inspector = fixture.componentInstance;
+ expect(inspector.openText(route('/src/app/pages/inbox.page.ts'))).toBe('open');
+ expect(inspector.isOpen(route('/src/app/pages/chat.page.ts'))).toBe(true);
+ expect(inspector.openText(route('/src/app/pages/chat.page.ts'))).toBe('open in sidebar');
+ expect(inspector.isOpen(route('/src/app/pages/about.page.ts'))).toBe(false);
+ });
+});
diff --git a/app/src/__tests__/app-shell.test.ts b/app/src/__tests__/app-shell.test.ts
index 171ecc21..11a335dd 100644
--- a/app/src/__tests__/app-shell.test.ts
+++ b/app/src/__tests__/app-shell.test.ts
@@ -305,4 +305,18 @@ describe('App tab bar', () => {
expect(now.filter((b) => b.tabIndex === 0)).toEqual([now[2]]);
fixture.destroy();
});
+
+ it('puts the scrolling content in the Tab order with a name, and still focuses it from code', async () => {
+ connection.answers = [true];
+ const fixture = TestBed.createComponent(App);
+ document.body.appendChild(host(fixture));
+ await settle(fixture);
+
+ const main = host(fixture).querySelector('main')!;
+ expect(main.tabIndex).toBe(0);
+ expect(main.getAttribute('aria-label')).toBeTruthy();
+ main.focus();
+ expect(document.activeElement).toBe(main);
+ fixture.destroy();
+ });
});
diff --git a/app/src/__tests__/cd-recording-status.test.ts b/app/src/__tests__/cd-recording-status.test.ts
index 085e56fd..9453a9bf 100644
--- a/app/src/__tests__/cd-recording-status.test.ts
+++ b/app/src/__tests__/cd-recording-status.test.ts
@@ -74,10 +74,13 @@ describe('CdRecording status', () => {
pageId: 'p1',
on: true,
});
- expect(live()).toBe('Recording started.');
+ // The server took the request; the page has not said it records yet.
+ expect(live()).not.toContain('started');
+ expect(visible()).toBe('Starting…');
await show(true, 3);
expect(live()).toBe('Recording started.');
+ expect(visible()).toBe('Recording. 3 cycles so far.');
await click('Stop recording');
// The page's last report still brings cycles: the total waits for it.
@@ -89,20 +92,51 @@ describe('CdRecording status', () => {
expect(live()).toBe('Cleared the recorded cycles.');
});
- it('announces the count it has when the last report does not come', async () => {
- await show(true, 2);
+ /** Clicks with fake timers, so the confirmation wait can run out. */
+ async function clickAndWait(text: string, ms: number) {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
- button('Stop recording').click();
+ button(text).click();
await vi.advanceTimersByTimeAsync(0);
fixture.detectChanges();
- expect(live()).not.toContain('stopped');
- await vi.advanceTimersByTimeAsync(3000);
+ const before = live();
+ await vi.advanceTimersByTimeAsync(ms);
fixture.detectChanges();
- expect(live()).toBe('Recording stopped. 2 cycles recorded.');
+ return before;
} finally {
vi.useRealTimers();
}
+ }
+
+ it('says the page did not confirm when no report shows the recording on', async () => {
+ const before = await clickAndWait('Record', 3000);
+ expect(before).not.toContain('started');
+ const failed =
+ 'The page did not confirm that the recording started. The tab may be in the background, closed or reloading.';
+ expect(live()).toBe(failed);
+ expect(visible()).toBe(failed);
+ // The page still reports no recording, so the button still offers Record.
+ expect(button('Record')).toBeTruthy();
+ expect(button('Stop recording')).toBeUndefined();
+ });
+
+ it('announces the start once a late report confirms it', async () => {
+ await clickAndWait('Record', 3000);
+ expect(live()).toContain('did not confirm');
+ await show(true, 1);
+ expect(live()).toBe('Recording started.');
+ expect(visible()).toBe('Recording. 1 cycle so far.');
+ expect(button('Stop recording')).toBeTruthy();
+ });
+
+ it('says the page did not confirm when the report after Stop does not come', async () => {
+ await show(true, 2);
+ const before = await clickAndWait('Stop recording', 3000);
+ expect(before).not.toContain('stopped');
+ expect(live()).toBe(
+ 'The page did not confirm that the recording stopped. The tab may be in the background, closed or reloading.',
+ );
+ expect(button('Stop recording')).toBeTruthy();
});
it('announces a repeated message again', async () => {
diff --git a/app/src/__tests__/component-tree-defer.test.ts b/app/src/__tests__/component-tree-defer.test.ts
index 293f9147..3e3b2871 100644
--- a/app/src/__tests__/component-tree-defer.test.ts
+++ b/app/src/__tests__/component-tree-defer.test.ts
@@ -1,11 +1,14 @@
import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { ComponentTree } from '../pages/component-tree';
-function client(pages: Record): DevframeRpcClient {
+function client(
+ pages: Record,
+ call: (...args: unknown[]) => Promise = () => Promise.resolve([]),
+): DevframeRpcClient {
const rpc = {
- call: () => Promise.resolve([]),
+ call,
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
@@ -19,6 +22,7 @@ function client(pages: Record): DevframeRpcClient {
describe('defer blocks', () => {
afterEach(() => {
document.body.innerHTML = '';
+ vi.useRealTimers();
});
it('style the state badge apart from the empty-state box', async () => {
@@ -120,4 +124,111 @@ describe('defer blocks', () => {
expect(await pill(page())).toBe('2');
expect(await pill(page(true))).toBe('2+');
});
+
+ it('shows the notes the agent tool prints, the @error block and the block times', async () => {
+ const hour = 3_600_000;
+ // Fix the panel clock so the ages stay exact; timers stay real for the waits.
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(1_700_000_000_000);
+ // The page's clock runs an hour ahead of the panel's.
+ const pageNow = Date.now() + hour;
+ const block = (id: string, extra: Record) => ({
+ id,
+ owner: { id: 'c1', name: 'App', tag: 'app-root' },
+ state: 'complete',
+ hydration: 'not-configured',
+ triggers: ['on idle'],
+ rootIds: [],
+ since: pageNow,
+ hasErrorBlock: false,
+ ...extra,
+ });
+ const page = {
+ pageId: 'p1',
+ roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
+ count: 1,
+ detail: null,
+ reportedAt: 1000,
+ clockOffset: -hour,
+ deferBlocks: [
+ block('d1', { state: 'error', hasErrorBlock: true }),
+ block('d2', { state: 'placeholder', since: pageNow - 30_000 }),
+ block('d3', { state: 'placeholder', since: pageNow - 2_000 }),
+ block('d4', { hydration: 'dehydrated', triggers: ['hydrate on viewport'] }),
+ block('d5', {
+ state: 'loading',
+ loading: { minimumTime: 1000, afterTime: 100 },
+ placeholder: { minimumTime: 500 },
+ }),
+ ],
+ };
+ const fixture = TestBed.createComponent(ComponentTree);
+ fixture.componentRef.setInput('rpc', client({ p1: page }));
+ for (let i = 0; i < 3; i++) {
+ await new Promise((resolve) => setTimeout(resolve));
+ await fixture.whenStable();
+ }
+ const rows = Array.from(
+ (fixture.nativeElement as HTMLElement).querySelectorAll('.defer-row'),
+ );
+ const flags = (row: HTMLElement) =>
+ Array.from(row.querySelectorAll('.flag')).map((el) => el.textContent?.trim());
+ expect(flags(rows[0]!)).toEqual(['failed', '@error block']);
+ expect(rows[0]!.querySelector('.note-failed')?.getAttribute('title')).toBe('failed to load');
+ // 30 s on the page's clock, not an hour and 30 s.
+ expect(flags(rows[1]!)).toEqual(['stuck 30s']);
+ expect(rows[1]!.querySelector('.note-stuck')?.getAttribute('title')).toBe(
+ 'on its placeholder for 30s',
+ );
+ expect(flags(rows[2]!)).toEqual([]);
+ expect(flags(rows[3]!)).toEqual(['dehydrated']);
+ expect(rows[4]!.querySelector('.timings')?.textContent?.trim()).toBe(
+ 'placeholder min 500ms · loading after 100ms, min 1s',
+ );
+ expect(rows[0]!.querySelector('.timings')).toBeNull();
+ fixture.destroy();
+ });
+
+ it('highlights every root element of a block in one request', async () => {
+ const call = vi.fn((..._args: unknown[]) => Promise.resolve([] as unknown));
+ const block = (id: string, rootIds: string[]) => ({
+ id,
+ owner: { id: 'c1', name: 'App', tag: 'app-root' },
+ state: 'complete',
+ hydration: 'not-configured',
+ triggers: [],
+ rootIds,
+ });
+ const page = {
+ pageId: 'p1',
+ roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }],
+ count: 1,
+ detail: null,
+ reportedAt: 1000,
+ deferBlocks: [block('d1', ['e1', 'e2']), block('d2', ['e3']), block('d3', [])],
+ };
+ const fixture = TestBed.createComponent(ComponentTree);
+ fixture.componentRef.setInput('rpc', client({ p1: page }, call));
+ for (let i = 0; i < 3; i++) {
+ await new Promise((resolve) => setTimeout(resolve));
+ await fixture.whenStable();
+ }
+ const rows = (fixture.nativeElement as HTMLElement).querySelectorAll('.defer-row');
+ const highlights = () =>
+ call.mock.calls.filter((args) => args[0] === 'request-page-highlight').map((args) => args[1]);
+ call.mockClear();
+ for (const row of Array.from(rows)) {
+ row.dispatchEvent(new Event('mouseenter'));
+ row.dispatchEvent(new Event('mouseleave'));
+ }
+ expect(highlights()).toEqual([
+ { pageId: 'p1', ids: ['e1', 'e2'] },
+ null,
+ { pageId: 'p1', id: 'e3' },
+ null,
+ { pageId: 'p1', id: 'c1' },
+ null,
+ ]);
+ fixture.destroy();
+ });
});
diff --git a/app/src/__tests__/defer-blocks-timer.test.ts b/app/src/__tests__/defer-blocks-timer.test.ts
new file mode 100644
index 00000000..df862f65
--- /dev/null
+++ b/app/src/__tests__/defer-blocks-timer.test.ts
@@ -0,0 +1,71 @@
+import { TestBed } from '@angular/core/testing';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { DeferBlocksSection, type DeferBlock } from '../pages/defer-blocks-section';
+
+const block = (id: string, extra: Partial = {}): DeferBlock => ({
+ id,
+ owner: { id: 'c1', name: 'App', tag: 'app-root' },
+ state: 'complete',
+ hydration: 'not-configured',
+ triggers: ['on idle'],
+ rootIds: [],
+ since: Date.now(),
+ ...extra,
+});
+
+describe('Defer blocks timer', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.useRealTimers();
+ TestBed.resetTestingModule();
+ });
+
+ function render(blocks: DeferBlock[]) {
+ const fixture = TestBed.createComponent(DeferBlocksSection);
+ fixture.componentRef.setInput('blocks', blocks);
+ fixture.detectChanges();
+ return fixture;
+ }
+
+ it('runs only while a block is on its placeholder', () => {
+ const fixture = render([]);
+ expect(vi.getTimerCount()).toBe(0);
+ fixture.componentRef.setInput('blocks', [block('d1')]);
+ fixture.detectChanges();
+ expect(vi.getTimerCount()).toBe(0);
+ fixture.componentRef.setInput('blocks', [block('d1', { state: 'placeholder' })]);
+ fixture.detectChanges();
+ expect(vi.getTimerCount()).toBe(1);
+ fixture.componentRef.setInput('blocks', [block('d1')]);
+ fixture.detectChanges();
+ expect(vi.getTimerCount()).toBe(0);
+ fixture.componentRef.setInput('blocks', [block('d1', { state: 'initial' })]);
+ fixture.detectChanges();
+ expect(vi.getTimerCount()).toBe(1);
+ fixture.destroy();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('counts the age up without changing the row name', () => {
+ const fixture = render([block('d1', { state: 'placeholder', since: Date.now() - 30_000 })]);
+ const row = () => (fixture.nativeElement as HTMLElement).querySelector('.defer-row')!;
+ /** The text a screen reader takes the name from: everything not aria-hidden. */
+ const name = () => {
+ const copy = row().cloneNode(true) as HTMLElement;
+ copy.querySelectorAll('[aria-hidden="true"]').forEach((el) => el.remove());
+ return copy.textContent!.replace(/\s+/g, ' ').trim();
+ };
+ const before = name();
+ expect(before).toContain('stuck');
+ expect(row().querySelector('.note-stuck')!.textContent!.replace(/\s+/g, ' ').trim()).toBe(
+ 'stuck 30s',
+ );
+ vi.advanceTimersByTime(5000);
+ fixture.detectChanges();
+ expect(row().querySelector('.note-stuck')!.textContent!.replace(/\s+/g, ' ').trim()).toBe(
+ 'stuck 35s',
+ );
+ expect(name()).toBe(before);
+ fixture.destroy();
+ });
+});
diff --git a/app/src/__tests__/forms-panels.test.ts b/app/src/__tests__/forms-panels.test.ts
index fce2eda9..f9869d41 100644
--- a/app/src/__tests__/forms-panels.test.ts
+++ b/app/src/__tests__/forms-panels.test.ts
@@ -20,6 +20,15 @@ function fakeClient(call: Call, forms: CollectedForm[] = []): DevframeRpcClient
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}
+function stateClient(snapshot: Record): DevframeRpcClient {
+ const rpc = {
+ call: () => Promise.resolve(''),
+ callEvent: () => Promise.resolve(),
+ sharedState: () => Promise.resolve({ value: () => snapshot, on: () => () => {} }),
+ };
+ return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
+}
+
const fail: Call = () => Promise.reject(new Error('offline'));
function field(path: string, extra: Partial = {}): FormFieldNode {
@@ -741,6 +750,148 @@ describe('FormsWebMcp', () => {
});
});
+describe('FormsInspector WebMCP tools not linked to a form', () => {
+ const unlinked = {
+ name: 'search_flights',
+ description: 'Find flights',
+ status: 'registered' as const,
+ seen: 'register' as const,
+ inputs: ['from: string'],
+ required: ['from'],
+ };
+
+ afterEach(() => scopeToPage(null));
+
+ it('lists them in their own section, with what the per-form block shows', async () => {
+ const fixture = TestBed.createComponent(FormsInspector);
+ fixture.componentRef.setInput(
+ 'rpc',
+ stateClient({
+ forms: [form],
+ webMcp: [{ pageId: 'p1', modelContext: true, tools: [unlinked] }],
+ }),
+ );
+ document.body.append(fixture.nativeElement);
+ await settle(fixture);
+ const host = fixture.nativeElement as HTMLElement;
+ const section = host.querySelector('app-forms-webmcp-others section')!;
+ expect(section.querySelector('h2')?.textContent).toContain('Other WebMCP tools');
+ expect(section.textContent).toContain('search_flights');
+ expect(section.textContent).toContain('Find flights');
+ expect(section.querySelector('.inputs li')?.textContent).toContain('required');
+ expect(section.textContent).toContain('No agent has called this tool');
+ });
+
+ it('shows them when the page has no form, and gives each tool a unique name', async () => {
+ const fixture = TestBed.createComponent(FormsInspector);
+ fixture.componentRef.setInput(
+ 'rpc',
+ stateClient({
+ webMcp: [
+ {
+ pageId: 'p1',
+ modelContext: true,
+ tools: [unlinked, { ...unlinked, name: 'book_hotel', inputs: undefined }],
+ },
+ ],
+ }),
+ );
+ document.body.append(fixture.nativeElement);
+ await settle(fixture);
+ const host = fixture.nativeElement as HTMLElement;
+ expect(text(fixture)).toContain('No forms on the page yet');
+ const tools = Array.from(host.querySelectorAll('app-forms-webmcp-others .webmcp'));
+ expect(tools).toHaveLength(2);
+ const ids = Array.from(host.querySelectorAll('[id]'), (el) => el.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ const names = tools.map((tool) =>
+ tool
+ .getAttribute('aria-labelledby')!
+ .split(' ')
+ .map((id) => host.querySelector(`#${id}`)?.textContent?.trim())
+ .join(' '),
+ );
+ expect(names).toEqual(['WebMCP tool search_flights', 'WebMCP tool book_hotel']);
+ });
+
+ it('shows nothing when every tool is linked, and only the tools of the page it runs in', async () => {
+ scopeToPage('p1');
+ const fixture = TestBed.createComponent(FormsInspector);
+ fixture.componentRef.setInput(
+ 'rpc',
+ stateClient({
+ forms: [form],
+ webMcp: [
+ { pageId: 'p1', modelContext: true, tools: [] },
+ { pageId: 'p2', modelContext: true, tools: [unlinked] },
+ ],
+ }),
+ );
+ await settle(fixture);
+ const host = fixture.nativeElement as HTMLElement;
+ expect(host.querySelector('app-forms-webmcp-others section')).toBeNull();
+ fixture.componentInstance.allPages.set(true);
+ await settle(fixture);
+ expect(host.querySelector('app-forms-webmcp-others section')?.textContent).toContain(
+ 'search_flights',
+ );
+ });
+});
+
+describe('FormsInspector setup errors', () => {
+ afterEach(() => scopeToPage(null));
+
+ it('lists them in a status notice, even when no form reached the list', async () => {
+ const fixture = TestBed.createComponent(FormsInspector);
+ fixture.componentRef.setInput(
+ 'rpc',
+ stateClient({
+ setupErrors: [
+ { pageId: 'p1', message: "NG01203: No value accessor for form control name: 'age'" },
+ ],
+ }),
+ );
+ await settle(fixture);
+ const notice = (fixture.nativeElement as HTMLElement).querySelector('.setup-errors')!;
+ expect(notice.getAttribute('role')).toBe('status');
+ expect(notice.textContent).toContain('1 forms setup error');
+ expect(notice.textContent).toContain('NG01203: No value accessor');
+ expect(text(fixture)).toContain('No forms on the page yet');
+ });
+
+ it('shows only the errors of the page it runs in until All pages is checked', async () => {
+ scopeToPage('p1');
+ const fixture = TestBed.createComponent(FormsInspector);
+ fixture.componentRef.setInput(
+ 'rpc',
+ stateClient({
+ forms: [form, { ...form, id: 'signup@p2' }],
+ setupErrors: [
+ { pageId: 'p1', message: 'NG01050: one' },
+ { pageId: 'p2', message: 'NG01050: two' },
+ ],
+ }),
+ );
+ await settle(fixture);
+ const host = fixture.nativeElement as HTMLElement;
+ const notice = () => host.querySelector('.setup-errors')?.textContent ?? '';
+ expect(notice()).toContain('NG01050: one');
+ expect(notice()).not.toContain('NG01050: two');
+ expect(notice()).not.toContain('on page');
+ fixture.componentInstance.allPages.set(true);
+ await settle(fixture);
+ expect(notice()).toContain('2 forms setup errors');
+ expect(notice()).toContain('on page p2');
+ });
+
+ it('shows no notice without setup errors', async () => {
+ const fixture = TestBed.createComponent(FormsInspector);
+ fixture.componentRef.setInput('rpc', stateClient({ forms: [form], setupErrors: [] }));
+ await settle(fixture);
+ expect((fixture.nativeElement as HTMLElement).querySelector('.setup-errors')).toBeNull();
+ });
+});
+
describe('FormsInspector selection', () => {
it("does not hand a closed tab's selected form over to another tab's form with the same label", async () => {
const login = (page: string): CollectedForm => ({
diff --git a/app/src/__tests__/network-hydration-mismatch.test.ts b/app/src/__tests__/network-hydration-mismatch.test.ts
new file mode 100644
index 00000000..6185b50d
--- /dev/null
+++ b/app/src/__tests__/network-hydration-mismatch.test.ts
@@ -0,0 +1,58 @@
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import type { DevframeRpcClient } from 'devframe/client';
+import { afterEach, describe, expect, it } from 'vitest';
+import { NetworkInspector } from '../pages/network-inspector';
+
+const page = {
+ pageId: 'p1',
+ url: 'http://localhost:4000/products',
+ title: 'Products',
+ hydration: {
+ enabled: true,
+ nodes: { hydrated: 10, skipped: 0, mismatched: 2 },
+ mismatches: [
+ { component: 'ProductCard', isClass: true, expected: '', actual: '
' },
+ { component: '#text', expected: 'Price' },
+ ],
+ skipHydrationHosts: [],
+ warnings: [],
+ },
+ calls: [],
+ reportedAt: 1,
+};
+
+function client(): DevframeRpcClient {
+ const values: Record = {
+ http: { serverCalls: [], pages: [page], rules: [], ssrOverrides: [] },
+ };
+ const rpc = {
+ sharedState: (name: string) =>
+ Promise.resolve({ value: () => values[name], on: () => () => {} }),
+ };
+ return {
+ connectionMeta: { configs: { pangular: { actions: {} } } },
+ scope: () => ({ rpc }),
+ } as unknown as DevframeRpcClient;
+}
+
+let fixture: ComponentFixture;
+
+describe('NetworkInspector hydration mismatches', () => {
+ afterEach(() => {
+ fixture?.destroy();
+ TestBed.resetTestingModule();
+ });
+
+ it('shows a component class as is and a node name in angle brackets', async () => {
+ fixture = TestBed.createComponent(NetworkInspector);
+ fixture.componentRef.setInput('rpc', client());
+ await fixture.whenStable();
+ await new Promise((resolve) => setTimeout(resolve));
+ await fixture.whenStable();
+ const host = fixture.nativeElement as HTMLElement;
+ const names = [...host.querySelectorAll('.mismatches > li > code:first-child')].map(
+ (code) => code.textContent,
+ );
+ expect(names).toEqual(['ProductCard', '<#text>']);
+ });
+});
diff --git a/app/src/app.ts b/app/src/app.ts
index 36e38e11..b4cb2f1c 100644
--- a/app/src/app.ts
+++ b/app/src/app.ts
@@ -479,7 +479,8 @@ function readView(): View | null {
}}
-
+
+
{{ backgroundNote() }}
{{ connectedNote() }}
@if (tab() === 'settings') {
@@ -961,6 +962,9 @@ function readView(): View | null {
main:focus {
outline: none;
}
+ main:focus-visible {
+ @include m.focus-ring(-2px);
+ }
main > * {
animation: enter 0.28s var(--ease) both;
}
diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts
index 456ea53f..3fef1caa 100644
--- a/app/src/pages/analog-inspector.ts
+++ b/app/src/pages/analog-inspector.ts
@@ -84,7 +84,7 @@ interface AnalogCall {
interface AnalogPage {
pageId: string;
url: string;
- chain: { path: string; file?: string; serverFile?: string }[];
+ chain: { path: string; file?: string; serverFile?: string; outlet?: string }[];
load?: { preview: string; bytes: number; keys: string[] };
serverContext?: string;
hydrated: number;
@@ -497,7 +497,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
kindText(row.route)
}}
@if (isOpen(row.route)) {
- open
+ {{ openText(row.route) }}
}
@@ -2198,7 +2198,15 @@ export class AnalogInspector {
const pages = this.state().pages ?? [];
return (this.hostPageId ? pages.find((p) => p.pageId === this.hostPageId) : pages[0]) ?? null;
});
- readonly openFiles = computed(() => new Set((this.page()?.chain ?? []).map((c) => c.file)));
+ /** Open page files, mapped to their named outlet ('' for the primary outlet). */
+ readonly openFiles = computed(() => {
+ const open = new Map();
+ for (const c of this.page()?.chain ?? []) {
+ // The primary outlet wins when one file renders in several outlets.
+ if (c.file && (!open.has(c.file) || !c.outlet)) open.set(c.file, c.outlet ?? '');
+ }
+ return open;
+ });
readonly allCalls = computed(() => this.state().calls ?? []);
readonly maxServerCalls = MAX_SERVER_CALLS;
readonly callQuery = signal('');
@@ -2381,6 +2389,11 @@ export class AnalogInspector {
return !!route.file && this.openFiles().has(route.file);
}
+ openText(route: AnalogRoute): string {
+ const outlet = route.file ? this.openFiles().get(route.file) : undefined;
+ return outlet ? `open in ${outlet}` : 'open';
+ }
+
kindText(route: AnalogRoute): string {
if (route.catchAll) return route.catchAll === 'optional' ? 'optional catch-all' : 'catch-all';
if (route.kind === 'implicit') return 'folder';
diff --git a/app/src/pages/cd-recording.ts b/app/src/pages/cd-recording.ts
index 551ab994..5015753a 100644
--- a/app/src/pages/cd-recording.ts
+++ b/app/src/pages/cd-recording.ts
@@ -34,8 +34,11 @@ export type {
CdCycle,
} from '@pangular-inspector/devtools/internal/config';
-/** How long Stop waits for the page's last report before it announces the count it has. */
-const STOP_REPORT_WAIT_MS = 3000;
+/**
+ * How long Record and Stop wait for the page's report that says the recording is on or off
+ * before they say the page did not confirm. The same wait as `record-change-detection`.
+ */
+const CONFIRM_WAIT_MS = 3000;
/** One page's recording, as the page reports it. */
export type CdPage = CdReport;
@@ -541,38 +544,60 @@ export class CdRecording {
return cdCauseText(cause);
}
- /** Set after Stop: the total is announced once the page's last report says it stopped. */
- private readonly stopping = signal(false);
- private stopTimer: ReturnType | undefined;
+ /**
+ * Set after Record (`true`) or Stop (`false`) reached the server: the result is announced
+ * once the page reports the recording on or off. Stop's report also brings the last cycles.
+ */
+ private readonly confirming = signal(null);
+ private confirmTimer: ReturnType | undefined;
constructor() {
effect(() => {
- if (!this.stopping() || this.livePage()?.recording === true) return;
- untracked(() => this.announceStopped());
+ const on = this.confirming();
+ if (on === null) return;
+ const recording = this.livePage()?.recording === true;
+ if (recording !== on) return;
+ untracked(() => this.confirmed(on));
});
- inject(DestroyRef).onDestroy(() => clearTimeout(this.stopTimer));
+ inject(DestroyRef).onDestroy(() => clearTimeout(this.confirmTimer));
}
- private announceStopped() {
- clearTimeout(this.stopTimer);
- this.stopping.set(false);
+ private confirmed(on: boolean) {
+ clearTimeout(this.confirmTimer);
+ this.confirming.set(null);
+ this.message.set('');
const count = this.livePage()?.cycles.length ?? 0;
- this.announce(`Recording stopped. ${count} ${count === 1 ? 'cycle' : 'cycles'} recorded.`);
+ this.announce(
+ on
+ ? 'Recording started.'
+ : `Recording stopped. ${count} ${count === 1 ? 'cycle' : 'cycles'} recorded.`,
+ );
+ }
+
+ /** No report confirmed in time: the button keeps the state the page last reported. */
+ private unconfirmed(on: boolean) {
+ const failed = `The page did not confirm that the recording ${on ? 'started' : 'stopped'}. The tab may be in the background, closed or reloading.`;
+ this.message.set(failed);
+ this.announce(failed);
}
async toggle() {
- if (this.recording()) {
- const sent = await this.send({ on: false }, '', () => '', 'request-change-detection-record', {
- quiet: true,
- });
- if (!sent) return;
- // The page sends its last cycles with the report that says it stopped.
- this.stopping.set(true);
- clearTimeout(this.stopTimer);
- this.stopTimer = setTimeout(() => this.announceStopped(), STOP_REPORT_WAIT_MS);
- } else {
- await this.send({ on: true }, 'Starting…', () => 'Recording started.');
- }
+ const on = !this.recording();
+ clearTimeout(this.confirmTimer);
+ this.confirming.set(null);
+ const sent = await this.send(
+ { on },
+ on ? 'Starting…' : 'Stopping…',
+ () => '',
+ 'request-change-detection-record',
+ { quiet: true, keepPending: true },
+ );
+ if (!sent) return;
+ this.confirming.set(on);
+ // A report that comes after the wait still confirms, and replaces the note.
+ this.confirmTimer = setTimeout(() => {
+ if (this.confirming() === on) this.unconfirmed(on);
+ }, CONFIRM_WAIT_MS);
}
showImport(file: CdImported) {
@@ -621,7 +646,7 @@ export class CdRecording {
pending: string,
done: () => string,
rpc = 'request-change-detection-record',
- { quiet = false } = {},
+ { quiet = false, keepPending = false } = {},
): Promise {
this.message.set(pending);
try {
@@ -630,7 +655,7 @@ export class CdRecording {
pageId: this.pageId(),
...request,
});
- this.message.set('');
+ if (!keepPending) this.message.set('');
if (!quiet) this.announce(done());
return true;
} catch {
diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts
index 3cddb9ca..3f94567d 100644
--- a/app/src/pages/component-tree.ts
+++ b/app/src/pages/component-tree.ts
@@ -50,6 +50,7 @@ import { ValueTree } from './component-value-tree';
import { ValueStore } from './component-value-store';
import { nestedMatches } from './component-value-state';
import { StoreGlobal } from '../ui/store-global';
+import { DeferBlocksSection, type DeferBlock } from './defer-blocks-section';
import { ForBlocksSection, type ForBlock } from './for-blocks-section';
import { LeaksSection, type LeaksPage } from './leaks-section';
import { VirtualWindow } from '../ui/virtual-window';
@@ -115,16 +116,6 @@ interface Detail {
expanded?: Record;
}
-interface DeferBlock {
- id: string;
- owner?: { id: string; name: string; tag: string };
- state: string;
- hydration: string;
- hydrateNever?: boolean;
- triggers: string[];
- rootIds: string[];
-}
-
interface Page {
pageId: string;
url?: string;
@@ -144,6 +135,8 @@ interface Page {
truncatedBy?: { components?: number; depth?: number; directiveHosts?: number };
detail: Detail | null;
reportedAt: number;
+ /** The server clock minus the page clock, in ms, for ages of page times. */
+ clockOffset?: number;
}
interface OutletInfo {
@@ -204,6 +197,7 @@ function storedDirectiveHosts(): boolean {
SourceLink,
ValueTree,
StoreGlobal,
+ DeferBlocksSection,
ForBlocksSection,
HydrationSection,
LeaksSection,
@@ -759,43 +753,13 @@ function storedDirectiveHosts(): boolean {
}
- @if (deferBlocks().length) {
-
-
- Defer blocks
- {{ deferCount() }}
-
-
- @for (block of deferBlocks(); track block.id) {
- -
-
-
- }
-
-
- }
+
@if (deferUnavailable()) {
this.page()?.deferBlocks ?? []);
- /** The block count, with a `+` when the page has more blocks than it reported. */
- readonly deferCount = computed(
- () => `${this.deferBlocks().length}${this.page()?.deferTruncated ? '+' : ''}`,
- );
readonly countLabel = computed(() =>
countText(
@@ -2645,19 +2553,24 @@ export class ComponentTree {
}
highlight(id: string | null) {
+ this.requestHighlight(id ? { id } : null);
+ }
+
+ /** One box around every root element of the block, or its owner when none is rendered. */
+ highlightBlock(block: DeferBlock) {
+ if (block.rootIds.length > 1) this.requestHighlight({ ids: block.rootIds });
+ else this.highlight(block.rootIds[0] ?? block.owner?.id ?? null);
+ }
+
+ private requestHighlight(target: { id: string } | { ids: string[] } | null) {
const client = this.rpc();
if (!client) return;
- const target = id ? { pageId: this.page()?.pageId, id } : null;
void client
.scope('pangular')
- .rpc.call('request-page-highlight', target)
+ .rpc.call('request-page-highlight', target && { pageId: this.page()?.pageId, ...target })
.catch(() => {});
}
- highlightBlock(block: DeferBlock) {
- this.highlight(block.rootIds[0] ?? block.owner?.id ?? null);
- }
-
showOwner(block: DeferBlock) {
if (block.owner && this.index().map.has(block.owner.id)) this.reveal(block.owner.id);
}
diff --git a/app/src/pages/defer-blocks-section.ts b/app/src/pages/defer-blocks-section.ts
new file mode 100644
index 00000000..77b84b75
--- /dev/null
+++ b/app/src/pages/defer-blocks-section.ts
@@ -0,0 +1,269 @@
+import {
+ Component,
+ DestroyRef,
+ computed,
+ effect,
+ inject,
+ input,
+ output,
+ signal,
+} from '@angular/core';
+import { deferNotes, type DeferNote } from '@pangular-inspector/devtools/internal/config';
+
+export interface DeferBlock {
+ id: string;
+ owner?: { id: string; name: string; tag: string };
+ state: string;
+ hydration: string;
+ hydrateNever?: boolean;
+ triggers: string[];
+ rootIds: string[];
+ /** Page clock, ms since epoch, when the state or hydration state last changed. */
+ since?: number;
+ hasErrorBlock?: boolean;
+ /** Times from `@loading (after …; minimum …)`, in ms. */
+ loading?: { minimumTime: number | null; afterTime: number | null };
+ /** The time from `@placeholder (minimum …)`, in ms. */
+ placeholder?: { minimumTime: number | null };
+}
+
+interface Row {
+ block: DeferBlock;
+ notes: DeferNote[];
+ timings: string;
+}
+
+/** `500ms`, `1s`, `1.5s`. */
+function duration(ms: number): string {
+ return ms < 1000 ? `${ms}ms` : `${Number((ms / 1000).toFixed(2))}s`;
+}
+
+/** The `@placeholder` and `@loading` times of a block, such as `loading after 100ms, min 1s`. */
+export function deferTimings(block: DeferBlock): string {
+ const parts: string[] = [];
+ const placeholder = block.placeholder?.minimumTime;
+ if (placeholder != null) parts.push(`placeholder min ${duration(placeholder)}`);
+ const loading = [
+ block.loading?.afterTime != null ? `after ${duration(block.loading.afterTime)}` : '',
+ block.loading?.minimumTime != null ? `min ${duration(block.loading.minimumTime)}` : '',
+ ].filter(Boolean);
+ if (loading.length) parts.push(`loading ${loading.join(', ')}`);
+ return parts.join(' · ');
+}
+
+/**
+ * The Defer blocks list on the Components page. Each row has the block's owner,
+ * state, hydration, the notes the defer-blocks agent tool prints (failed, stuck
+ * on its placeholder, dehydrated), whether it has an `@error` block, its
+ * `@placeholder` and `@loading` times and its triggers. Ages are read on the
+ * page's clock, through `clockOffset`, which assumes the panel's clock agrees
+ * with the server's.
+ */
+@Component({
+ selector: 'app-defer-blocks',
+ template: `
+ @if (rows().length) {
+
+
+ Defer blocks
+ {{ rows().length }}{{ truncated() ? '+' : '' }}
+
+
+ @for (row of rows(); track row.block.id) {
+ -
+
+
+ }
+
+
+ }
+ `,
+ styles: `
+ @use 'mixins' as m;
+
+ :host {
+ display: block;
+ }
+ .defer {
+ margin-top: 16px;
+ }
+ .defer-title {
+ @include m.label;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0 0 8px;
+ }
+ .pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 16px;
+ padding: 0 5px;
+ border-radius: 99px;
+ background: var(--surface-3);
+ color: var(--text-2);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0;
+ }
+ .defer-list {
+ display: grid;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+ .defer-row {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ width: 100%;
+ height: auto;
+ min-height: var(--control-h);
+ padding: 6px 12px;
+ background: var(--surface);
+ border-color: var(--border);
+ text-align: left;
+ }
+ .defer-row:focus-visible {
+ @include m.focus-ring;
+ }
+ .defer-row[aria-disabled='true'] {
+ cursor: default;
+ }
+ .name {
+ min-width: 0;
+ color: var(--text-strong);
+ font-size: 12px;
+ font-weight: 600;
+ @include m.truncate;
+ }
+ .mono {
+ font-family: var(--font-mono);
+ }
+ .defer-state {
+ padding: 0 8px;
+ border-radius: 99px;
+ background: var(--surface-3);
+ color: var(--text-2);
+ font-size: 11px;
+ line-height: 18px;
+ }
+ .defer-state-complete {
+ @include m.tint(ok);
+ }
+ .defer-state-error {
+ @include m.tint(danger);
+ }
+ .defer-state-loading,
+ .defer-state-placeholder {
+ @include m.tint(warn);
+ }
+ .flag {
+ padding: 0 6px;
+ border: 1px solid var(--border-strong);
+ border-radius: 5px;
+ color: var(--text-2);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ line-height: 16px;
+ }
+ .note-failed {
+ @include m.tint(danger);
+ }
+ .note-stuck {
+ @include m.tint(warn);
+ }
+ .timings {
+ color: var(--text-2);
+ font-size: 11px;
+ }
+ .triggers {
+ min-width: 0;
+ color: var(--text-2);
+ font-size: 12px;
+ @include m.truncate;
+ }
+ `,
+})
+export class DeferBlocksSection {
+ readonly blocks = input([]);
+ /** The page has more defer blocks than the ones in `blocks`. */
+ readonly truncated = input(false);
+ /** The server clock minus the page clock, in ms, so ages are read on the page's clock. */
+ readonly clockOffset = input(0);
+ readonly showOwner = output();
+ /** The block to highlight in the app, or `null` to clear it. */
+ readonly highlight = output();
+
+ /** Moves every second while a block waits, so its time on its placeholder counts up. */
+ private readonly now = signal(Date.now());
+ /** Some block is still on its placeholder, so its age can grow into a note. */
+ private readonly waiting = computed(() =>
+ this.blocks().some(
+ (block) => !!block.since && (block.state === 'placeholder' || block.state === 'initial'),
+ ),
+ );
+
+ protected readonly rows = computed(() => {
+ const pageNow = this.now() - this.clockOffset();
+ return this.blocks().map((block) => ({
+ block,
+ // A block without a time (an older page) gets no age notes.
+ notes: deferNotes({ ...block, since: block.since || pageNow }, pageNow),
+ timings: deferTimings(block),
+ }));
+ });
+
+ constructor() {
+ let tick: ReturnType | undefined;
+ const stop = () => {
+ clearInterval(tick);
+ tick = undefined;
+ };
+ effect(() => {
+ if (!this.waiting()) return stop();
+ if (tick !== undefined) return;
+ this.now.set(Date.now());
+ tick = setInterval(() => this.now.set(Date.now()), 1000);
+ });
+ inject(DestroyRef).onDestroy(stop);
+ }
+}
diff --git a/app/src/pages/forms-inspector.ts b/app/src/pages/forms-inspector.ts
index 2b457964..aed5c06f 100644
--- a/app/src/pages/forms-inspector.ts
+++ b/app/src/pages/forms-inspector.ts
@@ -21,7 +21,7 @@ import { LimitNote } from '../ui/limit-note';
import { FormsFieldDetail } from './forms-field-detail';
import { FormsLint, FormsSubmit } from './forms-report';
import { FormsTimeline } from './forms-timeline';
-import { FormsWebMcp } from './forms-webmcp';
+import { FormsWebMcp, FormsWebMcpOthers } from './forms-webmcp';
import {
FORMS_STYLES,
KIND_LABELS,
@@ -34,6 +34,7 @@ import {
type FormEvent,
type FormFieldError,
type FormFieldNode,
+ type FormSetupError,
type WebMcpPage,
} from './forms-types';
@@ -76,6 +77,7 @@ interface FormsSnapshot {
instrumented?: string[];
dropped?: Record;
webMcp?: WebMcpPage[];
+ setupErrors?: FormSetupError[];
}
interface FieldRow {
@@ -101,6 +103,7 @@ function countFields(node: FormFieldNode): number {
FormsSubmit,
FormsLint,
FormsWebMcp,
+ FormsWebMcpOthers,
LimitNote,
],
host: { '(keydown.escape)': 'cancelPick()' },
@@ -109,6 +112,28 @@ function countFields(node: FormFieldNode): number {
Signal Forms, reactive and template-driven forms on the live page, with each field's state and
errors, a change timeline, submit explanations and a lint. Development builds only.
+ @if (pageSetupErrors().length) {
+
+
+ Angular reported {{ pageSetupErrors().length }} forms setup
+ {{ pageSetupErrors().length === 1 ? 'error' : 'errors' }}
+
+
+ A form with a setup error can be missing from the list or behave unlike its template.
+ Check the directives and value accessors it names.
+
+
+ @for (error of pageSetupErrors(); track $index) {
+ -
+
{{ error.message }}
+ @if (setupErrorPages()) {
+ on page {{ error.pageId }}
+ }
+
+ }
+
+
+ }
@if (!rpc()) {