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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Inspect Angular component trees, signals, dependency injection, routes, forms, p
npm install @santoshyadavdev/ng-devtools devframe
```

Then follow the [installation guide](./apps/docs/src/content/getting-started/installation.md) for your setup: Angular CLI with Express, Vite and Analog, or the standalone CLI. For a coding agent, run `npx @santoshyadavdev/ng-devtools mcp`.
Then follow the [installation guide](./apps/docs/src/content/getting-started/installation.md) for your setup: Angular CLI with Express, Vite and Analog, the standalone CLI, or [Angular Native](./apps/docs/src/content/getting-started/angular-native.md). For a coding agent, run `npx @santoshyadavdev/ng-devtools mcp`.

## Documentation

Expand Down
125 changes: 125 additions & 0 deletions app/src/__tests__/angular-native-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { angularNativePage } from '../native-page';
import { hostPageId, scopeToPage } from '../page-id';

const connection = vi.hoisted(() => ({
resolve: (_client: unknown) => {},
}));

vi.mock('devframe/client', () => ({
connectDevframe: () =>
new Promise((resolve) => {
connection.resolve = resolve;
}),
}));

const { App } = await import('../app');

type Pages = Record<string, { pageId: string; reportedAt: number; platform?: string }>;

function fakeClient(initial: Pages) {
let pages = initial;
const listeners = new Set<(value: unknown) => void>();
const componentTree = {
value: () => ({ pages }),
on: (_event: string, listener: (value: unknown) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
const rpc = {
call: () => new Promise(() => {}),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve(
name === 'component-tree' ? componentTree : { value: () => null, on: () => () => {} },
),
};
const client = {
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: { on: () => () => {} },
} as unknown as DevframeRpcClient;
const report = (next: Pages) => {
pages = next;
for (const listener of listeners) listener({ pages });
};
return { client, report };
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

function host(fixture: ComponentFixture<unknown>): HTMLElement {
return fixture.nativeElement as HTMLElement;
}

const tabNames = (fixture: ComponentFixture<unknown>) =>
[...host(fixture).querySelectorAll('nav button')].map((b) => b.textContent?.trim());

const page = { roots: [], count: 0, detail: null };
const browser = { ...page, pageId: 'web1', reportedAt: 3000 };
const native = { ...page, pageId: 'an42', reportedAt: 2000, platform: 'angular-native' };

describe('angularNativePage', () => {
it('picks the newest Angular Native page and ignores browser pages', () => {
const older = { pageId: 'an01', reportedAt: 1000, platform: 'angular-native' };
expect(angularNativePage({ web1: browser, an42: native, an01: older }, null)).toBe('an42');
expect(angularNativePage({ web1: browser }, null)).toBeNull();
expect(angularNativePage(undefined, null)).toBeNull();
});

it('keeps the page shown before while it still reports', () => {
const newer = { pageId: 'an99', reportedAt: 9000, platform: 'angular-native' };
expect(angularNativePage({ an42: native, an99: newer }, 'an42')).toBe('an42');
expect(angularNativePage({ an99: newer }, 'an42')).toBe('an99');
});
});

describe('Angular Native view', () => {
beforeEach(() => history.replaceState(null, '', '/?view=angular-native'));
afterEach(() => {
document.body.innerHTML = '';
history.replaceState(null, '', '/');
scopeToPage(null);
});

it('shows the setup empty state while no Angular Native app reports', async () => {
const fixture = TestBed.createComponent(App);
await settle(fixture);
connection.resolve(fakeClient({ web1: browser }).client);
await settle(fixture);
const text = host(fixture).textContent ?? '';
expect(host(fixture).querySelector('h1')?.textContent).toContain('Angular Native');
expect(text).toContain('No Angular Native app is connected');
const link = host(fixture).querySelector<HTMLAnchorElement>('a.cta');
expect(link?.textContent).toContain('Set up Angular Native');
expect(link?.href).toContain('getting-started/angular-native.md');
expect(tabNames(fixture)).toEqual([]);
});

it('opens the live tabs scoped to the app once it reports, and goes back when it leaves', async () => {
const fixture = TestBed.createComponent(App);
await settle(fixture);
const { client, report } = fakeClient({ web1: browser });
connection.resolve(client);
await settle(fixture);

report({ web1: browser, an42: native });
await settle(fixture);
expect(tabNames(fixture)).toEqual(['Components', 'Signals', 'Injectors', 'Store', 'Pipes']);
expect(host(fixture).querySelector('nav button.active')?.textContent).toContain('Components');
expect(host(fixture).textContent).not.toContain('No Angular Native app is connected');
expect(hostPageId()).toBe('an42');

report({ web1: browser });
await settle(fixture);
expect(host(fixture).textContent).toContain('No Angular Native app is connected');
});
});
141 changes: 141 additions & 0 deletions app/src/__tests__/native-controls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it } from 'vitest';
import { isAngularNativePage } from '../native-page';
import { ComponentTree } from '../pages/component-tree';

function fakeClient(pages: Record<string, unknown>): DevframeRpcClient {
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () => (name === 'component-tree' ? { pages } : null),
on: () => () => {},
}),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

const root = { id: 'c1', name: 'App', tag: 'app-root', children: [] };

function page(pageId: string, platform?: string) {
return { pageId, platform, roots: [root], count: 1, detail: null, reportedAt: 1000 };
}

async function render(platform?: string) {
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', fakeClient({ p1: page('p1', platform) }));
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
return fixture;
}

function buttons(fixture: ComponentFixture<unknown>): string[] {
return [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].map(
(button) => button.textContent?.trim() ?? '',
);
}

function liveClient(initial: Record<string, unknown>) {
let pages = initial;
const listeners = new Set<(value: unknown) => void>();
const calls: { name: string; arg: unknown }[] = [];
const rpc = {
call: (name: string, arg: unknown) => {
calls.push({ name, arg });
return name === 'request-component-pick' ? new Promise(() => {}) : Promise.resolve([]);
},
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () => (name === 'component-tree' ? { pages } : null),
on: (_event: string, listener: (value: unknown) => void) => {
if (name === 'component-tree') listeners.add(listener);
return () => listeners.delete(listener);
},
}),
};
const client = { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
const report = (next: Record<string, unknown>) => {
pages = next;
for (const listener of listeners) listener({ pages });
};
return { client, calls, report };
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

describe('browser-only controls', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('knows an Angular Native page by its platform', () => {
expect(isAngularNativePage({ platform: 'angular-native' })).toBe(true);
expect(isAngularNativePage({})).toBe(false);
expect(isAngularNativePage(null)).toBe(false);
});

it('shows the component picker and change detection recording for a browser page', async () => {
const fixture = await render();
expect(buttons(fixture)).toContain('Pick component on page');
expect(buttons(fixture)).toContain('Record');
expect((fixture.nativeElement as HTMLElement).querySelector('#cd-heading')).not.toBeNull();
});

it('hides them for an Angular Native page and keeps the tree', async () => {
const fixture = await render('angular-native');
const host = fixture.nativeElement as HTMLElement;
expect(buttons(fixture)).not.toContain('Pick component on page');
expect(buttons(fixture)).not.toContain('Record');
expect(host.querySelector('#cd-heading')).toBeNull();
expect(host.textContent).toContain('App');
expect(host.querySelector('input[type="search"]')).not.toBeNull();
});

it('cancels a pick on its own page when the dropdown shows another page', async () => {
const { client, calls } = liveClient({ p1: page('p1'), p2: page('p2') });
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client);
await settle(fixture);
const tree = fixture.componentInstance;
const shown = tree.page()!.pageId;
const other = shown === 'p1' ? 'p2' : 'p1';

void tree.pick();
await settle(fixture);
expect(buttons(fixture)).toContain('Cancel pick');

tree.selectPage(other);
await settle(fixture);
expect(calls).toContainEqual({ name: 'cancel-component-pick', arg: { pageId: shown } });
expect(tree.picking()).toBe(false);
expect(buttons(fixture)).toContain('Pick component on page');
});

it('cancels a pick on the browser page when an Angular Native page takes over', async () => {
const { client, calls, report } = liveClient({ p1: page('p1') });
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client);
await settle(fixture);
const tree = fixture.componentInstance;

void tree.pick();
await settle(fixture);
report({ an42: page('an42', 'angular-native') });
await settle(fixture);

expect(tree.page()?.pageId).toBe('an42');
expect(calls).toContainEqual({ name: 'cancel-component-pick', arg: { pageId: 'p1' } });
expect(tree.picking()).toBe(false);
expect(buttons(fixture)).not.toContain('Cancel pick');
});
});
Loading
Loading