()),
+ collectInjectorTree: () => ({ count: 0, roots: [] }),
+}));
+
+async function flush() {
+ for (let i = 0; i < 30; i++) await Promise.resolve();
+}
+
+describe('NativeScript overlay reports', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal('WebSocket', class {});
+ vi.stubGlobal('ng', { getComponent: () => null });
+ calls.length = 0;
+ failTree = false;
+ failOnce = null;
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it('keeps sending the other collectors when one push is rejected', async () => {
+ failTree = true;
+ const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts');
+ const dispose = initNativeScriptOverlay({
+ baseURL: 'http://localhost:9999/',
+ intervalMs: 3000,
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ await flush();
+ const names = calls.map((c) => c.name);
+ expect(names).toContain('push-component-tree');
+ expect(names).toContain('push-signal-graph');
+ expect(names).toContain('push-injector-tree');
+ expect(names).toContain('push-ngrx');
+ dispose();
+ });
+
+ it.each(['push-component-tree', 'push-injector-tree'])(
+ 'retries an unchanged %s on the next tick after it was rejected',
+ async (name) => {
+ failOnce = name;
+ const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts');
+ const dispose = initNativeScriptOverlay({
+ baseURL: 'http://localhost:9999/',
+ intervalMs: 3000,
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ await flush();
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(calls.filter((c) => c.name === name)).toHaveLength(2);
+ dispose();
+ },
+ );
+
+ it('re-sends an unchanged tree within the page expiry at a slow interval', async () => {
+ const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts');
+ const dispose = initNativeScriptOverlay({
+ baseURL: 'http://localhost:9999/',
+ intervalMs: 5000,
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ await flush();
+ await vi.advanceTimersByTimeAsync(60_000);
+ const times = calls.filter((c) => c.name === 'push-component-tree').map((c) => c.at);
+ const gaps = times.slice(1).map((at, i) => at - times[i]);
+ expect(times.length).toBeGreaterThan(2);
+ expect(Math.max(...gaps)).toBeLessThanOrEqual(15_000);
+ dispose();
+ });
+});
diff --git a/packages/devtools/src/__tests__/pipes-runtime.test.ts b/packages/devtools/src/__tests__/pipes-runtime.test.ts
index b138ad57..1bfc80a9 100644
--- a/packages/devtools/src/__tests__/pipes-runtime.test.ts
+++ b/packages/devtools/src/__tests__/pipes-runtime.test.ts
@@ -110,6 +110,15 @@ Component({
template: `{{ items().join(',') | uppercase }}
`,
})(Cart);
+class Labelled {
+ value = 'a';
+}
+Component({
+ selector: 'app-labelled',
+ imports: [UpperCasePipe],
+ template: `uppercase | {{ value | uppercase }}
`,
+})(Labelled);
+
describe('stale pure-pipe check (experimental, real Angular render)', () => {
it('recovers a real binding slot and reads the last argument Angular compared', async () => {
const fixture = await mount(Cart);
@@ -125,6 +134,16 @@ describe('stale pure-pipe check (experimental, real Angular render)', () => {
expect(readBoundArg(upper, check!)).toBe('c,d');
});
+ it('is not fooled by static text equal to the pipe name', async () => {
+ const fixture = await mount(Labelled);
+ const usages = findPipeUsages(ng(), document.querySelectorAll('*'));
+ const upper = usages.find((u) => u.name === 'uppercase')!;
+ const check = staleCheckFor(upper);
+ expect(check).not.toBeNull();
+ expect(readBoundArg(upper, check!)).toBe('a');
+ fixture.destroy();
+ });
+
it('returns null gracefully when the template source does not match the expected shape', () => {
const fakeSlot = {
name: 'uppercase',
diff --git a/packages/devtools/src/__tests__/popup.test.ts b/packages/devtools/src/__tests__/popup.test.ts
index a8ffd304..ea8e2eba 100644
--- a/packages/devtools/src/__tests__/popup.test.ts
+++ b/packages/devtools/src/__tests__/popup.test.ts
@@ -323,6 +323,18 @@ describe.sequential('devtools popup', () => {
expect(stored().docked).toBe('float');
});
+ it('draws no resize grip on a docked panel, whose size is fixed', async () => {
+ await loadPopup(true);
+ const sheet = document
+ .getElementById('pangular-popup-root')!
+ .shadowRoot!.querySelector('style')!.textContent!;
+ for (const mode of ['bottom', 'right']) {
+ const rule = new RegExp(`\\.panel\\.dock-${mode}\\s*\\{([^}]*)\\}`).exec(sheet)![1];
+ expect(rule).toContain('!important');
+ expect(rule).toMatch(/resize\s*:\s*none/);
+ }
+ });
+
it('lets Escape clear a search box in the frame without closing the panel', async () => {
await loadPopup(true);
const doc = await frameDocument();
diff --git a/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts b/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts
new file mode 100644
index 00000000..68c407c5
--- /dev/null
+++ b/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts
@@ -0,0 +1,70 @@
+// @vitest-environment jsdom
+import '@angular/compiler';
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
+import { Router, provideRouter, type Routes } from '@angular/router';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { watchRouter, type NavigationRecord } from '../router.ts';
+import { runAction } from '../router-actions.ts';
+
+TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+
+class Page {}
+Component({ selector: 'app-page', template: '' })(Page);
+
+let release: (value: boolean) => void = () => {};
+let guardedRuns = 0;
+
+const routes: Routes = [
+ { path: '', component: Page },
+ {
+ path: 'slow',
+ component: Page,
+ canMatch: [() => new Promise((resolve) => (release = resolve))],
+ canActivate: [
+ () => {
+ guardedRuns++;
+ return true;
+ },
+ ],
+ },
+];
+
+describe('probe that times out', () => {
+ let router: Router;
+ let navigations: NavigationRecord[];
+ let stop: (() => void) | null;
+
+ beforeEach(async () => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
+ router = TestBed.inject(Router);
+ navigations = [];
+ stop = watchRouter(router as never, navigations, () => {});
+ await router.navigateByUrl('/');
+ guardedRuns = 0;
+ });
+
+ afterEach(() => {
+ stop?.();
+ vi.useRealTimers();
+ });
+
+ it('aborts the probe navigation and marks it as a probe', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const pending = runAction(
+ router as never,
+ navigations,
+ { action: 'probe', url: '/slow' },
+ () => {},
+ );
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(await pending).toMatchObject({ error: expect.stringContaining('10s') });
+ release(true);
+ await vi.advanceTimersByTimeAsync(50);
+ expect(router.url).toBe('/');
+ expect(guardedRuns).toBe(0);
+ expect(navigations.some((n) => n.url === '/slow' && n.probe === true)).toBe(true);
+ });
+});
diff --git a/packages/devtools/src/__tests__/router-config-tools-gaps.test.ts b/packages/devtools/src/__tests__/router-config-tools-gaps.test.ts
new file mode 100644
index 00000000..280ea09a
--- /dev/null
+++ b/packages/devtools/src/__tests__/router-config-tools-gaps.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest';
+import type { RouteNode } from '../router-config.ts';
+import { explainRenderModeText, lintRoutes } from '../rpc/router-config-tools.ts';
+import type { RouterPage } from '../rpc/router-tools.ts';
+
+function node(fullPath: string, extra: Partial = {}): RouteNode {
+ const path = extra.path ?? fullPath.split('/').pop() ?? '';
+ return { id: fullPath, path, fullPath, kind: 'component', component: 'C', ...extra };
+}
+
+function page(extra: Partial = {}): RouterPage {
+ return { pageId: 'p', snapshot: null, navigations: [], reportedAt: 1, changedAt: 1, ...extra };
+}
+
+describe('duplicate-path lint', () => {
+ it('does not flag same-path siblings that have different children', () => {
+ const config = [
+ node('/', { path: '', children: [node('/a', { path: 'a' })] }),
+ node('/', { path: '', children: [node('/b', { path: 'b' })] }),
+ ];
+ expect(lintRoutes(page({ config })).map((f) => f.rule)).not.toContain('duplicate-path');
+ });
+
+ it('keeps comparing against an earlier leaf when a middle sibling has children', () => {
+ const config = [
+ node('/home'),
+ node('/home', { children: [node('/home/x', { path: 'x' })] }),
+ node('/home'),
+ ];
+ expect(lintRoutes(page({ config })).filter((f) => f.rule === 'duplicate-path')).toHaveLength(1);
+ });
+
+ it('still flags identical leaf routes', () => {
+ const config = [node('/home'), node('/home')];
+ expect(lintRoutes(page({ config })).filter((f) => f.rule === 'duplicate-path')).toHaveLength(1);
+ });
+});
+
+describe('explain-render-mode', () => {
+ const entries = [{ path: 'admin/dashboard', renderMode: 'Server', file: 'x' }];
+
+ it('does not claim a build failure for entries under an unloaded lazy route', () => {
+ const config = [
+ node('/admin', { kind: 'lazy', lazy: 'unloaded', component: undefined, path: 'admin' }),
+ ];
+ const text = explainRenderModeText({ pages: [page({ config })] }, entries, {});
+ expect(text).not.toContain('no matching client route');
+ });
+
+ it('still reports an entry under a route that only loads a component lazily', () => {
+ const config = [
+ node('/admin', { kind: 'component', lazy: 'unloaded', component: undefined, path: 'admin' }),
+ ];
+ const text = explainRenderModeText({ pages: [page({ config })] }, entries, {});
+ expect(text).toContain('no matching client route');
+ });
+
+ it('does not claim a build failure when the config was truncated', () => {
+ const text = explainRenderModeText(
+ { pages: [page({ config: [node('/other')], configTruncated: 3 })] },
+ entries,
+ {},
+ );
+ expect(text).not.toContain('no matching client route');
+ });
+
+ it('still reports an entry with no client route', () => {
+ const text = explainRenderModeText(
+ { pages: [page({ config: [node('/other')] })] },
+ entries,
+ {},
+ );
+ expect(text).toContain('no matching client route');
+ });
+});
diff --git a/packages/devtools/src/__tests__/router-gaps.test.ts b/packages/devtools/src/__tests__/router-gaps.test.ts
new file mode 100644
index 00000000..af2a5647
--- /dev/null
+++ b/packages/devtools/src/__tests__/router-gaps.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import { walkConfig } from '../router-config.ts';
+import { detectSetup } from '../router-setup.ts';
+import { redirectCycles } from '../rpc/router-loops.ts';
+import type { RouteNode } from '../router-config.ts';
+
+describe('redirectCycles', () => {
+ const node = (path: string, extra: Partial): RouteNode => ({
+ id: path,
+ path,
+ fullPath: `/${path}`,
+ kind: 'redirect',
+ ...extra,
+ });
+
+ it('ignores redirects in a named outlet', () => {
+ const config = [
+ node('a', { outlet: 'popup', redirectTo: 'b' }),
+ node('b', { redirectTo: 'a' }),
+ ];
+ expect(redirectCycles(config)).toEqual([]);
+ });
+
+ it('still finds a cycle in the primary outlet', () => {
+ const config = [node('a', { redirectTo: 'b' }), node('b', { redirectTo: 'a' })];
+ expect(redirectCycles(config)).toEqual([['/a', '/b', '/a']]);
+ });
+});
+
+describe('walkConfig depth limit', () => {
+ it('counts the routes it leaves out below the depth limit', () => {
+ let route: Record = { path: 'leaf' };
+ for (let i = 0; i < 14; i++) route = { path: `p${i}`, children: [route] };
+ const cut = { routes: 0 };
+ const nodes = walkConfig({ config: [route] }, cut);
+ let shown = 0;
+ const count = (list: RouteNode[]) =>
+ list.forEach((n) => {
+ shown++;
+ count(n.children ?? []);
+ });
+ count(nodes);
+ expect(cut.routes).toBeGreaterThan(0);
+ expect(shown + cut.routes).toBe(15);
+ });
+});
+
+describe('router strategies', () => {
+ it('reports a missing strategy as unknown', () => {
+ const ng = { getInjector: () => undefined };
+ const setup = detectSetup(ng as never, {} as never, 1, null);
+ expect(setup.strategies['locationStrategy']).toBe('unknown');
+ expect(setup.strategies['titleStrategy']).toBe('unknown');
+ });
+});
diff --git a/packages/devtools/src/cd-overlay.ts b/packages/devtools/src/cd-overlay.ts
index c56c6598..8db0ab17 100644
--- a/packages/devtools/src/cd-overlay.ts
+++ b/packages/devtools/src/cd-overlay.ts
@@ -1,4 +1,4 @@
-import { addProfilerListener } from './change-detection.ts';
+import { addProfilerListener, keepaliveDue } from './change-detection.ts';
import { createCdRecorder, type CdRecording } from './cd-recorder.ts';
import { elementId } from './element-id.ts';
@@ -21,8 +21,6 @@ export interface CdReport extends CdRecording {
supported: boolean;
}
-const KEEPALIVE_MS = 8000;
-
/**
* Records change detection cycles while the panel or an agent turns recording
* on, and reports them. It does nothing until then.
@@ -32,6 +30,7 @@ export function attachChangeDetection(
pageId: string,
getNg: () => unknown,
maxCycles: number,
+ tickMs: () => number = () => 0,
) {
const recorder = createCdRecorder({ maxCycles });
const hostIds = new WeakMap